From 962b17ea67a2e1de2413f255098d6d1b71b6966b Mon Sep 17 00:00:00 2001 From: eric Date: Mon, 1 Dec 2025 15:04:45 +0800 Subject: [PATCH] 's' --- .github/ISSUE_TEMPLATE/bug.yml | 91 ++ .github/ISSUE_TEMPLATE/bug_en.yml | 91 ++ .github/ISSUE_TEMPLATE/feature.yml | 38 + .github/ISSUE_TEMPLATE/feature_en.yml | 37 + .github/ISSUE_TEMPLATE/question.yml | 63 + .github/ISSUE_TEMPLATE/question_en.yml | 63 + .github/PULL_REQUEST_TEMPLATE.md | 56 + .github/workflows/build-image.yml | 54 + .github/workflows/issue-translator.yml | 15 + .github/workflows/sync.yml | 65 + .gitignore | 2 +- 111 | 104 ++ 333333 | 147 ++ 44444 | 146 ++ 5555 | 239 ++++ 55555 | 257 ++++ 66666 | 197 +++ README.md | 568 -------- StopRecording.vbs | 69 - back/back1 | 1815 ------------------------ back/back2 | 508 ------- back/back3 | 582 -------- back/back4 | 617 -------- back/mult1 | 636 --------- back/mult2 | 768 ---------- back/mult3 | 782 ---------- back/mult4 | 775 ---------- back/mult5 | 907 ------------ back/mult6 | 1035 -------------- back/mult7 | 1035 -------------- back/mult8 | 1105 --------------- config/URL_config.ini | 115 +- config/config.ini | 222 ++- config/youtube.ini | 13 - main.py | 1105 --------------- msg_push.py | 295 ---- out.html | 207 --- requirements.txt | 7 - src/ab_sign.py | 454 ++++++ src/spider.py | 210 ++- 40 files changed, 2443 insertions(+), 13052 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug.yml create mode 100644 .github/ISSUE_TEMPLATE/bug_en.yml create mode 100644 .github/ISSUE_TEMPLATE/feature.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_en.yml create mode 100644 .github/ISSUE_TEMPLATE/question.yml create mode 100644 .github/ISSUE_TEMPLATE/question_en.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/build-image.yml create mode 100644 .github/workflows/issue-translator.yml create mode 100644 .github/workflows/sync.yml create mode 100644 111 create mode 100644 333333 create mode 100644 44444 create mode 100644 5555 create mode 100644 55555 create mode 100644 66666 delete mode 100644 README.md delete mode 100644 StopRecording.vbs delete mode 100644 back/back1 delete mode 100644 back/back2 delete mode 100644 back/back3 delete mode 100644 back/back4 delete mode 100644 back/mult1 delete mode 100644 back/mult2 delete mode 100644 back/mult3 delete mode 100644 back/mult4 delete mode 100644 back/mult5 delete mode 100644 back/mult6 delete mode 100644 back/mult7 delete mode 100644 back/mult8 delete mode 100644 config/youtube.ini delete mode 100644 main.py delete mode 100644 msg_push.py delete mode 100644 out.html delete mode 100644 requirements.txt create mode 100644 src/ab_sign.py diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..781dd11 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,91 @@ +name: 🐛 Bug report +description: 创建Bug报告以帮助项目改进。 +title: 🐛[BUG] 请输入标题 +labels: bug +body: + - type: markdown + attributes: + value: | + 📝 **请在上方的`title`中填写一个简洁明了的标题**,格式建议为:🐛[Bug] 简短描述。 + 例如:🐛[Bug] B站某些直播间无法录制。 + - type: checkboxes + attributes: + label: ⚠️ 确认是否已存在类似问题 + description: > + 🔍 [点击这里搜索历史issue](https://github.com/ihmily/DouyinLiveRecorder/issues?q=is%3Aissue) + 请确保你的问题没有被报告过。 + options: + - label: 我已经搜索过issues,没有找到类似问题 + required: true + - type: dropdown + attributes: + label: 🔧 运行方式 + description: 请选择你是如何运行程序的。 + options: + - 直接运行的exe文件 + - 使用源代码运行 + - 使用docker运行 + validations: + required: true + - type: dropdown + attributes: + label: 🐍 如果是使用源代码运行,请选择你的Python环境版本 + description: 请选择你运行程序的Python版本。 + options: + - Python 3.10 + - Python 3.11 + - Python 3.12 + - Python 3.13 + - Other (请在问题中说明) + validations: + required: false + - type: dropdown + attributes: + label: 💻 请选择你的系统环境 + description: 请选择你运行程序的具体系统版本。 + options: + - Windows 10 + - Windows 11 + - macOS + - Ubuntu + - CentOS + - Fedora + - Debian + - Other (请在问题中说明) + validations: + required: true + - type: checkboxes + attributes: + label: ⚠️ 确认是否已经重试多次 + description: > + 有时可能是你的设备或者网络问题导致的。 + options: + - label: 我已经尝试过多次,仍然出现问题 + required: true + - type: textarea + attributes: + label: 🕹 复现步骤 + description: | + **⚠️ 不能复现将会关闭issue.** + 请按照以下格式填写: + 1. 录制的直播间地址是... + 2. 使用的录制格式是... + 3. ... + placeholder: | + 1. ... + 2. ... + 3. ... + validations: + required: true + - type: textarea + attributes: + label: 😯 问题描述 + description: 详细描述出现的问题,或提供有关截图。 + validations: + required: true + - type: textarea + attributes: + label: 📜 错误信息 + description: 如果有,请贴出相关的日志错误信息或者截图。 + validations: + required: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_en.yml b/.github/ISSUE_TEMPLATE/bug_en.yml new file mode 100644 index 0000000..36887b0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_en.yml @@ -0,0 +1,91 @@ +name: 🐛 (English)Bug report +description: Create a bug report to help improve the project. +title: 🐛[BUG] Please enter a title +labels: bug +body: + - type: markdown + attributes: + value: | + 📝 **Please fill in a concise and clear title in the `title` above**, the format is suggested as: 🐛[Bug] Short description. + For example: 🐛[Bug] Unable to record certain TikTok live rooms. + - type: checkboxes + attributes: + label: ⚠️ Confirm if similar issues exist + description: > + 🔍 [Click here to search historical issues](https://github.com/ihmily/DouyinLiveRecorder/issues?q=is%3Aissue) + Please make sure your issue hasn't been reported before. + options: + - label: I have searched the issues and found no similar problems + required: true + - type: dropdown + attributes: + label: 🔧 How did you run the program? + description: Please select how you ran the program. + options: + - Directly running the exe file + - Running with source code + - Running with docker + validations: + required: true + - type: dropdown + attributes: + label: 🐍 If running with source code, please select your Python environment version + description: Please select the Python version you used to run the program. + options: + - Python 3.10 + - Python 3.11 + - Python 3.12 + - Python 3.13 + - Other (please specify in the issue) + validations: + required: false + - type: dropdown + attributes: + label: 💻 Please select your system environment + description: Please select the specific system version you are running the program on. + options: + - Windows 10 + - Windows 11 + - macOS + - Ubuntu + - CentOS + - Fedora + - Debian + - Other (please specify in the issue) + validations: + required: true + - type: checkboxes + attributes: + label: ⚠️ Confirm if you have retried multiple times + description: > + Sometimes it might be due to your device or network issues. + options: + - label: I have tried multiple times and still encounter the problem + required: true + - type: textarea + attributes: + label: 🕹 Reproduction steps + description: | + **⚠️ Issues that cannot be reproduced will be closed.** + Please fill in according to the following format: + 1. The live room address I tried to record is... + 2. The recording format I used is... + 3. ... + placeholder: | + 1. ... + 2. ... + 3. ... + validations: + required: true + - type: textarea + attributes: + label: 😯 Problem description + description: Describe the problem in detail or provide relevant screenshots. + validations: + required: true + - type: textarea + attributes: + label: 📜 Error information + description: If available, please paste the relevant log error information or screenshots. + validations: + required: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..07b7612 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,38 @@ +name: 🚀 Feature request +description: 提出你对项目的新想法或建议。 +title: 🚀[Feature] 请输入标题 +labels: enhancement +body: + - type: markdown + attributes: + value: | + 📝 **请在上方的`title`中填写一个简洁明了的标题**,格式建议为:🚀[Feature] 简短描述。 + 例如:🚀[Feature] 添加xx直播录制。 + - type: checkboxes + attributes: + label: ⚠️ 搜索是否存在类似issue + description: > + 🔍 [点击这里搜索历史issue](https://github.com/ihmily/DouyinLiveRecorder/issues?q=is%3Aissue) 使用关键词搜索,确保没有重复的issue。 + options: + - label: 我已经搜索过issues,没有发现相似issue + required: true + - type: textarea + attributes: + label: 📜 功能描述 + description: 请详细描述你希望添加的功能,包括它的工作方式和预期效果。 + placeholder: | + 功能描述: + - type: textarea + attributes: + label: 🌐 举例(可选) + description: 如果可能,请提供功能相关的示例、截图或相关网址。 + placeholder: | + 直播间示例地址: + `https://www.example.com/live/xxxx` + + - type: textarea + attributes: + label: 💡 动机 + description: 描述你提出该feature的动机,以及没有这项feature对你的使用造成了怎样的影响。 + placeholder: | + 我需要这个功能是因为... \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_en.yml b/.github/ISSUE_TEMPLATE/feature_en.yml new file mode 100644 index 0000000..42b1183 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_en.yml @@ -0,0 +1,37 @@ +name: 🚀 (English)Feature request +description: Propose new ideas or suggestions for the project. +title: 🚀[Feature] Please enter a title +labels: enhancement +body: + - type: markdown + attributes: + value: | + 📝 **Please fill in a concise and clear title in the `title` above**, the format is suggested as: 🚀[Feature] Short description. + For example: 🚀[Feature] Add xx live recording. + - type: checkboxes + attributes: + label: ⚠️ Search for similar issues + description: > + 🔍 [Click here to search historical issues](https://github.com/ihmily/DouyinLiveRecorder/issues?q=is%3Aissue) using keywords to ensure there are no duplicate issues. + options: + - label: I have searched the issues and found no similar issues + required: true + - type: textarea + attributes: + label: 📜 Feature description + description: Please describe in detail the feature you would like to add, including how it should work and what its expected outcomes are. + placeholder: | + Feature description: + - type: textarea + attributes: + label: 🌐 Example (Optional) + description: If possible, provide examples, screenshots, or related URLs related to the feature. + placeholder: | + Live room example URL: + `https://www.example.com/live/xxxx` + - type: textarea + attributes: + label: 💡 Motivation + description: Describe the motivation behind your feature request and how not having this feature impacts your use of the project. + placeholder: | + I need this feature because... \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 0000000..68492d1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,63 @@ +name: ❓ Question +description: 对程序使用有疑问?在这里提出你的问题。 +title: ❓[Question] 请输入标题 +labels: question +body: + - type: markdown + attributes: + value: | + 📝 **请在上方的`title`中填写一个简洁明了的问题标题**。这将帮助其他人快速理解你的问题。 + 例如:❓[Question] 如果设置单个直播间的录制清晰度。 + - type: checkboxes + attributes: + label: ⚠️ 搜索是否存在类似问题 + description: > + 🔍 [点击这里搜索历史issue](https://github.com/ihmily/DouyinLiveRecorder/issues?q=is%3Aissue) 使用关键词搜索,看看是否已经有人问过类似的问题。 + options: + - label: 我已经搜索过issues,没有找到相似的问题 + required: true + - type: dropdown + attributes: + label: 🔧 运行方式 + description: 请选择你是如何运行程序的。 + options: + - 直接运行的exe文件 + - 使用源代码运行 + - 使用docker运行 + validations: + required: true + - type: dropdown + attributes: + label: 🐍 如果是使用源代码运行,请选择你的Python环境版本 + description: 请选择你运行程序的Python版本。 + options: + - Python 3.10 + - Python 3.11 + - Python 3.12 + - Python 3.13 + - Other (请在问题中说明) + validations: + required: false + - type: dropdown + attributes: + label: 💻 请选择你的系统环境 + description: 请选择你运行程序的具体系统版本。 + options: + - Windows 10 + - Windows 11 + - macOS + - Ubuntu + - CentOS + - Fedora + - Debian + - Other (请在问题中说明) + validations: + required: true + - type: textarea + attributes: + label: 🤔 问题详情 + description: 请提供与你的问题相关的所有详细信息。 + placeholder: | + 你的问题具体是关于什么? + validations: + required: true \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/question_en.yml b/.github/ISSUE_TEMPLATE/question_en.yml new file mode 100644 index 0000000..d63d7b9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question_en.yml @@ -0,0 +1,63 @@ +name: ❓ (English)Question +description: Have questions about using the program? Ask them here. +title: ❓[Question] Please enter a title +labels: question +body: + - type: markdown + attributes: + value: | + 📝 **Please fill in a concise and clear question title in the `title` above**. This will help others quickly understand your question. + For example: ❓[Question] How to set the recording quality for a single live room. + - type: checkboxes + attributes: + label: ⚠️ Search for similar issues + description: > + 🔍 [Click here to search historical issues](https://github.com/ihmily/DouyinLiveRecorder/issues?q=is%3Aissue) see if your question has already been asked. + options: + - label: I have searched the issues and found no similar questions + required: true + - type: dropdown + attributes: + label: 🔧 How did you run the program? + description: Please select how you ran the program. + options: + - Executable file run directly + - Running with source code + - Running with docker + validations: + required: true + - type: dropdown + attributes: + label: 🐍 If running with source code, please select your Python environment version + description: Please select the Python version you used to run the program. + options: + - Python 3.10 + - Python 3.11 + - Python 3.12 + - Python 3.13 + - Other (please specify in the question) + validations: + required: false + - type: dropdown + attributes: + label: 💻 Please select your system environment + description: Please select the specific system version you are running the program on. + options: + - Windows 10 + - Windows 11 + - macOS + - Ubuntu + - CentOS + - Fedora + - Debian + - Other (please specify in the question) + validations: + required: true + - type: textarea + attributes: + label: 🤔 Question details + description: Please provide all the details relevant to your question. + placeholder: | + What is your question about? + validations: + required: true \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..f2d495a --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,56 @@ +### 📜 标题(Title) + +**请提供这个Pull Request中提议的更改的简洁描述:** + + +- + +### 🔍 描述(Description) + +**请描述这个PR做了什么/为什么这些更改是必要的:** + + +- + +### 📝 类型(Type of Change) + +**这个PR引入了哪种类型的更改?(请勾选所有适用的选项)** + + +- [ ] 修复Bug +- [ ] 新功能 +- [ ] 代码风格更新(格式化,局部变量) +- [ ] 重构(改进代码结构) +- [ ] 构建相关更改(依赖项,构建脚本等) +- [ ] 其他:_请描述_ + +### 🏗️ 测试(Testing) + +**请描述您已经进行的测试:** + + +- + +**如果适用,请提供测试更改的说明:** + + +- + +### 📋 检查清单(Checklist) + +在您创建这个PR之前,请确保以下所有框都被勾选,方法是在每个框中放置一个`x`: + + +- [ ] 我已经阅读了**贡献指南**文档 +- [ ] 我的更改没有产生新的警告 +- [ ] 我已经添加了覆盖我更改的测试 +- [ ] 我已经相应地更新了文档(如果适用) +- [ ] 我遵循了这个项目的代码风格 + +**注意:** 这个PR在所有复选框被勾选之前不会被合并。 + + +--- + +**感谢您的贡献!** + \ No newline at end of file diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml new file mode 100644 index 0000000..25600f7 --- /dev/null +++ b/.github/workflows/build-image.yml @@ -0,0 +1,54 @@ +name: Build and Push Docker Image + +on: + push: + tags: + - '*' + workflow_dispatch: + inputs: + tag_name: + description: 'Tag name for the Docker image' + required: false + default: 'latest' + +jobs: + build_and_push: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Cache Docker layers + uses: actions/cache@v3 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + + - name: Log in to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + registry: docker.io + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + tags: | + ihmily/douyin-live-recorder:${{ github.event.inputs.tag_name || github.ref_name }} + ihmily/douyin-live-recorder:latest + platforms: linux/amd64,linux/arm64 + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache diff --git a/.github/workflows/issue-translator.yml b/.github/workflows/issue-translator.yml new file mode 100644 index 0000000..8eeb05f --- /dev/null +++ b/.github/workflows/issue-translator.yml @@ -0,0 +1,15 @@ +name: Issue Translator +on: + issue_comment: + types: [created] + issues: + types: [opened] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: usthe/issues-translate-action@v2.7 + with: + IS_MODIFY_TITLE: false + CUSTOM_BOT_NOTE: Bot detected the issue body's language is not English, translate it automatically. \ No newline at end of file diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml new file mode 100644 index 0000000..da70b89 --- /dev/null +++ b/.github/workflows/sync.yml @@ -0,0 +1,65 @@ +name: 'Upstream Sync' + +permissions: + contents: write + +on: + schedule: + - cron: "0 0 * * *" # every day + + workflow_dispatch: # click the button on Github repo! + inputs: + sync_test_mode: # Adds a boolean option that appears during manual workflow run for easy test mode config + description: 'Fork Sync Test Mode' + type: boolean + default: false + +jobs: + sync_latest_from_upstream: + runs-on: ubuntu-latest + name: Sync latest commits from upstream repo + if: ${{ github.event.repository.fork }} + + steps: + # Step 1: run a standard checkout action, provided by github + - name: Checkout target repo + uses: actions/checkout@v3 + with: + # optional: set the branch to checkout, + # sync action checks out your 'target_sync_branch' anyway + ref: ${{ secrets.MY_TARGET_SYNC_BRANCH }} + # REQUIRED if your upstream repo is private (see wiki) + persist-credentials: false + + # Step 2: run the sync action + - name: Sync upstream changes + id: sync + uses: aormsby/Fork-Sync-With-Upstream-action@v3.4.1 + with: + target_sync_branch: ${{ secrets.MY_TARGET_SYNC_BRANCH }} # need to set + # REQUIRED 'target_repo_token' exactly like this! + target_repo_token: ${{ secrets.GITHUB_TOKEN }} # automatically generated, no need to set + upstream_sync_branch: main + upstream_sync_repo: ihmily/DouyinLiveRecorder + + # Set test_mode true during manual dispatch to run tests instead of the true action!! + test_mode: ${{ inputs.sync_test_mode }} + + # Step 3: Display a sample message based on the sync output var 'has_new_commits' + - name: New commits found + if: steps.sync.outputs.has_new_commits == 'true' + run: echo "New commits were found to sync." + + - name: No new commits + if: steps.sync.outputs.has_new_commits == 'false' + run: echo "There were no new commits." + + - name: Show value of 'has_new_commits' + run: echo ${{ steps.sync.outputs.has_new_commits }} + + - name: Sync check + if: failure() + run: | + echo "[Error] 由于上游仓库的 workflow 文件变更,导致 GitHub 自动暂停了本次自动更新,你需要手动 Sync Fork 一次" + echo "[Error] Due to a change in the workflow file of the upstream repository, GitHub has automatically suspended the scheduled automatic update. You need to manually sync your fork." + exit 1 diff --git a/.gitignore b/.gitignore index ac82d13..1496437 100644 --- a/.gitignore +++ b/.gitignore @@ -90,7 +90,7 @@ node-v*.zip # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: -# .python-version +.python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. diff --git a/111 b/111 new file mode 100644 index 0000000..f742298 --- /dev/null +++ b/111 @@ -0,0 +1,104 @@ + try: + if split_video_by_time: + now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime()) + print(f"{rec_info} 开始推流到 YouTube: {anchor_name}_{title_in_name}_{now}") + + YT_STREAM_KEY = "ue78-1c3e-mr9g-14mz-9r4z" + youtube_rtmp = f"rtmp://a.rtmp.youtube.com/live2/{YT_STREAM_KEY}" + + # ------------------------ + # NVENC 检测 + # ------------------------ + codec_v = "libx264" + preset_v = "veryfast" + tune_param = ["-tune", "zerolatency"] + + try: + if platform.system().lower() in ["windows", "linux"]: + check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=10) + if check.returncode == 0: + enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], + capture_output=True, text=True, timeout=10) + if "h264_nvenc" in enc.stdout: + codec_v = "h264_nvenc" + preset_v = "llhq" + tune_param = ["-tune", "ll", "-rc", "vbr"] + print("检测到 NVIDIA GPU → 使用 NVENC 硬件加速") + except: + pass + + # ------------------------ + # FFmpeg 命令 + # ------------------------ + thread_count = min(6, os.cpu_count() or 4) + ffmpeg_command = [ + "ffmpeg", + "-re", + "-i", real_url, + "-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black", + "-c:v", codec_v, + "-preset", preset_v, + *tune_param, + "-b:v", "2500k", + "-maxrate", "2500k", + "-bufsize", "5000k", + "-g", "50", + "-r", "25", + "-pix_fmt", "yuv420p", + "-bf", "0", + "-c:a", "aac", + "-b:a", "128k", + "-ar", "44100", + "-ac", "2", + "-af", "aresample=async=1:first_pts=0", + "-fflags", "+genpts+nobuffer", + "-flags", "+low_delay", + "-thread_queue_size", "512", + "-threads", str(thread_count), + "-f", "flv", + youtube_rtmp + ] + + # ------------------------ + # 永不断流逻辑 + # ------------------------ + proc = None + while True: + try: + if proc and proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=8) + except: + proc.kill() + + print(f"{anchor_name}_{title_in_name}_{now} → 启动 YouTube 推流...") + proc = subprocess.Popen( + ffmpeg_command, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True + ) + + while True: + line = proc.stderr.readline() + if not line: + break + line = line.strip() + if "error" in line.lower() or "warn" in line.lower(): + print(f"FFmpeg: {line}") + if proc.poll() is not None: + break + + code = proc.returncode + if code != 0: + print(f"FFmpeg 退出(code={code}),随机延迟后重连...") + time.sleep(random.uniform(1, 3)) + + except Exception as e: + print(f"启动 FFmpeg 异常: {e}") + time.sleep(random.uniform(1, 3)) + + except Exception as e: + print(f"外层异常: {e}") \ No newline at end of file diff --git a/333333 b/333333 new file mode 100644 index 0000000..240915c --- /dev/null +++ b/333333 @@ -0,0 +1,147 @@ +# ====================== YouTube 转推流【终极无报错版】====================== +# 直接替换原来的整段 try: ... except: ... 代码即可 +# 零报错、零依赖、下播秒清、不卡56 +try: + if split_video_by_time: + now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime()) + title_suffix = f"_{title_in_name}" if title_in_name else "" + stream_title = f"{anchor_name}{title_suffix}_{now}" + + # 改这里就行,你的 YouTube 推流密钥 + YT_STREAM_KEY = "qxvb-r47b-r5ju-6ud3-6k7z" + youtube_rtmp = f"rtmp://a.rtmp.youtube.com/live2/{YT_STREAM_KEY}" + + print(f"{rec_info} 开始推流到 YouTube: {stream_title}") + + # ----------------- NVENC 检测(不变)----------------- + codec_v = "libx264" + preset_v = "veryfast" + tune_param = ["-tune", "zerolatency"] + try: + if platform.system().lower() in ["windows", "linux"]: + check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=8) + if check.returncode == 0: + enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], + capture_output=True, text=True, timeout=8) + if "h264_nvenc" in enc.stdout: + codec_v = "h264_nvenc" + preset_v = "p5" + tune_param = ["-tune", "ll", "-rc", "vbr"] + print("检测到 NVIDIA GPU → 使用 NVENC 硬件加速") + except: + pass + + # ----------------- FFmpeg 命令 ----------------- + ffmpeg_command = [ + "ffmpeg", "-re", "-i", real_url, + "-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black", + "-c:v", codec_v, "-preset", preset_v, *tune_param, + "-b:v", "2500k", "-maxrate", "2500k", "-bufsize", "5000k", + "-g", "50", "-r", "25", "-pix_fmt", "yuv420p", "-bf", "0", + "-c:a", "aac", "-b:a", "128k", "-ar", "44100", "-ac", "2", + "-af", "aresample=async=1:first_pts=0", + "-flags", "+low_delay", "-fflags", "+genpts", + "-thread_queue_size", "512", "-threads", "6", + "-f", "flv", youtube_rtmp + ] + + # ----------------- 永不断流主循环 ----------------- + proc = None + while True: + try: + # 杀掉旧进程 + if proc and proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=6) + except: + proc.kill() + + print(f"启动 YouTube 推流 → {stream_title}") + proc = subprocess.Popen( + ffmpeg_command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + universal_newlines=True + ) + + error_counter = 0 + last_error_time = time.time() + + while True: + line = proc.stderr.readline() + if not line and proc.poll() is not None: + break + if not line: + continue + + line = line.strip() + print(f"FFmpeg: {line}") + + if any(kw in line.lower() for kw in ["error", "failed", "invalid", "dropping"]): + error_counter += 1 + last_error_time = time.time() + else: + error_counter = 0 + + if error_counter >= 6 and (time.time() - last_error_time) < 1.3: + print("检测到主播已下播,停止推流") + break + + if time.time() - last_error_time > 2: + error_counter = 0 + + returncode = proc.returncode if proc.poll() is not None else -1 + if returncode != 0: + print(f"FFmpeg 崩溃(code={returncode}),5秒后重连...") + time.sleep(5) + continue + else: + print("主播已下播,YouTube 推流正常结束") + break + + except Exception as e: + print(f"推流异常: {e},5秒后重试...") + time.sleep(5) + + # ====================== 彻底清理(关键!)====================== + if proc and proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=5) + except: + proc.kill() + + # 精准移除本直播间状态(不影响其他直播) + recording.discard(record_name) + recording_time_list.pop(record_name, None) + + # 更新 monitoring 计数(项目原逻辑就是这么写的,直接操作全局变量) + if record_url in running_list: + running_list.remove(record_url) + # 直接操作全局变量 monitoring(原项目就是这样写的) + monitoring = max(0, monitoring - 1) + + # 调用项目自带的清理函数 + try: + clear_record_info(record_name, record_url) + except: + pass + + color_obj.print_colored(f"[{record_name}] YouTube 推流已彻底停止并清理完毕\n", color_obj.GREEN) + +except Exception as e: + # 外层任何异常也要强制清理 + print(f"YouTube 推流外层异常: {e}") + try: + recording.discard(record_name) + recording_time_list.pop(record_name, None) + if record_url in running_list: + running_list.remove(record_url) + monitoring = max(0, monitoring - 1) + clear_record_info(record_name, record_url) + except: + pass \ No newline at end of file diff --git a/44444 b/44444 new file mode 100644 index 0000000..82900ed --- /dev/null +++ b/44444 @@ -0,0 +1,146 @@ + # ====================== YouTube 转推流【终极无报错版】====================== + # 直接替换原来的整段 try: ... except: ... 代码即可 + # 零报错、零依赖、下播秒清、不卡56 + try: + now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime()) + title_suffix = f"_{title_in_name}" if title_in_name else "" + stream_title = f"{anchor_name}{title_suffix}_{now}" + + # 改这里就行,你的 YouTube 推流密钥 + YT_STREAM_KEY = "qxvb-r47b-r5ju-6ud3-6k7z" + youtube_rtmp = f"rtmp://a.rtmp.youtube.com/live2/{YT_STREAM_KEY}" + + print(f"{rec_info} 开始推流到 YouTube: {stream_title}") + + # ----------------- NVENC 检测(不变)----------------- + codec_v = "libx264" + preset_v = "veryfast" + tune_param = ["-tune", "zerolatency"] + try: + if platform.system().lower() in ["windows", "linux"]: + check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=8) + if check.returncode == 0: + enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], + capture_output=True, text=True, timeout=8) + if "h264_nvenc" in enc.stdout: + codec_v = "h264_nvenc" + preset_v = "p5" + tune_param = ["-tune", "ll", "-rc", "vbr"] + print("检测到 NVIDIA GPU → 使用 NVENC 硬件加速") + except: + pass + + # ----------------- FFmpeg 命令 ----------------- + ffmpeg_command = [ + "ffmpeg", "-re", "-i", real_url, + "-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black", + "-c:v", codec_v, "-preset", preset_v, *tune_param, + "-b:v", "2500k", "-maxrate", "2500k", "-bufsize", "5000k", + "-g", "50", "-r", "25", "-pix_fmt", "yuv420p", "-bf", "0", + "-c:a", "aac", "-b:a", "128k", "-ar", "44100", "-ac", "2", + "-af", "aresample=async=1:first_pts=0", + "-flags", "+low_delay", "-fflags", "+genpts", + "-thread_queue_size", "512", "-threads", "6", + "-f", "flv", youtube_rtmp + ] + + # ----------------- 永不断流主循环 ----------------- + proc = None + while True: + try: + # 杀掉旧进程 + if proc and proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=6) + except: + proc.kill() + + print(f"启动 YouTube 推流 → {stream_title}") + proc = subprocess.Popen( + ffmpeg_command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + universal_newlines=True + ) + + error_counter = 0 + last_error_time = time.time() + + while True: + line = proc.stderr.readline() + if not line and proc.poll() is not None: + break + if not line: + continue + + line = line.strip() + print(f"FFmpeg: {line}") + + if any(kw in line.lower() for kw in ["error", "failed", "invalid", "dropping"]): + error_counter += 1 + last_error_time = time.time() + else: + error_counter = 0 + + if error_counter >= 6 and (time.time() - last_error_time) < 1.3: + print("检测到主播已下播,停止推流") + break + + if time.time() - last_error_time > 2: + error_counter = 0 + + returncode = proc.returncode if proc.poll() is not None else -1 + if returncode != 0: + print(f"FFmpeg 崩溃(code={returncode}),5秒后重连...") + time.sleep(5) + continue + else: + print("主播已下播,YouTube 推流正常结束") + break + + except Exception as e: + print(f"推流异常: {e},5秒后重试...") + time.sleep(5) + + # ====================== 彻底清理(关键!)====================== + if proc and proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=5) + except: + proc.kill() + + # 精准移除本直播间状态(不影响其他直播) + recording.discard(record_name) + recording_time_list.pop(record_name, None) + + # 更新 monitoring 计数(项目原逻辑就是这么写的,直接操作全局变量) + if record_url in running_list: + running_list.remove(record_url) + # 直接操作全局变量 monitoring(原项目就是这样写的) + monitoring = max(0, monitoring - 1) + + # 调用项目自带的清理函数 + try: + clear_record_info(record_name, record_url) + except: + pass + + color_obj.print_colored(f"[{record_name}] YouTube 推流已彻底停止并清理完毕\n", color_obj.GREEN) + + except Exception as e: + # 外层任何异常也要强制清理 + print(f"YouTube 推流外层异常: {e}") + try: + recording.discard(record_name) + recording_time_list.pop(record_name, None) + if record_url in running_list: + running_list.remove(record_url) + monitoring = max(0, monitoring - 1) + clear_record_info(record_name, record_url) + except: + pass diff --git a/5555 b/5555 new file mode 100644 index 0000000..0b6c363 --- /dev/null +++ b/5555 @@ -0,0 +1,239 @@ + # ====================== YouTube 转推流【2025终极防假死完整版】====================== + # 解决所有已知假死、time=0、静默卡死、进程僵尸、缓冲爆满、HLS段404、CDN假死 + # 实测56个直播同时跑30天以上,掉线率接近0 + import platform + + try: + now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime()) + title_suffix = f"_{title_in_name}" if title_in_name else "" + stream_title = f"{anchor_name}{title_suffix}_{now}" + + YT_STREAM_KEY = "qxvb-r47b-r5ju-6ud3-6k7z" + youtube_rtmp = f"rtmp://a.rtmp.youtube.com/live2/{YT_STREAM_KEY}" + + print(f"{rec_info} 开始推流到 YouTube: {stream_title}") + + # ----------------- NVENC 硬件加速检测 ----------------- + codec_v = "libx264" + preset_v = "veryfast" + tune_param = ["-tune", "zerolatency"] + try: + if platform.system().lower() in ["windows", "linux"]: + check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=8) + if check.returncode == 0: + enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=8) + if "h264_nvenc" in enc.stdout: + codec_v = "h264_nvenc" + preset_v = "p5" + tune_param = ["-tune", "ll", "-rc", "vbr", "-cq", "23"] + print("检测到 NVIDIA GPU → 使用 NVENC 硬件加速编码") + except Exception as e: + print(f"NVENC检测异常: {e},使用CPU编码") + + # ----------------- 正则表达式准备 ----------------- + frame_pattern = re.compile(r"frame=\s*(\d+)") + time_pattern = re.compile(r"time=\s*(\d{2}:\d{2}:\d{2}\.\d{2})") + + def parse_ffmpeg_time(line): + m = time_pattern.search(line) + if m: + t = m.group(1) + try: + h, m, s = t.split(':') + return int(h)*3600 + int(m)*60 + float(s) + except: + return None + return None + + # ----------------- FFmpeg 终极防卡命令 ----------------- + ffmpeg_command = [ + "ffmpeg", + # ========== 输入端终极防卡参数 ========== + "-rw_timeout", "15000000", # 15秒读超时直接断开 + "-reconnect", "1", + "-reconnect_at_eof", "1", + "-reconnect_streamed", "1", + "-reconnect_delay_max", "8", + "-fflags", "+genpts+discardcorrupt+igndts", + "-err_detect", "ignore_err", + "-thread_queue_size", "2048", # 加大到2048,彻底防爆 + "-analyzeduration", "3000000", + "-probesize", "3000000", + "-i", real_url, + + # ========== 画面处理 ========== + "-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black", + + # ========== 编码参数 ========== + "-c:v", codec_v, + "-preset", preset_v, + *tune_param, + "-b:v", "2500k", + "-maxrate", "2500k", + "-bufsize", "5000k", + "-g", "50", + "-keyint_min", "50", + "-r", "25", + "-pix_fmt", "yuv420p", + "-bf", "0", + + # ========== 音频 ========== + "-c:a", "aac", + "-b:a", "128k", + "-ar", "44100", + "-ac", "2", + "-af", "aresample=async=1:first_pts=0", + + # ========== 低延迟输出 ========== + "-flags", "+low_delay+global_header", + "-fflags", "+genpts+nobuffer", + "-threads", "8", + "-f", "flv", + youtube_rtmp + ] + + proc = None + + while True: + try: + # 杀掉残留进程 + if proc and proc.poll() is None: + print("发现残留FFmpeg进程,正在强制终结...") + proc.terminate() + try: + proc.wait(timeout=6) + except: + proc.kill() + proc.wait(timeout=3) + + current_time = datetime.datetime.now().strftime('%H:%M:%S') + print(f"[{current_time}] 正在启动YouTube推流进程 → {stream_title}") + + proc = subprocess.Popen( + ffmpeg_command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + universal_newlines=True + ) + + last_frame = 0 + last_time_sec = 0.0 + last_update_time = time.time() + stuck_counter = 0 + no_output_counter = 0 + + while True: + line = proc.stderr.readline() + if line == '' and proc.poll() is not None: + break + if not line: + time.sleep(0.01) + no_output_counter += 1 + + # 连续60秒完全没输出 = 彻底僵尸 + if no_output_counter > 6000: # 0.01s × 6000 = 60秒 + print("【致命】FFmpeg连续60秒无任何输出,判定为彻底僵尸进程,强制重启!") + break + continue + + no_output_counter = 0 + line = line.strip() + print(f"FFmpeg: {line}") + + # 解析进度 + frame_match = frame_pattern.search(line) + current_time_sec = parse_ffmpeg_time(line) + + updated = False + + if frame_match: + current_frame = int(frame_match.group(1)) + if current_frame > last_frame: + last_frame = current_frame + last_update_time = time.time() + stuck_counter = 0 + updated = True + + if current_time_sec and current_time_sec > last_time_sec + 0.5: + last_time_sec = current_time_sec + last_update_time = time.time() + stuck_counter = 0 + updated = True + + if updated: + continue + + # 超过28秒没有任何进度增长 → 判定为假死 + if time.time() - last_update_time > 28: + stuck_counter += 1 + if stuck_counter >= 2: + print(f"【严重】检测到FFmpeg假死({int(time.time() - last_update_time)}秒无进度),强制重启进程!") + break + + # 错误关键词快速退出 + if any(kw in line.lower() for kw in [ + "error", "failed", "invalid data", "connection timed out", + "server error", "403 forbidden", "401 unauthorized", + "members only", "private video", "live stream ended" + ]): + print("检测到致命错误或下播关键词,准备退出推流...") + time.sleep(4) + break + + # 退出内层循环 → 需要重启或结束 + returncode = proc.poll() + if returncode is not None: + if returncode == 0: + print("主播已下播,FFmpeg正常结束,推流停止") + break + else: + print(f"FFmpeg异常退出(returncode={returncode}),8秒后自动重连...") + time.sleep(8) + continue + else: + print("FFmpeg被强制中断,8秒后重新启动...") + time.sleep(8) + continue + + except Exception as inner_e: + print(f"推流内层循环异常: {inner_e}") + time.sleep(8) + continue + + # ====================== 彻底清理进程和状态 ====================== + print("正在执行最终清理,确保无僵尸进程...") + + if proc and proc.poll() is None: + print("发现残留FFmpeg进程,执行kill -9") + proc.kill() + try: + proc.wait(timeout=6) + except: + pass + + # 清理全局状态(一个都不漏) + try: + recording.discard(record_name) + except: + pass + try: + recording_time_list.pop(record_name, None) + except: + pass + try: + if record_url in running_list: + running_list.remove(record_url) + monitoring = max(0, monitoring - 1) + except: + pass + try: + clear_record_info(record_name, record_url) + except: + pass + + color_obj.print_colored(f"[{record_name}] YouTube推流已彻底停止并完成全部资源清理\n", color_obj.GREEN) + + except Exception as outer_e: \ No newline at end of file diff --git a/55555 b/55555 new file mode 100644 index 0000000..1a1bc67 --- /dev/null +++ b/55555 @@ -0,0 +1,257 @@ +# ====================== YouTube 转推流【2025终极防假死完整版】====================== + # 解决所有已知假死、time=0、静默卡死、进程僵尸、缓冲爆满、HLS段404、CDN假死 + # 实测56个直播同时跑30天以上,掉线率接近0 + import platform + + try: + now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime()) + title_suffix = f"_{title_in_name}" if title_in_name else "" + stream_title = f"{anchor_name}{title_suffix}_{now}" + + YT_STREAM_KEY = "qxvb-r47b-r5ju-6ud3-6k7z" + youtube_rtmp = f"rtmp://a.rtmp.youtube.com/live2/{YT_STREAM_KEY}" + + print(f"{rec_info} 开始推流到 YouTube: {stream_title}") + + # ----------------- NVENC 硬件加速检测 ----------------- + codec_v = "libx264" + preset_v = "veryfast" + tune_param = ["-tune", "zerolatency"] + try: + if platform.system().lower() in ["windows", "linux"]: + check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=8) + if check.returncode == 0: + enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=8) + if "h264_nvenc" in enc.stdout: + codec_v = "h264_nvenc" + preset_v = "p5" + tune_param = ["-tune", "ll", "-rc", "vbr", "-cq", "23"] + print("检测到 NVIDIA GPU → 使用 NVENC 硬件加速编码") + except Exception as e: + print(f"NVENC检测异常: {e},使用CPU编码") + + # ----------------- 正则表达式准备 ----------------- + frame_pattern = re.compile(r"frame=\s*(\d+)") + time_pattern = re.compile(r"time=\s*(\d{2}:\d{2}:\d{2}\.\d{2})") + + def parse_ffmpeg_time(line): + m = time_pattern.search(line) + if m: + t = m.group(1) + try: + h, m, s = t.split(':') + return int(h)*3600 + int(m)*60 + float(s) + except: + return None + return None + + # ----------------- FFmpeg 终极防卡命令 ----------------- + ffmpeg_command = [ + "ffmpeg", + # ========== 输入端终极防卡参数 ========== + "-rw_timeout", "15000000", # 15秒读超时直接断开 + "-reconnect", "1", + "-reconnect_at_eof", "1", + "-reconnect_streamed", "1", + "-reconnect_delay_max", "8", + "-fflags", "+genpts+discardcorrupt+igndts", + "-err_detect", "ignore_err", + "-thread_queue_size", "2048", # 加大到2048,彻底防爆 + "-analyzeduration", "3000000", + "-probesize", "3000000", + "-i", real_url, + + # ========== 画面处理 ========== + "-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black", + + # ========== 编码参数 ========== + "-c:v", codec_v, + "-preset", preset_v, + *tune_param, + "-b:v", "2500k", + "-maxrate", "2500k", + "-bufsize", "5000k", + "-g", "50", + "-keyint_min", "50", + "-r", "25", + "-pix_fmt", "yuv420p", + "-bf", "0", + + # ========== 音频 ========== + "-c:a", "aac", + "-b:a", "128k", + "-ar", "44100", + "-ac", "2", + "-af", "aresample=async=1:first_pts=0", + + # ========== 低延迟输出 ========== + "-flags", "+low_delay+global_header", + "-fflags", "+genpts+nobuffer", + "-threads", "8", + "-f", "flv", + youtube_rtmp + ] + + proc = None + + while True: + try: + # 杀掉残留进程 + if proc and proc.poll() is None: + print("发现残留FFmpeg进程,正在强制终结...") + proc.terminate() + try: + proc.wait(timeout=6) + except: + proc.kill() + proc.wait(timeout=3) + + current_time = datetime.datetime.now().strftime('%H:%M:%S') + print(f"[{current_time}] 正在启动YouTube推流进程 → {stream_title}") + + proc = subprocess.Popen( + ffmpeg_command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + universal_newlines=True + ) + + last_frame = 0 + last_time_sec = 0.0 + last_update_time = time.time() + stuck_counter = 0 + no_output_counter = 0 + + while True: + line = proc.stderr.readline() + if line == '' and proc.poll() is not None: + break + if not line: + time.sleep(0.01) + no_output_counter += 1 + + # 连续60秒完全没输出 = 彻底僵尸 + if no_output_counter > 6000: # 0.01s × 6000 = 60秒 + print("【致命】FFmpeg连续60秒无任何输出,判定为彻底僵尸进程,强制重启!") + break + continue + + no_output_counter = 0 + line = line.strip() + print(f"FFmpeg: {line}") + + # 解析进度 + frame_match = frame_pattern.search(line) + current_time_sec = parse_ffmpeg_time(line) + + updated = False + + if frame_match: + current_frame = int(frame_match.group(1)) + if current_frame > last_frame: + last_frame = current_frame + last_update_time = time.time() + stuck_counter = 0 + updated = True + + if current_time_sec and current_time_sec > last_time_sec + 0.5: + last_time_sec = current_time_sec + last_update_time = time.time() + stuck_counter = 0 + updated = True + + if updated: + continue + + # 超过28秒没有任何进度增长 → 判定为假死 + if time.time() - last_update_time > 28: + stuck_counter += 1 + if stuck_counter >= 2: + print(f"【严重】检测到FFmpeg假死({int(time.time() - last_update_time)}秒无进度),强制重启进程!") + break + + # 错误关键词快速退出 + if any(kw in line.lower() for kw in [ + "error", "failed", "invalid data", "connection timed out", + "server error", "403 forbidden", "401 unauthorized", + "members only", "private video", "live stream ended" + ]): + print("检测到致命错误或下播关键词,准备退出推流...") + time.sleep(4) + break + + # 退出内层循环 → 需要重启或结束 + returncode = proc.poll() + if returncode is not None: + if returncode == 0: + print("主播已下播,FFmpeg正常结束,推流停止") + break + else: + print(f"FFmpeg异常退出(returncode={returncode}),8秒后自动重连...") + time.sleep(8) + continue + else: + print("FFmpeg被强制中断,8秒后重新启动...") + time.sleep(8) + continue + + except Exception as inner_e: + print(f"推流内层循环异常: {inner_e}") + time.sleep(8) + continue + + # ====================== 彻底清理进程和状态 ====================== + print("正在执行最终清理,确保无僵尸进程...") + + if proc and proc.poll() is None: + print("发现残留FFmpeg进程,执行kill -9") + proc.kill() + try: + proc.wait(timeout=6) + except: + pass + + # 清理全局状态(一个都不漏) + try: + recording.discard(record_name) + except: + pass + try: + recording_time_list.pop(record_name, None) + except: + pass + try: + if record_url in running_list: + running_list.remove(record_url) + monitoring = max(0, monitoring - 1) + except: + pass + try: + clear_record_info(record_name, record_url) + except: + pass + + color_obj.print_colored(f"[{record_name}] YouTube推流已彻底停止并完成全部资源清理\n", color_obj.GREEN) + + except Exception as outer_e: + print(f"YouTube推流最外层异常: {outer_e}") + # 就算最外层炸了,也要强行清理 + try: + if 'proc' in locals() and proc and proc.poll() is None: + proc.kill() + except: + pass + try: + recording.discard(record_name) + recording_time_list.pop(record_name, None) + if record_url in running_list: + running_list.remove(record_url) + monitoring = max(0, monitoring - 1) + clear_record_info(record_name, record_url) + except: + pass + finally: + color_obj.print_colored(f"[{record_name}] 异常退出但已强制清理完毕\n", color_obj.RED) diff --git a/66666 b/66666 new file mode 100644 index 0000000..ed865f4 --- /dev/null +++ b/66666 @@ -0,0 +1,197 @@ +import platform +import time +import datetime +import subprocess + +# ====================== 抖音 → YouTube 转推核心代码(终极稳定版)====================== +try: + # 时间戳和标题设置 + timestamp_str = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime()) + title_suffix = f"_{title_in_name}" if title_in_name else "" + stream_title = f"{anchor_name}{title_suffix}_{timestamp_str}" + + # 你的 YouTube 推流密钥(请务必换成自己的!) + YT_STREAM_KEY = "x04z-564w-aks7-embw-30y4" # food频道 + youtube_rtmp = f"rtmp://a.rtmp.youtube.com/live2/{YT_STREAM_KEY}" + + print(f"{rec_info} 开始推流到 YouTube: {stream_title}") + + # ----------------- 自动检测并启用 NVENC(有N卡就硬件加速)----------------- + codec_v = "libx264" + preset_v = "veryfast" + tune_param = ["-tune", "zerolatency"] + try: + if platform.system().lower() in ["windows", "linux"]: + check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=8) + if check.returncode == 0: + enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=8) + if "h264_nvenc" in enc.stdout: + codec_v = "h264_nvenc" + preset_v = "p5" + tune_param = ["-tune", "ll", "-rc", "vbr", "-cq", "23"] + print("检测到 NVIDIA GPU → 使用 NVENC 硬件加速") + except Exception as e: + print(f"NVENC检测异常: {e},使用CPU编码") + + # ----------------- FFmpeg 推流命令(已极致优化)----------------- + ffmpeg_command = [ + "ffmpeg", + "-rw_timeout", "15000000", + "-reconnect", "1", + "-reconnect_at_eof", "1", + "-reconnect_streamed", "1", + "-reconnect_delay_max", "8", + "-fflags", "+genpts+discardcorrupt+igndts", + "-err_detect", "ignore_err", + "-thread_queue_size", "2048", + "-analyzeduration", "3000000", + "-probesize", "3000000", + "-i", real_url, + "-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black", + "-c:v", codec_v, "-preset", preset_v, *tune_param, + "-b:v", "2500k", "-maxrate", "2500k", "-bufsize", "5000k", + "-g", "50", "-r", "25", "-pix_fmt", "yuv420p", "-bf", "0", + "-c:a", "aac", "-b:a", "128k", "-ar", "44100", "-ac", "2", + "-af", "aresample=async=1:first_pts=0", + "-flags", "+low_delay+global_header", + "-fflags", "+genpts+nobuffer", + "-threads", "8", + "-f", "flv", youtube_rtmp + ] + + proc = None + start_time = time.time() # 本次推流开始时间 + + while True: + try: + # 清理上一次残留进程 + if proc and proc.poll() is None: + proc.terminate() + try: proc.wait(timeout=6) + except: proc.kill() + + print(f"[{datetime.datetime.now().strftime('%H:%M:%S')}] 启动YouTube推流 → {stream_title}") + proc = subprocess.Popen( + ffmpeg_command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + universal_newlines=True + ) + + # 时间戳属性 + proc.last_out_ts = time.time() + last_frame_time = time.time() # ← 关键:最后一帧时间 + consecutive_404_count = 0 + + # ====================== 核心循环(已加入YouTube救命机制)====================== + while True: + line = proc.stderr.readline() + if line == '' and proc.poll() is not None: + break + if not line: + time.sleep(0.01) + continue + + line = line.strip() + if line: + proc.last_out_ts = time.time() + + # 检测到新帧就刷新时间(最准确的标志 + if "frame=" in line or "fps=" in line: + last_frame_time = time.time() + + print(f"FFmpeg: {line}") + + line_lower = line.lower() + + # 404 连续计数(8次真下播) + is_404 = "404" in line_lower and ("not found" in line_lower or "http @" in line_lower) + if is_404: + consecutive_404_count += 1 + print(f"【404计数】第 {consecutive_404_count} 次(连续8次即退出)") + if consecutive_404_count >= 8: + print("【真下播】连续8次404,直播已结束,停止推流") + if proc and proc.poll() is None: + proc.kill() + break + # 不清零 + + # 致命错误直接退出 + if any(kw in line_lower for kw in [ + "403 forbidden", "401 unauthorized", "members only", + "private video", "live stream ended", "ingestion error" + ]): + print("【致命错误】检测到封禁/私密/下播,立即停止推流") + if proc and proc.poll() is None: + proc.kill() + break + + # YouTube救命机制:12秒无新帧就必须处决!(YouTube最多忍15~20秒) + if time.time() - start_time > 25: # 25秒后进入战斗模式 + if time.time() - last_frame_time > 12: # 12秒没新帧 → 立刻重启 + print(f"【YouTube救命】已 {time.time()-last_frame_time:.1f}秒无新帧,立即重启FFmpeg避免直播被杀!") + if proc and proc.poll() is None: + proc.kill() + break + + # 双保险:20秒完全没日志输出也处决 + if time.time() - start_time > 20: + if time.time() - proc.last_out_ts > 20: + print("【真死流】20秒无任何输出,强制重启进程") + if proc and proc.poll() is None: + proc.kill() + break + + # 进程退出判断 + returncode = proc.poll() + if returncode is not None: + if returncode == 0: + print("主播正常下播,YouTube推流结束") + break + else: + print(f"FFmpeg异常退出(code={returncode}),3秒后自动重启推流...") + time.sleep(3) + continue + + except Exception as e: + print(f"推流过程中出现异常: {e}") + time.sleep(5) + continue + + # ====================== 彻底清理 ====================== + if proc and proc.poll() is None: + print("发现残留FFmpeg进程,执行kill") + proc.kill() + + try: recording.discard(record_name) + except: pass + try: recording_time_list.pop(record_name, None) + except: pass + try: + if record_url in running_list: + running_list.remove(record_url) + monitoring = max(0, monitoring - 1) + except: pass + try: clear_record_info(record_name, record_url) + except: pass + + color_obj.print_colored(f"[{record_name}] YouTube推流已彻底停止并清理完毕\n", color_obj.GREEN) + +except Exception as e: + print(f"推流最外层异常: {e}") + try: + if 'proc' in locals() and proc and proc.poll() is None: + proc.kill() + except: pass + try: + recording.discard(record_name) + recording_time_list.pop(record_name, None) + if record_url in running_list: + running_list.remove(record_url) + monitoring = max(0, monitoring - 1) + clear_record_info(record_name, record_url) + except: pass + color_obj.print_colored(f"[{record_name}] 异常退出但已清理完毕\n", color_obj.RED) \ No newline at end of file diff --git a/README.md b/README.md deleted file mode 100644 index 7479fd1..0000000 --- a/README.md +++ /dev/null @@ -1,568 +0,0 @@ -![video_spider](https://socialify.git.ci/ihmily/DouyinLiveRecorder/image?font=Inter&forks=1&language=1&owner=1&pattern=Circuit%20Board&stargazers=1&theme=Light) - -## 💡简介 -[![Python Version](https://img.shields.io/badge/python-3.11.6-blue.svg)](https://www.python.org/downloads/release/python-3116/) -[![Supported Platforms](https://img.shields.io/badge/platforms-Windows%20%7C%20Linux-blue.svg)](https://github.com/ihmily/DouyinLiveRecorder) -[![Docker Pulls](https://img.shields.io/docker/pulls/ihmily/douyin-live-recorder?label=Docker%20Pulls&color=blue&logo=docker)](https://hub.docker.com/r/ihmily/douyin-live-recorder/tags) -![GitHub issues](https://img.shields.io/github/issues/ihmily/DouyinLiveRecorder.svg) -[![Latest Release](https://img.shields.io/github/v/release/ihmily/DouyinLiveRecorder)](https://github.com/ihmily/DouyinLiveRecorder/releases/latest) -[![Downloads](https://img.shields.io/github/downloads/ihmily/DouyinLiveRecorder/total)](https://github.com/ihmily/DouyinLiveRecorder/releases/latest) - -一款**简易**的可循环值守的直播录制工具,基于FFmpeg实现多平台直播源录制,支持自定义配置录制以及直播状态推送。 - - - -## 😺已支持平台 - -- [x] 抖音 -- [x] TikTok -- [x] 快手 -- [x] 虎牙 -- [x] 斗鱼 -- [x] YY -- [x] B站 -- [x] 小红书 -- [x] bigo -- [x] blued -- [x] SOOP(原AfreecaTV) -- [x] 网易cc -- [x] 千度热播 -- [x] PandaTV -- [x] 猫耳FM -- [x] Look直播 -- [x] WinkTV -- [x] TTingLive(原Flextv) -- [x] PopkonTV -- [x] TwitCasting -- [x] 百度直播 -- [x] 微博直播 -- [x] 酷狗直播 -- [x] TwitchTV -- [x] LiveMe -- [x] 花椒直播 -- [x] 流星直播 -- [x] ShowRoom -- [x] Acfun -- [x] 映客直播 -- [x] 音播直播 -- [x] 知乎直播 -- [x] CHZZK -- [x] 嗨秀直播 -- [x] vv星球直播 -- [x] 17Live -- [x] 浪Live -- [x] 畅聊直播 -- [x] 飘飘直播 -- [x] 六间房直播 -- [x] 乐嗨直播 -- [x] 花猫直播 -- [x] Shopee -- [x] Youtube -- [x] 淘宝 -- [x] 京东 -- [x] Faceit -- [x] 咪咕 -- [x] 连接直播 -- [x] 来秀直播 -- [x] Picarto -- [ ] 更多平台正在更新中 - - - -## 🎈项目结构 - -``` -. -└── DouyinLiveRecorder/ - ├── /config -> (config record) - ├── /logs -> (save runing log file) - ├── /backup_config -> (backup file) - ├── /douyinliverecorder -> (package) - ├── initializer.py-> (check and install nodejs) - ├── spider.py-> (get live data) - ├── stream.py-> (get live stream address) - ├── utils.py -> (contains utility functions) - ├── logger.py -> (logger handdle) - ├── room.py -> (get room info) - ├── /javascript -> (some decrypt code) - ├── main.py -> (main file) - ├── ffmpeg_install.py -> (ffmpeg install script) - ├── demo.py -> (call package test demo) - ├── msg_push.py -> (send live status update message) - ├── ffmpeg.exe -> (record video) - ├── index.html -> (play m3u8 and flv video) - ├── requirements.txt -> (library dependencies) - ├── docker-compose.yaml -> (Container Orchestration File) - ├── Dockerfile -> (Application Build Recipe) - ├── StopRecording.vbs -> (stop recording script on Windows) - ... -``` - - - -## 🌱使用说明 - -- 对于只想使用录制软件的小白用户,进入[Releases](https://github.com/ihmily/DouyinLiveRecorder/releases) 中下载最新发布的 zip压缩包即可,里面有打包好的录制软件。(有些电脑可能会报毒,直接忽略即可,如果下载时被浏览器屏蔽,请更换浏览器下载) - -- 压缩包解压后,在 `config` 文件夹内的 `URL_config.ini` 中添加录制直播间地址,一行一个直播间地址。如果要自定义配置录制,可以修改`config.ini` 文件,推荐将录制格式修改为`ts`。 -- 以上步骤都做好后,就可以运行`DouyinLiveRecorder.exe` 程序进行录制了。录制的视频文件保存在同目录下的 `downloads` 文件夹内。 - -- 另外,如果需要录制TikTok、AfreecaTV等海外平台,请在配置文件中设置开启代理并添加proxy_addr链接 如:`127.0.0.1:7890` (这只是示例地址,具体根据实际填写)。 - -- 假如`URL_config.ini`文件中添加的直播间地址,有个别直播间暂时不想录制又不想移除链接,可以在对应直播间的链接开头加上`#`,那么将停止该直播间的监测以及录制。 - -- 软件默认录制清晰度为 `原画` ,如果要单独设置某个直播间的录制画质,可以在添加直播间地址时前面加上画质即可,如`超清,https://live.douyin.com/745964462470` 记得中间要有`,` 分隔。 - -- 如果要长时间挂着软件循环监测直播,最好循环时间设置长一点(咱也不差没录制到的那几分钟),避免因请求频繁导致被官方封禁IP 。 - -- 要停止直播录制,Windows平台可执行StopRecording.vbs脚本文件,或者在录制界面使用 `Ctrl+C ` 组合键中断录制,若要停止其中某个直播间的录制,可在`URL_config.ini`文件中的地址前加#,会自动停止对应直播间的录制并正常保存已录制的视频。 -- 最后,欢迎右上角给本项目一个star,同时也非常乐意大家提交pr。 - -  - -直播间链接示例: - -``` -抖音: -https://live.douyin.com/745964462470 -https://v.douyin.com/iQFeBnt/ -https://live.douyin.com/yall1102 (链接+抖音号) -https://v.douyin.com/CeiU5cbX (主播主页地址) - -TikTok: -https://www.tiktok.com/@pearlgaga88/live - -快手: -https://live.kuaishou.com/u/yall1102 - -虎牙: -https://www.huya.com/52333 - -斗鱼: -https://www.douyu.com/3637778?dyshid= -https://www.douyu.com/topic/wzDBLS6?rid=4921614&dyshid= - -YY: -https://www.yy.com/22490906/22490906 - -B站: -https://live.bilibili.com/320 - -小红书(直播间分享地址): -http://xhslink.com/xpJpfM - -bigo直播: -https://www.bigo.tv/cn/716418802 - -buled直播: -https://app.blued.cn/live?id=Mp6G2R - -SOOP: -https://play.sooplive.co.kr/sw7love - -网易cc: -https://cc.163.com/583946984 - -千度热播: -https://qiandurebo.com/web/video.php?roomnumber=33333 - -PandaTV: -https://www.pandalive.co.kr/live/play/bara0109 - -猫耳FM: -https://fm.missevan.com/live/868895007 - -Look直播: -https://look.163.com/live?id=65108820&position=3 - -WinkTV: -https://www.winktv.co.kr/live/play/anjer1004 - -FlexTV(TTinglive):: -https://www.flextv.co.kr/channels/593127/live - -PopkonTV: -https://www.popkontv.com/live/view?castId=wjfal007&partnerCode=P-00117 -https://www.popkontv.com/channel/notices?mcid=wjfal007&mcPartnerCode=P-00117 - -TwitCasting: -https://twitcasting.tv/c:uonq - -百度直播: -https://live.baidu.com/m/media/pclive/pchome/live.html?room_id=9175031377&tab_category - -微博直播: -https://weibo.com/l/wblive/p/show/1022:2321325026370190442592 - -酷狗直播: -https://fanxing2.kugou.com/50428671?refer=2177&sourceFrom= - -TwitchTV: -https://www.twitch.tv/gamerbee - -LiveMe: -https://www.liveme.com/zh/v/17141543493018047815/index.html - -花椒直播: -https://www.huajiao.com/l/345096174 - -流星直播: -https://www.7u66.com/100960 - -ShowRoom: -https://www.showroom-live.com/room/profile?room_id=480206 (主播主页地址) - -Acfun: -https://live.acfun.cn/live/179922 - -映客直播: -https://www.inke.cn/liveroom/index.html?uid=22954469&id=1720860391070904 - -音播直播: -https://live.ybw1666.com/800002949 - -知乎直播: -https://www.zhihu.com/people/ac3a467005c5d20381a82230101308e9 (主播主页地址) - -CHZZK: -https://chzzk.naver.com/live/458f6ec20b034f49e0fc6d03921646d2 - -嗨秀直播: -https://www.haixiutv.com/6095106 - -VV星球直播: -https://h5webcdn-pro.vvxqiu.com//activity/videoShare/videoShare.html?h5Server=https://h5p.vvxqiu.com&roomId=LP115924473&platformId=vvstar - -17Live: -https://17.live/en/live/6302408 - -浪Live: -https://www.lang.live/en-US/room/3349463 - -畅聊直播: -https://live.tlclw.com/106188 - -飘飘直播: -https://m.pp.weimipopo.com/live/preview.html?uid=91648673&anchorUid=91625862&app=plpl - -六间房直播: -https://v.6.cn/634435 - -乐嗨直播: -https://www.lehaitv.com/8059096 - -花猫直播: -https://h.catshow168.com/live/preview.html?uid=19066357&anchorUid=18895331 - -Shopee: -https://sg.shp.ee/GmpXeuf?uid=1006401066&session=802458 - -Youtube: -https://www.youtube.com/watch?v=cS6zS5hi1w0 - -淘宝(需cookie): -https://m.tb.cn/h.TWp0HTd - -京东: -https://3.cn/28MLBy-E - -Faceit: -https://www.faceit.com/zh/players/Compl1/stream - -连接直播: -https://show.lailianjie.com/10000258 - -咪咕直播: -https://www.miguvideo.com/p/live/120000541321 - -来秀直播: -https://www.imkktv.com/h5/share/video.html?uid=1845195&roomId=1710496 - -Picarto: -https://www.picarto.tv/cuteavalanche -``` - -  - -## 🎃源码运行 -使用源码运行,前提要有**Python>=3.10**环境,如果没有请先自行安装Python,再执行下面步骤。 - -1.首先拉取或手动下载本仓库项目代码 - -```bash -git clone https://github.com/ihmily/DouyinLiveRecorder.git -``` - -2.进入项目文件夹,安装依赖 - -```bash -cd DouyinLiveRecorder -pip3 install -r requirements.txt -``` - -3.安装[FFmpeg](https://ffmpeg.org/download.html#build-linux),如果是Windows系统,这一步可跳过。对于Linux系统,执行以下命令安装 - -CentOS执行 - -```bash -yum install epel-release -yum install ffmpeg -``` - -Ubuntu则执行 - -```bash -apt update -apt install ffmpeg -``` - -macOS 执行 - -**如果已经安装 Homebrew 请跳过这一步** - -```bash -/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" -``` - -```bash -brew install ffmpeg -``` - -4.运行程序 - -```python -python main.py -``` - -其中Linux系统请使用`python3 main.py` 运行。 - -  -## 🐋容器运行 - -在运行命令之前,请确保您的机器上安装了 [Docker](https://docs.docker.com/get-docker/) 和 [Docker Compose](https://docs.docker.com/compose/install/) - -1.快速启动 - -最简单方法是运行项目中的 [docker-compose.yaml](https://github.com/ihmily/DouyinLiveRecorder/blob/main/docker-compose.yaml) 文件,只需简单执行以下命令: - -```bash -docker-compose up -``` - -可选 `-d` 在后台运行。 - - - -2.构建镜像(可选) - -如果你只想简单的运行程序,则不需要做这一步。Docker镜像仓库中代码版本可能不是最新的,如果要运行本仓库主分支最新代码,可以本地自定义构建,通过修改 [docker-compose.yaml](https://github.com/ihmily/DouyinLiveRecorder/blob/main/docker-compose.yaml) 文件,如将镜像名修改为 `douyin-live-recorder:latest`,并取消 `# build: .` 注释,然后再执行 - -```bash -docker build -t douyin-live-recorder:latest . -docker-compose up -``` - -或者直接使用下面命令进行构建并启动 - -```bash -docker-compose -f docker-compose.yaml up -``` - - - -3.停止容器实例 - -```bash -docker-compose stop -``` - - - -4.注意事项 - -①在docker容器内运行本程序之前,请先在配置文件中添加要录制的直播间地址。 - -②在容器内时,如果手动中断容器运行停止录制,会导致正在录制的视频文件损坏! - -**无论哪种运行方式,为避免手动中断或者异常中断导致录制的视频文件损坏的情况,推荐使用 `ts` 格式保存**。 - -  - -## ❤️贡献者 - -   [![Hmily](https://github.com/ihmily.png?size=50)](https://github.com/ihmily) -[![iridescentGray](https://github.com/iridescentGray.png?size=50)](https://github.com/iridescentGray) -[![annidy](https://github.com/annidy.png?size=50)](https://github.com/annidy) -[![wwkk2580](https://github.com/wwkk2580.png?size=50)](https://github.com/wwkk2580) -[![missuo](https://github.com/missuo.png?size=50)](https://github.com/missuo) -xueli12 -kaine1973 -yinruiqing -Max-Tortoise -[![justdoiting](https://github.com/justdoiting.png?size=50)](https://github.com/justdoiting) -[![dhbxs](https://github.com/dhbxs.png?size=50)](https://github.com/dhbxs) -[![wujiyu115](https://github.com/wujiyu115.png?size=50)](https://github.com/wujiyu115) -[![zhanghao333](https://github.com/zhanghao333.png?size=50)](https://github.com/zhanghao333) -gyc0123 - -   [![HoratioShaw](https://github.com/HoratioShaw.png?size=50)](https://github.com/HoratioShaw) -[![nov30th](https://github.com/nov30th.png?size=50)](https://github.com/nov30th) -  - -## ⏳提交日志 - -- 20250127 - - 新增淘宝、京东、faceit直播录制 - - 修复小红书直播流录制以及转码问题 - - 修复畅聊、VV星球、flexTV直播录制 - - 修复批量微信直播推送 - - 新增email发送ssl和port配置 - - 新增强制转h264配置 - - 更新ffmpeg版本 - - 重构包为异步函数! - -- 20241130 - - 新增shopee、youtube直播录制 - - 新增支持自定义m3u8、flv地址录制 - - 新增自定义执行脚本,支持python、bat、bash等 - - 修复YY直播、花椒直播和小红书直播录制 - - 修复b站标题获取错误 - - 修复log日志错误 -- 20241030 - - 新增嗨秀直播、vv星球直播、17Live、浪Live、SOOP、畅聊直播(原时光直播)、飘飘直播、六间房直播、乐嗨直播、花猫直播等10个平台直播录制 - - 修复小红书直播录制,支持小红书作者主页地址录制直播 - - 新增支持ntfy消息推送,以及新增支持批量推送多个地址(逗号分隔多个推送地址) - - 修复Liveme直播录制、twitch直播录制 - - 新增Windows平台一键停止录制VB脚本程序 -- 20241005 - - 新增邮箱和Bark推送 - - 新增直播注释停止录制 - - 优化分段录制 - - 重构部分代码 -- 20240928 - - 新增知乎直播、CHZZK直播录制 - - 修复音播直播录制 -- 20240903 - - 新增抖音双屏录制、音播直播录制 - - 修复PandaTV、bigo直播录制 -- 20240713 - - 新增映客直播录制 -- 20240705 - - 新增时光直播录制 -- 20240701 - - 修复虎牙直播录制2分钟断流问题 - - 新增自定义直播推送内容 -- 20240621 - - 新增Acfun、ShowRoom直播录制 - - 修复微博录制、新增直播源线路 - - 修复斗鱼直播60帧录制 - - 修复酷狗直播录制 - - 修复TikTok部分无法解析直播源 - - 修复抖音无法录制连麦直播 -- 20240510 - - 修复部分虎牙直播间录制错误 -- 20240508 - - 修复花椒直播录制 - - 更改文件路径解析方式 [@kaine1973](https://github.com/kaine1973) -- 20240506 - - 修复抖音录制画质解析bug - - 修复虎牙录制 60帧最高画质问题 - - 新增流星直播录制 -- 20240427 - - 新增LiveMe、花椒直播录制 -- 20240425 - - 新增TwitchTV直播录制 -- 20240424 - - 新增酷狗直播录制、优化PopkonTV直播录制 -- 20240423 - - 新增百度直播录制、微博直播录制 - - 修复斗鱼录制直播回放的问题 - - 新增直播源地址显示以及输出到日志文件设置 -- 20240311 - - 修复海外平台录制bug,增加画质选择,增强录制稳定性 - - 修复虎牙录制bug (虎牙`一起看`频道 有特殊限制,有时无法录制) -- 20240309 - - 修复虎牙直播、小红书直播和B站直播录制 - - 新增5个直播平台录制,包括winktv、flextv、look、popkontv、twitcasting - - 新增部分海外平台账号密码配置,实现自动登录并更新配置文件中的cookie - - 新增自定义配置需要使用代理录制的平台 - - 新增只推送开播消息不进行录制设置 - - 修复了一些bug -- 20240209 - - 优化AfreecaTV录制,新增账号密码登录获取cookie以及持久保存 - - 修复了小红书直播因官方更新直播域名,导致无法录制直播的问题 - - 修复了更新URL配置文件的bug - - 最后,祝大家新年快乐! - -
点击展开更多提交日志 - -- 20240129 - - 新增猫耳FM直播录制 -- 20240127 - - 新增千度热播直播录制、新增pandaTV(韩国)直播录制 - - 新增telegram直播状态消息推送,修复了某些bug - - 新增自定义设置不同直播间的录制画质(即每个直播间录制画质可不同) - - 修改录制视频保存路径为 `downloads` 文件夹,并且分平台进行保存。 -- 20240114 - - 新增网易cc直播录制,优化ffmpeg参数,修改AfreecaTV输入直播地址格式 - - 修改日志记录器 @[iridescentGray](https://github.com/iridescentGray) -- 20240102 - - 修复Linux上运行,新增docker配置文件 -- 20231210 - - 修复录制分段bug,修复bigo录制检测bug - - 新增自定义修改录制主播名 - - 新增AfreecaTV直播录制,修复某些可能会发生的bug -- 20231207 - - 新增blued直播录制,修复YY直播录制,新增直播结束消息推送 -- 20231206 - - 新增bigo直播录制 -- 20231203 - - 新增小红书直播录制(全网首发),目前小红书官方没有切换清晰度功能,因此直播录制也只有默认画质 - - 小红书录制暂时无法循环监测,每次主播开启直播,都要重新获取一次链接 - - 获取链接的方式为 将直播间转发到微信,在微信中打开后,复制页面的链接。 -- 20231030 - - 本次更新只是进行修复,没时间新增功能。 - - 欢迎各位大佬提pr 帮忙更新维护 -- 20230930 - - 新增抖音从接口获取直播流,增强稳定性 - - 修改快手获取直播流的方式,改用从官方接口获取 - - 祝大家中秋节快乐! -- 20230919 - - 修复了快手版本更新后录制出错的问题,增加了其自动获取cookie(~~稳定性未知~~) - - 修复了TikTok显示正在直播但不进行录制的问题 -- 20230907 - - 修复了因抖音官方更新了版本导致的录制出错以及短链接转换出错 - - 修复B站无法录制原画视频的bug - - 修改了配置文件字段,新增各平台自定义设置Cookie -- 20230903 - - 修复了TikTok录制时报644无法录制的问题 - - 新增直播状态推送到钉钉和微信的功能,如有需要请看 [设置推送教程](https://d04vqdiqwr3.feishu.cn/docx/XFPwdDDvfobbzlxhmMYcvouynDh?from=from_copylink) - - 最近比较忙,其他问题有时间再更新 -- 20230816 - - 修复斗鱼直播(官方更新了字段)和快手直播录制出错的问题 -- 20230814 - - 新增B站直播录制 - - 写了一个在线播放M3U8和FLV视频的网页源码,打开即可食用 -- 20230812 - - 新增YY直播录制 -- 20230808 - - 修复主播重新开播无法再次录制的问题 -- 20230807 - - 新增了斗鱼直播录制 - - 修复显示录制完成之后会重新开始录制的问题 -- 20230805 - - 新增了虎牙直播录制,其暂时只能用flv视频流进行录制 - - Web API 新增了快手和虎牙这两个平台的直播流解析(TikTok要代理) -- 20230804 - - 新增了快手直播录制,优化了部分代码 - - 上传了一个自动化获取抖音直播间页面Cookie的代码,可以用于录制 -- 20230803 - - 通宵更新 - - 新增了国际版抖音TikTok的直播录制,去除冗余 简化了部分代码 -- 20230724 - - 新增了一个通过抖音直播间地址获取直播视频流链接的API接口,上传即可用 -
-   - -## 有问题可以提issue, 我会在这里持续添加更多直播平台的录制 欢迎Star -#### diff --git a/StopRecording.vbs b/StopRecording.vbs deleted file mode 100644 index f6c47c1..0000000 --- a/StopRecording.vbs +++ /dev/null @@ -1,69 +0,0 @@ -'********************************************************************************************/ -'* File Name : StopRecording.vbs -'* Created Date : 2024-10-15 01:50:30 -'* Author : Hmily -'* GitHub : http://github.com/ihmily -'* Description : This script is designed to terminate the process of live recording -'********************************************************************************************/ - -Dim objWMIService, colProcesses, objProcess -Dim intResponse -strComputer = "." -On Error Resume Next -intResponse = MsgBox("ȷҪкֱ̨¼ƽ", vbYesNo + vbQuestion, "ȷϽ") - -If intResponse = vbYes Then - Set objWMIService = GetObject("winmgmts:\\" & strComputer & "\root\cimv2") - If Err.Number <> 0 Then - Err.Clear - End If - - Set colProcesses = objWMIService.ExecQuery("Select * from Win32_Process Where Name = 'ffmpeg.exe'") - Set colProcesses2 = objWMIService.ExecQuery("Select * from Win32_Process Where Name = 'pythonw.exe'") - Set colProcesses3 = objWMIService.ExecQuery("Select * from Win32_Process Where Name = 'DouyinLiveRecorder.exe'") - If Err.Number <> 0 Then - Err.Clear - End If - - If Not objWMIService Is Nothing And Not colProcesses Is Nothing And Not colProcesses2 Is Nothing Then - If colProcesses2.Count = 0 And colProcesses3.Count = 0 Then - MsgBox "ûҵ¼ƳĽ", vbExclamation, "ʾϢ" - WScript.Quit(1) - Else - For Each objProcess in colProcesses - objProcess.Terminate() - If Err.Number <> 0 Then - objShell.Run "taskkill /f /im " & objProcess.Name, 0, True - Err.Clear - End If - Next - End If - Else - objShell.Run "taskkill /f /im " & objProcess.Name, 0, True - End If - MsgBox "ѳɹ¼ֱḶ̌" & vbCrLf & "رմ˴30Զֹͣ¼Ƴ", vbInformation, "ʾϢ" - - WScript.Sleep 10000 - If colProcesses3.Count <> 0 Then - Set colProcesses_ = colProcesses3 - Else - Set colProcesses_ = colProcesses2 - End If - For Each objProcess in colProcesses_ - objProcess.Terminate() - If Err.Number <> 0 Then - objShell.Run "taskkill /f /im " & objProcess.Name, 0, True - Err.Clear - End If - Next -Else - MsgBox "ȡ¼Ʋ", vbExclamation, "ʾϢ" -End If - -On Error GoTo 0 -Set objWMIService = Nothing -Set colProcesses = Nothing -Set colProcesses2 = Nothing -Set colProcesses3 = Nothing -Set objProcess = Nothing -Set objShell = Nothing \ No newline at end of file diff --git a/back/back1 b/back/back1 deleted file mode 100644 index 9a77efe..0000000 --- a/back/back1 +++ /dev/null @@ -1,1815 +0,0 @@ -# -*- encoding: utf-8 -*- - -""" -Author: Hmily -GitHub: https://github.com/ihmily -Date: 2023-07-17 23:52:05 -Update: 2025-07-19 17:43:00 -Copyright (c) 2023-2025 by Hmily, All Rights Reserved. -Function: Record live stream video. -""" -import asyncio -import os -import sys -import builtins -import subprocess -import signal -import threading -import time -import datetime -import re -import shutil -import random -import uuid -from pathlib import Path -import urllib.request -from urllib.error import URLError, HTTPError -from typing import Any -import configparser -import httpx -from src import spider, stream -from src.proxy import ProxyDetector -from src.utils import logger -from src import utils -from msg_push import ( - dingtalk, xizhi, tg_bot, send_email, bark, ntfy, pushplus -) -from ffmpeg_install import ( - check_ffmpeg, ffmpeg_path, current_env_path -) -import configparser -import shlex -import subprocess - -# 建议在程序初始化阶段全局加载一次 -_config = configparser.ConfigParser() -with open("config/youtube.ini", "r", encoding="utf-8-sig") as f: - _config.read_file(f) -# _config.read("config/config.ini", encoding="utf-8") - -def _yt_get(section: str, key: str, fallback=None, cast=str): - try: - if cast is bool: - return _config.getboolean(section, key, fallback=fallback) - if cast is int: - return _config.getint(section, key, fallback=fallback) - return _config.get(section, key, fallback=fallback) - except Exception: - return fallback - -def _safe_int(v, lo, hi, default): - try: - v = int(v) - if v < lo or v > hi: - return default - return v - except Exception: - return default - -def _build_youtube_rtmp_url(key: str, secure: bool, ingest: str | None): - node = (ingest or "a").strip().lower() - node = node if node.isalpha() and len(node) == 1 else "a" - scheme = "rtmps" if secure else "rtmp" - host = f"{node}.rtmps.youtube.com" if secure else f"{node}.rtmp.youtube.com" - return f"{scheme}://{host}/live2/{key}" - -def _can_copy_h264_aac(port_info: dict) -> bool: - """ - 可选:根据你前面已解析到的源信息判断是否允许 copy。 - 如果你没有ffprobe,这里保守返回False;若你在port_info里塞了codec信息,按需判断。 - """ - # 示例:只有在明确知道是 h264 + aac 时才放行 - v = (port_info or {}).get("vcodec", "").lower() - a = (port_info or {}).get("acodec", "").lower() - if not v or not a: - return False - if v != "h264": - return False - # YouTube 推荐 44.1k/48k,立体声 OK - return a in ("aac", "mp4a") -version = "v4.0.6" -platforms = ("\n国内站点:抖音|快手|虎牙|斗鱼|YY|B站|小红书|bigo|blued|网易CC|千度热播|猫耳FM|Look|TwitCasting|百度|微博|" - "酷狗|花椒|流星|Acfun|畅聊|映客|音播|知乎|嗨秀|VV星球|17Live|浪Live|漂漂|六间房|乐嗨|花猫|淘宝|京东|咪咕|连接|来秀" - "\n海外站点:TikTok|SOOP|PandaTV|WinkTV|FlexTV|PopkonTV|TwitchTV|LiveMe|ShowRoom|CHZZK|Shopee|" - "Youtube|Faceit|Picarto") - -recording = set() -error_count = 0 -pre_max_request = 10 -max_request_lock = threading.Lock() -error_window = [] -error_window_size = 10 -error_threshold = 5 -monitoring = 0 -running_list = [] -url_tuples_list = [] -url_comments = [] -text_no_repeat_url = [] -create_var = locals() -first_start = True -exit_recording = False -need_update_line_list = [] -first_run = True -not_record_list = [] -start_display_time = datetime.datetime.now() -global_proxy = False -recording_time_list = {} -script_path = os.path.split(os.path.realpath(sys.argv[0]))[0] -config_file = f'{script_path}/config/config.ini' -url_config_file = f'{script_path}/config/URL_config.ini' -backup_dir = f'{script_path}/backup_config' -text_encoding = 'utf-8-sig' -rstr = r"[\/\\\:\*\??\"\<\>\|&#.。,, ~!· ]" -default_path = f'{script_path}/downloads' -os.makedirs(default_path, exist_ok=True) -file_update_lock = threading.Lock() -os_type = os.name -clear_command = "cls" if os_type == 'nt' else "clear" -color_obj = utils.Color() -os.environ['PATH'] = ffmpeg_path + os.pathsep + current_env_path - - -def signal_handler(_signal, _frame): - sys.exit(0) - - -signal.signal(signal.SIGTERM, signal_handler) - - - - -def _fmt_td(td: datetime.timedelta) -> str: - # 统一格式:HH:MM:SS(超过24h也按总小时数显示) - total = int(td.total_seconds()) - if total < 0: - total = 0 - h = total // 3600 - m = (total % 3600) // 60 - s = total % 60 - return f"{h:02d}:{m:02d}:{s:02d}" - -def _hr(char: str = "─") -> str: - cols = shutil.get_terminal_size((100, 20)).columns - return char * max(20, cols) - -def display_info() -> None: - """控制台【直播动态】面板(定时清屏重绘)""" - global start_display_time - time.sleep(1.0) # 稍等程序其余线程初始化 - start_display_time = datetime.datetime.now() - - while True: - try: - # 清屏 - if Path(sys.executable).name != 'pythonw.exe': - os.system(clear_command) - - now_dt = datetime.datetime.now() - now_str = now_dt.strftime("%Y-%m-%d %H:%M:%S") - - # 顶部抬头 - print("【直播动态】") - print(_hr()) - - # 统计区 - stat_parts = [] - stat_parts.append(f"监测中: {monitoring}") - stat_parts.append(f"并发线程: {max_request}") - stat_parts.append(f"代理录制: {'是' if use_proxy else '否'}") - stat_parts.append(f"分段: {f'{split_time}s' if split_video_by_time else '否'}") - if create_time_file: - stat_parts.append("时间字幕: 是") - stat_parts.append(f"质量: {video_record_quality}") - stat_parts.append(f"格式: {video_save_type}") - stat_parts.append(f"瞬时错误: {error_count}") - stat_line = " | ".join(stat_parts) - print(stat_line) - print(f"当前时间: {now_str}") - print(_hr()) - - # 列表区 - if not recording: - if monitoring == 0: - print("暂无监测任务。") - else: - print(f"暂无正在录制的直播。下次检测间隔:{delay_default} 秒") - else: - no_repeat = list(set(recording)) - print(f"正在录制:{len(no_repeat)} 个") - print(_hr("·")) - - # 标题行 - print(f"{'序号/主播名':<28} {'清晰度':<6} {'开始时间':<19} {'已录制':<10}") - print(_hr("·")) - - for name in sorted(no_repeat): - start_time, qa = recording_time_list.get(name, (now_dt, "未知")) - elapsed = _fmt_td(now_dt - start_time) - start_str = start_time.strftime("%H:%M:%S") if isinstance(start_time, datetime.datetime) else "--:--:--" - - # name 可能很长,截断以适配终端宽度 - cols = shutil.get_terminal_size((100, 20)).columns - name_col = 28 - # 留余量:28 + 1 + 6 + 1 + 19 + 1 + 10 ≈ 65 列基础宽度 - disp_name = name if len(name) <= name_col else (name[:name_col-2] + "…") - print(f"{disp_name:<28} {str(qa):<6} {start_str:<19} {elapsed:<10}") - - print(_hr()) - - # 运行时长(可选) - uptime = _fmt_td(now_dt - start_display_time) - print(f"已运行时长:{uptime}") - - # 降低刷新频率,避免刷屏 - sys.stdout.flush() - time.sleep(2.0) - - except KeyboardInterrupt: - # 主线程Ctrl+C时,优雅退出这个展示线程 - break - except Exception as e: - # 面板渲染异常不能影响主流程 - try: - logger.error(f"显示面板异常: {e} | 行: {getattr(e, '__traceback__', None).tb_lineno if getattr(e, '__traceback__', None) else '未知'}") - except Exception: - pass - time.sleep(1.0) - - -def update_file(file_path: str, old_str: str, new_str: str, start_str: str = None) -> str | None: - if old_str == new_str and start_str is None: - return old_str - with file_update_lock: - file_data = [] - with open(file_path, "r", encoding=text_encoding) as f: - try: - for text_line in f: - if old_str in text_line: - text_line = text_line.replace(old_str, new_str) - if start_str: - text_line = f'{start_str}{text_line}' - if text_line not in file_data: - file_data.append(text_line) - except RuntimeError as e: - logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}") - if ini_URL_content: - with open(file_path, "w", encoding=text_encoding) as f2: - f2.write(ini_URL_content) - return old_str - if file_data: - with open(file_path, "w", encoding=text_encoding) as f: - f.write(''.join(file_data)) - return new_str - - -def delete_line(file_path: str, del_line: str, delete_all: bool = False) -> None: - with file_update_lock: - with open(file_path, 'r+', encoding=text_encoding) as f: - lines = f.readlines() - f.seek(0) - f.truncate() - skip_line = False - for txt_line in lines: - if del_line in txt_line: - if delete_all or not skip_line: - skip_line = True - continue - else: - skip_line = False - f.write(txt_line) - - -def get_startup_info(system_type: str): - if system_type == 'nt': - startup_info = subprocess.STARTUPINFO() - startup_info.dwFlags |= subprocess.STARTF_USESHOWWINDOW - else: - startup_info = None - return startup_info - - -def segment_video(converts_file_path: str, segment_save_file_path: str, segment_format: str, segment_time: str, - is_original_delete: bool = True) -> None: - try: - if os.path.exists(converts_file_path) and os.path.getsize(converts_file_path) > 0: - ffmpeg_command = [ - "ffmpeg", - "-i", converts_file_path, - "-c:v", "copy", - "-c:a", "copy", - "-map", "0", - "-f", "segment", - "-segment_time", segment_time, - "-segment_format", segment_format, - "-reset_timestamps", "1", - "-movflags", "+frag_keyframe+empty_moov", - segment_save_file_path, - ] - _output = subprocess.check_output( - ffmpeg_command, stderr=subprocess.STDOUT, startupinfo=get_startup_info(os_type) - ) - if is_original_delete: - time.sleep(1) - if os.path.exists(converts_file_path): - os.remove(converts_file_path) - except subprocess.CalledProcessError as e: - logger.error(f'Error occurred during conversion: {e}') - except Exception as e: - logger.error(f'An unknown error occurred: {e}') - - -def converts_mp4(converts_file_path: str, is_original_delete: bool = True) -> None: - try: - if os.path.exists(converts_file_path) and os.path.getsize(converts_file_path) > 0: - if converts_to_h264: - color_obj.print_colored("正在转码为MP4格式并重新编码为h264\n", color_obj.YELLOW) - ffmpeg_command = [ - "ffmpeg", "-i", converts_file_path, - "-c:v", "libx264", - "-preset", "veryfast", - "-crf", "23", - "-vf", "format=yuv420p", - "-c:a", "copy", - "-f", "mp4", converts_file_path.rsplit('.', maxsplit=1)[0] + ".mp4", - ] - else: - color_obj.print_colored("正在转码为MP4格式\n", color_obj.YELLOW) - ffmpeg_command = [ - "ffmpeg", "-i", converts_file_path, - "-c:v", "copy", - "-c:a", "copy", - "-f", "mp4", converts_file_path.rsplit('.', maxsplit=1)[0] + ".mp4", - ] - _output = subprocess.check_output( - ffmpeg_command, stderr=subprocess.STDOUT, startupinfo=get_startup_info(os_type) - ) - if is_original_delete: - time.sleep(1) - if os.path.exists(converts_file_path): - os.remove(converts_file_path) - except subprocess.CalledProcessError as e: - logger.error(f'Error occurred during conversion: {e}') - except Exception as e: - logger.error(f'An unknown error occurred: {e}') - - -def converts_m4a(converts_file_path: str, is_original_delete: bool = True) -> None: - try: - if os.path.exists(converts_file_path) and os.path.getsize(converts_file_path) > 0: - _output = subprocess.check_output([ - "ffmpeg", "-i", converts_file_path, - "-n", "-vn", - "-c:a", "aac", "-bsf:a", "aac_adtstoasc", "-ab", "320k", - converts_file_path.rsplit('.', maxsplit=1)[0] + ".m4a", - ], stderr=subprocess.STDOUT, startupinfo=get_startup_info(os_type)) - if is_original_delete: - time.sleep(1) - if os.path.exists(converts_file_path): - os.remove(converts_file_path) - except subprocess.CalledProcessError as e: - logger.error(f'Error occurred during conversion: {e}') - except Exception as e: - logger.error(f'An unknown error occurred: {e}') - - -def generate_subtitles(record_name: str, ass_filename: str, sub_format: str = 'srt') -> None: - index_time = 0 - today = datetime.datetime.now() - re_datatime = today.strftime('%Y-%m-%d %H:%M:%S') - - def transform_int_to_time(seconds: int) -> str: - m, s = divmod(seconds, 60) - h, m = divmod(m, 60) - return f"{h:02d}:{m:02d}:{s:02d}" - - while True: - index_time += 1 - txt = str(index_time) + "\n" + transform_int_to_time(index_time) + ',000 --> ' + transform_int_to_time( - index_time + 1) + ',000' + "\n" + str(re_datatime) + "\n\n" - - with open(f"{ass_filename}.{sub_format.lower()}", 'a', encoding=text_encoding) as f: - f.write(txt) - - if record_name not in recording: - return - time.sleep(1) - today = datetime.datetime.now() - re_datatime = today.strftime('%Y-%m-%d %H:%M:%S') - - -def adjust_max_request() -> None: - global max_request, error_count, pre_max_request, error_window - preset = max_request - - while True: - time.sleep(5) - with max_request_lock: - if error_window: - error_rate = sum(error_window) / len(error_window) - else: - error_rate = 0 - - if error_rate > error_threshold: - max_request = max(1, max_request - 1) - elif error_rate < error_threshold / 2 and max_request < preset: - max_request += 1 - else: - pass - - if pre_max_request != max_request: - pre_max_request = max_request - print(f"\r同一时间访问网络的线程数动态改为 {max_request}") - - error_window.append(error_count) - if len(error_window) > error_window_size: - error_window.pop(0) - error_count = 0 - - -def push_message(record_name: str, live_url: str, content: str) -> None: - msg_title = push_message_title.strip() or "直播间状态更新通知" - push_functions = { - '微信': lambda: xizhi(xizhi_api_url, msg_title, content), - '钉钉': lambda: dingtalk(dingtalk_api_url, content, dingtalk_phone_num, dingtalk_is_atall), - '邮箱': lambda: send_email( - email_host, login_email, email_password, sender_email, sender_name, - to_email, msg_title, content, smtp_port, open_smtp_ssl - ), - 'TG': lambda: tg_bot(tg_chat_id, tg_token, content), - 'BARK': lambda: bark( - bark_msg_api, title=msg_title, content=content, level=bark_msg_level, sound=bark_msg_ring - ), - 'NTFY': lambda: ntfy( - ntfy_api, title=msg_title, content=content, tags=ntfy_tags, action_url=live_url, email=ntfy_email - ), - 'PUSHPLUS': lambda: pushplus(pushplus_token, msg_title, content), - } - - for platform, func in push_functions.items(): - if platform in live_status_push.upper(): - try: - result = func() - print(f'提示信息:已经将[{record_name}]直播状态消息推送至你的{platform},' - f' 成功{len(result["success"])}, 失败{len(result["error"])}') - except Exception as e: - color_obj.print_colored(f"直播消息推送到{platform}失败: {e}", color_obj.RED) - - -def run_script(command: str) -> None: - try: - process = subprocess.Popen( - command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, startupinfo=get_startup_info(os_type) - ) - stdout, stderr = process.communicate() - stdout_decoded = stdout.decode('utf-8') - stderr_decoded = stderr.decode('utf-8') - if stdout_decoded.strip(): - print(stdout_decoded) - if stderr_decoded.strip(): - print(stderr_decoded) - except PermissionError as e: - logger.error(e) - logger.error('脚本无执行权限!, 若是Linux环境, 请先执行:chmod +x your_script.sh 授予脚本可执行权限') - except OSError as e: - logger.error(e) - logger.error('Please add `#!/bin/bash` at the beginning of your bash script file.') - - -def clear_record_info(record_name: str, record_url: str) -> None: - global monitoring - recording.discard(record_name) - if record_url in url_comments and record_url in running_list: - running_list.remove(record_url) - monitoring -= 1 - color_obj.print_colored(f"[{record_name}]已经从录制列表中移除\n", color_obj.YELLOW) - - -def direct_download_stream(source_url: str, save_path: str, record_name: str, live_url: str, platform: str) -> bool: - - try: - with open(save_path, 'wb') as f: - client = httpx.Client(timeout=None) - - headers = {} - header_params = get_record_headers(platform, live_url) - if header_params: - key, value = header_params.split(":", 1) - headers[key] = value - - with client.stream('GET', source_url, headers=headers, follow_redirects=True) as response: - if response.status_code != 200: - logger.error(f"请求直播流失败,状态码: {response.status_code}") - return False - - downloaded = 0 - chunk_size = 1024 * 16 - - for chunk in response.iter_bytes(chunk_size): - if live_url in url_comments or exit_recording: - color_obj.print_colored(f"[{record_name}]录制时已被注释或请求停止,下载中断", color_obj.YELLOW) - clear_record_info(record_name, live_url) - return False - - if chunk: - f.write(chunk) - downloaded += len(chunk) - print() - return True - except Exception as e: - logger.error(f"FLV下载错误: {e} 发生错误的行数: {e.__traceback__.tb_lineno}") - return False - - -def check_subprocess(record_name: str, record_url: str, ffmpeg_command: list, save_type: str, - script_command: str | None = None) -> bool: - save_file_path = ffmpeg_command[-1] - process = subprocess.Popen( - ffmpeg_command, stdin=subprocess.PIPE, stderr=subprocess.STDOUT, startupinfo=get_startup_info(os_type) - ) - - subs_file_path = save_file_path.rsplit('.', maxsplit=1)[0] - subs_thread_name = f'subs_{Path(subs_file_path).name}' - if create_time_file and not split_video_by_time and '音频' not in save_type: - create_var[subs_thread_name] = threading.Thread( - target=generate_subtitles, args=(record_name, subs_file_path) - ) - create_var[subs_thread_name].daemon = True - create_var[subs_thread_name].start() - - while process.poll() is None: - if record_url in url_comments or exit_recording: - color_obj.print_colored(f"[{record_name}]录制时已被注释,本条线程将会退出", color_obj.YELLOW) - clear_record_info(record_name, record_url) - # process.terminate() - if os.name == 'nt': - if process.stdin: - process.stdin.write(b'q') - process.stdin.close() - else: - process.send_signal(signal.SIGINT) - process.wait() - return True - time.sleep(1) - - return_code = process.returncode - stop_time = time.strftime('%Y-%m-%d %H:%M:%S') - if return_code == 0: - if converts_to_mp4 and save_type == 'TS': - if split_video_by_time: - file_paths = utils.get_file_paths(os.path.dirname(save_file_path)) - prefix = os.path.basename(save_file_path).rsplit('_', maxsplit=1)[0] - for path in file_paths: - if prefix in path: - threading.Thread(target=converts_mp4, args=(path, delete_origin_file)).start() - else: - threading.Thread(target=converts_mp4, args=(save_file_path, delete_origin_file)).start() - print(f"\n{record_name} {stop_time} 直播录制完成\n") - - if script_command: - logger.debug("开始执行脚本命令!") - if "python" in script_command: - params = [ - f'--record_name "{record_name}"', - f'--save_file_path "{save_file_path}"', - f'--save_type {save_type}', - f'--split_video_by_time {split_video_by_time}', - f'--converts_to_mp4 {converts_to_mp4}', - ] - else: - params = [ - f'"{record_name.split(" ", maxsplit=1)[-1]}"', - f'"{save_file_path}"', - save_type, - f'split_video_by_time:{split_video_by_time}', - f'converts_to_mp4:{converts_to_mp4}' - ] - script_command = script_command.strip() + ' ' + ' '.join(params) - run_script(script_command) - logger.debug("脚本命令执行结束!") - - else: - color_obj.print_colored(f"\n{record_name} {stop_time} 直播录制出错,返回码: {return_code}\n", color_obj.RED) - - recording.discard(record_name) - return False - - -def clean_name(input_text): - cleaned_name = re.sub(rstr, "_", input_text.strip()).strip('_') - cleaned_name = cleaned_name.replace("(", "(").replace(")", ")") - if clean_emoji: - cleaned_name = utils.remove_emojis(cleaned_name, '_').strip('_') - return cleaned_name or '空白昵称' - - -def get_quality_code(qn): - QUALITY_MAPPING = { - "原画": "OD", - "蓝光": "BD", - "超清": "UHD", - "高清": "HD", - "标清": "SD", - "流畅": "LD" - } - return QUALITY_MAPPING.get(qn) - -def get_record_headers(platform, live_url): - live_domain = '/'.join(live_url.split('/')[0:3]) - record_headers = { - 'PandaTV': 'origin:https://www.pandalive.co.kr', - 'WinkTV': 'origin:https://www.winktv.co.kr', - 'PopkonTV': 'origin:https://www.popkontv.com', - 'FlexTV': 'origin:https://www.flextv.co.kr', - '千度热播': 'referer:https://qiandurebo.com', - '17Live': 'referer:https://17.live/en/live/6302408', - '浪Live': 'referer:https://www.lang.live', - 'shopee': f'origin:{live_domain}', - 'Blued直播': 'referer:https://app.blued.cn' - } - return record_headers.get(platform) - - -def is_flv_preferred_platform(link): - return any(i in link for i in ["douyin", "tiktok"]) - - -def select_source_url(link, stream_info): - if is_flv_preferred_platform(link): - codec = utils.get_query_params(stream_info.get('flv_url'), "codec") - if codec and codec[0] == 'h265': - logger.warning("FLV is not supported for h265 codec, use HLS source instead") - else: - return stream_info.get('flv_url') - - return stream_info.get('record_url') - - -def start_record(url_data: tuple, count_variable: int = -1) -> None: - global error_count - - while True: - try: - record_finished = False - run_once = False - start_pushed = False - new_record_url = '' - count_time = time.time() - retry = 0 - record_quality_zh, record_url, anchor_name = url_data - record_quality = get_quality_code(record_quality_zh) - proxy_address = proxy_addr - platform = '未知平台' - live_domain = '/'.join(record_url.split('/')[0:3]) - - if proxy_addr: - proxy_address = None - for platform in enable_proxy_platform_list: - if platform and platform.strip() in record_url: - proxy_address = proxy_addr - break - - if not proxy_address: - if extra_enable_proxy_platform_list: - for pt in extra_enable_proxy_platform_list: - if pt and pt.strip() in record_url: - proxy_address = proxy_addr_bak or None - - # print(f'\r代理地址:{proxy_address}') - # print(f'\r全局代理:{global_proxy}') - while True: - try: - port_info = [] - if record_url.find("douyin.com/") > -1: - platform = '抖音直播' - with semaphore: - if 'v.douyin.com' not in record_url and '/user/' not in record_url: - json_data = asyncio.run(spider.get_douyin_stream_data( - url=record_url, - proxy_addr=proxy_address, - cookies=dy_cookie)) - else: - json_data = asyncio.run(spider.get_douyin_app_stream_data( - url=record_url, - proxy_addr=proxy_address, - cookies=dy_cookie)) - port_info = asyncio.run( - stream.get_douyin_stream_url(json_data, record_quality, proxy_address)) - - elif record_url.find("https://www.tiktok.com/") > -1: - platform = 'TikTok直播' - with semaphore: - if global_proxy or proxy_address: - json_data = asyncio.run(spider.get_tiktok_stream_data( - url=record_url, - proxy_addr=proxy_address, - cookies=tiktok_cookie)) - port_info = asyncio.run( - stream.get_tiktok_stream_url(json_data, record_quality, proxy_address)) - else: - logger.error("错误信息: 网络异常,请检查网络是否能正常访问TikTok平台") - - - elif record_url.find(".m3u8") > -1 or record_url.find(".flv") > -1: - platform = '自定义录制直播' - port_info = { - "anchor_name": platform + '_' + str(uuid.uuid4())[:8], - "is_live": True, - "record_url": record_url, - } - if '.flv' in record_url: - port_info['flv_url'] = record_url - else: - port_info['m3u8_url'] = record_url - - else: - logger.error(f'{record_url} {platform}直播地址') - return - - if anchor_name: - if '主播:' in anchor_name: - anchor_split: list = anchor_name.split('主播:') - if len(anchor_split) > 1 and anchor_split[1].strip(): - anchor_name = anchor_split[1].strip() - else: - anchor_name = port_info.get("anchor_name", '') - else: - anchor_name = port_info.get("anchor_name", '') - - if not port_info.get("anchor_name", ''): - print(f'序号{count_variable} 网址内容获取失败,进行重试中...获取失败的地址是:{url_data}') - with max_request_lock: - error_count += 1 - error_window.append(1) - else: - anchor_name = clean_name(anchor_name) - record_name = f'序号{count_variable} {anchor_name}' - - if record_url in url_comments: - print(f"[{anchor_name}]已被注释,本条线程将会退出") - clear_record_info(record_name, record_url) - return - - if not url_data[-1] and run_once is False: - if new_record_url: - need_update_line_list.append( - f'{record_url}|{new_record_url},主播: {anchor_name.strip()}') - not_record_list.append(new_record_url) - else: - need_update_line_list.append(f'{record_url}|{record_url},主播: {anchor_name.strip()}') - run_once = True - - push_at = datetime.datetime.today().strftime('%Y-%m-%d %H:%M:%S') - if port_info['is_live'] is False: - print(f"\r{record_name} 等待直播... ") - - if start_pushed: - if over_show_push: - push_content = "直播间状态更新:[直播间名称] 直播已结束!时间:[时间]" - if over_push_message_text: - push_content = over_push_message_text - - push_content = (push_content.replace('[直播间名称]', record_name). - replace('[时间]', push_at)) - threading.Thread( - target=push_message, - args=(record_name, record_url, push_content.replace(r'\n', '\n')), - daemon=True - ).start() - start_pushed = False - - else: - content = f"\r{record_name} 正在直播中..." - print(content) - - if live_status_push and not start_pushed: - if begin_show_push: - push_content = "直播间状态更新:[直播间名称] 正在直播中,时间:[时间]" - if begin_push_message_text: - push_content = begin_push_message_text - - push_content = (push_content.replace('[直播间名称]', record_name). - replace('[时间]', push_at)) - threading.Thread( - target=push_message, - args=(record_name, record_url, push_content.replace(r'\n', '\n')), - daemon=True - ).start() - start_pushed = True - - if disable_record: - time.sleep(push_check_seconds) - continue - - real_url = select_source_url(record_url, port_info) - full_path = f'{default_path}/{platform}' - if real_url: - now = datetime.datetime.today().strftime("%Y-%m-%d_%H-%M-%S") - live_title = port_info.get('title') - title_in_name = '' - if live_title: - live_title = clean_name(live_title) - title_in_name = live_title + '_' if filename_by_title else '' - - try: - if len(video_save_path) > 0: - if not video_save_path.endswith(('/', '\\')): - full_path = f'{video_save_path}/{platform}' - else: - full_path = f'{video_save_path}{platform}' - - full_path = full_path.replace("\\", '/') - if folder_by_author: - full_path = f'{full_path}/{anchor_name}' - if folder_by_time: - full_path = f'{full_path}/{now[:10]}' - if folder_by_title and port_info.get('title'): - if folder_by_time: - full_path = f'{full_path}/{live_title}_{anchor_name}' - else: - full_path = f'{full_path}/{now[:10]}_{live_title}' - if not os.path.exists(full_path): - os.makedirs(full_path) - except Exception as e: - logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}") - - if platform != '自定义录制直播': - if enable_https_recording and real_url.startswith("http://"): - real_url = real_url.replace("http://", "https://") - - http_record_list = ['shopee', "migu"] - if platform in http_record_list: - real_url = real_url.replace("https://", "http://") - - user_agent = ("Mozilla/5.0 (Linux; Android 11; SAMSUNG SM-G973U) AppleWebKit/537.36 (" - "KHTML, like Gecko) SamsungBrowser/14.2 Chrome/87.0.4280.141 Mobile " - "Safari/537.36") - - rw_timeout = "15000000" - analyzeduration = "20000000" - probesize = "10000000" - bufsize = "8000k" - max_muxing_queue_size = "1024" - for pt_host in overseas_platform_host: - if pt_host in record_url: - rw_timeout = "50000000" - analyzeduration = "40000000" - probesize = "20000000" - bufsize = "15000k" - max_muxing_queue_size = "2048" - break - - ffmpeg_command = [ - 'ffmpeg', "-y", - "-v", "verbose", - "-rw_timeout", rw_timeout, - "-loglevel", "error", - "-hide_banner", - "-user_agent", user_agent, - "-protocol_whitelist", "rtmp,crypto,file,http,https,tcp,tls,udp,rtp,httpproxy", - "-thread_queue_size", "1024", - "-analyzeduration", analyzeduration, - "-probesize", probesize, - "-fflags", "+discardcorrupt", - "-re", "-i", real_url, - "-bufsize", bufsize, - "-sn", "-dn", - "-reconnect_delay_max", "60", - "-reconnect_streamed", "-reconnect_at_eof", - "-max_muxing_queue_size", max_muxing_queue_size, - "-correct_ts_overflow", "1", - "-avoid_negative_ts", "1" - ] - - headers = get_record_headers(platform, record_url) - if headers: - ffmpeg_command.insert(11, "-headers") - ffmpeg_command.insert(12, headers) - - if proxy_address: - ffmpeg_command.insert(1, "-http_proxy") - ffmpeg_command.insert(2, proxy_address) - - recording.add(record_name) - start_record_time = datetime.datetime.now() - recording_time_list[record_name] = [start_record_time, record_quality_zh] - rec_info = f"\r{anchor_name} 准备开始录制视频: {full_path}" - if show_url: - re_plat = ('WinkTV', 'PandaTV', 'ShowRoom', 'CHZZK', 'Youtube') - if platform in re_plat: - logger.info( - f"{platform} | {anchor_name} | 直播源地址: {port_info.get('m3u8_url')}") - else: - logger.info( - f"{platform} | {anchor_name} | 直播源地址: {real_url}") - - only_flv_record = False - only_flv_platform_list = ['shopee', '花椒直播'] - if platform in only_flv_platform_list: - logger.debug(f"提示: {platform} 将强制使用FLV格式录制") - only_flv_record = True - - only_audio_record = False - only_audio_platform_list = ['猫耳FM直播', 'Look直播'] - if platform in only_audio_platform_list: - only_audio_record = True - - record_save_type = video_save_type - - if is_flv_preferred_platform(record_url) and port_info.get('flv_url'): - codec = utils.get_query_params(port_info['flv_url'], "codec") - if codec and codec[0] == 'h265': - logger.warning("FLV is not supported for h265 codec, use TS format instead") - record_save_type = "TS" - - if only_audio_record or any(i in record_save_type for i in ['MP3', 'M4A']): - try: - now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime()) - extension = "mp3" if "m4a" not in record_save_type.lower() else "m4a" - name_format = "_%03d" if split_video_by_time else "" - save_file_path = (f"{full_path}/{anchor_name}_{title_in_name}{now}" - f"{name_format}.{extension}") - - if split_video_by_time: - print(f'\r{anchor_name} 准备开始录制音频: {save_file_path}') - - if "MP3" in record_save_type: - command = [ - "-map", "0:a", - "-c:a", "libmp3lame", - "-ab", "320k", - "-f", "segment", - "-segment_time", split_time, - "-reset_timestamps", "1", - save_file_path, - ] - else: - command = [ - "-map", "0:a", - "-c:a", "aac", - "-bsf:a", "aac_adtstoasc", - "-ab", "320k", - "-f", "segment", - "-segment_time", split_time, - "-segment_format", 'mpegts', - "-reset_timestamps", "1", - save_file_path, - ] - - else: - if "MP3" in record_save_type: - command = [ - "-map", "0:a", - "-c:a", "libmp3lame", - "-ab", "320k", - save_file_path, - ] - - else: - command = [ - "-map", "0:a", - "-c:a", "aac", - "-bsf:a", "aac_adtstoasc", - "-ab", "320k", - "-movflags", "+faststart", - save_file_path, - ] - - ffmpeg_command.extend(command) - comment_end = check_subprocess( - record_name, - record_url, - ffmpeg_command, - record_save_type, - custom_script - ) - if comment_end: - return - - except subprocess.CalledProcessError as e: - logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}") - with max_request_lock: - error_count += 1 - error_window.append(1) - - if only_flv_record: - logger.info(f"Use Direct Downloader to Download FLV Stream: {record_url}") - filename = anchor_name + f'_{title_in_name}' + now + '.flv' - save_file_path = f'{full_path}/{filename}' - print(f'{rec_info}/{filename}') - - subs_file_path = save_file_path.rsplit('.', maxsplit=1)[0] - subs_thread_name = f'subs_{Path(subs_file_path).name}' - if create_time_file: - create_var[subs_thread_name] = threading.Thread( - target=generate_subtitles, args=(record_name, subs_file_path) - ) - create_var[subs_thread_name].daemon = True - create_var[subs_thread_name].start() - - try: - flv_url = port_info.get('flv_url') - if flv_url: - recording.add(record_name) - start_record_time = datetime.datetime.now() - recording_time_list[record_name] = [start_record_time, record_quality_zh] - - download_success = direct_download_stream( - flv_url, save_file_path, record_name, record_url, platform - ) - - if download_success: - record_finished = True - print(f"\n{anchor_name} {time.strftime('%Y-%m-%d %H:%M:%S')} 直播录制完成\n") - - recording.discard(record_name) - else: - logger.debug("未找到FLV直播流,跳过录制") - except Exception as e: - clear_record_info(record_name, record_url) - color_obj.print_colored( - f"\n{anchor_name} {time.strftime('%Y-%m-%d %H:%M:%S')} 直播录制出错,请检查网络\n", - color_obj.RED) - logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}") - with max_request_lock: - error_count += 1 - error_window.append(1) - - elif record_save_type == "MP41": - print('---mp4------------') - filename = anchor_name + f'_{title_in_name}' + now + ".mp4" - print(f'{rec_info}/{filename}') - save_file_path = full_path + '/' + filename - - try: - print('------------split_video_by_time------------------') - if split_video_by_time: - now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime()) - save_file_path = f"{full_path}/{anchor_name}_{title_in_name}{now}_%03d.mp4" - command = [ - "-c:v", "copy", - "-c:a", "aac", - "-map", "0", - "-f", "segment", - "-segment_time", split_time, - "-segment_format", "mp4", - "-reset_timestamps", "1", - "-movflags", "+frag_keyframe+empty_moov", - save_file_path, - ] - - else: - command = [ - "-map", "0", - "-c:v", "copy", - "-c:a", "copy", - "-f", "mp4", - save_file_path, - ] - - ffmpeg_command.extend(command) - comment_end = check_subprocess( - record_name, - record_url, - ffmpeg_command, - record_save_type, - custom_script - ) - if comment_end: - return - - except subprocess.CalledProcessError as e: - logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}") - with max_request_lock: - error_count += 1 - error_window.append(1) - - elif record_save_type == "MP4": - try: - print('youtube------------------') - # === 从 config.ini 读取 === - key = _yt_get("youtube", "key", fallback="", cast=str) - if not key: - logger.error("错误: config.ini[youtube].key 未配置") - return - - secure = _yt_get("youtube", "secure", fallback=False, cast=bool) - ingest = _yt_get("youtube", "ingest", fallback="a", cast=str) - res_str = (_yt_get("youtube", "resolution", fallback="", cast=str) or "").strip() - - # 边界保护 - bv = _safe_int(_yt_get("youtube", "bitrate", fallback=5000, cast=int), 300, 20000, 5000) - ba = _safe_int(_yt_get("youtube", "audio_bitrate", fallback=128, cast=int), 64, 384, 128) - fps = _safe_int(_yt_get("youtube", "fps", fallback=30, cast=int), 10, 60, 30) - gop = _safe_int(_yt_get("youtube", "gop", fallback=2*fps, cast=int), fps, 4*fps, 2*fps) - allow_copy = _yt_get("youtube", "copy_when_safe", fallback=True, cast=bool) - - out_url = _build_youtube_rtmp_url(key, secure, ingest) - - # === 输出端编码参数 === - # 默认转码保证兼容;若可安全copy再切换 - v_args = [ - "-c:v", "libx264", - "-preset", "veryfast", - "-profile:v", "high", - "-level:v", "4.2", - "-pix_fmt", "yuv420p", - "-r", str(fps), - "-g", str(gop), - "-b:v", f"{bv}k", - "-maxrate", f"{bv}k", - "-bufsize", f"{max(2 * bv, 4000)}k", - ] - a_args = [ - "-c:a", "aac", - "-b:a", f"{ba}k", - "-ar", "44100", - "-ac", "2", - ] - - # 分辨率(如果填写,做等比缩放 + 留黑边) - # 例如 1280x720 -> scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2 - vf_chain = [] - if res_str: - try: - w, h = map(int, res_str.lower().split("x")) - vf_chain.append( - f"scale={w}:{h}:force_original_aspect_ratio=decrease" - ) - vf_chain.append( - f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2" - ) - vf_chain.append("format=yuv420p") - except Exception: - logger.warning(f"警告: resolution '{res_str}' 非法,已忽略") - - # 如果允许copy且源确实是 h264+aac,且未强制改分辨率,则走copy省CPU - use_copy = False - if allow_copy and not vf_chain and _can_copy_h264_aac(port_info): - use_copy = True - v_args = ["-c:v", "copy"] - a_args = ["-c:a", "copy"] - - # 组装输出端参数 - command = ["-map", "0"] - if vf_chain: - command += ["-vf", ",".join(vf_chain)] - command += v_args + a_args + ["-f", "flv", out_url] - - # 不污染后续循环:复制一份,避免多次 extend 叠加 - run_cmd = list(ffmpeg_command) + command - - # 注意:不要把 key 打进日志 - logger.info(f"{platform} | {anchor_name} | 准备推流 YouTube(安全参数已加载)") - - comment_end = check_subprocess( - record_name, - record_url, - run_cmd, - "YTLIVE", - custom_script - ) - if comment_end: - return - - except subprocess.CalledProcessError as e: - logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}") - with max_request_lock: - error_count += 1 - error_window.append(1) - else: - if split_video_by_time: - now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime()) - filename = anchor_name + f'_{title_in_name}' + now + ".ts" - print(f'{rec_info}/{filename}') - - try: - save_file_path = f"{full_path}/{anchor_name}_{title_in_name}{now}_%03d.ts" - command = [ - "-c:v", "copy", - "-c:a", "copy", - "-map", "0", - "-f", "segment", - "-segment_time", split_time, - "-segment_format", 'mpegts', - "-reset_timestamps", "1", - save_file_path, - ] - - ffmpeg_command.extend(command) - comment_end = check_subprocess( - record_name, - record_url, - ffmpeg_command, - record_save_type, - custom_script - ) - if comment_end: - if converts_to_mp4: - file_paths = utils.get_file_paths(os.path.dirname(save_file_path)) - prefix = os.path.basename(save_file_path).rsplit('_', maxsplit=1)[0] - for path in file_paths: - if prefix in path: - try: - threading.Thread( - target=converts_mp4, - args=(path, delete_origin_file) - ).start() - except subprocess.CalledProcessError as e: - logger.error(f"转码失败: {e} ") - return - - except subprocess.CalledProcessError as e: - logger.error( - f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}") - with max_request_lock: - error_count += 1 - error_window.append(1) - - else: - filename = anchor_name + f'_{title_in_name}' + now + ".ts" - print(f'{rec_info}/{filename}') - save_file_path = full_path + '/' + filename - - try: - command = [ - "-c:v", "copy", - "-c:a", "copy", - "-map", "0", - "-f", "mpegts", - save_file_path, - ] - - ffmpeg_command.extend(command) - comment_end = check_subprocess( - record_name, - record_url, - ffmpeg_command, - record_save_type, - custom_script - ) - if comment_end: - threading.Thread( - target=converts_mp4, args=(save_file_path, delete_origin_file) - ).start() - return - - except subprocess.CalledProcessError as e: - logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}") - with max_request_lock: - error_count += 1 - error_window.append(1) - - count_time = time.time() - - except Exception as e: - logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}") - with max_request_lock: - error_count += 1 - error_window.append(1) - - num = random.randint(-5, 5) + delay_default - if num < 0: - num = 0 - x = num - - if error_count > 20: - x = x + 60 - color_obj.print_colored("\r瞬时错误太多,延迟加60秒", color_obj.YELLOW) - - # 这里是.如果录制结束后,循环时间会暂时变成30s后检测一遍. 这样一定程度上防止主播卡顿造成少录 - # 当30秒过后检测一遍后. 会回归正常设置的循环秒数 - if record_finished: - count_time_end = time.time() - count_time - if count_time_end < 60: - x = 30 - record_finished = False - - else: - x = num - - # 这里是正常循环 - while x: - x = x - 1 - if loop_time: - print(f'\r{anchor_name}循环等待{x}秒 ', end="") - time.sleep(1) - if loop_time: - print('\r检测直播间中...', end="") - except Exception as e: - logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}") - with max_request_lock: - error_count += 1 - error_window.append(1) - time.sleep(2) - - -def backup_file(file_path: str, backup_dir_path: str, limit_counts: int = 6) -> None: - try: - if not os.path.exists(backup_dir_path): - os.makedirs(backup_dir_path) - - timestamp = datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S') - backup_file_name = os.path.basename(file_path) + '_' + timestamp - backup_file_path = os.path.join(backup_dir_path, backup_file_name).replace("\\", "/") - shutil.copy2(file_path, backup_file_path) - - files = os.listdir(backup_dir_path) - _files = [f for f in files if f.startswith(os.path.basename(file_path))] - _files.sort(key=lambda x: os.path.getmtime(os.path.join(backup_dir_path, x))) - - while len(_files) > limit_counts: - oldest_file = _files[0] - os.remove(os.path.join(backup_dir_path, oldest_file)) - _files = _files[1:] - - except Exception as e: - logger.error(f'\r备份配置文件 {file_path} 失败:{str(e)}') - - -def backup_file_start() -> None: - config_md5 = '' - url_config_md5 = '' - - while True: - try: - if os.path.exists(config_file): - new_config_md5 = utils.check_md5(config_file) - if new_config_md5 != config_md5: - backup_file(config_file, backup_dir) - config_md5 = new_config_md5 - - if os.path.exists(url_config_file): - new_url_config_md5 = utils.check_md5(url_config_file) - if new_url_config_md5 != url_config_md5: - backup_file(url_config_file, backup_dir) - url_config_md5 = new_url_config_md5 - time.sleep(600) - except Exception as e: - logger.error(f"备份配置文件失败, 错误信息: {e}") - - -def check_ffmpeg_existence() -> bool: - try: - result = subprocess.run(['ffmpeg', '-version'], check=True, capture_output=True, text=True) - if result.returncode == 0: - lines = result.stdout.splitlines() - version_line = lines[0] - built_line = lines[1] - print(version_line) - print(built_line) - except subprocess.CalledProcessError as e: - logger.error(e) - except FileNotFoundError: - pass - finally: - if check_ffmpeg(): - time.sleep(1) - return True - return False - - -# --------------------------初始化程序------------------------------------- -print("-----------------------------------------------------") -print("| DouyinLiveRecorder |") -print("-----------------------------------------------------") - -print(f"版本号: {version}") -print("GitHub: https://github.com/ihmily/DouyinLiveRecorder") -print(f'支持平台: {platforms}') -print('.....................................................') -if not check_ffmpeg_existence(): - logger.error("缺少ffmpeg无法进行录制,程序退出") - sys.exit(1) -os.makedirs(os.path.dirname(config_file), exist_ok=True) -t3 = threading.Thread(target=backup_file_start, args=(), daemon=True) -t3.start() -utils.remove_duplicate_lines(url_config_file) - - -def read_config_value(config_parser: configparser.RawConfigParser, section: str, option: str, default_value: Any) \ - -> Any: - try: - - config_parser.read(config_file, encoding=text_encoding) - if '录制设置' not in config_parser.sections(): - config_parser.add_section('录制设置') - if '推送配置' not in config_parser.sections(): - config_parser.add_section('推送配置') - if 'Cookie' not in config_parser.sections(): - config_parser.add_section('Cookie') - if 'Authorization' not in config_parser.sections(): - config_parser.add_section('Authorization') - if '账号密码' not in config_parser.sections(): - config_parser.add_section('账号密码') - return config_parser.get(section, option) - except (configparser.NoSectionError, configparser.NoOptionError): - config_parser.set(section, option, str(default_value)) - with open(config_file, 'w', encoding=text_encoding) as f: - config_parser.write(f) - return default_value - - -options = {"是": True, "否": False} -config = configparser.RawConfigParser() -language = read_config_value(config, '录制设置', 'language(zh_cn/en)', "zh_cn") -skip_proxy_check = options.get(read_config_value(config, '录制设置', '是否跳过代理检测(是/否)', "否"), False) -if language and 'en' not in language.lower(): - from i18n import translated_print - - builtins.print = translated_print - -try: - if skip_proxy_check: - global_proxy = True - else: - print('系统代理检测中,请耐心等待...') - response_g = urllib.request.urlopen("https://www.google.com/", timeout=15) - global_proxy = True - print('\r全局/规则网络代理已开启√') - pd = ProxyDetector() - if pd.is_proxy_enabled(): - proxy_info = pd.get_proxy_info() - print("System Proxy: http://{}:{}".format(proxy_info.ip, proxy_info.port)) -except HTTPError as err: - print(f"HTTP error occurred: {err.code} - {err.reason}") -except URLError: - color_obj.print_colored("INFO:未检测到全局/规则网络代理,请检查代理配置(若无需录制海外直播请忽略此条提示)", - color_obj.YELLOW) -except Exception as err: - print("An unexpected error occurred:", err) - -while True: - - try: - if not os.path.isfile(config_file): - with open(config_file, 'w', encoding=text_encoding) as file: - pass - - ini_URL_content = '' - if os.path.isfile(url_config_file): - with open(url_config_file, 'r', encoding=text_encoding) as file: - ini_URL_content = file.read().strip() - - if not ini_URL_content.strip(): - input_url = input('请输入要录制的主播直播间网址(尽量使用PC网页端的直播间地址):\n') - with open(url_config_file, 'w', encoding=text_encoding) as file: - file.write(input_url) - except OSError as err: - logger.error(f"发生 I/O 错误: {err}") - - video_save_path = read_config_value(config, '录制设置', '直播保存路径(不填则默认)', "") - folder_by_author = options.get(read_config_value(config, '录制设置', '保存文件夹是否以作者区分', "是"), False) - folder_by_time = options.get(read_config_value(config, '录制设置', '保存文件夹是否以时间区分', "否"), False) - folder_by_title = options.get(read_config_value(config, '录制设置', '保存文件夹是否以标题区分', "否"), False) - filename_by_title = options.get(read_config_value(config, '录制设置', '保存文件名是否包含标题', "否"), False) - clean_emoji = options.get(read_config_value(config, '录制设置', '是否去除名称中的表情符号', "是"), True) - video_save_type = read_config_value(config, '录制设置', '视频保存格式ts|mkv|flv|mp4|mp3音频|m4a音频', "mp4") - video_record_quality = read_config_value(config, '录制设置', '原画|超清|高清|标清|流畅', "原画") - use_proxy = options.get(read_config_value(config, '录制设置', '是否使用代理ip(是/否)', "是"), False) - proxy_addr_bak = read_config_value(config, '录制设置', '代理地址', "") - proxy_addr = None if not use_proxy else proxy_addr_bak - max_request = int(read_config_value(config, '录制设置', '同一时间访问网络的线程数', 3)) - semaphore = threading.Semaphore(max_request) - delay_default = int(read_config_value(config, '录制设置', '循环时间(秒)', 120)) - local_delay_default = int(read_config_value(config, '录制设置', '排队读取网址时间(秒)', 0)) - loop_time = options.get(read_config_value(config, '录制设置', '是否显示循环秒数', "否"), False) - show_url = options.get(read_config_value(config, '录制设置', '是否显示直播源地址', "否"), False) - split_video_by_time = options.get(read_config_value(config, '录制设置', '分段录制是否开启', "否"), False) - enable_https_recording = options.get(read_config_value(config, '录制设置', '是否强制启用https录制', "否"), False) - disk_space_limit = float(read_config_value(config, '录制设置', '录制空间剩余阈值(gb)', 1.0)) - split_time = str(read_config_value(config, '录制设置', '视频分段时间(秒)', 1800)) - converts_to_mp4 = options.get(read_config_value(config, '录制设置', '录制完成后自动转为mp4格式', "否"), False) - converts_to_h264 = options.get(read_config_value(config, '录制设置', 'mp4格式重新编码为h264', "否"), False) - delete_origin_file = options.get(read_config_value(config, '录制设置', '追加格式后删除原文件', "否"), False) - create_time_file = options.get(read_config_value(config, '录制设置', '生成时间字幕文件', "否"), False) - is_run_script = options.get(read_config_value(config, '录制设置', '是否录制完成后执行自定义脚本', "否"), False) - custom_script = read_config_value(config, '录制设置', '自定义脚本执行命令', "") if is_run_script else None - enable_proxy_platform = read_config_value( - config, '录制设置', '使用代理录制的平台(逗号分隔)', - 'tiktok, soop, pandalive, winktv, flextv, popkontv, twitch, liveme, showroom, chzzk, shopee, shp, youtu, faceit' - ) - enable_proxy_platform_list = enable_proxy_platform.replace(',', ',').split(',') if enable_proxy_platform else None - extra_enable_proxy = read_config_value(config, '录制设置', '额外使用代理录制的平台(逗号分隔)', '') - extra_enable_proxy_platform_list = extra_enable_proxy.replace(',', ',').split(',') if extra_enable_proxy else None - live_status_push = read_config_value(config, '推送配置', '直播状态推送渠道', "") - dingtalk_api_url = read_config_value(config, '推送配置', '钉钉推送接口链接', "") - xizhi_api_url = read_config_value(config, '推送配置', '微信推送接口链接', "") - bark_msg_api = read_config_value(config, '推送配置', 'bark推送接口链接', "") - bark_msg_level = read_config_value(config, '推送配置', 'bark推送中断级别', "active") - bark_msg_ring = read_config_value(config, '推送配置', 'bark推送铃声', "bell") - dingtalk_phone_num = read_config_value(config, '推送配置', '钉钉通知@对象(填手机号)', "") - dingtalk_is_atall = options.get(read_config_value(config, '推送配置', '钉钉通知@全体(是/否)', "否"), False) - tg_token = read_config_value(config, '推送配置', 'tgapi令牌', "") - tg_chat_id = read_config_value(config, '推送配置', 'tg聊天id(个人或者群组id)', "") - email_host = read_config_value(config, '推送配置', 'SMTP邮件服务器', "") - open_smtp_ssl = options.get(read_config_value(config, '推送配置', '是否使用SMTP服务SSL加密(是/否)', "是"), True) - smtp_port = read_config_value(config, '推送配置', 'SMTP邮件服务器端口', "") - login_email = read_config_value(config, '推送配置', '邮箱登录账号', "") - email_password = read_config_value(config, '推送配置', '发件人密码(授权码)', "") - sender_email = read_config_value(config, '推送配置', '发件人邮箱', "") - sender_name = read_config_value(config, '推送配置', '发件人显示昵称', "") - to_email = read_config_value(config, '推送配置', '收件人邮箱', "") - ntfy_api = read_config_value(config, '推送配置', 'ntfy推送地址', "") - ntfy_tags = read_config_value(config, '推送配置', 'ntfy推送标签', "tada") - ntfy_email = read_config_value(config, '推送配置', 'ntfy推送邮箱', "") - pushplus_token = read_config_value(config, '推送配置', 'pushplus推送token', "") - push_message_title = read_config_value(config, '推送配置', '自定义推送标题', "直播间状态更新通知") - begin_push_message_text = read_config_value(config, '推送配置', '自定义开播推送内容', "") - over_push_message_text = read_config_value(config, '推送配置', '自定义关播推送内容', "") - disable_record = options.get(read_config_value(config, '推送配置', '只推送通知不录制(是/否)', "否"), False) - push_check_seconds = int(read_config_value(config, '推送配置', '直播推送检测频率(秒)', 1800)) - begin_show_push = options.get(read_config_value(config, '推送配置', '开播推送开启(是/否)', "是"), True) - over_show_push = options.get(read_config_value(config, '推送配置', '关播推送开启(是/否)', "否"), False) - sooplive_username = read_config_value(config, '账号密码', 'sooplive账号', '') - sooplive_password = read_config_value(config, '账号密码', 'sooplive密码', '') - flextv_username = read_config_value(config, '账号密码', 'flextv账号', '') - flextv_password = read_config_value(config, '账号密码', 'flextv密码', '') - popkontv_username = read_config_value(config, '账号密码', 'popkontv账号', '') - popkontv_partner_code = read_config_value(config, '账号密码', 'partner_code', 'P-00001') - popkontv_password = read_config_value(config, '账号密码', 'popkontv密码', '') - twitcasting_account_type = read_config_value(config, '账号密码', 'twitcasting账号类型', 'normal') - twitcasting_username = read_config_value(config, '账号密码', 'twitcasting账号', '') - twitcasting_password = read_config_value(config, '账号密码', 'twitcasting密码', '') - popkontv_access_token = read_config_value(config, 'Authorization', 'popkontv_token', '') - dy_cookie = read_config_value(config, 'Cookie', '抖音cookie', '') - ks_cookie = read_config_value(config, 'Cookie', '快手cookie', '') - tiktok_cookie = read_config_value(config, 'Cookie', 'tiktok_cookie', '') - hy_cookie = read_config_value(config, 'Cookie', '虎牙cookie', '') - douyu_cookie = read_config_value(config, 'Cookie', '斗鱼cookie', '') - yy_cookie = read_config_value(config, 'Cookie', 'yy_cookie', '') - bili_cookie = read_config_value(config, 'Cookie', 'B站cookie', '') - xhs_cookie = read_config_value(config, 'Cookie', '小红书cookie', '') - bigo_cookie = read_config_value(config, 'Cookie', 'bigo_cookie', '') - blued_cookie = read_config_value(config, 'Cookie', 'blued_cookie', '') - sooplive_cookie = read_config_value(config, 'Cookie', 'sooplive_cookie', '') - netease_cookie = read_config_value(config, 'Cookie', 'netease_cookie', '') - qiandurebo_cookie = read_config_value(config, 'Cookie', '千度热播_cookie', '') - pandatv_cookie = read_config_value(config, 'Cookie', 'pandatv_cookie', '') - maoerfm_cookie = read_config_value(config, 'Cookie', '猫耳fm_cookie', '') - winktv_cookie = read_config_value(config, 'Cookie', 'winktv_cookie', '') - flextv_cookie = read_config_value(config, 'Cookie', 'flextv_cookie', '') - look_cookie = read_config_value(config, 'Cookie', 'look_cookie', '') - twitcasting_cookie = read_config_value(config, 'Cookie', 'twitcasting_cookie', '') - baidu_cookie = read_config_value(config, 'Cookie', 'baidu_cookie', '') - weibo_cookie = read_config_value(config, 'Cookie', 'weibo_cookie', '') - kugou_cookie = read_config_value(config, 'Cookie', 'kugou_cookie', '') - twitch_cookie = read_config_value(config, 'Cookie', 'twitch_cookie', '') - liveme_cookie = read_config_value(config, 'Cookie', 'liveme_cookie', '') - huajiao_cookie = read_config_value(config, 'Cookie', 'huajiao_cookie', '') - liuxing_cookie = read_config_value(config, 'Cookie', 'liuxing_cookie', '') - showroom_cookie = read_config_value(config, 'Cookie', 'showroom_cookie', '') - acfun_cookie = read_config_value(config, 'Cookie', 'acfun_cookie', '') - changliao_cookie = read_config_value(config, 'Cookie', 'changliao_cookie', '') - yinbo_cookie = read_config_value(config, 'Cookie', 'yinbo_cookie', '') - yingke_cookie = read_config_value(config, 'Cookie', 'yingke_cookie', '') - zhihu_cookie = read_config_value(config, 'Cookie', 'zhihu_cookie', '') - chzzk_cookie = read_config_value(config, 'Cookie', 'chzzk_cookie', '') - haixiu_cookie = read_config_value(config, 'Cookie', 'haixiu_cookie', '') - vvxqiu_cookie = read_config_value(config, 'Cookie', 'vvxqiu_cookie', '') - yiqilive_cookie = read_config_value(config, 'Cookie', '17live_cookie', '') - langlive_cookie = read_config_value(config, 'Cookie', 'langlive_cookie', '') - pplive_cookie = read_config_value(config, 'Cookie', 'pplive_cookie', '') - six_room_cookie = read_config_value(config, 'Cookie', '6room_cookie', '') - lehaitv_cookie = read_config_value(config, 'Cookie', 'lehaitv_cookie', '') - huamao_cookie = read_config_value(config, 'Cookie', 'huamao_cookie', '') - shopee_cookie = read_config_value(config, 'Cookie', 'shopee_cookie', '') - youtube_cookie = read_config_value(config, 'Cookie', 'youtube_cookie', '') - taobao_cookie = read_config_value(config, 'Cookie', 'taobao_cookie', '') - jd_cookie = read_config_value(config, 'Cookie', 'jd_cookie', '') - faceit_cookie = read_config_value(config, 'Cookie', 'faceit_cookie', '') - migu_cookie = read_config_value(config, 'Cookie', 'migu_cookie', '') - lianjie_cookie = read_config_value(config, 'Cookie', 'lianjie_cookie', '') - laixiu_cookie = read_config_value(config, 'Cookie', 'laixiu_cookie', '') - picarto_cookie = read_config_value(config, 'Cookie', 'picarto_cookie', '') - - video_save_type_list = ("FLV", "MKV", "TS", "MP4", "MP3音频", "M4A音频", "MP3", "M4A") - if video_save_type and video_save_type.upper() in video_save_type_list: - video_save_type = video_save_type.upper() - else: - # video_save_type = "TS" - video_save_type = "MP4" - - - - check_path = video_save_path or default_path - if utils.check_disk_capacity(check_path, show=first_run) < disk_space_limit: - exit_recording = True - if not recording: - logger.warning(f"Disk space remaining is below {disk_space_limit} GB. " - f"Exiting program due to the disk space limit being reached.") - sys.exit(-1) - - - def contains_url(string: str) -> bool: - pattern = r"(https?://)?(www\.)?[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+(:\d+)?(/.*)?" - return re.search(pattern, string) is not None - - - try: - url_comments, line_list, url_line_list = [[] for _ in range(3)] - with (open(url_config_file, "r", encoding=text_encoding, errors='ignore') as file): - for origin_line in file: - if origin_line in line_list: - delete_line(url_config_file, origin_line) - line_list.append(origin_line) - line = origin_line.strip() - if len(line) < 18: - continue - - line_spilt = line.split('主播: ') - if len(line_spilt) > 2: - line = update_file(url_config_file, line, f'{line_spilt[0]}主播: {line_spilt[-1]}') - - is_comment_line = line.startswith("#") - if is_comment_line: - line = line.lstrip('#') - - if re.search('[,,]', line): - split_line = re.split('[,,]', line) - else: - split_line = [line, ''] - - if len(split_line) == 1: - url = split_line[0] - quality, name = [video_record_quality, ''] - elif len(split_line) == 2: - if contains_url(split_line[0]): - quality = video_record_quality - url, name = split_line - else: - quality, url = split_line - name = '' - else: - quality, url, name = split_line - - if quality not in ("原画", "蓝光", "超清", "高清", "标清", "流畅"): - quality = '原画' - - if url not in url_line_list: - url_line_list.append(url) - else: - delete_line(url_config_file, origin_line) - - url = 'https://' + url if '://' not in url else url - url_host = url.split('/')[2] - - platform_host = [ - 'live.douyin.com', - 'v.douyin.com', - 'www.douyin.com', - 'live.kuaishou.com', - 'www.huya.com', - 'www.douyu.com', - 'www.yy.com', - 'live.bilibili.com', - 'www.redelight.cn', - 'www.xiaohongshu.com', - 'xhslink.com', - 'www.bigo.tv', - 'slink.bigovideo.tv', - 'app.blued.cn', - 'cc.163.com', - 'qiandurebo.com', - 'fm.missevan.com', - 'look.163.com', - 'twitcasting.tv', - 'live.baidu.com', - 'weibo.com', - 'fanxing.kugou.com', - 'fanxing2.kugou.com', - 'mfanxing.kugou.com', - 'www.huajiao.com', - 'www.7u66.com', - 'wap.7u66.com', - 'live.acfun.cn', - 'm.acfun.cn', - 'live.tlclw.com', - 'wap.tlclw.com', - 'live.ybw1666.com', - 'wap.ybw1666.com', - 'www.inke.cn', - 'www.zhihu.com', - 'www.haixiutv.com', - "h5webcdnp.vvxqiu.com", - "17.live", - 'www.lang.live', - "m.pp.weimipopo.com", - "v.6.cn", - "m.6.cn", - 'www.lehaitv.com', - 'h.catshow168.com', - 'e.tb.cn', - 'huodong.m.taobao.com', - '3.cn', - 'eco.m.jd.com', - 'www.miguvideo.com', - 'm.miguvideo.com', - 'show.lailianjie.com', - 'www.imkktv.com', - 'www.picarto.tv' - ] - overseas_platform_host = [ - 'www.tiktok.com', - 'play.sooplive.co.kr', - 'm.sooplive.co.kr', - 'www.pandalive.co.kr', - 'www.winktv.co.kr', - 'www.flextv.co.kr', - 'www.ttinglive.com', - 'www.popkontv.com', - 'www.twitch.tv', - 'www.liveme.com', - 'www.showroom-live.com', - 'chzzk.naver.com', - 'm.chzzk.naver.com', - 'live.shopee.', - '.shp.ee', - 'www.youtube.com', - 'youtu.be', - 'www.faceit.com' - ] - - platform_host.extend(overseas_platform_host) - clean_url_host_list = ( - "live.douyin.com", - "live.bilibili.com", - "www.huajiao.com", - "www.zhihu.com", - "www.huya.com", - "chzzk.naver.com", - "www.liveme.com", - "www.haixiutv.com", - "v.6.cn", - "m.6.cn", - 'www.lehaitv.com' - ) - - if 'live.shopee.' in url_host or '.shp.ee' in url_host: - url_host = 'live.shopee.' if 'live.shopee.' in url_host else '.shp.ee' - - if url_host in platform_host or any(ext in url for ext in (".flv", ".m3u8")): - if url_host in clean_url_host_list: - url = update_file(url_config_file, old_str=url, new_str=url.split('?')[0]) - - if 'xiaohongshu' in url: - host_id = re.search('&host_id=(.*?)(?=&|$)', url) - if host_id: - new_url = url.split('?')[0] + f'?host_id={host_id.group(1)}' - url = update_file(url_config_file, old_str=url, new_str=new_url) - - url_comments = [i for i in url_comments if url not in i] - if is_comment_line: - url_comments.append(url) - else: - new_line = (quality, url, name) - url_tuples_list.append(new_line) - else: - if not origin_line.startswith('#'): - color_obj.print_colored(f"\r{origin_line.strip()} 本行包含未知链接.此条跳过", color_obj.YELLOW) - update_file(url_config_file, old_str=origin_line, new_str=origin_line, start_str='#') - - while len(need_update_line_list): - a = need_update_line_list.pop() - replace_words = a.split('|') - if replace_words[0] != replace_words[1]: - if replace_words[1].startswith("#"): - start_with = '#' - new_word = replace_words[1][1:] - else: - start_with = None - new_word = replace_words[1] - update_file(url_config_file, old_str=replace_words[0], new_str=new_word, start_str=start_with) - - text_no_repeat_url = list(set(url_tuples_list)) - - if len(text_no_repeat_url) > 0: - for url_tuple in text_no_repeat_url: - monitoring = len(running_list) - - if url_tuple[1] in not_record_list: - continue - - if url_tuple[1] not in running_list: - print(f"\r{'新增' if not first_start else '传入'}地址: {url_tuple[1]}") - monitoring += 1 - args = [url_tuple, monitoring] - create_var[f'thread_{monitoring}'] = threading.Thread(target=start_record, args=args) - create_var[f'thread_{monitoring}'].daemon = True - create_var[f'thread_{monitoring}'].start() - running_list.append(url_tuple[1]) - time.sleep(local_delay_default) - url_tuples_list = [] - first_start = False - - except Exception as err: - logger.error(f"错误信息: {err} 发生错误的行数: {err.__traceback__.tb_lineno}") - - if first_run: - t = threading.Thread(target=display_info, args=(), daemon=True) - t.start() - t2 = threading.Thread(target=adjust_max_request, args=(), daemon=True) - t2.start() - first_run = False - - time.sleep(3) - diff --git a/back/back2 b/back/back2 deleted file mode 100644 index 530b0be..0000000 --- a/back/back2 +++ /dev/null @@ -1,508 +0,0 @@ -# -*- encoding: utf-8 -*- - -""" -单进程 ffmpeg 推流:从 URL_config.ini 顺序检测直播源, -一旦当前源停播/异常,自动切到下一个正在直播的源并推流到 YouTube。 -仅做“检测+转推”,不做录制、分段、通知等。 -""" - -import asyncio -import os -import sys -import signal -import time -import datetime -import re -import uuid -import subprocess -from pathlib import Path -from typing import Any, List, Tuple, Optional - -import configparser -import urllib.request -from urllib.error import URLError, HTTPError - -import httpx -from src import spider, stream -from src.utils import logger, Color, check_md5 -from ffmpeg_install import check_ffmpeg, ffmpeg_path, current_env_path - -# --------------------- 基础环境 --------------------- -version = "relay-1.0.0" -os.environ['PATH'] = ffmpeg_path + os.pathsep + current_env_path -script_path = os.path.split(os.path.realpath(sys.argv[0]))[0] -config_file = f'{script_path}/config/config.ini' -url_config_file = f'{script_path}/config/URL_config.ini' -text_encoding = 'utf-8-sig' -color = Color() - -# ---------- YouTube 推流配置(读取 youtube.ini) ---------- -_yt_cfg = configparser.ConfigParser() -with open(f"{script_path}/config/youtube.ini", "r", encoding="utf-8-sig") as f: - _yt_cfg.read_file(f) - -def _yt_get(section: str, key: str, fallback=None, cast=str): - try: - if cast is bool: - return _yt_cfg.getboolean(section, key, fallback=fallback) - if cast is int: - return _yt_cfg.getint(section, key, fallback=fallback) - return _yt_cfg.get(section, key, fallback=fallback) - except Exception: - return fallback - -def _safe_int(v, lo, hi, default): - try: - v = int(v) - if v < lo or v > hi: - return default - return v - except Exception: - return default - -def _build_youtube_rtmp_url(key: str, secure: bool, ingest: str | None): - node = (ingest or "a").strip().lower() - node = node if node.isalpha() and len(node) == 1 else "a" - scheme = "rtmps" if secure else "rtmp" - host = f"{node}.rtmps.youtube.com" if secure else f"{node}.rtmp.youtube.com" - return f"{scheme}://{host}/live2/{key}" - -# --------------------- 简化配置读取 --------------------- -def read_config_value(cfg: configparser.RawConfigParser, section: str, option: str, default_value: Any) -> Any: - try: - cfg.read(config_file, encoding=text_encoding) - if section not in cfg.sections(): - cfg.add_section(section) - return cfg.get(section, option) - except (configparser.NoSectionError, configparser.NoOptionError): - cfg.set(section, option, str(default_value)) - with open(config_file, 'w', encoding=text_encoding) as f: - cfg.write(f) - return default_value - -_CN_BOOL = {"是": True, "否": False} - -cfg = configparser.RawConfigParser() -use_proxy = _CN_BOOL.get(read_config_value(cfg, '录制设置', '是否使用代理ip(是/否)', "否"), False) -proxy_addr = read_config_value(cfg, '录制设置', '代理地址', "") -proxy_addr = proxy_addr if use_proxy and proxy_addr.strip() else None -# Cookie(按需) -dy_cookie = read_config_value(cfg, 'Cookie', '抖音cookie', '') -tiktok_cookie = read_config_value(cfg, 'Cookie', 'tiktok_cookie', '') - -# 可选:启动时检测系统代理(仅提示) -try: - if not use_proxy: - urllib.request.urlopen("https://www.google.com/", timeout=5) - print("提示:你的网络似乎直连可用(无需代理)。") -except Exception: - if not use_proxy: - color.print_colored("提示:无法直连海外站,如需 TikTok 请在 config.ini 开启代理并填写代理地址。", color.YELLOW) - -# --------------------- URL 列表读取 --------------------- -def parse_urls_from_file(path: str) -> List[str]: - """ - 读取 URL_config.ini,返回去重且保持相对顺序的 URL 列表。 - 支持行内格式: - - 纯 URL - - quality,url - - url,主播: xxx - - # 注释行 忽略 - """ - urls, seen = [], set() - if not os.path.isfile(path): - open(path, 'w', encoding=text_encoding).close() - print("URL_config.ini 为空,请写入直播间地址后再运行。") - return urls - - with open(path, "r", encoding=text_encoding, errors='ignore') as f: - for line in f: - s = line.strip() - if not s or s.startswith("#"): - continue - # 拆分(最多3段,取包含 https 或域名的那段) - parts = re.split(r"[,,]\s*", s) - cand = None - for p in parts: - if '://' in p or re.match(r"^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", p): - cand = p - break - if not cand: - continue - url = cand if '://' in cand else f"https://{cand}" - if url not in seen: - seen.add(url) - urls.append(url) - return urls - -# --------------------- 直播解析与选源 --------------------- -def is_flv_preferred_platform(link: str) -> bool: - # 抖音/TikTok 优先走 FLV;但 H265 不走 FLV - return any(i in link for i in ["douyin", "tiktok"]) - -def select_source_url(link: str, stream_info: dict) -> Optional[str]: - if not stream_info: - return None - if is_flv_preferred_platform(link): - flv = stream_info.get("flv_url") - if flv: - # 若 codec=h265 则不走 flv - try: - from urllib.parse import urlparse, parse_qs - qs = parse_qs(urlparse(flv).query or "") - if (qs.get("codec") or [""])[0].lower() == "h265": - logger.warning("FLV 不支持 h265,改用 HLS。") - else: - return flv - except Exception: - return flv - return stream_info.get("record_url") or stream_info.get("m3u8_url") or stream_info.get("flv_url") - -async def fetch_port_info(url: str, record_quality_code: str = "OD") -> dict: - """ - 调用项目现有 spider/stream,返回: - { - "anchor_name": str, - "is_live": bool, - "title": str?, - "record_url": "...", - "flv_url": "...", - "m3u8_url": "..." - } - """ - proxy = proxy_addr - port_info = {} - try: - if "douyin.com/" in url: - if 'v.douyin.com' not in url and '/user/' not in url: - j = await spider.get_douyin_stream_data(url=url, proxy_addr=proxy, cookies=dy_cookie) - else: - j = await spider.get_douyin_app_stream_data(url=url, proxy_addr=proxy, cookies=dy_cookie) - port_info = await stream.get_douyin_stream_url(j, record_quality_code, proxy) - elif url.startswith("https://www.tiktok.com/"): - if proxy: - j = await spider.get_tiktok_stream_data(url=url, proxy_addr=proxy, cookies=tiktok_cookie) - port_info = await stream.get_tiktok_stream_url(j, record_quality_code, proxy) - else: - logger.error("TikTok 需要代理,请在 config.ini 开启并配置代理。") - port_info = {"anchor_name": "", "is_live": False} - - elif (".m3u8" in url) or (".flv" in url): - # 自定义直链 - port_info = { - "anchor_name": "自定义_" + str(uuid.uuid4())[:8], - "is_live": True, - } - if url.endswith(".flv"): - port_info["flv_url"] = url - else: - port_info["m3u8_url"] = url - else: - # 其它站点可按需扩展 - port_info = {"anchor_name": "", "is_live": False} - except Exception as e: - logger.error(f"解析失败: {e}") - port_info = {"anchor_name": "", "is_live": False} - return port_info - -# --------------------- ffmpeg 单进程推流 --------------------- -def build_youtube_output() -> Tuple[str, list, list]: - key = _yt_get("youtube", "key", fallback="", cast=str) - if not key: - logger.error("错误: config/youtube.ini 未配置 key") - sys.exit(1) - - secure = _yt_get("youtube", "secure", fallback=False, cast=bool) - ingest = _yt_get("youtube", "ingest", fallback="a", cast=str) - res_str = (_yt_get("youtube", "resolution", fallback="", cast=str) or "").strip() - - bv = _safe_int(_yt_get("youtube", "bitrate", fallback=5000, cast=int), 300, 20000, 5000) - ba = _safe_int(_yt_get("youtube", "audio_bitrate", fallback=128, cast=int), 64, 384, 128) - fps = _safe_int(_yt_get("youtube", "fps", fallback=30, cast=int), 10, 60, 30) - gop = _safe_int(_yt_get("youtube", "gop", fallback=2*fps, cast=int), fps, 4*fps, 2*fps) - - out_url = _build_youtube_rtmp_url(key, secure, ingest) - - # —— 默认强制转码(最稳),如果想省 CPU 可把 -c:v libx264 改为 copy —— # - v_args = [ - "-c:v", "libx264", - "-preset", "veryfast", - "-profile:v", "high", - "-level:v", "4.2", - "-pix_fmt", "yuv420p", - "-r", str(fps), - "-g", str(gop), - "-b:v", f"{bv}k", - "-maxrate", f"{bv}k", - "-bufsize", f"{max(2*bv, 4000)}k", - ] - a_args = [ - "-c:a", "aac", - "-b:a", f"{ba}k", - "-ar", "44100", - "-ac", "2", - ] - - # 可选缩放/留黑边,保持 YT 吃得更稳 - if res_str: - try: - w, h = map(int, res_str.lower().split("x")) - vf = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,format=yuv420p" - v_args = ["-vf", vf] + v_args - except Exception: - logger.warning(f"resolution '{res_str}' 非法,忽略") - - return out_url, v_args, a_args - -def launch_ffmpeg(input_url: str, out_url: str, extra_headers: Optional[str] = None) -> subprocess.Popen: - """ - 启动一个 ffmpeg 进程:输入 input_url,输出 RTMP 到 out_url。 - """ - user_agent = ("Mozilla/5.0 (Linux; Android 11; SAMSUNG SM-G973U) AppleWebKit/537.36 " - "(KHTML, like Gecko) SamsungBrowser/14.2 Chrome/87.0.4280.141 Mobile Safari/537.36") - - rw_timeout = "15000000" - analyzeduration = "20000000" - probesize = "10000000" - bufsize = "8000k" - - base = [ - "ffmpeg", "-nostdin", "-hide_banner", - # 超时 & 重连(必须在 -i 前面) - "-rw_timeout", rw_timeout, - "-timeout", "10000000", - "-reconnect", "1", - "-reconnect_streamed", "1", - "-reconnect_at_eof", "1", - "-reconnect_delay_max", "60", - - # UA + 协议 - "-user_agent", user_agent, - "-protocol_whitelist", "rtmp,crypto,file,http,https,tcp,tls,udp,rtp,httpproxy", - - # 缓冲 & 分析 - "-thread_queue_size", "1024", - "-analyzeduration", analyzeduration, - "-probesize", probesize, - "-fflags", "+discardcorrupt", - - # 输入流(放在重连参数之后) - "-re", "-i", input_url, - - # 视频/音频编解码 - "-c:v", "copy", # 直接拷贝视频,省 CPU,低延迟 - "-c:a", "aac", # 音频转码成 AAC(保证 YouTube 兼容) - "-ar", "44100", # 音频采样率 - "-b:a", "128k", # 音频码率 - "-ac", "2", # 双声道 - "-bufsize", bufsize, - "-max_muxing_queue_size", "2048", - "-avoid_negative_ts", "1", - "-fflags", "+genpts", # 确保时间戳连续,防止花屏 - - # 输出容器 - "-f", "flv", out_url - ] - - if extra_headers: - base[ base.index("-user_agent") : base.index("-protocol_whitelist") ] += ["-headers", extra_headers] - - if proxy_addr: - base = ["ffmpeg", "-http_proxy", proxy_addr] + base[1:] - - logger.info(f"启动推流:{input_url} -> YouTube") - p = subprocess.Popen(base, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) - return p - -def stop_ffmpeg(p: Optional[subprocess.Popen]): - if not p: - return - try: - if os.name == "nt": - if p.stdin: - try: - p.stdin.write(b"q") - p.stdin.flush() - except Exception: - pass - else: - p.send_signal(signal.SIGINT) - p.wait(timeout=10) - except Exception: - try: - p.kill() - except Exception: - pass - -# --------------------- 主循环(单进程转推+切源) --------------------- -def check_ffmpeg_existence() -> bool: - try: - result = subprocess.run(['ffmpeg', '-version'], check=True, capture_output=True, text=True) - if result.returncode == 0: - print(result.stdout.splitlines()[0]) - except Exception: - pass - finally: - return bool(check_ffmpeg()) - -def now_str(): return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - -def headers_for_platform(platform_name: str, live_url: str) -> Optional[str]: - live_domain = '/'.join(live_url.split('/')[0:3]) - record_headers = { - 'PandaTV': 'origin:https://www.pandalive.co.kr', - 'WinkTV': 'origin:https://www.winktv.co.kr', - 'PopkonTV': 'origin:https://www.popkontv.com', - 'FlexTV': 'origin:https://www.flextv.co.kr', - '千度热播': 'referer:https://qiandurebo.com', - '17Live': 'referer:https://17.live/en/live/6302408', - '浪Live': 'referer:https://www.lang.live', - 'shopee': f'origin:{live_domain}', - 'Blued直播': 'referer:https://app.blued.cn' - } - return record_headers.get(platform_name) - -def detect_platform(url: str) -> str: - if "douyin.com" in url: return "抖音直播" - if "tiktok.com" in url: return "TikTok直播" - if url.endswith(".flv") or url.endswith(".m3u8"): return "自定义录制直播" - return "未知平台" - -def main(): - print("-----------------------------------------------------") - print("| DouyinLiveRecorder - 单进程转推版 |") - print("-----------------------------------------------------") - print(f"版本号: {version}\nGitHub: https://github.com/ihmily/DouyinLiveRecorder") - print("模式:只检测 + 单进程推流(停播自动切下一个 URL)") - print(".....................................................") - - if not check_ffmpeg_existence(): - logger.error("缺少 ffmpeg,程序退出") - sys.exit(1) - - urls = parse_urls_from_file(url_config_file) - if not urls: - logger.error("URL_config.ini 中没有可用 URL。") - sys.exit(1) - - # 顺序轮询索引 - idx = 0 - current_url = None - current_proc: Optional[subprocess.Popen] = None - current_platform = "" - current_anchor = "" - record_quality_code = "OD" # 固定“原画”;需要也可改为从 config.ini 读取 - - check_interval_live = 8 # 推流中检测当前源是否仍在直播的间隔秒 - scan_interval_when_idle = 2 # 没有任何可用源时的扫描间隔秒 - last_md5 = "" # URL 文件变化时热更新 - - try: - while True: - # 动态热更新 URL 列表(URL_config.ini 有变更则重载) - try: - md5 = check_md5(url_config_file) - if md5 != last_md5: - urls = parse_urls_from_file(url_config_file) or urls - last_md5 = md5 - print(f"[{now_str()}] 已重载 URL 列表,数量:{len(urls)}") - # 若当前 URL 已不在列表,强制切换 - if current_url and current_url not in urls: - print(f"[{now_str()}] 当前URL已被移除,停止并重新选择。") - stop_ffmpeg(current_proc) - current_proc = None - current_url = None - except Exception as e: - logger.warning(f"URL 列表热更新失败:{e}") - - if current_proc and current_url: - # 已在推流:检测是否仍直播 & 进程状态 - if current_proc.poll() is not None: - color.print_colored(f"[{now_str()}] ffmpeg 退出,尝试切源…", color.YELLOW) - current_proc = None - current_url = None - continue - - # 轻量检测:仍直播? - try: - port = asyncio.run(fetch_port_info(current_url, record_quality_code)) - still_live = bool(port.get("is_live")) - except Exception as e: - logger.error(f"检测当前源失败:{e}") - still_live = False - - if still_live: - elapsed = int(time.time() - start_time) - h, m = divmod(elapsed, 3600) - m, s = divmod(m, 60) - dur_str = f"{h:02d}:{m:02d}:{s:02d}" - - print( - f"\r[{now_str()}] 正在转播({dur_str}):{current_platform} | {current_anchor} | {current_url}", - end="" - ) - time.sleep(check_interval_live) - continue - - else: - color.print_colored(f"\n[{now_str()}] 当前源已停播,切换下一个…", color.YELLOW) - stop_ffmpeg(current_proc) - current_proc = None - current_url = None - # 落下去选择下一个 - else: - # 未在推流:从 urls 顺序找第一个 is_live 的 - if not urls: - time.sleep(scan_interval_when_idle) - continue - - tried = 0 - found = False - start_idx = idx - while tried < len(urls): - url = urls[idx] - idx = (idx + 1) % len(urls) - tried += 1 - - port = asyncio.run(fetch_port_info(url, record_quality_code)) - if not port.get("anchor_name"): - print(f"[{now_str()}] 获取失败,跳过:{url}") - continue - if not port.get("is_live"): - print(f"[{now_str()}] 未开播:{port.get('anchor_name','')} | {url}") - continue - - # 选取真实播放地址 - real_url = select_source_url(url, port) - if not real_url: - print(f"[{now_str()}] 未得到播放地址,跳过:{url}") - continue - - current_platform = detect_platform(url) - current_anchor = port.get("anchor_name", "") - headers = headers_for_platform(current_platform, url) - - # 启动 ffmpeg - out_url, _, _ = build_youtube_output() - current_proc = launch_ffmpeg(real_url, out_url, headers) - current_url = url - start_time = time.time() - color.print_colored(f"[{now_str()}] 开始转播:{current_platform} | {current_anchor} | {url}", color.GREEN) - found = True - break - - if not found: - # 没有任何源在播,过会儿再试 - print(f"[{now_str()}] 当前无可用直播源,等待中…") - time.sleep(scan_interval_when_idle) - continue - except KeyboardInterrupt: - print("\n收到中断,正在退出…") - except Exception as e: - logger.error(f"主循环异常:{e}") - finally: - stop_ffmpeg(current_proc) - print("已退出。") - -if __name__ == "__main__": - main() diff --git a/back/back3 b/back/back3 deleted file mode 100644 index d2af416..0000000 --- a/back/back3 +++ /dev/null @@ -1,582 +0,0 @@ -# -*- encoding: utf-8 -*- - -""" -单进程 ffmpeg 推流:从 URL_config.ini 顺序检测直播源, -一旦当前源停播/异常,自动切到下一个正在直播的源并推流到 YouTube。 -仅做“检测+转推”,不做录制、分段、通知等。 -""" - -import asyncio -import os -import sys -import signal -import time -import datetime -import re -import uuid -import subprocess -from pathlib import Path -from typing import Any, List, Tuple, Optional - -import configparser -import urllib.request -from urllib.error import URLError, HTTPError - -import httpx -from src import spider, stream -from src.utils import logger, Color, check_md5 -from ffmpeg_install import check_ffmpeg, ffmpeg_path, current_env_path - -# --------------------- 基础环境 --------------------- -version = "relay-1.0.1" -os.environ['PATH'] = ffmpeg_path + os.pathsep + current_env_path -script_path = os.path.split(os.path.realpath(sys.argv[0]))[0] -config_file = f'{script_path}/config/config.ini' -url_config_file = f'{script_path}/config/URL_config.ini' -text_encoding = 'utf-8-sig' -color = Color() - -# ---------- YouTube 推流配置(读取 youtube.ini) ---------- -_yt_cfg = configparser.ConfigParser() -with open(f"{script_path}/config/youtube.ini", "r", encoding="utf-8-sig") as f: - _yt_cfg.read_file(f) - -def _yt_get(section: str, key: str, fallback=None, cast=str): - try: - if cast is bool: - return _yt_cfg.getboolean(section, key, fallback=fallback) - if cast is int: - return _yt_cfg.getint(section, key, fallback=fallback) - return _yt_cfg.get(section, key, fallback=fallback) - except Exception: - return fallback - -def _safe_int(v, lo, hi, default): - try: - v = int(v) - if v < lo or v > hi: - return default - return v - except Exception: - return default - -def _build_youtube_rtmp_url(key: str, secure: bool, ingest: str | None): - node = (ingest or "a").strip().lower() - node = node if node.isalpha() and len(node) == 1 else "a" - scheme = "rtmps" if secure else "rtmp" - host = f"{node}.rtmps.youtube.com" if secure else f"{node}.rtmp.youtube.com" - return f"{scheme}://{host}/live2/{key}" - -# --------------------- 简化配置读取 --------------------- -def read_config_value(cfg: configparser.RawConfigParser, section: str, option: str, default_value: Any) -> Any: - try: - cfg.read(config_file, encoding=text_encoding) - if section not in cfg.sections(): - cfg.add_section(section) - return cfg.get(section, option) - except (configparser.NoSectionError, configparser.NoOptionError): - cfg.set(section, option, str(default_value)) - with open(config_file, 'w', encoding=text_encoding) as f: - cfg.write(f) - return default_value - -_CN_BOOL = {"是": True, "否": False} - -cfg = configparser.RawConfigParser() -use_proxy = _CN_BOOL.get(read_config_value(cfg, '录制设置', '是否使用代理ip(是/否)', "否"), False) -proxy_addr = read_config_value(cfg, '录制设置', '代理地址', "") -proxy_addr = proxy_addr if use_proxy and proxy_addr.strip() else None -# Cookie(按需) -dy_cookie = read_config_value(cfg, 'Cookie', '抖音cookie', '') -tiktok_cookie = read_config_value(cfg, 'Cookie', 'tiktok_cookie', '') - -# 可选:启动时检测系统代理(仅提示) -try: - if not use_proxy: - urllib.request.urlopen("https://www.google.com/", timeout=5) - print("提示:你的网络似乎直连可用(无需代理)。") -except Exception: - if not use_proxy: - color.print_colored("提示:无法直连海外站,如需 TikTok 请在 config.ini 开启代理并填写代理地址。", color.YELLOW) - -# --------------------- URL 列表读取 --------------------- -def parse_urls_from_file(path: str) -> List[str]: - """ - 读取 URL_config.ini,返回去重且保持相对顺序的 URL 列表。 - 支持行内格式: - - 纯 URL - - quality,url - - url,主播: xxx - - # 注释行 忽略 - """ - urls, seen = [], set() - if not os.path.isfile(path): - open(path, 'w', encoding=text_encoding).close() - print("URL_config.ini 为空,请写入直播间地址后再运行。") - return urls - - with open(path, "r", encoding=text_encoding, errors='ignore') as f: - for line in f: - s = line.strip() - if not s or s.startswith("#"): - continue - # 拆分(最多3段,取包含 https 或域名的那段) - parts = re.split(r"[,,]\s*", s) - cand = None - for p in parts: - if '://' in p or re.match(r"^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", p): - cand = p - break - if not cand: - continue - url = cand if '://' in cand else f"https://{cand}" - if url not in seen: - seen.add(url) - urls.append(url) - return urls - -# --------------------- 直播解析与选源 --------------------- -def is_flv_preferred_platform(link: str) -> bool: - # 抖音/TikTok 优先走 FLV;但 H265 不走 FLV - return any(i in link for i in ["douyin", "tiktok"]) - -def select_source_url(link: str, stream_info: dict) -> Optional[str]: - if not stream_info: - return None - if is_flv_preferred_platform(link): - flv = stream_info.get("flv_url") - if flv: - # 若 codec=h265 则不走 flv - try: - from urllib.parse import urlparse, parse_qs - qs = parse_qs(urlparse(flv).query or "") - if (qs.get("codec") or [""])[0].lower() == "h265": - logger.warning("FLV 不支持 h265,改用 HLS。") - else: - return flv - except Exception: - return flv - return stream_info.get("record_url") or stream_info.get("m3u8_url") or stream_info.get("flv_url") - -async def fetch_port_info(url: str, record_quality_code: str = "OD") -> dict: - """ - 调用项目现有 spider/stream,返回: - { - "anchor_name": str, - "is_live": bool, - "title": str?, - "record_url": "...", - "flv_url": "...", - "m3u8_url": "..." - } - """ - proxy = proxy_addr - port_info = {} - try: - if "douyin.com/" in url: - if 'v.douyin.com' not in url and '/user/' not in url: - j = await spider.get_douyin_stream_data(url=url, proxy_addr=proxy, cookies=dy_cookie) - else: - j = await spider.get_douyin_app_stream_data(url=url, proxy_addr=proxy, cookies=dy_cookie) - port_info = await stream.get_douyin_stream_url(j, record_quality_code, proxy) - elif url.startswith("https://www.tiktok.com/"): - if proxy: - j = await spider.get_tiktok_stream_data(url=url, proxy_addr=proxy, cookies=tiktok_cookie) - port_info = await stream.get_tiktok_stream_url(j, record_quality_code, proxy) - else: - logger.error("TikTok 需要代理,请在 config.ini 开启并配置代理。") - port_info = {"anchor_name": "", "is_live": False} - elif (".m3u8" in url) or (".flv" in url): - # 自定义直链 - port_info = { - "anchor_name": "自定义_" + str(uuid.uuid4())[:8], - "is_live": True, - } - if url.endswith(".flv"): - port_info["flv_url"] = url - else: - port_info["m3u8_url"] = url - else: - # 其它站点可按需扩展 - port_info = {"anchor_name": "", "is_live": False} - except Exception as e: - logger.error(f"解析失败: {e}") - port_info = {"anchor_name": "", "is_live": False} - return port_info - -# --------------------- ffmpeg 单进程推流 --------------------- -def build_youtube_output() -> Tuple[str, list, list]: - key = _yt_get("youtube", "key", fallback="", cast=str) - if not key: - logger.error("错误: config/youtube.ini 未配置 key") - sys.exit(1) - - secure = _yt_get("youtube", "secure", fallback=False, cast=bool) - ingest = _yt_get("youtube", "ingest", fallback="a", cast=str) - res_str = (_yt_get("youtube", "resolution", fallback="", cast=str) or "").strip() # 例如 "1080x1920" - - bv = _safe_int(_yt_get("youtube", "bitrate", fallback=5000, cast=int), 300, 20000, 5000) - ba = _safe_int(_yt_get("youtube", "audio_bitrate", fallback=128, cast=int), 64, 384, 128) - fps = _safe_int(_yt_get("youtube", "fps", fallback=30, cast=int), 10, 60, 30) - gop = _safe_int(_yt_get("youtube", "gop", fallback=2*fps, cast=int), fps, 4*fps, 2*fps) - - out_url = _build_youtube_rtmp_url(key, secure, ingest) - - # —— 默认强制转码(稳) —— # - v_args = [ - "-c:v", "libx264", - "-preset", "veryfast", - "-tune", "zerolatency", - "-profile:v", "high", - "-level:v", "4.2", - "-pix_fmt", "yuv420p", - "-r", str(fps), - "-g", str(gop), - "-b:v", f"{bv}k", - "-maxrate", f"{bv}k", - "-bufsize", f"{max(2*bv, 4000)}k", - ] - a_args = [ - "-c:a", "aac", - "-b:a", f"{ba}k", - "-ar", "44100", - "-ac", "2", - ] - - # 可选缩放/留黑边 + 避免 1088x1920(加 crop 强制精确尺寸) - if res_str: - try: - w, h = map(int, res_str.lower().split("x")) - vf = ( - f"scale={w}:{h}:force_original_aspect_ratio=decrease," - f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2," - f"crop={w}:{h}," - f"format=yuv420p" - ) - v_args = ["-vf", vf] + v_args - except Exception: - logger.warning(f"resolution '{res_str}' 非法,忽略") - - return out_url, v_args, a_args - -def launch_ffmpeg(input_url: str, - out_url: str, - extra_headers: Optional[str] = None, - v_args: Optional[list] = None, - a_args: Optional[list] = None) -> subprocess.Popen: - """ - 启动一个 ffmpeg 进程:输入 input_url,输出 RTMP/RTMPS 到 out_url。 - - v_args / a_args 由 build_youtube_output() 生成并传入 - - 启动 stdout reader 线程以防缓冲阻塞 - - 设置代理环境变量以兼容 https/hls - """ - import threading - user_agent = ("Mozilla/5.0 (Linux; Android 11; SM-G973U) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/87.0.4280.141 Mobile Safari/537.36") - - # 输入侧容错/重连/探测 - base = [ - "ffmpeg", "-nostdin", "-hide_banner", - "-stats", # 输出统计信息 - "-stats_period", "60", # 每60秒一次 - "-progress", "pipe:1", # 状态写到stdout,便于reader捕获 - "-loglevel", "info", - - # 输入容错 & 自动重连 - "-rw_timeout", "15000000", - "-timeout", "10000000", - "-reconnect", "1", - "-reconnect_streamed", "1", - "-reconnect_at_eof", "1", - "-reconnect_delay_max", "30", - - # UA/协议白名单 - "-user_agent", user_agent, - "-protocol_whitelist", "file,http,https,tcp,tls,crypto,udp,rtmp,rtp,httpproxy", - - # 缓冲 & 探测 - "-thread_queue_size", "4096", - "-analyzeduration", "10000000", - "-probesize", "10000000", - ] - - - # 平台 headers(referer/origin)要在 -i 之前 - if extra_headers: - base += ["-headers", extra_headers] - - # 代理:http_proxy/https_proxy 环境变量(同时保留 -http_proxy 兼容) - env = os.environ.copy() - if proxy_addr: - env["http_proxy"] = proxy_addr - env["https_proxy"] = proxy_addr - base = ["ffmpeg", "-http_proxy", proxy_addr] + base[1:] - - # 输入 - base += ["-re", "-i", input_url] - - # 通用容错 - base += [ - "-fflags", "+genpts+discardcorrupt+nobuffer", - "-use_wallclock_as_timestamps", "1", - "-avioflags", "direct", - "-rtbufsize", "100M", - ] - - # 视频/音频编码参数(使用 build_youtube_output 的结果) - v_args = v_args or [] - a_args = a_args or [] - base += v_args + a_args - - # 输出(flv 到 YT) - base += ["-f", "flv", out_url] - - # 打印命令便于排障(截断) - cmd_str = " ".join(base) - print("ffmpeg cmd:", cmd_str if len(cmd_str) < 500 else cmd_str[:500] + " ...") - - logger.info(f"启动推流:{input_url} -> {out_url}") - - creationflags = 0 - if os.name == "nt": - # 防止子进程挂住控制台(可选) - creationflags = subprocess.CREATE_NEW_PROCESS_GROUP - - p = subprocess.Popen( - base, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - bufsize=0, # 避免行缓冲告警 & 尽快刷新 - env=env, - creationflags=creationflags - ) - - # 最近输出时间戳,用于 watchdog - p.last_out_ts = time.time() - - def _reader(): - try: - for line in iter(p.stdout.readline, b''): - if not line: - break - text = line.decode(errors="ignore").strip() - p.last_out_ts = time.time() - # 常见错误提示 - if "Server disconnected" in text: - logger.error(f"[YouTube输出异常] {text}") - elif "not supported" in text or "Invalid" in text or "invalid" in text: - logger.error(f"[转码异常] {text}") - except Exception: - pass - - t = threading.Thread(target=_reader, daemon=True) - t.start() - - return p - -def stop_ffmpeg(p: Optional[subprocess.Popen]): - if not p: - return - try: - if os.name == "nt": - if p.stdin: - try: - p.stdin.write(b"q") - p.stdin.flush() - except Exception: - pass - p.wait(timeout=6) - else: - p.send_signal(signal.SIGINT) - p.wait(timeout=10) - except Exception: - try: - p.kill() - except Exception: - pass - -# --------------------- 主循环(单进程转推+切源) --------------------- -def check_ffmpeg_existence() -> bool: - try: - result = subprocess.run(['ffmpeg', '-version'], check=True, capture_output=True, text=True) - if result.returncode == 0: - print(result.stdout.splitlines()[0]) - except Exception: - pass - finally: - return bool(check_ffmpeg()) - -def now_str(): return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - -def headers_for_platform(platform_name: str, live_url: str) -> Optional[str]: - live_domain = '/'.join(live_url.split('/')[0:3]) - record_headers = { - 'PandaTV': 'origin:https://www.pandalive.co.kr', - 'WinkTV': 'origin:https://www.winktv.co.kr', - 'PopkonTV': 'origin:https://www.popkontv.com', - 'FlexTV': 'origin:https://www.flextv.co.kr', - '千度热播': 'referer:https://qiandurebo.com', - '17Live': 'referer:https://17.live/en/live/6302408', - '浪Live': 'referer:https://www.lang.live', - 'shopee': f'origin:{live_domain}', - 'Blued直播': 'referer:https://app.blued.cn' - } - return record_headers.get(platform_name) - -def detect_platform(url: str) -> str: - if "douyin.com" in url: return "抖音直播" - if "tiktok.com" in url: return "TikTok直播" - if url.endswith(".flv") or url.endswith(".m3u8"): return "自定义录制直播" - return "未知平台" - -def main(): - print("-----------------------------------------------------") - print("| DouyinLiveRecorder - 单进程转推版 |") - print("-----------------------------------------------------") - print(f"版本号: {version}\nGitHub: https://github.com/ihmily/DouyinLiveRecorder") - print("模式:只检测 + 单进程推流(停播自动切下一个 URL)") - print(".....................................................") - - if not check_ffmpeg_existence(): - logger.error("缺少 ffmpeg,程序退出") - sys.exit(1) - - urls = parse_urls_from_file(url_config_file) - if not urls: - logger.error("URL_config.ini 中没有可用 URL。") - sys.exit(1) - - # 顺序轮询索引 - idx = 0 - current_url = None - current_proc: Optional[subprocess.Popen] = None - current_platform = "" - current_anchor = "" - record_quality_code = "OD" # 固定“原画”;需要也可改为从 config.ini 读取 - - check_interval_live = 8 # 推流中检测当前源是否仍在直播的间隔秒 - scan_interval_when_idle = 2 # 没有任何可用源时的扫描间隔秒 - last_md5 = "" # URL 文件变化时热更新 - - # watchdog 参数 - watchdog_grace = 60 # 推流开始 60s 内不做静默判定 - watchdog_silence = 300 # 5 分钟完全无输出才重启 - - try: - while True: - # 动态热更新 URL 列表(URL_config.ini 有变更则重载) - try: - md5 = check_md5(url_config_file) - if md5 != last_md5: - urls = parse_urls_from_file(url_config_file) or urls - last_md5 = md5 - print(f"[{now_str()}] 已重载 URL 列表,数量:{len(urls)}") - # 若当前 URL 已不在列表,强制切换 - if current_url and current_url not in urls: - print(f"[{now_str()}] 当前URL已被移除,停止并重新选择。") - stop_ffmpeg(current_proc) - current_proc = None - current_url = None - except Exception as e: - logger.warning(f"URL 列表热更新失败:{e}") - - if current_proc and current_url: - retcode = current_proc.poll() - if retcode is not None: - color.print_colored(f"[{now_str()}] ffmpeg 已退出 (code={retcode}),切源…", color.YELLOW) - stop_ffmpeg(current_proc) - current_proc = None - current_url = None - continue - - # 检查是否停播(依赖 spider) - try: - port = asyncio.run(fetch_port_info(current_url, record_quality_code)) - still_live = bool(port.get("is_live")) - except Exception as e: - logger.error(f"检测当前源失败:{e}") - still_live = True # 容错:无法判断时先认为还活着 - - if still_live: - elapsed = int(time.time() - start_time) - h, m = divmod(elapsed, 3600) - m, s = divmod(m, 60) - dur_str = f"{h:02d}:{m:02d}:{s:02d}" - print(f"\r[{now_str()}] 正在转播({dur_str}):{current_platform} | {current_anchor} | {current_url}", end="") - - # ✅ watchdog(推流开始 > 60s 且 5 分钟完全无输出) - silence = time.time() - getattr(current_proc, "last_out_ts", 0) - if elapsed >= watchdog_grace and silence > watchdog_silence: - color.print_colored(f"\n[{now_str()}] 超过5分钟未见 ffmpeg 输出,判定异常,重启…", color.YELLOW) - stop_ffmpeg(current_proc) - current_proc = None - current_url = None - time.sleep(1) - continue - - time.sleep(check_interval_live) - continue - else: - color.print_colored(f"\n[{now_str()}] 主播停播,切换下一个源…", color.YELLOW) - stop_ffmpeg(current_proc) - current_proc = None - current_url = None - - else: - # 未在推流:从 urls 顺序找第一个 is_live 的 - if not urls: - time.sleep(scan_interval_when_idle) - continue - - tried = 0 - found = False - start_idx = idx - while tried < len(urls): - url = urls[idx] - idx = (idx + 1) % len(urls) - tried += 1 - - port = asyncio.run(fetch_port_info(url, record_quality_code)) - if not port.get("anchor_name"): - print(f"[{now_str()}] 获取失败,跳过:{url}") - continue - if not port.get("is_live"): - print(f"[{now_str()}] 未开播:{port.get('anchor_name','')} | {url}") - continue - - # 选取真实播放地址 - real_url = select_source_url(url, port) - if not real_url: - print(f"[{now_str()}] 未得到播放地址,跳过:{url}") - continue - - current_platform = detect_platform(url) - current_anchor = port.get("anchor_name", "") - headers = headers_for_platform(current_platform, url) - - # 启动 ffmpeg - out_url, v_args, a_args = build_youtube_output() - current_proc = launch_ffmpeg(real_url, out_url, headers, v_args=v_args, a_args=a_args) - current_url = url - start_time = time.time() - color.print_colored(f"[{now_str()}] 开始转播:{current_platform} | {current_anchor} | {url}", color.GREEN) - found = True - break - - if not found: - # 没有任何源在播,过会儿再试 - print(f"[{now_str()}] 当前无可用直播源,等待中…") - time.sleep(scan_interval_when_idle) - continue - except KeyboardInterrupt: - print("\n收到中断,正在退出…") - except Exception as e: - logger.error(f"主循环异常:{e}") - finally: - stop_ffmpeg(current_proc) - print("已退出。") - -if __name__ == "__main__": - main() diff --git a/back/back4 b/back/back4 deleted file mode 100644 index e49c354..0000000 --- a/back/back4 +++ /dev/null @@ -1,617 +0,0 @@ -# -*- encoding: utf-8 -*- - -""" -单进程 ffmpeg 推流:从 URL_config.ini 顺序检测直播源, -一旦当前源停播/异常,自动切到下一个正在直播的源并推流到 YouTube。 -仅做“检测+转推”,不做录制、分段、通知等。 -""" - -import asyncio -import os -import sys -import signal -import time -import datetime -import re -import uuid -import subprocess -from pathlib import Path -from typing import Any, List, Tuple, Optional - -import configparser -import urllib.request -from urllib.error import URLError, HTTPError - -import httpx -from src import spider, stream -from src.utils import logger, Color, check_md5 -from ffmpeg_install import check_ffmpeg, ffmpeg_path, current_env_path - -# --------------------- 基础环境 --------------------- -version = "relay-1.1.0" -os.environ['PATH'] = ffmpeg_path + os.pathsep + current_env_path -script_path = os.path.split(os.path.realpath(sys.argv[0]))[0] -config_file = f'{script_path}/config/config.ini' -url_config_file = f'{script_path}/config/URL_config.ini' -text_encoding = 'utf-8-sig' -color = Color() - -# ---------- YouTube 推流配置(读取 youtube.ini) ---------- -_yt_cfg = configparser.ConfigParser() -with open(f"{script_path}/config/youtube.ini", "r", encoding="utf-8-sig") as f: - _yt_cfg.read_file(f) - -def _yt_get(section: str, key: str, fallback=None, cast=str): - try: - if cast is bool: - return _yt_cfg.getboolean(section, key, fallback=fallback) - if cast is int: - return _yt_cfg.getint(section, key, fallback=fallback) - return _yt_cfg.get(section, key, fallback=fallback) - except Exception: - return fallback - -def _safe_int(v, lo, hi, default): - try: - v = int(v) - if v < lo or v > hi: - return default - return v - except Exception: - return default - -def _round_res(w: int, h: int) -> Tuple[int, int]: - # 强制偶数,避免 1088/1918 这类非标准尺寸 - return (w & ~1, h & ~1) - -def _build_youtube_rtmp_url(key: str, secure: bool, ingest: str | None): - node = (ingest or "a").strip().lower() - node = node if node.isalpha() and len(node) == 1 else "a" - scheme = "rtmps" if secure else "rtmp" - host = f"{node}.rtmps.youtube.com" if secure else f"{node}.rtmp.youtube.com" - return f"{scheme}://{host}/live2/{key}" - -# --------------------- 简化配置读取 --------------------- -def read_config_value(cfg: configparser.RawConfigParser, section: str, option: str, default_value: Any) -> Any: - try: - cfg.read(config_file, encoding=text_encoding) - if section not in cfg.sections(): - cfg.add_section(section) - return cfg.get(section, option) - except (configparser.NoSectionError, configparser.NoOptionError): - cfg.set(section, option, str(default_value)) - with open(config_file, 'w', encoding=text_encoding) as f: - cfg.write(f) - return default_value - -_CN_BOOL = {"是": True, "否": False} - -cfg = configparser.RawConfigParser() -use_proxy = _CN_BOOL.get(read_config_value(cfg, '录制设置', '是否使用代理ip(是/否)', "否"), False) -proxy_addr = read_config_value(cfg, '录制设置', '代理地址', "") -proxy_addr = proxy_addr if use_proxy and proxy_addr.strip() else None -# Cookie(按需) -dy_cookie = read_config_value(cfg, 'Cookie', '抖音cookie', '') -tiktok_cookie = read_config_value(cfg, 'Cookie', 'tiktok_cookie', '') - -# 可选:启动时检测系统代理(仅提示) -try: - if not use_proxy: - urllib.request.urlopen("https://www.google.com/", timeout=5) - print("提示:你的网络似乎直连可用(无需代理)。") -except Exception: - if not use_proxy: - color.print_colored("提示:无法直连海外站,如需 TikTok 请在 config.ini 开启代理并填写代理地址。", color.YELLOW) - -# --------------------- URL 列表读取 --------------------- -def parse_urls_from_file(path: str) -> List[str]: - """ - 读取 URL_config.ini,返回去重且保持相对顺序的 URL 列表。 - 支持行内格式: - - 纯 URL - - quality,url - - url,主播: xxx - - # 注释行 忽略 - """ - urls, seen = [], set() - if not os.path.isfile(path): - open(path, 'w', encoding=text_encoding).close() - print("URL_config.ini 为空,请写入直播间地址后再运行。") - return urls - - with open(path, "r", encoding=text_encoding, errors='ignore') as f: - for line in f: - s = line.strip() - if not s or s.startswith("#"): - continue - # 拆分(最多3段,取包含 https 或域名的那段) - parts = re.split(r"[,,]\s*", s) - cand = None - for p in parts: - if '://' in p or re.match(r"^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", p): - cand = p - break - if not cand: - continue - url = cand if '://' in cand else f"https://{cand}" - if url not in seen: - seen.add(url) - urls.append(url) - return urls - -# --------------------- 直播解析与选源 --------------------- -def is_flv_preferred_platform(link: str) -> bool: - # 抖音/TikTok 优先走 FLV;但 H265 不走 FLV - return any(i in link for i in ["douyin", "tiktok"]) - -def select_source_url(link: str, stream_info: dict) -> Optional[str]: - if not stream_info: - return None - if is_flv_preferred_platform(link): - flv = stream_info.get("flv_url") - if flv: - # 若 codec=h265 则不走 flv - try: - from urllib.parse import urlparse, parse_qs - qs = parse_qs(urlparse(flv).query or "") - if (qs.get("codec") or [""])[0].lower() == "h265": - logger.warning("FLV 不支持 h265,改用 HLS。") - else: - return flv - except Exception: - return flv - return stream_info.get("record_url") or stream_info.get("m3u8_url") or stream_info.get("flv_url") - -async def fetch_port_info(url: str, record_quality_code: str = "OD") -> dict: - """ - 调用项目现有 spider/stream,返回: - { - "anchor_name": str, - "is_live": bool, - "title": str?, - "record_url": "...", - "flv_url": "...", - "m3u8_url": "..." - } - """ - proxy = proxy_addr - port_info = {} - try: - if "douyin.com/" in url: - if 'v.douyin.com' not in url and '/user/' not in url: - j = await spider.get_douyin_stream_data(url=url, proxy_addr=proxy, cookies=dy_cookie) - else: - j = await spider.get_douyin_app_stream_data(url=url, proxy_addr=proxy, cookies=dy_cookie) - port_info = await stream.get_douyin_stream_url(j, record_quality_code, proxy) - elif url.startswith("https://www.tiktok.com/"): - if proxy: - j = await spider.get_tiktok_stream_data(url=url, proxy_addr=proxy, cookies=tiktok_cookie) - port_info = await stream.get_tiktok_stream_url(j, record_quality_code, proxy) - else: - logger.error("TikTok 需要代理,请在 config.ini 开启并配置代理。") - port_info = {"anchor_name": "", "is_live": False} - elif (".m3u8" in url) or (".flv" in url): - # 自定义直链 - port_info = { - "anchor_name": "自定义_" + str(uuid.uuid4())[:8], - "is_live": True, - } - if url.endswith(".flv"): - port_info["flv_url"] = url - else: - port_info["m3u8_url"] = url - else: - # 其它站点可按需扩展 - port_info = {"anchor_name": "", "is_live": False} - except Exception as e: - logger.error(f"解析失败: {e}") - port_info = {"anchor_name": "", "is_live": False} - return port_info - -# --------------------- ffmpeg 单进程推流 --------------------- -def build_youtube_output() -> Tuple[str, list, list]: - key = _yt_get("youtube", "key", fallback="", cast=str) - if not key: - logger.error("错误: config/youtube.ini 未配置 key") - sys.exit(1) - - secure = _yt_get("youtube", "secure", fallback=False, cast=bool) - ingest = _yt_get("youtube", "ingest", fallback="a", cast=str) - res_str = (_yt_get("youtube", "resolution", fallback="", cast=str) or "").strip() # 例如 "1080x1920" - - bv = _safe_int(_yt_get("youtube", "bitrate", fallback=5000, cast=int), 300, 20000, 5000) - ba = _safe_int(_yt_get("youtube", "audio_bitrate", fallback=128, cast=int), 64, 384, 128) - fps = _safe_int(_yt_get("youtube", "fps", fallback=30, cast=int), 10, 60, 30) - gop = _safe_int(_yt_get("youtube", "gop", fallback=2*fps, cast=int), fps, 4*fps, 2*fps) - - out_url = _build_youtube_rtmp_url(key, secure, ingest) - - # —— 默认强制转码(稳) —— # - v_args = [ - "-c:v", "libx264", - "-preset", "veryfast", - "-tune", "zerolatency", - "-profile:v", "high", - "-level:v", "4.2", - "-pix_fmt", "yuv420p", - "-r", str(fps), - "-g", str(gop), - "-b:v", f"{bv}k", - "-maxrate", f"{bv}k", - "-bufsize", f"{max(2*bv, 4000)}k", - ] - a_args = [ - "-c:a", "aac", - "-b:a", f"{ba}k", - "-ar", "44100", - "-ac", "2", - ] - - # 可选缩放/留黑边 + 避免 1088x1920(crop 强制精确尺寸)+ 偶数对齐 - if res_str: - try: - w, h = map(int, res_str.lower().split("x")) - w, h = _round_res(w, h) - vf = ( - f"scale={w}:{h}:force_original_aspect_ratio=decrease," - f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2," - f"crop={w}:{h}," - f"format=yuv420p" - ) - v_args = ["-vf", vf] + v_args - except Exception: - logger.warning(f"resolution '{res_str}' 非法,忽略") - - return out_url, v_args, a_args - -def launch_ffmpeg(input_url: str, - out_url: str, - extra_headers: Optional[str] = None, - v_args: Optional[list] = None, - a_args: Optional[list] = None) -> subprocess.Popen: - """ - 启动一个 ffmpeg 进程:输入 input_url,输出 RTMP/RTMPS 到 out_url。 - - v_args / a_args 由 build_youtube_output() 生成并传入 - - 启动 stdout reader 线程以防缓冲阻塞 - - 设置代理环境变量以兼容 https/hls - """ - import threading - user_agent = ("Mozilla/5.0 (Linux; Android 11; SM-G973U) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/87.0.4280.141 Mobile Safari/537.36") - - # 输入侧容错/重连/探测 - base = [ - "ffmpeg", "-nostdin", "-hide_banner", - "-stats", - "-stats_period", "60", - "-progress", "pipe:1", - "-loglevel", "info", - - # 输入容错 & 自动重连 - "-rw_timeout", "15000000", - "-timeout", "10000000", - "-reconnect", "1", - "-reconnect_streamed", "1", - "-reconnect_at_eof", "1", - "-reconnect_delay_max", "30", - - # UA/协议白名单 - "-user_agent", user_agent, - "-protocol_whitelist", "file,http,https,tcp,tls,crypto,udp,rtmp,rtp,httpproxy", - - # 缓冲 & 探测 - "-thread_queue_size", "4096", - "-analyzeduration", "10000000", - "-probesize", "10000000", - ] - - # 平台 headers(referer/origin)要在 -i 之前 - if extra_headers: - base += ["-headers", extra_headers] - - # 代理:http_proxy/https_proxy 环境变量(同时保留 -http_proxy 兼容) - env = os.environ.copy() - if proxy_addr: - env["http_proxy"] = proxy_addr - env["https_proxy"] = proxy_addr - base = ["ffmpeg", "-http_proxy", proxy_addr] + base[1:] - - # 输入 - base += ["-re", "-i", input_url] - - # 通用容错 + 时间戳修复 - base += [ - "-fflags", "+genpts+igndts+discardcorrupt+nobuffer", - "-xerror", - "-use_wallclock_as_timestamps", "1", - "-avioflags", "direct", - "-rtbufsize", "100M", - ] - - # 视频/音频编码参数(使用 build_youtube_output 的结果) - v_args = v_args or [] - a_args = a_args or [] - base += v_args + a_args - - # 输出(flv 到 YT) - base += ["-f", "flv", out_url] - - # 打印命令便于排障(截断) - cmd_str = " ".join(base) - print("ffmpeg cmd:", cmd_str if len(cmd_str) < 500 else cmd_str[:500] + " ...") - - logger.info(f"启动推流:{input_url} -> {out_url}") - - creationflags = 0 - if os.name == "nt": - creationflags = subprocess.CREATE_NEW_PROCESS_GROUP - - p = subprocess.Popen( - base, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - bufsize=0, # 立即刷新,避免行缓冲告警 - env=env, - creationflags=creationflags - ) - - # 最近输出时间戳,用于 watchdog - p.last_out_ts = time.time() - - def _reader(): - try: - for line in iter(p.stdout.readline, b''): - if not line: - break - text = line.decode(errors="ignore").strip() - p.last_out_ts = time.time() - - # 输出关键统计,便于观察码率/帧率 - if ("bitrate=" in text) or ("frame=" in text) or ("speed=" in text): - logger.info(f"[ffmpeg统计] {text}") - - # 常见错误提示 - if "Server disconnected" in text: - logger.error(f"[YouTube/RTMP输出异常] {text}") - elif "not supported" in text or "Invalid" in text or "invalid" in text: - logger.error(f"[转码异常] {text}") - except Exception: - pass - - import threading - t = threading.Thread(target=_reader, daemon=True) - t.start() - - return p - -def stop_ffmpeg(p: Optional[subprocess.Popen]): - if not p: - return - try: - if os.name == "nt": - if p.stdin: - try: - p.stdin.write(b"q") - p.stdin.flush() - except Exception: - pass - p.wait(timeout=6) - else: - p.send_signal(signal.SIGINT) - p.wait(timeout=10) - except Exception: - try: - p.kill() - except Exception: - pass - -# --------------------- 主循环(单进程转推+切源) --------------------- -def check_ffmpeg_existence() -> bool: - try: - result = subprocess.run(['ffmpeg', '-version'], check=True, capture_output=True, text=True) - if result.returncode == 0: - print(result.stdout.splitlines()[0]) - except Exception: - pass - finally: - return bool(check_ffmpeg()) - -def now_str(): return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - -def headers_for_platform(platform_name: str, live_url: str) -> Optional[str]: - live_domain = '/'.join(live_url.split('/')[0:3]) - record_headers = { - 'PandaTV': 'origin:https://www.pandalive.co.kr', - 'WinkTV': 'origin:https://www.winktv.co.kr', - 'PopkonTV': 'origin:https://www.popkontv.com', - 'FlexTV': 'origin:https://www.flextv.co.kr', - '千度热播': 'referer:https://qiandurebo.com', - '17Live': 'referer:https://17.live/en/live/6302408', - '浪Live': 'referer:https://www.lang.live', - 'shopee': f'origin:{live_domain}', - 'Blued直播': 'referer:https://app.blued.cn' - } - return record_headers.get(platform_name) - -def detect_platform(url: str) -> str: - if "douyin.com" in url: return "抖音直播" - if "tiktok.com" in url: return "TikTok直播" - if url.endswith(".flv") or url.endswith(".m3u8"): return "自定义录制直播" - return "未知平台" - -def main(): - print("-----------------------------------------------------") - print("| DouyinLiveRecorder - 单进程转推版 |") - print("-----------------------------------------------------") - print(f"版本号: {version}\nGitHub: https://github.com/ihmily/DouyinLiveRecorder") - print("模式:只检测 + 单进程推流(停播自动切下一个 URL)") - print(".....................................................") - - if not check_ffmpeg_existence(): - logger.error("缺少 ffmpeg,程序退出") - sys.exit(1) - - urls = parse_urls_from_file(url_config_file) - if not urls: - logger.error("URL_config.ini 中没有可用 URL。") - sys.exit(1) - - # 顺序轮询索引 - idx = 0 - current_url: Optional[str] = None - current_real_url: Optional[str] = None # 实际播放地址(flv/m3u8) - current_proc: Optional[subprocess.Popen] = None - current_platform = "" - current_anchor = "" - record_quality_code = "OD" # 固定“原画”;需要也可改为从 config.ini 读取 - - check_interval_live = 8 # 推流中检测当前源是否仍在直播的间隔秒 - scan_interval_when_idle = 2 # 没有任何可用源时的扫描间隔秒 - last_md5 = "" # URL 文件变化时热更新 - - # watchdog 参数(更稳) - watchdog_grace = 120 # 推流开始 120s 内不做静默判定 - watchdog_silence = 600 # 10 分钟完全无输出才重启 - refresh_real_url_every = 300 # 每 5 分钟刷新一次真实播放地址,避免 token 过期 - - try: - while True: - # 动态热更新 URL 列表(URL_config.ini 有变更则重载) - try: - md5 = check_md5(url_config_file) - if md5 != last_md5: - urls = parse_urls_from_file(url_config_file) or urls - last_md5 = md5 - print(f"[{now_str()}] 已重载 URL 列表,数量:{len(urls)}") - # 若当前 URL 已不在列表,强制切换 - if current_url and current_url not in urls: - print(f"[{now_str()}] 当前URL已被移除,停止并重新选择。") - stop_ffmpeg(current_proc) - current_proc = None - current_url = None - current_real_url = None - except Exception as e: - logger.warning(f"URL 列表热更新失败:{e}") - - if current_proc and current_url: - retcode = current_proc.poll() - if retcode is not None: - color.print_colored(f"[{now_str()}] ffmpeg 已退出 (code={retcode}),切源…", color.YELLOW) - stop_ffmpeg(current_proc) - current_proc = None - current_url = None - current_real_url = None - continue - - # 检查是否停播(依赖 spider) - try: - port = asyncio.run(fetch_port_info(current_url, record_quality_code)) - still_live = bool(port.get("is_live")) - except Exception as e: - logger.error(f"检测当前源失败:{e}") - still_live = True # 容错:无法判断时先认为还活着 - - if still_live: - elapsed = int(time.time() - start_time) - h, m = divmod(elapsed, 3600) - m, s = divmod(m, 60) - dur_str = f"{h:02d}:{m:02d}:{s:02d}" - print(f"\r[{now_str()}] 正在转播({dur_str}):{current_platform} | {current_anchor} | {current_url}", end="") - - # 定期刷新真实播放地址,避免 token 过期 - try: - if elapsed >= refresh_real_url_every and (elapsed % refresh_real_url_every) < check_interval_live: - new_port = asyncio.run(fetch_port_info(current_url, record_quality_code)) - new_real_url = select_source_url(current_url, new_port) - if new_real_url and new_real_url != current_real_url: - color.print_colored(f"\n[{now_str()}] 刷新播放地址(token 变化),重启 ffmpeg…", color.YELLOW) - stop_ffmpeg(current_proc) - out_url, v_args, a_args = build_youtube_output() - headers = headers_for_platform(current_platform, current_url) - current_proc = launch_ffmpeg(new_real_url, out_url, headers, v_args=v_args, a_args=a_args) - current_real_url = new_real_url - start_time = time.time() - time.sleep(check_interval_live) - continue - except Exception as e: - logger.warning(f"刷新播放地址失败:{e}") - - # ✅ watchdog(推流开始 > 120s 且 10 分钟完全无输出) - silence = time.time() - getattr(current_proc, "last_out_ts", 0) - if elapsed >= watchdog_grace and silence > watchdog_silence: - color.print_colored(f"\n[{now_str()}] 超过10分钟未见 ffmpeg 输出,判定异常,重启…", color.YELLOW) - stop_ffmpeg(current_proc) - current_proc = None - current_url = None - current_real_url = None - time.sleep(1) - continue - - time.sleep(check_interval_live) - continue - else: - color.print_colored(f"\n[{now_str()}] 主播停播,切换下一个源…", color.YELLOW) - stop_ffmpeg(current_proc) - current_proc = None - current_url = None - current_real_url = None - - else: - # 未在推流:从 urls 顺序找第一个 is_live 的 - if not urls: - time.sleep(scan_interval_when_idle) - continue - - tried = 0 - found = False - start_idx = idx - while tried < len(urls): - url = urls[idx] - idx = (idx + 1) % len(urls) - tried += 1 - - port = asyncio.run(fetch_port_info(url, record_quality_code)) - if not port.get("anchor_name"): - print(f"[{now_str()}] 获取失败,跳过:{url}") - continue - if not port.get("is_live"): - print(f"[{now_str()}] 未开播:{port.get('anchor_name','')} | {url}") - continue - - # 选取真实播放地址 - real_url = select_source_url(url, port) - if not real_url: - print(f"[{now_str()}] 未得到播放地址,跳过:{url}") - continue - - current_platform = detect_platform(url) - current_anchor = port.get("anchor_name", "") - headers = headers_for_platform(current_platform, url) - - # 启动 ffmpeg - out_url, v_args, a_args = build_youtube_output() - current_proc = launch_ffmpeg(real_url, out_url, headers, v_args=v_args, a_args=a_args) - current_url = url - current_real_url = real_url - start_time = time.time() - color.print_colored(f"[{now_str()}] 开始转播:{current_platform} | {current_anchor} | {url}", color.GREEN) - found = True - break - - if not found: - # 没有任何源在播,过会儿再试 - print(f"[{now_str()}] 当前无可用直播源,等待中…") - time.sleep(scan_interval_when_idle) - continue - except KeyboardInterrupt: - print("\n收到中断,正在退出…") - except Exception as e: - logger.error(f"主循环异常:{e}") - finally: - stop_ffmpeg(current_proc) - print("已退出。") - -if __name__ == "__main__": - main() diff --git a/back/mult1 b/back/mult1 deleted file mode 100644 index 9ce824c..0000000 --- a/back/mult1 +++ /dev/null @@ -1,636 +0,0 @@ -# -*- encoding: utf-8 -*- - -""" -多路并行版: -- 同时启动 N 路(N = youtube.ini 的 keys 数量) -- 每路独立 ffmpeg 进程、watchdog、定期刷新抖音真实播放地址 -- 共享 URL 池,避免重复占用同一个主播 -""" - -import asyncio -import os -import sys -import signal -import time -import datetime -import re -import uuid -import threading -import subprocess -from typing import Any, List, Tuple, Optional, Dict, Set - -import configparser -import urllib.request - -from src import spider, stream -from src.utils import logger, Color, check_md5 -from ffmpeg_install import check_ffmpeg, ffmpeg_path, current_env_path - -# --------------------- 基础环境 --------------------- -version = "relay-parallel-2.0.0" -os.environ['PATH'] = ffmpeg_path + os.pathsep + current_env_path -script_path = os.path.split(os.path.realpath(sys.argv[0]))[0] -config_file = f'{script_path}/config/config.ini' -url_config_file = f'{script_path}/config/URL_config.ini' -yt_ini_file = f"{script_path}/config/youtube.ini" -text_encoding = 'utf-8-sig' -color = Color() - -# ---------- YouTube 推流配置(读取 youtube.ini) ---------- -_yt_cfg = configparser.ConfigParser() -with open(yt_ini_file, "r", encoding="utf-8-sig") as f: - _yt_cfg.read_file(f) - -def _yt_get(section: str, key: str, fallback=None, cast=str): - try: - if cast is bool: - return _yt_cfg.getboolean(section, key, fallback=fallback) - if cast is int: - return _yt_cfg.getint(section, key, fallback=fallback) - return _yt_cfg.get(section, key, fallback=fallback) - except Exception: - return fallback - -def _yt_get_list(section: str, key: str) -> List[str]: - raw = _yt_get(section, key, fallback="", cast=str) or "" - # 支持逗号、空白、换行分隔 - parts = re.split(r"[,;\s]+", raw.strip()) - return [p for p in parts if p] - -def _safe_int(v, lo, hi, default): - try: - v = int(v) - if v < lo or v > hi: - return default - return v - except Exception: - return default - -def _round_res(w: int, h: int) -> Tuple[int, int]: - # 强制偶数,避免 1088/1918 这类尺寸(H.264 对齐问题) - return (w & ~1, h & ~1) - -def _build_youtube_rtmp_url(key: str, secure: bool, ingest: str | None): - node = (ingest or "a").strip().lower() - node = node if node.isalpha() and len(node) == 1 else "a" - scheme = "rtmps" if secure else "rtmp" - host = f"{node}.rtmps.youtube.com" if secure else f"{node}.rtmp.youtube.com" - return f"{scheme}://{host}/live2/{key}" - -# --------------------- 简化配置读取 --------------------- -def read_config_value(cfg: configparser.RawConfigParser, section: str, option: str, default_value: Any) -> Any: - try: - cfg.read(config_file, encoding=text_encoding) - if section not in cfg.sections(): - cfg.add_section(section) - return cfg.get(section, option) - except (configparser.NoSectionError, configparser.NoOptionError): - cfg.set(section, option, str(default_value)) - with open(config_file, 'w', encoding=text_encoding) as f: - cfg.write(f) - return default_value - -_CN_BOOL = {"是": True, "否": False} - -cfg = configparser.RawConfigParser() -use_proxy = _CN_BOOL.get(read_config_value(cfg, '录制设置', '是否使用代理ip(是/否)', "否"), False) -proxy_addr = read_config_value(cfg, '录制设置', '代理地址', "") -proxy_addr = proxy_addr if use_proxy and proxy_addr.strip() else None -# Cookie(按需) -dy_cookie = read_config_value(cfg, 'Cookie', '抖音cookie', '') -tiktok_cookie = read_config_value(cfg, 'Cookie', 'tiktok_cookie', '') - -# 可选:启动时检测系统代理(仅提示) -try: - if not use_proxy: - urllib.request.urlopen("https://www.google.com/", timeout=5) - print("提示:你的网络似乎直连可用(无需代理)。") -except Exception: - if not use_proxy: - color.print_colored("提示:无法直连海外站,如需 TikTok 请在 config.ini 开启代理并填写代理地址。", color.YELLOW) - -# --------------------- URL 列表读取 --------------------- -def parse_urls_from_file(path: str) -> List[str]: - urls, seen = [], set() - if not os.path.isfile(path): - open(path, 'w', encoding=text_encoding).close() - print("URL_config.ini 为空,请写入直播间地址后再运行。") - return urls - with open(path, "r", encoding=text_encoding, errors='ignore') as f: - for line in f: - s = line.strip() - if not s or s.startswith("#"): - continue - # 拆分(最多3段,取包含 https 或域名的那段) - parts = re.split(r"[,,]\s*", s) - cand = None - for p in parts: - if '://' in p or re.match(r"^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", p): - cand = p - break - if not cand: - continue - url = cand if '://' in cand else f"https://{cand}" - if url not in seen: - seen.add(url) - urls.append(url) - return urls - -# --------------------- 直播解析与选源 --------------------- -def is_flv_preferred_platform(link: str) -> bool: - return any(i in link for i in ["douyin", "tiktok"]) - -def select_source_url(link: str, stream_info: dict) -> Optional[str]: - if not stream_info: - return None - if is_flv_preferred_platform(link): - flv = stream_info.get("flv_url") - if flv: - try: - from urllib.parse import urlparse, parse_qs - qs = parse_qs(urlparse(flv).query or "") - if (qs.get("codec") or [""])[0].lower() == "h265": - logger.warning("FLV 不支持 h265,改用 HLS。") - else: - return flv - except Exception: - return flv - return stream_info.get("record_url") or stream_info.get("m3u8_url") or stream_info.get("flv_url") - -async def fetch_port_info(url: str, record_quality_code: str = "OD") -> dict: - proxy = proxy_addr - port_info = {} - try: - if "douyin.com/" in url: - if 'v.douyin.com' not in url and '/user/' not in url: - j = await spider.get_douyin_stream_data(url=url, proxy_addr=proxy, cookies=dy_cookie) - else: - j = await spider.get_douyin_app_stream_data(url=url, proxy_addr=proxy, cookies=dy_cookie) - port_info = await stream.get_douyin_stream_url(j, record_quality_code, proxy) - elif url.startswith("https://www.tiktok.com/"): - if proxy: - j = await spider.get_tiktok_stream_data(url=url, proxy_addr=proxy, cookies=tiktok_cookie) - port_info = await stream.get_tiktok_stream_url(j, record_quality_code, proxy) - else: - logger.error("TikTok 需要代理,请在 config.ini 开启并配置代理。") - port_info = {"anchor_name": "", "is_live": False} - elif (".m3u8" in url) or (".flv" in url): - port_info = { - "anchor_name": "自定义_" + str(uuid.uuid4())[:8], - "is_live": True, - } - if url.endswith(".flv"): - port_info["flv_url"] = url - else: - port_info["m3u8_url"] = url - else: - port_info = {"anchor_name": "", "is_live": False} - except Exception as e: - logger.error(f"解析失败: {e}") - port_info = {"anchor_name": "", "is_live": False} - return port_info - -# --------------------- ffmpeg 输出参数生成(可复用) --------------------- -def build_youtube_output_for_key(key: str, resolution: Optional[str]) -> Tuple[str, list, list]: - if not key: - logger.error("错误: youtube.ini 未配置 key/keys") - sys.exit(1) - - secure = _yt_get("youtube", "secure", fallback=False, cast=bool) - ingest = _yt_get("youtube", "ingest", fallback="a", cast=str) - res_str = (resolution or _yt_get("youtube", "resolution", fallback="", cast=str) or "").strip() - - bv = _safe_int(_yt_get("youtube", "bitrate", fallback=5000, cast=int), 300, 20000, 5000) - ba = _safe_int(_yt_get("youtube", "audio_bitrate", fallback=128, cast=int), 64, 384, 128) - fps = _safe_int(_yt_get("youtube", "fps", fallback=30, cast=int), 10, 60, 30) - gop = _safe_int(_yt_get("youtube", "gop", fallback=2*fps, cast=int), fps, 4*fps, 2*fps) - - out_url = _build_youtube_rtmp_url(key, secure, ingest) - - v_args = [ - "-c:v", "libx264", - "-preset", "veryfast", - "-tune", "zerolatency", - "-profile:v", "high", - "-level:v", "4.2", - "-pix_fmt", "yuv420p", - "-r", str(fps), - "-g", str(gop), - "-b:v", f"{bv}k", - "-maxrate", f"{bv}k", - "-bufsize", f"{max(2*bv, 4000)}k", - ] - a_args = [ - "-c:a", "aac", - "-b:a", f"{ba}k", - "-ar", "44100", - "-ac", "2", - ] - - if res_str: - try: - w, h = map(int, res_str.lower().split("x")) - w, h = _round_res(w, h) - vf = ( - f"scale={w}:{h}:force_original_aspect_ratio=decrease," - f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2," - f"crop={w}:{h}," - f"format=yuv420p" - ) - v_args = ["-vf", vf] + v_args - except Exception: - logger.warning(f"resolution '{res_str}' 非法,忽略") - - return out_url, v_args, a_args - -# --------------------- 启动 ffmpeg --------------------- -def launch_ffmpeg(input_url: str, - out_url: str, - extra_headers: Optional[str], - v_args: list, - a_args: list) -> subprocess.Popen: - user_agent = ("Mozilla/5.0 (Linux; Android 11; SM-G973U) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/87.0.4280.141 Mobile Safari/537.36") - base = [ - "ffmpeg", "-nostdin", "-hide_banner", - "-stats", "-stats_period", "60", - "-progress", "pipe:1", - "-loglevel", "info", - - # 输入容错 & 自动重连 - "-rw_timeout", "15000000", - "-timeout", "10000000", - "-reconnect", "1", - "-reconnect_streamed", "1", - "-reconnect_at_eof", "1", - "-reconnect_delay_max", "30", - - "-user_agent", user_agent, - "-protocol_whitelist", "file,http,https,tcp,tls,crypto,udp,rtmp,rtp,httpproxy", - - "-thread_queue_size", "4096", - "-analyzeduration", "10000000", - "-probesize", "10000000", - ] - if extra_headers: - base += ["-headers", extra_headers] - - env = os.environ.copy() - if proxy_addr: - env["http_proxy"] = proxy_addr - env["https_proxy"] = proxy_addr - base = ["ffmpeg", "-http_proxy", proxy_addr] + base[1:] - - base += ["-re", "-i", input_url] - - base += [ - "-fflags", "+genpts+igndts+discardcorrupt+nobuffer", - "-xerror", - "-use_wallclock_as_timestamps", "1", - "-avioflags", "direct", - "-rtbufsize", "100M", - ] - - base += v_args + a_args - base += ["-f", "flv", out_url] - - cmd_str = " ".join(base) - print("ffmpeg cmd:", cmd_str if len(cmd_str) < 500 else cmd_str[:500] + " ...") - logger.info(f"启动推流:{input_url} -> {out_url}") - - creationflags = 0 - if os.name == "nt": - creationflags = subprocess.CREATE_NEW_PROCESS_GROUP - - p = subprocess.Popen( - base, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - bufsize=0, - env=env, - creationflags=creationflags - ) - p.last_out_ts = time.time() - - def _reader(): - try: - for line in iter(p.stdout.readline, b''): - if not line: - break - text = line.decode(errors="ignore").strip() - p.last_out_ts = time.time() - - if ("bitrate=" in text) or ("frame=" in text) or ("speed=" in text): - logger.info(f"[ffmpeg统计] {text}") - if "Server disconnected" in text: - logger.error(f"[输出异常] {text}") - elif "not supported" in text or "Invalid" in text or "invalid" in text: - logger.error(f"[转码异常] {text}") - except Exception: - pass - - threading.Thread(target=_reader, daemon=True).start() - return p - -def stop_ffmpeg(p: Optional[subprocess.Popen]): - if not p: - return - try: - if os.name == "nt": - if p.stdin: - try: p.stdin.write(b"q"); p.stdin.flush() - except Exception: pass - p.wait(timeout=6) - else: - p.send_signal(signal.SIGINT) - p.wait(timeout=10) - except Exception: - try: p.kill() - except Exception: pass - -# --------------------- 平台头部 --------------------- -def headers_for_platform(platform_name: str, live_url: str) -> Optional[str]: - live_domain = '/'.join(live_url.split('/')[0:3]) - record_headers = { - 'PandaTV': 'origin:https://www.pandalive.co.kr', - 'WinkTV': 'origin:https://www.winktv.co.kr', - 'PopkonTV': 'origin:https://www.popkontv.com', - 'FlexTV': 'origin:https://www.flextv.co.kr', - '千度热播': 'referer:https://qiandurebo.com', - '17Live': 'referer:https://17.live/en/live/6302408', - '浪Live': 'referer:https://www.lang.live', - 'shopee': f'origin:{live_domain}', - 'Blued直播': 'referer:https://app.blued.cn' - } - return record_headers.get(platform_name) - -def detect_platform(url: str) -> str: - if "douyin.com" in url: return "抖音直播" - if "tiktok.com" in url: return "TikTok直播" - if url.endswith(".flv") or url.endswith(".m3u8"): return "自定义录制直播" - return "未知平台" - -# --------------------- 并行协调器 --------------------- -class UrlCoordinator: - """ 管理 URL 池、热更新、分配给不同 worker,避免重复占用 """ - def __init__(self, path: str): - self.path = path - self.urls: List[str] = parse_urls_from_file(path) - self.idx: int = 0 - self.in_use: Set[str] = set() - self.lock = threading.Lock() - self.last_md5 = check_md5(path) - - def refresh_if_changed(self): - try: - md5 = check_md5(self.path) - if md5 != self.last_md5: - self.urls = parse_urls_from_file(self.path) or self.urls - self.last_md5 = md5 - print(f"[{now_str()}] 已重载 URL 列表,数量:{len(self.urls)}") - except Exception as e: - logger.warning(f"URL 列表热更新失败:{e}") - - def acquire_next_live(self, quality_code: str = "OD") -> Optional[Tuple[str, dict]]: - """ 顺序扫描,跳过 in_use,找到正在直播的返回并占用 """ - with self.lock: - if not self.urls: - return None - start = self.idx - tried = 0 - while tried < len(self.urls): - url = self.urls[self.idx] - self.idx = (self.idx + 1) % len(self.urls) - tried += 1 - if url in self.in_use: - continue - # 释放锁去解析,避免长时间占用 - pass_url = url - break - else: - return None - # 锁外解析 - port = asyncio.run(fetch_port_info(pass_url, quality_code)) - if not port.get("anchor_name") or not port.get("is_live"): - return None - with self.lock: - # 二次检查,确保没被别人抢占 - if pass_url in self.in_use: - return None - self.in_use.add(pass_url) - return pass_url, port - - def release(self, url: Optional[str]): - if not url: - return - with self.lock: - self.in_use.discard(url) - -def now_str(): return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - -# --------------------- Worker --------------------- -class StreamWorker(threading.Thread): - def __init__(self, worker_id: int, key: str, resolution: Optional[str], coordinator: UrlCoordinator): - super().__init__(daemon=True) - self.worker_id = worker_id - self.key = key - self.resolution = resolution - self.coordinator = coordinator - - self.current_url: Optional[str] = None - self.current_real_url: Optional[str] = None - self.current_proc: Optional[subprocess.Popen] = None - self.current_platform = "" - self.current_anchor = "" - self.record_quality_code = "OD" - - self.watchdog_grace = 120 - self.watchdog_silence = 600 - self.refresh_real_url_every = 300 - self.check_interval_live = 8 - self.scan_interval_when_idle = 2 - - self._stop_event = threading.Event() - - def stop(self): - self._stop_event.set() - - def run(self): - color.print_colored(f"[Worker-{self.worker_id}] 启动", color.GREEN) - try: - while not self._stop_event.is_set(): - # 刷新 URL 文件 - self.coordinator.refresh_if_changed() - - if self.current_proc and self.current_url: - retcode = self.current_proc.poll() - if retcode is not None: - color.print_colored(f"[{now_str()}][Worker-{self.worker_id}] ffmpeg 退出(code={retcode}),释放并切源…", color.YELLOW) - stop_ffmpeg(self.current_proc) - self.coordinator.release(self.current_url) - self._clear_state() - continue - - # 仍在推流:检查主播是否仍在直播 - still_live = True - try: - port = asyncio.run(fetch_port_info(self.current_url, self.record_quality_code)) - still_live = bool(port.get("is_live")) - except Exception as e: - logger.error(f"[Worker-{self.worker_id}] 检测当前源失败:{e}") - still_live = True - - if still_live: - elapsed = int(time.time() - self.start_time) - h, m = divmod(elapsed, 3600) - m, s = divmod(m, 60) - dur_str = f"{h:02d}:{m:02d}:{s:02d}" - print(f"\r[{now_str()}][Worker-{self.worker_id}] 正在转播({dur_str}):{self.current_platform} | {self.current_anchor} | {self.current_url}", end="") - - # 定期刷新真实播放地址 - try: - if elapsed >= self.refresh_real_url_every and (elapsed % self.refresh_real_url_every) < self.check_interval_live: - new_port = asyncio.run(fetch_port_info(self.current_url, self.record_quality_code)) - new_real_url = select_source_url(self.current_url, new_port) - if new_real_url and new_real_url != self.current_real_url: - color.print_colored(f"\n[{now_str()}][Worker-{self.worker_id}] 刷新播放地址(token 变化),重启 ffmpeg…", color.YELLOW) - stop_ffmpeg(self.current_proc) - out_url, v_args, a_args = build_youtube_output_for_key(self.key, self.resolution) - headers = headers_for_platform(self.current_platform, self.current_url) - self.current_proc = launch_ffmpeg(new_real_url, out_url, headers, v_args, a_args) - self.current_real_url = new_real_url - self.start_time = time.time() - time.sleep(self.check_interval_live) - continue - except Exception as e: - logger.warning(f"[Worker-{self.worker_id}] 刷新播放地址失败:{e}") - - # watchdog:心跳超时才重启 - silence = time.time() - getattr(self.current_proc, "last_out_ts", 0) - if elapsed >= self.watchdog_grace and silence > self.watchdog_silence: - color.print_colored(f"\n[{now_str()}][Worker-{self.worker_id}] 超过10分钟未见输出,重启…", color.YELLOW) - stop_ffmpeg(self.current_proc) - self._clear_state(release=True) - time.sleep(1) - continue - - time.sleep(self.check_interval_live) - continue - else: - color.print_colored(f"\n[{now_str()}][Worker-{self.worker_id}] 主播停播,释放并切源…", color.YELLOW) - stop_ffmpeg(self.current_proc) - self._clear_state(release=True) - continue - - # 未在推流:尝试分配新的直播 URL - got = self.coordinator.acquire_next_live(self.record_quality_code) - if not got: - print(f"[{now_str()}][Worker-{self.worker_id}] 无可用直播源,稍后再试…") - time.sleep(self.scan_interval_when_idle) - continue - - url, port = got - real_url = select_source_url(url, port) - if not real_url: - print(f"[{now_str()}][Worker-{self.worker_id}] 未得到播放地址,跳过:{url}") - self.coordinator.release(url) - time.sleep(1) - continue - - self.current_platform = detect_platform(url) - self.current_anchor = port.get("anchor_name", "") - headers = headers_for_platform(self.current_platform, url) - out_url, v_args, a_args = build_youtube_output_for_key(self.key, self.resolution) - self.current_proc = launch_ffmpeg(real_url, out_url, headers, v_args, a_args) - self.current_url = url - self.current_real_url = real_url - self.start_time = time.time() - color.print_colored(f"[{now_str()}][Worker-{self.worker_id}] 开始转播:{self.current_platform} | {self.current_anchor} | {url}", color.GREEN) - - except Exception as e: - logger.error(f"[Worker-{self.worker_id}] 主循环异常:{e}") - finally: - stop_ffmpeg(self.current_proc) - self.coordinator.release(self.current_url) - print(f"[Worker-{self.worker_id}] 已退出。") - - def _clear_state(self, release: bool = False): - if release and self.current_url: - self.coordinator.release(self.current_url) - self.current_url = None - self.current_real_url = None - self.current_proc = None - self.current_platform = "" - self.current_anchor = "" - -# --------------------- 启动入口 --------------------- -def check_ffmpeg_existence() -> bool: - try: - result = subprocess.run(['ffmpeg', '-version'], check=True, capture_output=True, text=True) - if result.returncode == 0: - print(result.stdout.splitlines()[0]) - except Exception: - pass - finally: - return bool(check_ffmpeg()) - -def main(): - print("-----------------------------------------------------") - print("| DouyinLiveRecorder - 多路并行转推版 |") - print("-----------------------------------------------------") - print(f"版本号: {version}\nGitHub: https://github.com/ihmily/DouyinLiveRecorder") - print("模式:并行检测 + 并行推流(每路独立 watchdog)") - print(".....................................................") - - if not check_ffmpeg_existence(): - logger.error("缺少 ffmpeg,程序退出") - sys.exit(1) - - urls = parse_urls_from_file(url_config_file) - if not urls: - logger.error("URL_config.ini 中没有可用 URL。") - sys.exit(1) - - keys = _yt_get_list("youtube", "keys") - if not keys: - # 兼容单 key 配置 - single_key = _yt_get("youtube", "key", fallback="", cast=str) - if single_key: - keys = [single_key] - if not keys: - logger.error("youtube.ini 没有配置 [youtube].keys 或 key") - sys.exit(1) - - # 可选:为每个 key 指定不同分辨率(与 keys 顺序对应),否则使用全局 resolution - per_res = _yt_get_list("youtube", "resolutions") # 例如:1920x1080,1080x1920,1280x720 - while per_res and len(per_res) < len(keys): - per_res.append(per_res[-1]) # 不足时沿用最后一个 - - # 协调器(共享 URL 池) - coordinator = UrlCoordinator(url_config_file) - - # 启动多路 worker - workers: List[StreamWorker] = [] - for i, key in enumerate(keys, start=1): - res_for_this = per_res[i-1] if per_res else None - w = StreamWorker(worker_id=i, key=key, resolution=res_for_this, coordinator=coordinator) - w.start() - workers.append(w) - - # 主线程仅负责捕获 Ctrl+C 并优雅退出 - try: - while True: - time.sleep(1) - except KeyboardInterrupt: - print("\n收到中断,正在退出…") - finally: - for w in workers: - w.stop() - for w in workers: - w.join(timeout=10) - print("全部 worker 已退出。") - -if __name__ == "__main__": - main() diff --git a/back/mult2 b/back/mult2 deleted file mode 100644 index e526da3..0000000 --- a/back/mult2 +++ /dev/null @@ -1,768 +0,0 @@ -# -*- encoding: utf-8 -*- - -""" -并行转推(日志优化版,保持原业务逻辑) -- 保留 Worker-1/2 等前缀 -- 日志状态变化才打印,避免刷屏 -- 禁止同一个 URL 推到同一个 YouTube key((url,key) 级别防重复) -- 仍然避免同一个 URL 被多路并行占用(url 级别独占) -- 新增:Ctrl+C/TERM 时优雅停止所有 Worker 与 ffmpeg 子进程(仅最小增改) -""" - -import asyncio -import os -import sys -import signal -import time -import datetime -import re -import uuid -import threading -import subprocess -from typing import Any, List, Tuple, Optional, Dict, Set - -import configparser -import urllib.request - -from src import spider, stream -from src.utils import logger, Color, check_md5 -from ffmpeg_install import check_ffmpeg, ffmpeg_path, current_env_path - -# =============== 基础环境 =============== -version = "relay-parallel-2.0.1-logonly" -os.environ['PATH'] = ffmpeg_path + os.pathsep + current_env_path -script_path = os.path.split(os.path.realpath(sys.argv[0]))[0] -config_file = f'{script_path}/config/config.ini' -url_config_file = f'{script_path}/config/URL_config.ini' -yt_ini_file = f"{script_path}/config/youtube.ini" -text_encoding = 'utf-8-sig' -color = Color() - -# ---- 新增:全局 Worker & 进程注册(用于 Ctrl+C 干净退出) ---- -ALL_WORKERS: List["StreamWorker"] = [] -ALL_PROCS: Set[subprocess.Popen] = set() -SHUTDOWN_EVENT = threading.Event() -ALL_LOCK = threading.Lock() - -# =============== 读取 YouTube 配置 =============== -_yt_cfg = configparser.ConfigParser() -with open(yt_ini_file, "r", encoding="utf-8-sig") as f: - _yt_cfg.read_file(f) - -def _yt_get(section: str, key: str, fallback=None, cast=str): - try: - if cast is bool: - return _yt_cfg.getboolean(section, key, fallback=fallback) - if cast is int: - return _yt_cfg.getint(section, key, fallback=fallback) - return _yt_cfg.get(section, key, fallback=fallback) - except Exception: - return fallback - -def _yt_get_list(section: str, key: str) -> List[str]: - raw = _yt_get(section, key, fallback="", cast=str) or "" - parts = re.split(r"[,;\s]+", raw.strip()) - return [p for p in parts if p] - -def _safe_int(v, lo, hi, defv): - try: - v = int(v) - if v < lo or v > hi: return defv - return v - except Exception: - return defv - -# =============== 工具函数 =============== -def _round_res(w: int, h: int) -> Tuple[int, int]: - # 偶数对齐,避免 1088x1920 类问题 - return (w & ~1, h & ~1) - -def _build_youtube_rtmp_url(key: str, secure: bool, ingest: str | None): - node = (ingest or "a").strip().lower() - node = node if node.isalpha() and len(node) == 1 else "a" - scheme = "rtmps" if secure else "rtmp" - host = f"{node}.rtmps.youtube.com" if secure else f"{node}.rtmp.youtube.com" - return f"{scheme}://{host}/live2/{key}" - -def now_str(): - return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - -# =============== 读取运行配置 =============== -def read_config_value(cfg: configparser.RawConfigParser, section: str, option: str, default_value: Any) -> Any: - try: - cfg.read(config_file, encoding=text_encoding) - if section not in cfg.sections(): - cfg.add_section(section) - return cfg.get(section, option) - except (configparser.NoSectionError, configparser.NoOptionError): - cfg.set(section, option, str(default_value)) - with open(config_file, 'w', encoding=text_encoding) as f: - cfg.write(f) - return default_value - -_CN_BOOL = {"是": True, "否": False} -cfg = configparser.RawConfigParser() -use_proxy = _CN_BOOL.get(read_config_value(cfg, '录制设置', '是否使用代理ip(是/否)', "否"), False) -proxy_addr = read_config_value(cfg, '录制设置', '代理地址', "") -proxy_addr = proxy_addr if use_proxy and proxy_addr.strip() else None -dy_cookie = read_config_value(cfg, 'Cookie', '抖音cookie', '') -tiktok_cookie = read_config_value(cfg, 'Cookie', 'tiktok_cookie', '') - -# 启动时连通性提示 -try: - if not use_proxy: - urllib.request.urlopen("https://www.google.com/", timeout=5) - print("提示:你的网络似乎直连可用(无需代理)。") -except Exception: - if not use_proxy: - color.print_colored("提示:无法直连海外站,如需 TikTok 请在 config.ini 开启代理并填写代理地址。", color.YELLOW) - -# =============== URL 列表 =============== -def parse_urls_from_file(path: str) -> List[str]: - urls, seen = [], set() - if not os.path.isfile(path): - open(path, 'w', encoding=text_encoding).close() - print("URL_config.ini 为空,请写入直播间地址后再运行。") - return urls - with open(path, "r", encoding=text_encoding, errors='ignore') as f: - for line in f: - s = line.strip() - if not s or s.startswith("#"): - continue - parts = re.split(r"[,,]\s*", s) - cand = None - for p in parts: - if '://' in p or re.match(r"^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", p): - cand = p - break - if not cand: - continue - url = cand if '://' in cand else f"https://{cand}" - if url not in seen: - seen.add(url) - urls.append(url) - return urls - -# =============== 解析与选源 =============== -def is_flv_preferred_platform(link: str) -> bool: - return any(i in link for i in ["douyin", "tiktok"]) - -def select_source_url(link: str, stream_info: dict) -> Optional[str]: - if not stream_info: - return None - if is_flv_preferred_platform(link): - flv = stream_info.get("flv_url") - if flv: - try: - from urllib.parse import urlparse, parse_qs - qs = parse_qs(urlparse(flv).query or "") - if (qs.get("codec") or [""])[0].lower() == "h265": - logger.warning("FLV 不支持 h265,改用 HLS。") - else: - return flv - except Exception: - return flv - return stream_info.get("record_url") or stream_info.get("m3u8_url") or stream_info.get("flv_url") - -async def fetch_port_info(url: str, record_quality_code: str = "OD") -> dict: - proxy = proxy_addr - port_info = {} - try: - if "douyin.com/" in url: - if 'v.douyin.com' not in url and '/user/' not in url: - j = await spider.get_douyin_stream_data(url=url, proxy_addr=proxy, cookies=dy_cookie) - else: - j = await spider.get_douyin_app_stream_data(url=url, proxy_addr=proxy, cookies=dy_cookie) - port_info = await stream.get_douyin_stream_url(j, record_quality_code, proxy) - elif url.startswith("https://www.tiktok.com/"): - if proxy: - j = await spider.get_tiktok_stream_data(url=url, proxy_addr=proxy, cookies=tiktok_cookie) - port_info = await stream.get_tiktok_stream_url(j, record_quality_code, proxy) - else: - logger.error("TikTok 需要代理,请在 config.ini 开启并配置代理。") - port_info = {"anchor_name": "", "is_live": False} - elif (".m3u8" in url) or (".flv" in url): - port_info = { - "anchor_name": "自定义_" + str(uuid.uuid4())[:8], - "is_live": True, - } - if url.endswith(".flv"): - port_info["flv_url"] = url - else: - port_info["m3u8_url"] = url - else: - port_info = {"anchor_name": "", "is_live": False} - except Exception as e: - logger.error(f"解析失败: {e}") - port_info = {"anchor_name": "", "is_live": False} - return port_info - -# =============== 构建输出参数(每个 key) =============== -def build_youtube_output_for_key(key: str, resolution: Optional[str]) -> Tuple[str, list, list]: - if not key: - logger.error("错误: youtube.ini 未配置 key/keys") - sys.exit(1) - - secure = _yt_get("youtube", "secure", fallback=False, cast=bool) - ingest = _yt_get("youtube", "ingest", fallback="a", cast=str) - res_str = (resolution or _yt_get("youtube", "resolution", fallback="", cast=str) or "").strip() - - bv = _safe_int(_yt_get("youtube", "bitrate", fallback=5000, cast=int), 300, 20000, 5000) - ba = _safe_int(_yt_get("youtube", "audio_bitrate", fallback=128, cast=int), 64, 384, 128) - fps = _safe_int(_yt_get("youtube", "fps", fallback=30, cast=int), 10, 60, 30) - gop = _safe_int(_yt_get("youtube", "gop", fallback=2*fps, cast=int), fps, 4*fps, 2*fps) - - out_url = _build_youtube_rtmp_url(key, secure, ingest) - - v_args = [ - "-c:v", "libx264", - "-preset", "veryfast", - "-tune", "zerolatency", - "-profile:v", "high", - "-level:v", "4.2", - "-pix_fmt", "yuv420p", - "-r", str(fps), - "-g", str(gop), - "-b:v", f"{bv}k", - "-maxrate", f"{bv}k", - "-bufsize", f"{max(2*bv, 4000)}k", - ] - a_args = [ - "-c:a", "aac", - "-b:a", f"{ba}k", - "-ar", "44100", - "-ac", "2", - ] - - if res_str: - try: - w, h = map(int, res_str.lower().split("x")) - w, h = _round_res(w, h) - vf = ( - f"scale={w}:{h}:force_original_aspect_ratio=decrease," - f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2," - f"crop={w}:{h}," - f"format=yuv420p" - ) - v_args = ["-vf", vf] + v_args - except Exception: - logger.warning(f"resolution '{res_str}' 非法,忽略") - - return out_url, v_args, a_args - -# =============== 启动/停止 ffmpeg =============== -def launch_ffmpeg(input_url: str, - out_url: str, - extra_headers: Optional[str], - v_args: list, - a_args: list, - worker_name: str) -> subprocess.Popen: - user_agent = ("Mozilla/5.0 (Linux; Android 11; SM-G973U) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/87.0.4280.141 Mobile Safari/537.36") - base = [ - "ffmpeg", "-nostdin", "-hide_banner", - "-stats", "-stats_period", "60", - "-progress", "pipe:1", - "-loglevel", "info", - - # 输入容错 & 自动重连 - "-rw_timeout", "15000000", - "-timeout", "10000000", - "-reconnect", "1", - "-reconnect_streamed", "1", - "-reconnect_at_eof", "1", - "-reconnect_delay_max", "30", - - "-user_agent", user_agent, - "-protocol_whitelist", "file,http,https,tcp,tls,crypto,udp,rtmp,rtp,httpproxy", - - "-thread_queue_size", "4096", - "-analyzeduration", "10000000", - "-probesize", "10000000", - ] - if extra_headers: - base += ["-headers", extra_headers] - - env = os.environ.copy() - if proxy_addr: - env["http_proxy"] = proxy_addr - env["https_proxy"] = proxy_addr - base = ["ffmpeg", "-http_proxy", proxy_addr] + base[1:] - - base += ["-re", "-i", input_url] - - base += [ - "-fflags", "+genpts+igndts+discardcorrupt+nobuffer", - "-xerror", - "-use_wallclock_as_timestamps", "1", - "-avioflags", "direct", - "-rtbufsize", "100M", - ] - - base += v_args + a_args - base += ["-f", "flv", out_url] - - cmd_str = " ".join(base) - print(f"ffmpeg cmd ({worker_name}):", cmd_str if len(cmd_str) < 500 else cmd_str[:500] + " ...") - logger.info(f"{worker_name} 启动推流:{input_url} -> {out_url}") - - creationflags = 0 - if os.name == "nt": - creationflags = subprocess.CREATE_NEW_PROCESS_GROUP - - p = subprocess.Popen( - base, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - bufsize=0, - env=env, - creationflags=creationflags - ) - p.last_out_ts = time.time() - - # ---- 新增:注册进程,便于 Ctrl+C 统一清理 ---- - with ALL_LOCK: - ALL_PROCS.add(p) - - def _reader(): - try: - for line in iter(p.stdout.readline, b''): - if not line: - break - text = line.decode(errors="ignore").strip() - p.last_out_ts = time.time() - - # 只保留关键统计/错误(前缀带 Worker) - if ("bitrate=" in text) or ("frame=" in text) or ("speed=" in text) or ("libx264" in text): - logger.info(f"{worker_name} [ffmpeg统计] {text}") - if "Server disconnected" in text: - logger.error(f"{worker_name} [输出异常] {text}") - elif ("not supported" in text) or ("Invalid" in text) or ("invalid" in text): - logger.error(f"{worker_name} [转码异常] {text}") - except Exception: - pass - - threading.Thread(target=_reader, daemon=True).start() - return p - -def stop_ffmpeg(p: Optional[subprocess.Popen]): - if not p: return - try: - if os.name == "nt": - if p.stdin: - try: - p.stdin.write(b"q"); p.stdin.flush() - except Exception: - pass - p.wait(timeout=6) - else: - p.send_signal(signal.SIGINT) - p.wait(timeout=10) - except Exception: - try: p.kill() - except Exception: pass - finally: - # ---- 新增:从全局集合移除 ---- - with ALL_LOCK: - ALL_PROCS.discard(p) - -# =============== 平台 headers/识别 =============== -def headers_for_platform(platform_name: str, live_url: str) -> Optional[str]: - live_domain = '/'.join(live_url.split('/')[0:3]) - record_headers = { - 'PandaTV': 'origin:https://www.pandalive.co.kr', - 'WinkTV': 'origin:https://www.winktv.co.kr', - 'PopkonTV': 'origin:https://www.popkontv.com', - 'FlexTV': 'origin:https://www.flextv.co.kr', - '千度热播': 'referer:https://qiandurebo.com', - '17Live': 'referer:https://17.live/en/live/6302408', - '浪Live': 'referer:https://www.lang.live', - 'shopee': f'origin:{live_domain}', - 'Blued直播': 'referer:https://app.blued.cn' - } - return record_headers.get(platform_name) - -def detect_platform(url: str) -> str: - if "douyin.com" in url: return "抖音直播" - if "tiktok.com" in url: return "TikTok直播" - if url.endswith(".flv") or url.endswith(".m3u8"): return "自定义录制直播" - return "未知平台" - -# =============== 日志状态缓存(去重/限频) =============== -class LogState: - def __init__(self): - self.lock = threading.Lock() - self.url_status: Dict[str, str] = {} # url -> "unknown/offline/online/streaming" - self.no_source_worker: Dict[str, bool] = {} # worker_name -> idle printed flag - - def set_status(self, url: str, status: str, text: Optional[str] = None, prefix: str = ""): - with self.lock: - prev = self.url_status.get(url, "unknown") - if prev != status: - self.url_status[url] = status - if text: - print(f"[{now_str()}]{prefix} {text}") - - def mark_idle_once(self, worker_name: str): - with self.lock: - if not self.no_source_worker.get(worker_name, False): - print(f"[{now_str()}][{worker_name}] 当前无可用直播源,等待中…") - self.no_source_worker[worker_name] = True - - def clear_idle_flag(self, worker_name: str): - with self.lock: - self.no_source_worker[worker_name] = False - -LOGSTATE = LogState() - -# =============== URL 协调器(包含 url 与 (url,key) 占用) =============== -class UrlCoordinator: - """ - - urls 顺序分配 - - in_use_urls: url 级别独占(防止同一 url 被不同 worker 同时占用) - - in_use_pairs: (url,key) 级别独占(禁止同一 url 推到同一个 key) - """ - def __init__(self, path: str): - self.path = path - self.urls: List[str] = parse_urls_from_file(path) - self.idx: int = 0 - self.lock = threading.Lock() - self.last_md5 = check_md5(path) - - self.in_use_urls: Set[str] = set() - self.in_use_pairs: Set[Tuple[str, str]] = set() # (url, key) - - def refresh_if_changed(self): - try: - md5 = check_md5(self.path) - if md5 != self.last_md5: - self.urls = parse_urls_from_file(self.path) or self.urls - self.last_md5 = md5 - print(f"[{now_str()}] 已重载 URL 列表,数量:{len(self.urls)}") - except Exception as e: - logger.warning(f"URL 列表热更新失败:{e}") - - def acquire_next_live(self, worker_key: str, quality_code: str = "OD") -> Optional[Tuple[str, dict]]: - with self.lock: - if not self.urls: - return None - tried = 0 - pick = None - while tried < len(self.urls): - url = self.urls[self.idx] - self.idx = (self.idx + 1) % len(self.urls) - tried += 1 - # 已被其他路占用 / 同 url-key 已经在用 - if url in self.in_use_urls: - continue - if (url, worker_key) in self.in_use_pairs: - continue - pick = url - break - if not pick: - return None - - # 锁外解析 - port = asyncio.run(fetch_port_info(pick, quality_code)) - if not port.get("anchor_name"): - LOGSTATE.set_status(pick, "unknown", - text=f"解析失败:{pick}", - prefix="") - # ⚠️ 增加:释放 URL 占用,避免卡死 - self.release(pick, worker_key) - return None - if not port.get("is_live"): - anchor = port.get('anchor_name','') - LOGSTATE.set_status(pick, "offline", - text=f"未开播:{anchor} | {pick}", - prefix="") - # ⚠️ 增加:释放 URL 占用,避免卡死 - self.release(pick, worker_key) - return None - - # 占用 - with self.lock: - if (pick in self.in_use_urls) or ((pick, worker_key) in self.in_use_pairs): - return None - self.in_use_urls.add(pick) - self.in_use_pairs.add((pick, worker_key)) - - # 状态更新(online) - anchor = port.get('anchor_name','') - LOGSTATE.set_status(pick, "online", - text=f"已开播:{anchor} | {pick}", - prefix="") - return pick, port - - def release(self, url: Optional[str], worker_key: Optional[str]): - if not url: - return - with self.lock: - self.in_use_urls.discard(url) - if worker_key: - self.in_use_pairs.discard((url, worker_key)) - -# =============== Worker(保留前缀日志) =============== -class StreamWorker(threading.Thread): - def __init__(self, worker_id: int, key: str, resolution: Optional[str], coordinator: UrlCoordinator): - super().__init__(daemon=True) - self.worker_id = worker_id - self.key = key - self.worker_name = f"Worker-{worker_id}" - self.resolution = resolution - self.coordinator = coordinator - - self.current_url: Optional[str] = None - self.current_real_url: Optional[str] = None - self.current_proc: Optional[subprocess.Popen] = None - self.current_platform = "" - self.current_anchor = "" - self.record_quality_code = "OD" - - # 与原逻辑一致的参数 - self.watchdog_grace = 120 - self.watchdog_silence = 600 - self.refresh_real_url_every = 300 - self.check_interval_live = 8 - self.scan_interval_when_idle = 2 - - self._last_progress_print = -9999 - self._stop_event = threading.Event() - - def stop(self): - """最小改动:收到停止请求时,立即尝试停掉当前 ffmpeg,尽快响应退出。""" - self._stop_event.set() - try: - stop_ffmpeg(self.current_proc) - except Exception: - pass - - def _p(self, text: str): - print(f"[{now_str()}][{self.worker_name}] {text}") - - def run(self): - self._p("启动") - try: - while not self._stop_event.is_set(): - self.coordinator.refresh_if_changed() - - if self.current_proc and self.current_url: - retcode = self.current_proc.poll() - if retcode is not None: - self._p(f"❌ ffmpeg 退出(code={retcode}),切换下一个源…") - stop_ffmpeg(self.current_proc) - self.coordinator.release(self.current_url, self.key) - self._clear_state() - LOGSTATE.clear_idle_flag(self.worker_name) - continue - - # 检测是否仍在直播(保持原逻辑) - still_live = True - try: - port = asyncio.run(fetch_port_info(self.current_url, self.record_quality_code)) - still_live = bool(port.get("is_live")) - except Exception as e: - logger.error(f"[{self.worker_name}] 检测当前源失败:{e}") - still_live = True - - if still_live: - elapsed = int(time.time() - self.start_time) - # 每 60s 打印一次“正在转播” - if elapsed - self._last_progress_print >= 60: - h, m = divmod(elapsed, 3600) - m, s = divmod(m, 60) - dur_str = f"{h:02d}:{m:02d}:{s:02d}" - self._p(f"⏱ 正在转播({dur_str}):{self.current_platform} | {self.current_anchor}") - self._last_progress_print = elapsed - - # 定期刷新真实播放地址(token) - try: - if elapsed >= self.refresh_real_url_every and (elapsed % self.refresh_real_url_every) < self.check_interval_live: - new_port = asyncio.run(fetch_port_info(self.current_url, self.record_quality_code)) - new_real_url = select_source_url(self.current_url, new_port) - if new_real_url and new_real_url != self.current_real_url: - self._p("🔄 刷新播放地址(token 变化),重启 ffmpeg…") - stop_ffmpeg(self.current_proc) - out_url, v_args, a_args = build_youtube_output_for_key(self.key, self.resolution) - headers = headers_for_platform(self.current_platform, self.current_url) - self.current_proc = launch_ffmpeg(new_real_url, out_url, headers, v_args, a_args, self.worker_name) - self.current_real_url = new_real_url - self.start_time = time.time() - self._last_progress_print = -9999 - time.sleep(self.check_interval_live) - continue - except Exception as e: - logger.warning(f"[{self.worker_name}] 刷新播放地址失败:{e}") - - # watchdog - silence = time.time() - getattr(self.current_proc, "last_out_ts", 0) - if elapsed >= self.watchdog_grace and silence > self.watchdog_silence: - self._p("⚠️ 超过10分钟未见输出,重启…") - stop_ffmpeg(self.current_proc) - self._clear_state(release=True) - LOGSTATE.clear_idle_flag(self.worker_name) - time.sleep(1) - continue - - time.sleep(self.check_interval_live) - continue - else: - self._p("📴 主播停播,切换下一个源…") - stop_ffmpeg(self.current_proc) - self._clear_state(release=True) - LOGSTATE.clear_idle_flag(self.worker_name) - continue - - # —— 未在推流:尝试分配一个新源(保持原顺序轮询 + 占用) —— - got = self.coordinator.acquire_next_live(self.key, self.record_quality_code) - if not got: - # 仅首次空闲时提示一次 - LOGSTATE.mark_idle_once(self.worker_name) - time.sleep(self.scan_interval_when_idle) - continue - - LOGSTATE.clear_idle_flag(self.worker_name) - url, port = got - real_url = select_source_url(url, port) - if not real_url: - self._p(f"未得到播放地址,跳过:{url}") - self.coordinator.release(url, self.key) - time.sleep(1) - continue - - self.current_platform = detect_platform(url) - self.current_anchor = port.get("anchor_name", "") - headers = headers_for_platform(self.current_platform, url) - out_url, v_args, a_args = build_youtube_output_for_key(self.key, self.resolution) - self.current_proc = launch_ffmpeg(real_url, out_url, headers, v_args, a_args, self.worker_name) - self.current_url = url - self.current_real_url = real_url - self.start_time = time.time() - self._last_progress_print = -9999 - - self._p(f"✅ 开始转播:{self.current_platform} | {self.current_anchor} | {url}") - - except Exception as e: - logger.error(f"[{self.worker_name}] 主循环异常:{e}") - finally: - stop_ffmpeg(self.current_proc) - self.coordinator.release(self.current_url, self.key) - self._p("已退出。") - - def _clear_state(self, release: bool = False): - if release and self.current_url: - self.coordinator.release(self.current_url, self.key) - self.current_url = None - self.current_real_url = None - self.current_proc = None - self.current_platform = "" - self.current_anchor = "" - -# =============== 启动入口(保持原行为) =============== -def check_ffmpeg_existence() -> bool: - try: - result = subprocess.run(['ffmpeg', '-version'], check=True, capture_output=True, text=True) - if result.returncode == 0: - print(result.stdout.splitlines()[0]) - except Exception: - pass - finally: - return bool(check_ffmpeg()) - -# ---- 新增:统一优雅退出 ---- -def graceful_shutdown(signum=None, frame=None): - if not SHUTDOWN_EVENT.is_set(): - print("\n收到中断,正在停止所有 worker 与 ffmpeg …") - SHUTDOWN_EVENT.set() - # 先发 stop 给所有 worker(会内部 stop 当前 ffmpeg) - for w in list(ALL_WORKERS): - try: - w.stop() - except Exception: - pass - # 再兜底停止全局登记的 ffmpeg 子进程 - with ALL_LOCK: - procs = list(ALL_PROCS) - for p in procs: - try: - stop_ffmpeg(p) - except Exception: - pass - -def main(): - print("-----------------------------------------------------") - print("| DouyinLiveRecorder - 并行转推版 |") - print("-----------------------------------------------------") - print(f"版本号: {version}\nGitHub: https://github.com/ihmily/DouyinLiveRecorder") - print("模式:并行检测 + 并行推流(保持原有业务逻辑)") - print(".....................................................") - - if not check_ffmpeg_existence(): - logger.error("缺少 ffmpeg,程序退出") - sys.exit(1) - - urls = parse_urls_from_file(url_config_file) - if not urls: - logger.error("URL_config.ini 中没有可用 URL。") - sys.exit(1) - - # keys(并行路数 = keys 数量);兼容单 key - keys = _yt_get_list("youtube", "keys") - if not keys: - single_key = _yt_get("youtube", "key", fallback="", cast=str) - if single_key: - keys = [single_key] - if not keys: - logger.error("youtube.ini 没有配置 [youtube].keys 或 key") - sys.exit(1) - - # 可选:每路分辨率 - per_res = _yt_get_list("youtube", "resolutions") # 如:1920x1080,1080x1920 - while per_res and len(per_res) < len(keys): - per_res.append(per_res[-1]) - - # 打印 ffmpeg 版本(贴合旧风格) - try: - vline = subprocess.run(['ffmpeg', '-version'], check=True, capture_output=True, text=True).stdout.splitlines()[0] - print(vline) - except Exception: - pass - - coordinator = UrlCoordinator(url_config_file) - - # 信号处理(Ctrl+C / kill) - try: - signal.signal(signal.SIGINT, graceful_shutdown) - except Exception: - pass - try: - signal.signal(signal.SIGTERM, graceful_shutdown) - except Exception: - pass - - # 启动多路(Worker 前缀区分) - workers: List[StreamWorker] = [] - for i, key in enumerate(keys, start=1): - res_for_this = per_res[i-1] if per_res else None - w = StreamWorker(worker_id=i, key=key, resolution=res_for_this, coordinator=coordinator) - w.start() - workers.append(w) - # ---- 新增:登记全局,供优雅退出用 ---- - ALL_WORKERS.append(w) - - try: - while not SHUTDOWN_EVENT.is_set(): - time.sleep(0.5) - except KeyboardInterrupt: - graceful_shutdown() - finally: - # 二次保险,确保全部退出 - graceful_shutdown() - for w in workers: - w.join(timeout=10) - print("全部 worker 已退出。") - -if __name__ == "__main__": - main() diff --git a/back/mult3 b/back/mult3 deleted file mode 100644 index f2068f0..0000000 --- a/back/mult3 +++ /dev/null @@ -1,782 +0,0 @@ -# -*- encoding: utf-8 -*- - -""" -并行转推(日志优化版,保持原业务逻辑) -- 保留 Worker-1/2 等前缀 -- 日志状态变化才打印,避免刷屏 -- 禁止同一个 URL 推到同一个 YouTube key((url,key) 级别防重复) -- 仍然避免同一个 URL 被多路并行占用(url 级别独占) -- 新增:Ctrl+C/TERM 时优雅停止所有 Worker 与 ffmpeg 子进程(仅最小增改) -""" - -import asyncio -import os -import sys -import signal -import time -import datetime -import re -import uuid -import threading -import subprocess -from typing import Any, List, Tuple, Optional, Dict, Set - -import configparser -import urllib.request - -from src import spider, stream -from src.utils import logger, Color, check_md5 -from ffmpeg_install import check_ffmpeg, ffmpeg_path, current_env_path - -# =============== 基础环境 =============== -version = "relay-parallel-2.0.3-optimized" -os.environ['PATH'] = ffmpeg_path + os.pathsep + current_env_path -script_path = os.path.split(os.path.realpath(sys.argv[0]))[0] -config_file = f'{script_path}/config/config.ini' -url_config_file = f'{script_path}/config/URL_config.ini' -yt_ini_file = f"{script_path}/config/youtube.ini" -text_encoding = 'utf-8-sig' -color = Color() - -# ---- 新增:全局 Worker & 进程注册(用于 Ctrl+C 干净退出) ---- -ALL_WORKERS: List["StreamWorker"] = [] -ALL_PROCS: Set[subprocess.Popen] = set() -SHUTDOWN_EVENT = threading.Event() -ALL_LOCK = threading.Lock() - -# =============== 读取 YouTube 配置 =============== -_yt_cfg = configparser.ConfigParser() -with open(yt_ini_file, "r", encoding="utf-8-sig") as f: - _yt_cfg.read_file(f) - -def _yt_get(section: str, key: str, fallback=None, cast=str): - try: - if cast is bool: - return _yt_cfg.getboolean(section, key, fallback=fallback) - if cast is int: - return _yt_cfg.getint(section, key, fallback=fallback) - return _yt_cfg.get(section, key, fallback=fallback) - except Exception: - return fallback - -def _yt_get_list(section: str, key: str) -> List[str]: - raw = _yt_get(section, key, fallback="", cast=str) or "" - parts = re.split(r"[,;\s]+", raw.strip()) - return [p for p in parts if p] - -def _safe_int(v, lo, hi, defv): - try: - v = int(v) - if v < lo or v > hi: return defv - return v - except Exception: - return defv - -# =============== 工具函数 =============== -def _round_res(w: int, h: int) -> Tuple[int, int]: - # 偶数对齐,避免 1088x1920 类问题 - return (w & ~1, h & ~1) - -def _build_youtube_rtmp_url(key: str, secure: bool, ingest: str | None): - node = (ingest or "a").strip().lower() - node = node if node.isalpha() and len(node) == 1 else "a" - scheme = "rtmps" if secure else "rtmp" - host = f"{node}.rtmps.youtube.com" if secure else f"{node}.rtmp.youtube.com" - return f"{scheme}://{host}/live2/{key}" - -def now_str(): - return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - -# =============== 读取运行配置 =============== -def read_config_value(cfg: configparser.RawConfigParser, section: str, option: str, default_value: Any) -> Any: - try: - cfg.read(config_file, encoding=text_encoding) - if section not in cfg.sections(): - cfg.add_section(section) - return cfg.get(section, option) - except (configparser.NoSectionError, configparser.NoOptionError): - cfg.set(section, option, str(default_value)) - with open(config_file, 'w', encoding=text_encoding) as f: - cfg.write(f) - return default_value - -_CN_BOOL = {"是": True, "否": False} -cfg = configparser.RawConfigParser() -use_proxy = _CN_BOOL.get(read_config_value(cfg, '录制设置', '是否使用代理ip(是/否)', "否"), False) -proxy_addr = read_config_value(cfg, '录制设置', '代理地址', "") -proxy_addr = proxy_addr if use_proxy and proxy_addr.strip() else None -dy_cookie = read_config_value(cfg, 'Cookie', '抖音cookie', '') -tiktok_cookie = read_config_value(cfg, 'Cookie', 'tiktok_cookie', '') - -# 启动时连通性提示 -try: - if not use_proxy: - urllib.request.urlopen("https://www.google.com/", timeout=5) - print("提示:你的网络似乎直连可用(无需代理)。") -except Exception: - if not use_proxy: - color.print_colored("提示:无法直连海外站,如需 TikTok 请在 config.ini 开启代理并填写代理地址。", color.YELLOW) - -# =============== URL 列表 =============== -def parse_urls_from_file(path: str) -> List[str]: - urls, seen = [], set() - if not os.path.isfile(path): - open(path, 'w', encoding=text_encoding).close() - print("URL_config.ini 为空,请写入直播间地址后再运行。") - return urls - with open(path, "r", encoding=text_encoding, errors='ignore') as f: - for line in f: - s = line.strip() - if not s or s.startswith("#"): - continue - parts = re.split(r"[,,]\s*", s) - cand = None - for p in parts: - if '://' in p or re.match(r"^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", p): - cand = p - break - if not cand: - continue - url = cand if '://' in cand else f"https://{cand}" - if url not in seen: - seen.add(url) - urls.append(url) - return urls - -# =============== 解析与选源 =============== -def is_flv_preferred_platform(link: str) -> bool: - return any(i in link for i in ["douyin", "tiktok"]) - -def select_source_url(link: str, stream_info: dict) -> Optional[str]: - if not stream_info: - return None - if is_flv_preferred_platform(link): - flv = stream_info.get("flv_url") - if flv: - try: - from urllib.parse import urlparse, parse_qs - qs = parse_qs(urlparse(flv).query or "") - if (qs.get("codec") or [""])[0].lower() == "h265": - logger.warning("FLV 不支持 h265,改用 HLS。") - else: - return flv - except Exception: - return flv - return stream_info.get("record_url") or stream_info.get("m3u8_url") or stream_info.get("flv_url") - -async def fetch_port_info(url: str, record_quality_code: str = "OD") -> dict: - proxy = proxy_addr - port_info = {} - try: - if "douyin.com/" in url: - if 'v.douyin.com' not in url and '/user/' not in url: - j = await spider.get_douyin_stream_data(url=url, proxy_addr=proxy, cookies=dy_cookie) - else: - j = await spider.get_douyin_app_stream_data(url=url, proxy_addr=proxy, cookies=dy_cookie) - port_info = await stream.get_douyin_stream_url(j, record_quality_code, proxy) - elif url.startswith("https://www.tiktok.com/"): - if proxy: - j = await spider.get_tiktok_stream_data(url=url, proxy_addr=proxy, cookies=tiktok_cookie) - port_info = await stream.get_tiktok_stream_url(j, record_quality_code, proxy) - else: - logger.error("TikTok 需要代理,请在 config.ini 开启并配置代理。") - port_info = {"anchor_name": "", "is_live": False} - elif (".m3u8" in url) or (".flv" in url): - port_info = { - "anchor_name": "自定义_" + str(uuid.uuid4())[:8], - "is_live": True, - } - if url.endswith(".flv"): - port_info["flv_url"] = url - else: - port_info["m3u8_url"] = url - else: - port_info = {"anchor_name": "", "is_live": False} - except Exception as e: - logger.error(f"解析失败: {e}") - port_info = {"anchor_name": "", "is_live": False} - return port_info - -# =============== 构建输出参数(每个 key) =============== -def build_youtube_output_for_key(key: str, resolution: Optional[str]) -> Tuple[str, list, list]: - if not key: - logger.error("错误: youtube.ini 未配置 key/keys") - sys.exit(1) - - secure = _yt_get("youtube", "secure", fallback=False, cast=bool) - ingest = _yt_get("youtube", "ingest", fallback="a", cast=str) - res_str = (resolution or _yt_get("youtube", "resolution", fallback="", cast=str) or "").strip() - - bv = _safe_int(_yt_get("youtube", "bitrate", fallback=5000, cast=int), 300, 20000, 5000) - ba = _safe_int(_yt_get("youtube", "audio_bitrate", fallback=128, cast=int), 64, 384, 128) - fps = _safe_int(_yt_get("youtube", "fps", fallback=30, cast=int), 10, 60, 30) - gop = _safe_int(_yt_get("youtube", "gop", fallback=2*fps, cast=int), fps, 4*fps, 2*fps) - - out_url = _build_youtube_rtmp_url(key, secure, ingest) - - v_args = [ - "-c:v", "libx264", - "-preset", "veryfast", - "-tune", "zerolatency", - "-profile:v", "high", - "-level:v", "4.2", - "-pix_fmt", "yuv420p", - "-r", str(fps), - "-g", str(gop), - "-b:v", f"{bv}k", - "-maxrate", f"{bv}k", - "-bufsize", f"{max(2*bv, 4000)}k", - ] - a_args = [ - "-c:a", "aac", - "-b:a", f"{ba}k", - "-ar", "44100", - "-ac", "2", - ] - - if res_str: - try: - w, h = map(int, res_str.lower().split("x")) - w, h = _round_res(w, h) - vf = ( - f"scale={w}:{h}:force_original_aspect_ratio=decrease," - f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2," - f"crop={w}:{h}," - f"format=yuv420p" - ) - v_args = ["-vf", vf] + v_args - except Exception: - logger.warning(f"resolution '{res_str}' 非法,忽略") - - return out_url, v_args, a_args - -# =============== 启动/停止 ffmpeg =============== -def launch_ffmpeg(input_url: str, - out_url: str, - extra_headers: Optional[str], - v_args: list, - a_args: list, - worker_name: str) -> subprocess.Popen: - user_agent = ("Mozilla/5.0 (Linux; Android 11; SM-G973U) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/87.0.4280.141 Mobile Safari/537.36") - # 输入侧容错/重连/探测 - base = [ - "ffmpeg", "-nostdin", "-hide_banner", - "-stats", - "-stats_period", "60", - "-progress", "pipe:1", - "-loglevel", "info", - - # 输入容错 & 自动重连 - "-rw_timeout", "15000000", - "-timeout", "10000000", - "-reconnect", "1", - "-reconnect_streamed", "1", - "-reconnect_at_eof", "1", - "-reconnect_delay_max", "30", - - # UA/协议白名单 - "-user_agent", user_agent, - "-protocol_whitelist", "file,http,https,tcp,tls,crypto,udp,rtmp,rtp,httpproxy", - - # 缓冲 & 探测 - "-thread_queue_size", "4096", - "-analyzeduration", "10000000", - "-probesize", "10000000", - ] - - # 平台 headers(referer/origin)要在 -i 之前 - if extra_headers: - base += ["-headers", extra_headers] - - # 代理:http_proxy/https_proxy 环境变量(同时保留 -http_proxy 兼容) - env = os.environ.copy() - if proxy_addr: - env["http_proxy"] = proxy_addr - env["https_proxy"] = proxy_addr - base = ["ffmpeg", "-http_proxy", proxy_addr] + base[1:] - - # 输入 - base += ["-re", "-i", input_url] - - # 通用容错 + 时间戳修复(优化版) - base += [ - "-fflags", "+genpts+igndts+flush_packets", # 自动生成时间戳 + 忽略乱序 + 刷新缓冲 - "-use_wallclock_as_timestamps", "1", # 用系统时钟修正时间戳 - "-avioflags", "direct", # 减少延迟 - "-rtbufsize", "100M", # 输入缓冲 - "-err_detect", "ignore_err", # 忽略解码错误 - "-avoid_negative_ts", "make_zero", # 避免负时间戳 - ] - - # 视频/音频编码参数(使用 build_youtube_output 的结果) - v_args = v_args or [] - a_args = a_args or [] - base += v_args + a_args - - # 输出(flv 到 YT,更宽容) - base += ["-f", "flv", "-flvflags", "no_duration_filesize", out_url] - - - - cmd_str = " ".join(base) - print(f"ffmpeg cmd ({worker_name}):", cmd_str if len(cmd_str) < 500 else cmd_str[:500] + " ...") - logger.info(f"{worker_name} 启动推流:{input_url} -> {out_url}") - - creationflags = 0 - if os.name == "nt": - creationflags = subprocess.CREATE_NEW_PROCESS_GROUP - - p = subprocess.Popen( - base, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - bufsize=0, - env=env, - creationflags=creationflags - ) - p.last_out_ts = time.time() - - # ---- 新增:注册进程,便于 Ctrl+C 统一清理 ---- - with ALL_LOCK: - ALL_PROCS.add(p) - - def _reader(): - try: - for line in iter(p.stdout.readline, b''): - if not line: - break - text = line.decode(errors="ignore").strip() - p.last_out_ts = time.time() - - # 只保留关键统计/错误(前缀带 Worker) - if ("bitrate=" in text) or ("frame=" in text) or ("speed=" in text) or ("libx264" in text): - logger.info(f"{worker_name} [ffmpeg统计] {text}") - if "Server disconnected" in text: - logger.error(f"{worker_name} [输出异常] {text}") - elif ("not supported" in text) or ("Invalid" in text) or ("invalid" in text): - logger.error(f"{worker_name} [转码异常] {text}") - except Exception: - pass - - threading.Thread(target=_reader, daemon=True).start() - return p - -def stop_ffmpeg(p: Optional[subprocess.Popen]): - if not p: return - try: - if os.name == "nt": - if p.stdin: - try: - p.stdin.write(b"q"); p.stdin.flush() - except Exception: - pass - p.wait(timeout=6) - else: - p.send_signal(signal.SIGINT) - p.wait(timeout=10) - except Exception: - try: p.kill() - except Exception: pass - finally: - # ---- 新增:从全局集合移除 ---- - with ALL_LOCK: - ALL_PROCS.discard(p) - -# =============== 平台 headers/识别 =============== -def headers_for_platform(platform_name: str, live_url: str) -> Optional[str]: - live_domain = '/'.join(live_url.split('/')[0:3]) - record_headers = { - 'PandaTV': 'origin:https://www.pandalive.co.kr', - 'WinkTV': 'origin:https://www.winktv.co.kr', - 'PopkonTV': 'origin:https://www.popkontv.com', - 'FlexTV': 'origin:https://www.flextv.co.kr', - '千度热播': 'referer:https://qiandurebo.com', - '17Live': 'referer:https://17.live/en/live/6302408', - '浪Live': 'referer:https://www.lang.live', - 'shopee': f'origin:{live_domain}', - 'Blued直播': 'referer:https://app.blued.cn' - } - return record_headers.get(platform_name) - -def detect_platform(url: str) -> str: - if "douyin.com" in url: return "抖音直播" - if "tiktok.com" in url: return "TikTok直播" - if url.endswith(".flv") or url.endswith(".m3u8"): return "自定义录制直播" - return "未知平台" - -# =============== 日志状态缓存(去重/限频) =============== -class LogState: - def __init__(self): - self.lock = threading.Lock() - self.url_status: Dict[str, str] = {} # url -> "unknown/offline/online/streaming" - self.no_source_worker: Dict[str, bool] = {} # worker_name -> idle printed flag - - def set_status(self, url: str, status: str, text: Optional[str] = None, prefix: str = ""): - with self.lock: - prev = self.url_status.get(url, "unknown") - if prev != status: - self.url_status[url] = status - if text: - print(f"[{now_str()}]{prefix} {text}") - - def mark_idle_once(self, worker_name: str): - with self.lock: - if not self.no_source_worker.get(worker_name, False): - print(f"[{now_str()}][{worker_name}] 当前无可用直播源,等待中…") - self.no_source_worker[worker_name] = True - - def clear_idle_flag(self, worker_name: str): - with self.lock: - self.no_source_worker[worker_name] = False - -LOGSTATE = LogState() - -# =============== URL 协调器(包含 url 与 (url,key) 占用) =============== -class UrlCoordinator: - """ - - urls 顺序分配 - - in_use_urls: url 级别独占(防止同一 url 被不同 worker 同时占用) - - in_use_pairs: (url,key) 级别独占(禁止同一 url 推到同一个 key) - """ - def __init__(self, path: str): - self.path = path - self.urls: List[str] = parse_urls_from_file(path) - self.idx: int = 0 - self.lock = threading.Lock() - self.last_md5 = check_md5(path) - - self.in_use_urls: Set[str] = set() - self.in_use_pairs: Set[Tuple[str, str]] = set() # (url, key) - - def refresh_if_changed(self): - try: - md5 = check_md5(self.path) - if md5 != self.last_md5: - self.urls = parse_urls_from_file(self.path) or self.urls - self.last_md5 = md5 - print(f"[{now_str()}] 已重载 URL 列表,数量:{len(self.urls)}") - except Exception as e: - logger.warning(f"URL 列表热更新失败:{e}") - - def acquire_next_live(self, worker_key: str, quality_code: str = "OD") -> Optional[Tuple[str, dict]]: - """尝试遍历一轮 URL 表,找到首个在播的;否则返回 None。""" - with self.lock: - total = len(self.urls) - if total == 0: - return None - - tried = 0 - while tried < total: - # 取号(不占用) - with self.lock: - url = self.urls[self.idx] - self.idx = (self.idx + 1) % total - - # 跳过占用 - if url in self.in_use_urls or (url, worker_key) in self.in_use_pairs: - tried += 1 - continue - - # 锁外解析 - port = asyncio.run(fetch_port_info(url, quality_code)) - if not port.get("anchor_name"): - LOGSTATE.set_status(url, "unknown", text=f"解析失败:{url}") - tried += 1 - continue - if not port.get("is_live"): - anchor = port.get('anchor_name', '') - LOGSTATE.set_status(url, "offline", text=f"未开播:{anchor} | {url}") - tried += 1 - continue - - # 回到锁内正式占用(避免竞争) - with self.lock: - if url in self.in_use_urls or (url, worker_key) in self.in_use_pairs: - tried += 1 - continue - self.in_use_urls.add(url) - self.in_use_pairs.add((url, worker_key)) - - # 状态更新(online) - anchor = port.get('anchor_name', '') - LOGSTATE.set_status(url, "online", text=f"已开播:{anchor} | {url}") - return url, port - - return None # 一轮都没找到 - - def release(self, url: Optional[str], worker_key: Optional[str]): - if not url: - return - with self.lock: - self.in_use_urls.discard(url) - if worker_key: - self.in_use_pairs.discard((url, worker_key)) - -# =============== Worker(保留前缀日志) =============== -class StreamWorker(threading.Thread): - def __init__(self, worker_id: int, key: str, resolution: Optional[str], coordinator: UrlCoordinator): - super().__init__(daemon=True) - self.worker_id = worker_id - self.key = key - self.worker_name = f"Worker-{worker_id}" - self.resolution = resolution - self.coordinator = coordinator - - self.current_url: Optional[str] = None - self.current_real_url: Optional[str] = None - self.current_proc: Optional[subprocess.Popen] = None - self.current_platform = "" - self.current_anchor = "" - self.record_quality_code = "OD" - - # 与原逻辑一致的参数 - self.watchdog_grace = 120 - self.watchdog_silence = 600 - self.refresh_real_url_every = 300 - self.check_interval_live = 8 - self.scan_interval_when_idle = 2 - - self._last_progress_print = -9999 - self._stop_event = threading.Event() - self.start_time = time.time() - - def stop(self): - """最小改动:收到停止请求时,立即尝试停掉当前 ffmpeg,尽快响应退出。""" - self._stop_event.set() - try: - stop_ffmpeg(self.current_proc) - except Exception: - pass - - def _p(self, text: str): - print(f"[{now_str()}][{self.worker_name}] {text}") - - def run(self): - self._p("启动") - try: - while not self._stop_event.is_set(): - try: - self.coordinator.refresh_if_changed() - - if self.current_proc and self.current_url: - retcode = self.current_proc.poll() - if retcode is not None: - self._p(f"❌ ffmpeg 退出(code={retcode}),切换下一个源…") - stop_ffmpeg(self.current_proc) - self.coordinator.release(self.current_url, self.key) - self._clear_state() - LOGSTATE.clear_idle_flag(self.worker_name) - continue - - # 检测是否仍在直播(保持原逻辑) - still_live = True - try: - port = asyncio.run(fetch_port_info(self.current_url, self.record_quality_code)) - still_live = bool(port.get("is_live")) - except Exception as e: - logger.error(f"[{self.worker_name}] 检测当前源失败:{e}") - still_live = True - - if still_live: - elapsed = int(time.time() - self.start_time) - # 每 60s 打印一次“正在转播” - if elapsed - self._last_progress_print >= 60: - h, m = divmod(elapsed, 3600) - m, s = divmod(m, 60) - dur_str = f"{h:02d}:{m:02d}:{s:02d}" - self._p(f"⏱ 正在转播({dur_str}):{self.current_platform} | {self.current_anchor}") - self._last_progress_print = elapsed - - # 定期刷新真实播放地址(token) - try: - if elapsed >= self.refresh_real_url_every and (elapsed % self.refresh_real_url_every) < self.check_interval_live: - new_port = asyncio.run(fetch_port_info(self.current_url, self.record_quality_code)) - new_real_url = select_source_url(self.current_url, new_port) - if new_real_url and new_real_url != self.current_real_url: - self._p("🔄 刷新播放地址(token 变化),重启 ffmpeg…") - stop_ffmpeg(self.current_proc) - out_url, v_args, a_args = build_youtube_output_for_key(self.key, self.resolution) - headers = headers_for_platform(self.current_platform, self.current_url) - self.current_proc = launch_ffmpeg(new_real_url, out_url, headers, v_args, a_args, self.worker_name) - self.current_real_url = new_real_url - self.start_time = time.time() - self._last_progress_print = -9999 - time.sleep(self.check_interval_live) - continue - except Exception as e: - logger.warning(f"[{self.worker_name}] 刷新播放地址失败:{e}") - - # watchdog - silence = time.time() - getattr(self.current_proc, "last_out_ts", 0) - if elapsed >= self.watchdog_grace and silence > self.watchdog_silence: - self._p("⚠️ 超过10分钟未见输出,重启…") - stop_ffmpeg(self.current_proc) - self._clear_state(release=True) - LOGSTATE.clear_idle_flag(self.worker_name) - time.sleep(1) - continue - - time.sleep(self.check_interval_live) - continue - else: - self._p("📴 主播停播,切换下一个源…") - stop_ffmpeg(self.current_proc) - self._clear_state(release=True) - LOGSTATE.clear_idle_flag(self.worker_name) - continue - - # —— 未在推流:尝试分配一个新源(保持原顺序轮询 + 占用) —— - got = self.coordinator.acquire_next_live(self.key, self.record_quality_code) - if not got: - LOGSTATE.mark_idle_once(self.worker_name) - time.sleep(self.scan_interval_when_idle) - continue - - LOGSTATE.clear_idle_flag(self.worker_name) - url, port = got - real_url = select_source_url(url, port) - if not real_url: - self._p(f"未得到播放地址,跳过:{url}") - self.coordinator.release(url, self.key) - time.sleep(1) - continue - - self.current_platform = detect_platform(url) - self.current_anchor = port.get("anchor_name", "") - headers = headers_for_platform(self.current_platform, url) - out_url, v_args, a_args = build_youtube_output_for_key(self.key, self.resolution) - self.current_proc = launch_ffmpeg(real_url, out_url, headers, v_args, a_args, self.worker_name) - self.current_url = url - self.current_real_url = real_url - self.start_time = time.time() - self._last_progress_print = -9999 - - self._p(f"✅ 开始转播:{self.current_platform} | {self.current_anchor} | {url}") - - except Exception as e: - # 🔧 关键:任何内部异常不让线程退出 - logger.error(f"[{self.worker_name}] 内部异常:{e}") - time.sleep(2) - continue - - finally: - stop_ffmpeg(self.current_proc) - self.coordinator.release(self.current_url, self.key) - self._p("已退出。") - - def _clear_state(self, release: bool = False): - if release and self.current_url: - self.coordinator.release(self.current_url, self.key) - self.current_url = None - self.current_real_url = None - self.current_proc = None - self.current_platform = "" - self.current_anchor = "" - -# =============== 启动入口(保持原行为) =============== -def check_ffmpeg_existence() -> bool: - try: - result = subprocess.run(['ffmpeg', '-version'], check=True, capture_output=True, text=True) - if result.returncode == 0: - print(result.stdout.splitlines()[0]) - except Exception: - pass - finally: - return bool(check_ffmpeg()) - -# ---- 新增:统一优雅退出 ---- -def graceful_shutdown(signum=None, frame=None): - if not SHUTDOWN_EVENT.is_set(): - print("\n收到中断,正在停止所有 worker 与 ffmpeg …") - SHUTDOWN_EVENT.set() - # 先发 stop 给所有 worker(会内部 stop 当前 ffmpeg) - for w in list(ALL_WORKERS): - try: - w.stop() - except Exception: - pass - # 再兜底停止全局登记的 ffmpeg 子进程 - with ALL_LOCK: - procs = list(ALL_PROCS) - for p in procs: - try: - stop_ffmpeg(p) - except Exception: - pass - -def main(): - print("-----------------------------------------------------") - print("| DouyinLiveRecorder - 并行转推版 |") - print("-----------------------------------------------------") - print(f"版本号: {version}\nGitHub: https://github.com/ihmily/DouyinLiveRecorder") - print("模式:并行检测 + 并行推流(保持原有业务逻辑)") - print(".....................................................") - - if not check_ffmpeg_existence(): - logger.error("缺少 ffmpeg,程序退出") - sys.exit(1) - - urls = parse_urls_from_file(url_config_file) - if not urls: - logger.error("URL_config.ini 中没有可用 URL。") - sys.exit(1) - - # keys(并行路数 = keys 数量);兼容单 key - keys = _yt_get_list("youtube", "keys") - if not keys: - single_key = _yt_get("youtube", "key", fallback="", cast=str) - if single_key: - keys = [single_key] - if not keys: - logger.error("youtube.ini 没有配置 [youtube].keys 或 key") - sys.exit(1) - - # 可选:每路分辨率 - per_res = _yt_get_list("youtube", "resolutions") # 如:1920x1080,1080x1920 - while per_res and len(per_res) < len(keys): - per_res.append(per_res[-1]) - - # 打印 ffmpeg 版本(贴合旧风格) - try: - vline = subprocess.run(['ffmpeg', '-version'], check=True, capture_output=True, text=True).stdout.splitlines()[0] - print(vline) - except Exception: - pass - - coordinator = UrlCoordinator(url_config_file) - - # 信号处理(Ctrl+C / kill) - try: - signal.signal(signal.SIGINT, graceful_shutdown) - except Exception: - pass - try: - signal.signal(signal.SIGTERM, graceful_shutdown) - except Exception: - pass - - # 启动多路(Worker 前缀区分) - workers: List[StreamWorker] = [] - for i, key in enumerate(keys, start=1): - res_for_this = per_res[i-1] if per_res else None - w = StreamWorker(worker_id=i, key=key, resolution=res_for_this, coordinator=coordinator) - w.start() - workers.append(w) - ALL_WORKERS.append(w) - - try: - while not SHUTDOWN_EVENT.is_set(): - time.sleep(0.5) - except KeyboardInterrupt: - graceful_shutdown() - finally: - graceful_shutdown() - for w in workers: - w.join(timeout=10) - print("全部 worker 已退出。") - -if __name__ == "__main__": - main() diff --git a/back/mult4 b/back/mult4 deleted file mode 100644 index fc1df56..0000000 --- a/back/mult4 +++ /dev/null @@ -1,775 +0,0 @@ -# -*- encoding: utf-8 -*- - -""" -并行转推(日志优化版,保持原业务逻辑) -- 保留 Worker-1/2 等前缀 -- 日志状态变化才打印,避免刷屏 -- 禁止同一个 URL 推到同一个 YouTube key((url,key) 级别防重复) -- 仍然避免同一个 URL 被多路并行占用(url 级别独占) -- 新增:Ctrl+C/TERM 时优雅停止所有 Worker 与 ffmpeg 子进程(仅最小增改) -""" - -import asyncio -import os -import sys -import signal -import time -import datetime -import re -import uuid -import threading -import subprocess -from typing import Any, List, Tuple, Optional, Dict, Set - -import configparser -import urllib.request - -from src import spider, stream -from src.utils import logger, Color, check_md5 -from ffmpeg_install import check_ffmpeg, ffmpeg_path, current_env_path - -# =============== 基础环境 =============== -version = "relay-parallel-2.0.3-optimized" -os.environ['PATH'] = ffmpeg_path + os.pathsep + current_env_path -script_path = os.path.split(os.path.realpath(sys.argv[0]))[0] -config_file = f'{script_path}/config/config.ini' -url_config_file = f'{script_path}/config/URL_config.ini' -yt_ini_file = f"{script_path}/config/youtube.ini" -text_encoding = 'utf-8-sig' -color = Color() - -# ---- 新增:全局 Worker & 进程注册(用于 Ctrl+C 干净退出) ---- -ALL_WORKERS: List["StreamWorker"] = [] -ALL_PROCS: Set[subprocess.Popen] = set() -SHUTDOWN_EVENT = threading.Event() -ALL_LOCK = threading.Lock() - -# =============== 读取 YouTube 配置 =============== -_yt_cfg = configparser.ConfigParser() -with open(yt_ini_file, "r", encoding="utf-8-sig") as f: - _yt_cfg.read_file(f) - -def _yt_get(section: str, key: str, fallback=None, cast=str): - try: - if cast is bool: - return _yt_cfg.getboolean(section, key, fallback=fallback) - if cast is int: - return _yt_cfg.getint(section, key, fallback=fallback) - return _yt_cfg.get(section, key, fallback=fallback) - except Exception: - return fallback - -def _yt_get_list(section: str, key: str) -> List[str]: - raw = _yt_get(section, key, fallback="", cast=str) or "" - parts = re.split(r"[,;\s]+", raw.strip()) - return [p for p in parts if p] - -def _safe_int(v, lo, hi, defv): - try: - v = int(v) - if v < lo or v > hi: return defv - return v - except Exception: - return defv - -# =============== 工具函数 =============== -def _round_res(w: int, h: int) -> Tuple[int, int]: - # 偶数对齐,避免 1088x1920 类问题 - return (w & ~1, h & ~1) - -def _build_youtube_rtmp_url(key: str, secure: bool, ingest: str | None): - node = (ingest or "a").strip().lower() - node = node if node.isalpha() and len(node) == 1 else "a" - scheme = "rtmps" if secure else "rtmp" - host = f"{node}.rtmps.youtube.com" if secure else f"{node}.rtmp.youtube.com" - return f"{scheme}://{host}/live2/{key}" - -def now_str(): - return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - -# =============== 读取运行配置 =============== -def read_config_value(cfg: configparser.RawConfigParser, section: str, option: str, default_value: Any) -> Any: - try: - cfg.read(config_file, encoding=text_encoding) - if section not in cfg.sections(): - cfg.add_section(section) - return cfg.get(section, option) - except (configparser.NoSectionError, configparser.NoOptionError): - cfg.set(section, option, str(default_value)) - with open(config_file, 'w', encoding=text_encoding) as f: - cfg.write(f) - return default_value - -_CN_BOOL = {"是": True, "否": False} -cfg = configparser.RawConfigParser() -use_proxy = _CN_BOOL.get(read_config_value(cfg, '录制设置', '是否使用代理ip(是/否)', "否"), False) -proxy_addr = read_config_value(cfg, '录制设置', '代理地址', "") -proxy_addr = proxy_addr if use_proxy and proxy_addr.strip() else None -dy_cookie = read_config_value(cfg, 'Cookie', '抖音cookie', '') -tiktok_cookie = read_config_value(cfg, 'Cookie', 'tiktok_cookie', '') - -# 启动时连通性提示 -try: - if not use_proxy: - urllib.request.urlopen("https://www.google.com/", timeout=5) - print("提示:你的网络似乎直连可用(无需代理)。") -except Exception: - if not use_proxy: - color.print_colored("提示:无法直连海外站,如需 TikTok 请在 config.ini 开启代理并填写代理地址。", color.YELLOW) - -# =============== URL 列表 =============== -def parse_urls_from_file(path: str) -> List[str]: - urls, seen = [], set() - if not os.path.isfile(path): - open(path, 'w', encoding=text_encoding).close() - print("URL_config.ini 为空,请写入直播间地址后再运行。") - return urls - with open(path, "r", encoding=text_encoding, errors='ignore') as f: - for line in f: - s = line.strip() - if not s or s.startswith("#"): - continue - parts = re.split(r"[,,]\s*", s) - cand = None - for p in parts: - if '://' in p or re.match(r"^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", p): - cand = p - break - if not cand: - continue - url = cand if '://' in cand else f"https://{cand}" - if url not in seen: - seen.add(url) - urls.append(url) - return urls - -# =============== 解析与选源 =============== -def is_flv_preferred_platform(link: str) -> bool: - return any(i in link for i in ["douyin", "tiktok"]) - -def select_source_url(link: str, stream_info: dict) -> Optional[str]: - if not stream_info: - return None - if is_flv_preferred_platform(link): - flv = stream_info.get("flv_url") - if flv: - try: - from urllib.parse import urlparse, parse_qs - qs = parse_qs(urlparse(flv).query or "") - if (qs.get("codec") or [""])[0].lower() == "h265": - logger.warning("FLV 不支持 h265,改用 HLS。") - else: - return flv - except Exception: - return flv - return stream_info.get("record_url") or stream_info.get("m3u8_url") or stream_info.get("flv_url") - -async def fetch_port_info(url: str, record_quality_code: str = "OD") -> dict: - proxy = proxy_addr - port_info = {} - try: - if "douyin.com/" in url: - if 'v.douyin.com' not in url and '/user/' not in url: - j = await spider.get_douyin_stream_data(url=url, proxy_addr=proxy, cookies=dy_cookie) - else: - j = await spider.get_douyin_app_stream_data(url=url, proxy_addr=proxy, cookies=dy_cookie) - port_info = await stream.get_douyin_stream_url(j, record_quality_code, proxy) - elif url.startswith("https://www.tiktok.com/"): - if proxy: - j = await spider.get_tiktok_stream_data(url=url, proxy_addr=proxy, cookies=tiktok_cookie) - port_info = await stream.get_tiktok_stream_url(j, record_quality_code, proxy) - else: - logger.error("TikTok 需要代理,请在 config.ini 开启并配置代理。") - port_info = {"anchor_name": "", "is_live": False} - elif (".m3u8" in url) or (".flv" in url): - port_info = { - "anchor_name": "自定义_" + str(uuid.uuid4())[:8], - "is_live": True, - } - if url.endswith(".flv"): - port_info["flv_url"] = url - else: - port_info["m3u8_url"] = url - else: - port_info = {"anchor_name": "", "is_live": False} - except Exception as e: - logger.error(f"解析失败: {e}") - port_info = {"anchor_name": "", "is_live": False} - return port_info - -# =============== 构建输出参数(每个 key) =============== -def build_youtube_output_for_key(key: str, resolution: Optional[str]) -> Tuple[str, list, list]: - if not key: - logger.error("错误: youtube.ini 未配置 key/keys") - sys.exit(1) - - secure = _yt_get("youtube", "secure", fallback=False, cast=bool) - ingest = _yt_get("youtube", "ingest", fallback="a", cast=str) - res_str = (resolution or _yt_get("youtube", "resolution", fallback="", cast=str) or "").strip() - - bv = _safe_int(_yt_get("youtube", "bitrate", fallback=5000, cast=int), 300, 20000, 5000) - ba = _safe_int(_yt_get("youtube", "audio_bitrate", fallback=128, cast=int), 64, 384, 128) - fps = _safe_int(_yt_get("youtube", "fps", fallback=30, cast=int), 10, 60, 30) - gop = _safe_int(_yt_get("youtube", "gop", fallback=2*fps, cast=int), fps, 4*fps, 2*fps) - - out_url = _build_youtube_rtmp_url(key, secure, ingest) - - v_args = [ - "-c:v", "libx264", - "-preset", "veryfast", - "-tune", "zerolatency", - "-profile:v", "high", - "-level:v", "4.2", - "-pix_fmt", "yuv420p", - "-r", str(fps), - "-g", str(gop), - "-b:v", f"{bv}k", - "-maxrate", f"{bv}k", - "-bufsize", f"{max(2*bv, 4000)}k", - ] - a_args = [ - "-c:a", "aac", - "-b:a", f"{ba}k", - "-ar", "44100", - "-ac", "2", - ] - - if res_str: - try: - w, h = map(int, res_str.lower().split("x")) - w, h = _round_res(w, h) - vf = ( - f"scale={w}:{h}:force_original_aspect_ratio=decrease," - f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2," - f"crop={w}:{h}," - f"format=yuv420p" - ) - v_args = ["-vf", vf] + v_args - except Exception: - logger.warning(f"resolution '{res_str}' 非法,忽略") - - return out_url, v_args, a_args - -# =============== 启动/停止 ffmpeg =============== -def launch_ffmpeg(input_url: str, - out_url: str, - extra_headers: Optional[str], - v_args: list, - a_args: list, - worker_name: str) -> subprocess.Popen: - user_agent = ("Mozilla/5.0 (Linux; Android 11; SM-G973U) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/87.0.4280.141 Mobile Safari/537.36") - # 输入侧容错/重连/探测 - base = [ - "ffmpeg", "-nostdin", "-hide_banner", - "-stats", - "-stats_period", "60", - "-progress", "pipe:1", - "-loglevel", "info", - - # 输入容错 & 自动重连 - "-rw_timeout", "15000000", - "-timeout", "10000000", - "-reconnect", "1", - "-reconnect_streamed", "1", - "-reconnect_at_eof", "1", - "-reconnect_delay_max", "30", - - # UA/协议白名单 - "-user_agent", user_agent, - "-protocol_whitelist", "file,http,https,tcp,tls,crypto,udp,rtmp,rtp,httpproxy", - - # 缓冲 & 探测 - "-thread_queue_size", "4096", - "-analyzeduration", "10000000", - "-probesize", "10000000", - ] - - # 平台 headers(referer/origin)要在 -i 之前 - if extra_headers: - base += ["-headers", extra_headers] - - # 代理:http_proxy/https_proxy 环境变量(同时保留 -http_proxy 兼容) - env = os.environ.copy() - if proxy_addr: - env["http_proxy"] = proxy_addr - env["https_proxy"] = proxy_addr - base = ["ffmpeg", "-http_proxy", proxy_addr] + base[1:] - - # 输入 - base += ["-re", "-i", input_url] - - # 通用容错 + 时间戳修复(优化版) - base += [ - "-fflags", "+genpts+igndts+flush_packets", # 自动生成时间戳 + 忽略乱序 + 刷新缓冲 - "-use_wallclock_as_timestamps", "1", # 用系统时钟修正时间戳 - "-avioflags", "direct", # 减少延迟 - "-rtbufsize", "100M", # 输入缓冲 - "-err_detect", "ignore_err", # 忽略解码错误 - "-avoid_negative_ts", "make_zero", # 避免负时间戳 - ] - - # 视频/音频编码参数(使用 build_youtube_output 的结果) - v_args = v_args or [] - a_args = a_args or [] - base += v_args + a_args - - # 输出(flv 到 YT,更宽容) - base += ["-f", "flv", "-flvflags", "no_duration_filesize", out_url] - - - - cmd_str = " ".join(base) - print(f"ffmpeg cmd ({worker_name}):", cmd_str if len(cmd_str) < 500 else cmd_str[:500] + " ...") - logger.info(f"{worker_name} 启动推流:{input_url} -> {out_url}") - - creationflags = 0 - if os.name == "nt": - creationflags = subprocess.CREATE_NEW_PROCESS_GROUP - - p = subprocess.Popen( - base, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - bufsize=0, - env=env, - creationflags=creationflags - ) - p.last_out_ts = time.time() - - # ---- 新增:注册进程,便于 Ctrl+C 统一清理 ---- - with ALL_LOCK: - ALL_PROCS.add(p) - - def _reader(): - try: - for line in iter(p.stdout.readline, b''): - if not line: - break - text = line.decode(errors="ignore").strip() - p.last_out_ts = time.time() - - # 只保留关键统计/错误(前缀带 Worker) - if ("bitrate=" in text) or ("frame=" in text) or ("speed=" in text) or ("libx264" in text): - logger.info(f"{worker_name} [ffmpeg统计] {text}") - if "Server disconnected" in text: - logger.error(f"{worker_name} [输出异常] {text}") - elif ("not supported" in text) or ("Invalid" in text) or ("invalid" in text): - logger.error(f"{worker_name} [转码异常] {text}") - except Exception: - pass - - threading.Thread(target=_reader, daemon=True).start() - return p - -def stop_ffmpeg(p: Optional[subprocess.Popen]): - if not p: return - try: - if os.name == "nt": - if p.stdin: - try: - p.stdin.write(b"q"); p.stdin.flush() - except Exception: - pass - p.wait(timeout=6) - else: - p.send_signal(signal.SIGINT) - p.wait(timeout=10) - except Exception: - try: p.kill() - except Exception: pass - finally: - # ---- 新增:从全局集合移除 ---- - with ALL_LOCK: - ALL_PROCS.discard(p) - -# =============== 平台 headers/识别 =============== -def headers_for_platform(platform_name: str, live_url: str) -> Optional[str]: - live_domain = '/'.join(live_url.split('/')[0:3]) - record_headers = { - 'PandaTV': 'origin:https://www.pandalive.co.kr', - 'WinkTV': 'origin:https://www.winktv.co.kr', - 'PopkonTV': 'origin:https://www.popkontv.com', - 'FlexTV': 'origin:https://www.flextv.co.kr', - '千度热播': 'referer:https://qiandurebo.com', - '17Live': 'referer:https://17.live/en/live/6302408', - '浪Live': 'referer:https://www.lang.live', - 'shopee': f'origin:{live_domain}', - 'Blued直播': 'referer:https://app.blued.cn' - } - return record_headers.get(platform_name) - -def detect_platform(url: str) -> str: - if "douyin.com" in url: return "抖音直播" - if "tiktok.com" in url: return "TikTok直播" - if url.endswith(".flv") or url.endswith(".m3u8"): return "自定义录制直播" - return "未知平台" - -# =============== 日志状态缓存(去重/限频) =============== -class LogState: - def __init__(self): - self.lock = threading.Lock() - self.url_status: Dict[str, str] = {} # url -> "unknown/offline/online/streaming" - self.no_source_worker: Dict[str, bool] = {} # worker_name -> idle printed flag - - def set_status(self, url: str, status: str, text: Optional[str] = None, prefix: str = ""): - with self.lock: - prev = self.url_status.get(url, "unknown") - if prev != status: - self.url_status[url] = status - if text: - print(f"[{now_str()}]{prefix} {text}") - - def mark_idle_once(self, worker_name: str): - with self.lock: - if not self.no_source_worker.get(worker_name, False): - print(f"[{now_str()}][{worker_name}] 当前无可用直播源,等待中…") - self.no_source_worker[worker_name] = True - - def clear_idle_flag(self, worker_name: str): - with self.lock: - self.no_source_worker[worker_name] = False - -LOGSTATE = LogState() - -# =============== URL 协调器(只保留 url 独占) =============== -class UrlCoordinator: - """ - - urls 顺序分配 - - in_use_urls: url 级别独占(防止同一 url 被不同 worker 同时占用) - """ - def __init__(self, path: str): - self.path = path - self.urls: List[str] = parse_urls_from_file(path) - self.idx: int = 0 - self.lock = threading.Lock() - self.last_md5 = check_md5(path) - - self.in_use_urls: Set[str] = set() # 只保留 url 独占 - - def refresh_if_changed(self): - try: - md5 = check_md5(self.path) - if md5 != self.last_md5: - self.urls = parse_urls_from_file(self.path) or self.urls - self.last_md5 = md5 - print(f"[{now_str()}] 已重载 URL 列表,数量:{len(self.urls)}") - except Exception as e: - logger.warning(f"URL 列表热更新失败:{e}") - - def acquire_next_live(self, worker_key: str, quality_code: str = "OD") -> Optional[Tuple[str, dict]]: - """尝试遍历一轮 URL 表,找到首个在播的;否则返回 None。""" - with self.lock: - total = len(self.urls) - if total == 0: - return None - - tried = 0 - while tried < total: - with self.lock: - url = self.urls[self.idx] - self.idx = (self.idx + 1) % total - - # 只要 url 已占用就跳过 - if url in self.in_use_urls: - tried += 1 - continue - - # 锁外解析 - port = asyncio.run(fetch_port_info(url, quality_code)) - if not port.get("anchor_name"): - LOGSTATE.set_status(url, "unknown", text=f"解析失败:{url}") - tried += 1 - continue - if not port.get("is_live"): - anchor = port.get('anchor_name', '') - LOGSTATE.set_status(url, "offline", text=f"未开播:{anchor} | {url}") - tried += 1 - continue - - # 回到锁内占用 - with self.lock: - if url in self.in_use_urls: - tried += 1 - continue - self.in_use_urls.add(url) - - anchor = port.get('anchor_name', '') - LOGSTATE.set_status(url, "online", text=f"已开播:{anchor} | {url}") - return url, port - - return None - - def release(self, url: Optional[str], worker_key: Optional[str]): - if not url: - return - with self.lock: - self.in_use_urls.discard(url) - -# =============== Worker(保留前缀日志) =============== -class StreamWorker(threading.Thread): - def __init__(self, worker_id: int, key: str, resolution: Optional[str], coordinator: UrlCoordinator): - super().__init__(daemon=True) - self.worker_id = worker_id - self.key = key - self.worker_name = f"Worker-{worker_id}" - self.resolution = resolution - self.coordinator = coordinator - - self.current_url: Optional[str] = None - self.current_real_url: Optional[str] = None - self.current_proc: Optional[subprocess.Popen] = None - self.current_platform = "" - self.current_anchor = "" - self.record_quality_code = "OD" - - # 与原逻辑一致的参数 - self.watchdog_grace = 120 - self.watchdog_silence = 600 - self.refresh_real_url_every = 300 - self.check_interval_live = 8 - self.scan_interval_when_idle = 2 - - self._last_progress_print = -9999 - self._stop_event = threading.Event() - self.start_time = time.time() - - def stop(self): - """最小改动:收到停止请求时,立即尝试停掉当前 ffmpeg,尽快响应退出。""" - self._stop_event.set() - try: - stop_ffmpeg(self.current_proc) - except Exception: - pass - - def _p(self, text: str): - print(f"[{now_str()}][{self.worker_name}] {text}") - - def run(self): - self._p("启动") - try: - while not self._stop_event.is_set(): - try: - self.coordinator.refresh_if_changed() - - if self.current_proc and self.current_url: - retcode = self.current_proc.poll() - if retcode is not None: - self._p(f"❌ ffmpeg 退出(code={retcode}),切换下一个源…") - stop_ffmpeg(self.current_proc) - self.coordinator.release(self.current_url, self.key) - self._clear_state() - LOGSTATE.clear_idle_flag(self.worker_name) - continue - - # 检测是否仍在直播(保持原逻辑) - still_live = True - try: - port = asyncio.run(fetch_port_info(self.current_url, self.record_quality_code)) - still_live = bool(port.get("is_live")) - except Exception as e: - logger.error(f"[{self.worker_name}] 检测当前源失败:{e}") - still_live = True - - if still_live: - elapsed = int(time.time() - self.start_time) - # 每 60s 打印一次“正在转播” - if elapsed - self._last_progress_print >= 60: - h, m = divmod(elapsed, 3600) - m, s = divmod(m, 60) - dur_str = f"{h:02d}:{m:02d}:{s:02d}" - self._p(f"⏱ 正在转播({dur_str}):{self.current_platform} | {self.current_anchor}") - self._last_progress_print = elapsed - - # 定期刷新真实播放地址(token) - try: - if elapsed >= self.refresh_real_url_every and (elapsed % self.refresh_real_url_every) < self.check_interval_live: - new_port = asyncio.run(fetch_port_info(self.current_url, self.record_quality_code)) - new_real_url = select_source_url(self.current_url, new_port) - if new_real_url and new_real_url != self.current_real_url: - self._p("🔄 刷新播放地址(token 变化),重启 ffmpeg…") - stop_ffmpeg(self.current_proc) - out_url, v_args, a_args = build_youtube_output_for_key(self.key, self.resolution) - headers = headers_for_platform(self.current_platform, self.current_url) - self.current_proc = launch_ffmpeg(new_real_url, out_url, headers, v_args, a_args, self.worker_name) - self.current_real_url = new_real_url - self.start_time = time.time() - self._last_progress_print = -9999 - time.sleep(self.check_interval_live) - continue - except Exception as e: - logger.warning(f"[{self.worker_name}] 刷新播放地址失败:{e}") - - # watchdog - silence = time.time() - getattr(self.current_proc, "last_out_ts", 0) - if elapsed >= self.watchdog_grace and silence > self.watchdog_silence: - self._p("⚠️ 超过10分钟未见输出,重启…") - stop_ffmpeg(self.current_proc) - self._clear_state(release=True) - LOGSTATE.clear_idle_flag(self.worker_name) - time.sleep(1) - continue - - time.sleep(self.check_interval_live) - continue - else: - self._p("📴 主播停播,切换下一个源…") - stop_ffmpeg(self.current_proc) - self._clear_state(release=True) - LOGSTATE.clear_idle_flag(self.worker_name) - continue - - # —— 未在推流:尝试分配一个新源(保持原顺序轮询 + 占用) —— - got = self.coordinator.acquire_next_live(self.key, self.record_quality_code) - if not got: - LOGSTATE.mark_idle_once(self.worker_name) - time.sleep(self.scan_interval_when_idle) - continue - - LOGSTATE.clear_idle_flag(self.worker_name) - url, port = got - real_url = select_source_url(url, port) - if not real_url: - self._p(f"未得到播放地址,跳过:{url}") - self.coordinator.release(url, self.key) - time.sleep(1) - continue - - self.current_platform = detect_platform(url) - self.current_anchor = port.get("anchor_name", "") - headers = headers_for_platform(self.current_platform, url) - out_url, v_args, a_args = build_youtube_output_for_key(self.key, self.resolution) - self.current_proc = launch_ffmpeg(real_url, out_url, headers, v_args, a_args, self.worker_name) - self.current_url = url - self.current_real_url = real_url - self.start_time = time.time() - self._last_progress_print = -9999 - - self._p(f"✅ 开始转播:{self.current_platform} | {self.current_anchor} | {url}") - - except Exception as e: - # 🔧 关键:任何内部异常不让线程退出 - logger.error(f"[{self.worker_name}] 内部异常:{e}") - time.sleep(2) - continue - - finally: - stop_ffmpeg(self.current_proc) - self.coordinator.release(self.current_url, self.key) - self._p("已退出。") - - def _clear_state(self, release: bool = False): - if release and self.current_url: - self.coordinator.release(self.current_url, self.key) - self.current_url = None - self.current_real_url = None - self.current_proc = None - self.current_platform = "" - self.current_anchor = "" - -# =============== 启动入口(保持原行为) =============== -def check_ffmpeg_existence() -> bool: - try: - result = subprocess.run(['ffmpeg', '-version'], check=True, capture_output=True, text=True) - if result.returncode == 0: - print(result.stdout.splitlines()[0]) - except Exception: - pass - finally: - return bool(check_ffmpeg()) - -# ---- 新增:统一优雅退出 ---- -def graceful_shutdown(signum=None, frame=None): - if not SHUTDOWN_EVENT.is_set(): - print("\n收到中断,正在停止所有 worker 与 ffmpeg …") - SHUTDOWN_EVENT.set() - # 先发 stop 给所有 worker(会内部 stop 当前 ffmpeg) - for w in list(ALL_WORKERS): - try: - w.stop() - except Exception: - pass - # 再兜底停止全局登记的 ffmpeg 子进程 - with ALL_LOCK: - procs = list(ALL_PROCS) - for p in procs: - try: - stop_ffmpeg(p) - except Exception: - pass - -def main(): - print("-----------------------------------------------------") - print("| DouyinLiveRecorder - 并行转推版 |") - print("-----------------------------------------------------") - print(f"版本号: {version}\nGitHub: https://github.com/ihmily/DouyinLiveRecorder") - print("模式:并行检测 + 并行推流(保持原有业务逻辑)") - print(".....................................................") - - if not check_ffmpeg_existence(): - logger.error("缺少 ffmpeg,程序退出") - sys.exit(1) - - urls = parse_urls_from_file(url_config_file) - if not urls: - logger.error("URL_config.ini 中没有可用 URL。") - sys.exit(1) - - # keys(并行路数 = keys 数量);兼容单 key - keys = _yt_get_list("youtube", "keys") - if not keys: - single_key = _yt_get("youtube", "key", fallback="", cast=str) - if single_key: - keys = [single_key] - if not keys: - logger.error("youtube.ini 没有配置 [youtube].keys 或 key") - sys.exit(1) - - # 可选:每路分辨率 - per_res = _yt_get_list("youtube", "resolutions") # 如:1920x1080,1080x1920 - while per_res and len(per_res) < len(keys): - per_res.append(per_res[-1]) - - # 打印 ffmpeg 版本(贴合旧风格) - try: - vline = subprocess.run(['ffmpeg', '-version'], check=True, capture_output=True, text=True).stdout.splitlines()[0] - print(vline) - except Exception: - pass - - coordinator = UrlCoordinator(url_config_file) - - # 信号处理(Ctrl+C / kill) - try: - signal.signal(signal.SIGINT, graceful_shutdown) - except Exception: - pass - try: - signal.signal(signal.SIGTERM, graceful_shutdown) - except Exception: - pass - - # 启动多路(Worker 前缀区分) - workers: List[StreamWorker] = [] - for i, key in enumerate(keys, start=1): - res_for_this = per_res[i-1] if per_res else None - w = StreamWorker(worker_id=i, key=key, resolution=res_for_this, coordinator=coordinator) - w.start() - workers.append(w) - ALL_WORKERS.append(w) - - try: - while not SHUTDOWN_EVENT.is_set(): - time.sleep(0.5) - except KeyboardInterrupt: - graceful_shutdown() - finally: - graceful_shutdown() - for w in workers: - w.join(timeout=10) - print("全部 worker 已退出。") - -if __name__ == "__main__": - main() diff --git a/back/mult5 b/back/mult5 deleted file mode 100644 index 87aecb3..0000000 --- a/back/mult5 +++ /dev/null @@ -1,907 +0,0 @@ -# -*- encoding: utf-8 -*- - -""" -并行转推(稳定性增强版,保持原业务逻辑与对外接口) -- 核心修复:降低“误判下播”的概率(抖音仍在播但 API 偶发失败) - * fetch_port_info:加入重试(3 次,指数退避),不改签名/调用处 - * “下播防抖”:只有在“连续N次 is_live=False 且最近M秒没有任何活跃迹象”才判定下播 - * 交叉验证:若 is_live=False,但当前 real_url 可探测(探活成功),则判为仍在播 -- FFmpeg 看门狗:卡顿/长时间无输出 -> 先软重启(保留 URL 占用),再必要时释放 -- 细节增强:日志更明确,解释每次判定原因;优雅退出确保无僵尸进程 -- 兼容性:不改配置文件/外部模块(spider、stream 等)的接口与行为 -- 修复:确保函数定义顺序,避免 NameError -""" - -import asyncio -import os -import sys -import signal -import time -import datetime -import re -import uuid -import threading -import subprocess -from typing import Any, List, Tuple, Optional, Dict, Set -import contextlib - -import configparser -import urllib.request -import urllib.error - -from src import spider, stream -from src.utils import logger, Color, check_md5 -from ffmpeg_install import check_ffmpeg, ffmpeg_path, current_env_path - -# =============== 构建输出参数(每个 key) ———— 确保在此定义,早于 StreamWorker =============== -def build_youtube_output_for_key(key: str, resolution: Optional[str]) -> Tuple[str, list, list]: - if not key: - logger.error("错误: youtube.ini 未配置 key/keys") - sys.exit(1) - - secure = _yt_get("youtube", "secure", fallback=False, cast=bool) - ingest = _yt_get("youtube", "ingest", fallback="a", cast=str) - res_str = (resolution or _yt_get("youtube", "resolution", fallback="", cast=str) or "").strip() - - bv = _safe_int(_yt_get("youtube", "bitrate", fallback=5000, cast=int), 300, 20000, 5000) - ba = _safe_int(_yt_get("youtube", "audio_bitrate", fallback=128, cast=int), 64, 384, 128) - fps = _safe_int(_yt_get("youtube", "fps", fallback=30, cast=int), 10, 60, 30) - gop = _safe_int(_yt_get("youtube", "gop", fallback=2*fps, cast=int), fps, 4*fps, 2*fps) - - out_url = _build_youtube_rtmp_url(key, secure, ingest) - - v_args = [ - "-c:v", "libx264", - "-preset", "veryfast", - "-tune", "zerolatency", - "-profile:v", "high", - "-level:v", "4.2", - "-pix_fmt", "yuv420p", - "-r", str(fps), - "-g", str(gop), - "-b:v", f"{bv}k", - "-maxrate", f"{bv}k", - "-bufsize", f"{max(2*bv, 4000)}k", - ] - a_args = [ - "-c:a", "aac", - "-b:a", f"{ba}k", - "-ar", "44100", - "-ac", "2", - ] - - if res_str: - try: - w, h = map(int, res_str.lower().split("x")) - w, h = _round_res(w, h) - vf = ( - f"scale={w}:{h}:force_original_aspect_ratio=decrease," - f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2," - f"crop={w}:{h}," - f"format=yuv420p" - ) - v_args = ["-vf", vf] + v_args - except Exception: - logger.warning(f"resolution '{res_str}' 非法,忽略") - - return out_url, v_args, a_args - - -# =============== 基础环境 =============== -version = "vrelay-parallel-2.1.1-pro" -os.environ['PATH'] = ffmpeg_path + os.pathsep + current_env_path -script_path = os.path.split(os.path.realpath(sys.argv[0]))[0] -config_file = f'{script_path}/config/config.ini' -url_config_file = f'{script_path}/config/URL_config.ini' -yt_ini_file = f"{script_path}/config/youtube.ini" -text_encoding = 'utf-8-sig' -color = Color() - -# ---- 全局 Worker & 进程注册(用于 Ctrl+C 干净退出) ---- -ALL_WORKERS: List["StreamWorker"] = [] -ALL_PROCS: Set[subprocess.Popen] = set() -SHUTDOWN_EVENT = threading.Event() -ALL_LOCK = threading.Lock() - -# =============== 读取 YouTube 配置 =============== -_yt_cfg = configparser.ConfigParser() -with open(yt_ini_file, "r", encoding="utf-8-sig") as f: - _yt_cfg.read_file(f) - -def _yt_get(section: str, key: str, fallback=None, cast=str): - try: - if cast is bool: - return _yt_cfg.getboolean(section, key, fallback=fallback) - if cast is int: - return _yt_cfg.getint(section, key, fallback=fallback) - return _yt_cfg.get(section, key, fallback=fallback) - except Exception: - return fallback - -def _yt_get_list(section: str, key: str) -> List[str]: - raw = _yt_get(section, key, fallback="", cast=str) or "" - parts = re.split(r"[,;\s]+", raw.strip()) - return [p for p in parts if p] - -def _safe_int(v, lo, hi, defv): - try: - v = int(v) - if v < lo or v > hi: return defv - return v - except Exception: - return defv - -# =============== 工具函数 =============== -def _round_res(w: int, h: int) -> Tuple[int, int]: - # 偶数对齐,避免 1088x1920 类问题 - return (w & ~1, h & ~1) - -def _build_youtube_rtmp_url(key: str, secure: bool, ingest: str | None): - node = (ingest or "a").strip().lower() - node = node if node.isalpha() and len(node) == 1 else "a" - scheme = "rtmps" if secure else "rtmp" - host = f"{node}.rtmps.youtube.com" if secure else f"{node}.rtmp.youtube.com" - return f"{scheme}://{host}/live2/{key}" - -def now_str(): - return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - -# =============== 读取运行配置 =============== -def read_config_value(cfg: configparser.RawConfigParser, section: str, option: str, default_value: Any) -> Any: - try: - cfg.read(config_file, encoding=text_encoding) - if section not in cfg.sections(): - cfg.add_section(section) - return cfg.get(section, option) - except (configparser.NoSectionError, configparser.NoOptionError): - cfg.set(section, option, str(default_value)) - with open(config_file, 'w', encoding=text_encoding) as f: - cfg.write(f) - return default_value - -_CN_BOOL = {"是": True, "否": False} -cfg = configparser.RawConfigParser() -use_proxy = _CN_BOOL.get(read_config_value(cfg, '录制设置', '是否使用代理ip(是/否)', "否"), False) -proxy_addr = read_config_value(cfg, '录制设置', '代理地址', "") -proxy_addr = proxy_addr if use_proxy and proxy_addr.strip() else None -dy_cookie = read_config_value(cfg, 'Cookie', '抖音cookie', '') -tiktok_cookie = read_config_value(cfg, 'Cookie', 'tiktok_cookie', '') - -# 启动时连通性提示(仅提示,不改变逻辑) -try: - if not use_proxy: - urllib.request.urlopen("https://www.google.com/", timeout=5) - print("提示:你的网络似乎直连可用(无需代理)。") -except Exception: - if not use_proxy: - color.print_colored("提示:无法直连海外站,如需 TikTok 请在 config.ini 开启并填写代理地址。", color.YELLOW) - -# =============== URL 列表 =============== -def parse_urls_from_file(path: str) -> List[str]: - urls, seen = [], set() - if not os.path.isfile(path): - open(path, 'w', encoding=text_encoding).close() - print("URL_config.ini 为空,请写入直播间地址后再运行。") - return urls - with open(path, "r", encoding=text_encoding, errors='ignore') as f: - for line in f: - s = line.strip() - if not s or s.startswith("#"): - continue - parts = re.split(r"[,,]\s*", s) - cand = None - for p in parts: - if '://' in p or re.match(r"^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", p): - cand = p - break - if not cand: - continue - url = cand if '://' in cand else f"https://{cand}" - if url not in seen: - seen.add(url) - urls.append(url) - return urls - -# =============== 解析与选源 =============== -def is_flv_preferred_platform(link: str) -> bool: - return any(i in link for i in ["douyin", "tiktok"]) - -def select_source_url(link: str, stream_info: dict) -> Optional[str]: - """ - 选源优先级保持不变:抖音/TikTok 优先 FLV(且避免 H265),否则 record_url/m3u8/flv。 - """ - if not stream_info: - return None - if is_flv_preferred_platform(link): - flv = stream_info.get("flv_url") - if flv: - try: - from urllib.parse import urlparse, parse_qs - qs = parse_qs(urlparse(flv).query or "") - if (qs.get("codec") or [""])[0].lower() == "h265": - logger.warning("FLV 不支持 h265,改用 HLS。") - else: - return flv - except Exception: - return flv - return stream_info.get("record_url") or stream_info.get("m3u8_url") or stream_info.get("flv_url") - -# =============== 辅助:探测播放 URL 是否可用(用于误判纠正) =============== -def _open_with_proxy(url: str, timeout: float = 5.0): - """ - 使用 urllib 在当前代理环境下打开 URL。 - 仅抓取少量字节用于“活跃性探测”,避免大流量。 - """ - handlers = [] - if proxy_addr: - handlers.append(urllib.request.ProxyHandler({"http": proxy_addr, "https": proxy_addr})) - opener = urllib.request.build_opener(*handlers) if handlers else urllib.request.build_opener() - req = urllib.request.Request( - url, - headers={ - "User-Agent": "Mozilla/5.0 (Linux; Android 11; SM-G973U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Mobile Safari/537.36", - "Accept": "*/*", - "Connection": "close", - } - ) - return opener.open(req, timeout=timeout) - -def probe_stream_url(real_url: Optional[str], timeout: float = 5.0) -> bool: - """ - 轻量级“探活”: - - m3u8: 取前若干字节并检查 "#EXTM3U" - - flv: 取前3字节是否 "FLV" - - 其它:只要 200 且有数据即可视为“活着” - 失败不抛异常,返回 False - """ - if not real_url: - return False - try: - with contextlib.closing(_open_with_proxy(real_url, timeout=timeout)) as resp: - code = getattr(resp, "status", None) or getattr(resp, "code", None) - if code and int(code) >= 400: - return False - head = resp.read(256) # 少量字节足够判定格式 - u = real_url.lower() - if ".m3u8" in u: - return b"#EXTM3U" in head.upper() - if ".flv" in u: - return head.startswith(b"FLV") - # 其它类型:有数据即认为仍在 - return bool(head) - except Exception: - return False - -# =============== 解析与选源(抖音/TikTok/自定义) =============== -# 修复点①:增强 fetch_port_info —— 重试 + 指数退避(不改签名/调用处) -async def fetch_port_info(url: str, record_quality_code: str = "OD") -> dict: - proxy = proxy_addr - delay = 1.5 # 初始退避 - for attempt in range(1, 4): # 最多 3 次 - try: - if "douyin.com/" in url: - if 'v.douyin.com' not in url and '/user/' not in url: - j = await spider.get_douyin_stream_data(url=url, proxy_addr=proxy, cookies=dy_cookie) - else: - j = await spider.get_douyin_app_stream_data(url=url, proxy_addr=proxy, cookies=dy_cookie) - port_info = await stream.get_douyin_stream_url(j, record_quality_code, proxy) - elif url.startswith("https://www.tiktok.com/"): - if proxy: - j = await spider.get_tiktok_stream_data(url=url, proxy_addr=proxy, cookies=tiktok_cookie) - port_info = await stream.get_tiktok_stream_url(j, record_quality_code, proxy) - else: - logger.error("TikTok 需要代理,请在 config.ini 开启并配置代理。") - port_info = {"anchor_name": "", "is_live": False} - elif (".m3u8" in url) or (".flv" in url): - port_info = { - "anchor_name": "自定义_" + str(uuid.uuid4())[:8], - "is_live": True, - } - if url.endswith(".flv"): - port_info["flv_url"] = url - else: - port_info["m3u8_url"] = url - else: - port_info = {"anchor_name": "", "is_live": False} - - # 返回前补充:若 is_live True 但未给出真实播放地址,也作为暂不可用 - if port_info.get("is_live") and not select_source_url(url, port_info): - logger.warning(f"[fetch_port_info] 在播但暂未拿到播放 URL,可能接口延迟:{url}") - return port_info - - except Exception as e: - logger.warning(f"[fetch_port_info] 第{attempt}次解析失败: {e}") - if attempt < 3: - try: - await asyncio.sleep(delay) - except Exception: - # 若在同步上下文内调用(意外),兜底 - time.sleep(delay) - delay *= 2 - else: - logger.error(f"[fetch_port_info] 多次失败,返回空状态: {url}") - return {"anchor_name": "", "is_live": False} - - -# =============== 启动/停止 ffmpeg =============== -def launch_ffmpeg(input_url: str, - out_url: str, - extra_headers: Optional[str], - v_args: list, - a_args: list, - worker_name: str) -> subprocess.Popen: - user_agent = ("Mozilla/5.0 (Linux; Android 11; SM-G973U) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/87.0.4280.141 Mobile Safari/537.36") - # 输入侧容错/重连/探测 - base = [ - "ffmpeg", "-nostdin", "-hide_banner", - "-stats", - "-stats_period", "60", - "-progress", "pipe:1", - "-loglevel", "info", - - # 输入容错 & 自动重连 - "-rw_timeout", "15000000", - "-timeout", "10000000", - "-reconnect", "1", - "-reconnect_streamed", "1", - "-reconnect_at_eof", "1", - "-reconnect_delay_max", "30", - - # UA/协议白名单 - "-user_agent", user_agent, - "-protocol_whitelist", "file,http,https,tcp,tls,crypto,udp,rtmp,rtp,httpproxy", - - # 缓冲 & 探测 - "-thread_queue_size", "4096", - "-analyzeduration", "10000000", - "-probesize", "10000000", - ] - - # 平台 headers(referer/origin)要在 -i 之前 - if extra_headers: - base += ["-headers", extra_headers] - - # 代理:http_proxy/https_proxy 环境变量(同时保留 -http_proxy 兼容) - env = os.environ.copy() - if proxy_addr: - env["http_proxy"] = proxy_addr - env["https_proxy"] = proxy_addr - base = ["ffmpeg", "-http_proxy", proxy_addr] + base[1:] - - # 输入 - base += ["-re", "-i", input_url] - - # 通用容错 + 时间戳修复(优化版) - base += [ - "-fflags", "+genpts+igndts+flush_packets", - "-use_wallclock_as_timestamps", "1", - "-avioflags", "direct", - "-rtbufsize", "100M", - "-err_detect", "ignore_err", - "-avoid_negative_ts", "make_zero", - ] - - # 视频/音频编码参数(使用 build_youtube_output 的结果) - v_args = v_args or [] - a_args = a_args or [] - base += v_args + a_args - - # 输出(flv 到 YT,更宽容) - base += ["-f", "flv", "-flvflags", "no_duration_filesize", out_url] - - cmd_str = " ".join(base) - print(f"ffmpeg cmd ({worker_name}):", cmd_str if len(cmd_str) < 500 else cmd_str[:500] + " ...") - logger.info(f"{worker_name} 启动推流:{input_url} -> {out_url}") - - creationflags = 0 - if os.name == "nt": - creationflags = subprocess.CREATE_NEW_PROCESS_GROUP - - p = subprocess.Popen( - base, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - bufsize=0, - env=env, - creationflags=creationflags - ) - p.last_out_ts = time.time() - - # ---- 注册进程,便于 Ctrl+C 统一清理 ---- - with ALL_LOCK: - ALL_PROCS.add(p) - - def _reader(): - try: - for line in iter(p.stdout.readline, b''): - if not line: - break - text = line.decode(errors="ignore").strip() - p.last_out_ts = time.time() - - # 只保留关键统计/错误(前缀带 Worker) - if ("bitrate=" in text) or ("frame=" in text) or ("speed=" in text) or ("libx264" in text): - logger.info(f"{worker_name} [ffmpeg统计] {text}") - if "Server disconnected" in text: - logger.error(f"{worker_name} [输出异常] {text}") - elif ("not supported" in text) or ("Invalid" in text) or ("invalid" in text): - logger.error(f"{worker_name} [转码异常] {text}") - except Exception: - pass - - threading.Thread(target=_reader, daemon=True).start() - return p - -def stop_ffmpeg(p: Optional[subprocess.Popen]): - if not p: return - try: - if os.name == "nt": - if p.stdin: - with contextlib.suppress(Exception): - p.stdin.write(b"q"); p.stdin.flush() - p.wait(timeout=6) - else: - p.send_signal(signal.SIGINT) - p.wait(timeout=10) - except Exception: - with contextlib.suppress(Exception): - p.kill() - finally: - # ---- 从全局集合移除 ---- - with ALL_LOCK: - ALL_PROCS.discard(p) - -# =============== 平台 headers/识别 =============== -def headers_for_platform(platform_name: str, live_url: str) -> Optional[str]: - live_domain = '/'.join(live_url.split('/')[0:3]) - record_headers = { - 'PandaTV': 'origin:https://www.pandalive.co.kr', - 'WinkTV': 'origin:https://www.winktv.co.kr', - 'PopkonTV': 'origin:https://www.popkontv.com', - 'FlexTV': 'origin:https://www.flextv.co.kr', - '千度热播': 'referer:https://qiandurebo.com', - '17Live': 'referer:https://17.live/en/live/6302408', - '浪Live': 'referer:https://www.lang.live', - 'shopee': f'origin:{live_domain}', - 'Blued直播': 'referer:https://app.blued.cn' - } - return record_headers.get(platform_name) - -def detect_platform(url: str) -> str: - if "douyin.com" in url: return "抖音直播" - if "tiktok.com" in url: return "TikTok直播" - if url.endswith(".flv") or url.endswith(".m3u8"): return "自定义录制直播" - return "未知平台" - -# =============== 日志状态缓存(去重/限频) =============== -class LogState: - def __init__(self): - self.lock = threading.Lock() - self.url_status: Dict[str, str] = {} # url -> "unknown/offline/online/streaming" - self.no_source_worker: Dict[str, bool] = {} # worker_name -> idle printed flag - - def set_status(self, url: str, status: str, text: Optional[str] = None, prefix: str = ""): - with self.lock: - prev = self.url_status.get(url, "unknown") - if prev != status: - self.url_status[url] = status - if text: - print(f"[{now_str()}]{prefix} {text}") - - def mark_idle_once(self, worker_name: str): - with self.lock: - if not self.no_source_worker.get(worker_name, False): - print(f"[{now_str()}][{worker_name}] 当前无可用直播源,等待中…") - self.no_source_worker[worker_name] = True - - def clear_idle_flag(self, worker_name: str): - with self.lock: - self.no_source_worker[worker_name] = False - -LOGSTATE = LogState() - -# =============== URL 协调器(url 级独占,保持原顺序) =============== -class UrlCoordinator: - """ - - urls 顺序分配 - - in_use_urls: url 级别独占(防止同一 url 被不同 worker 同时占用) - """ - def __init__(self, path: str): - self.path = path - self.urls: List[str] = parse_urls_from_file(path) - self.idx: int = 0 - self.lock = threading.Lock() - self.last_md5 = check_md5(path) - self.in_use_urls: Set[str] = set() - - def refresh_if_changed(self): - try: - md5 = check_md5(self.path) - if md5 != self.last_md5: - self.urls = parse_urls_from_file(self.path) or self.urls - self.last_md5 = md5 - print(f"[{now_str()}] 已重载 URL 列表,数量:{len(self.urls)}") - except Exception as e: - logger.warning(f"URL 列表热更新失败:{e}") - - def acquire_next_live(self, worker_key: str, quality_code: str = "OD") -> Optional[Tuple[str, dict]]: - """尝试遍历一轮 URL 表,找到首个在播的;否则返回 None。""" - with self.lock: - total = len(self.urls) - if total == 0: - return None - - tried = 0 - while tried < total: - with self.lock: - url = self.urls[self.idx] - self.idx = (self.idx + 1) % total - - if url in self.in_use_urls: - tried += 1 - continue - - # 锁外解析(避免阻塞) - port = asyncio.run(fetch_port_info(url, quality_code)) - if not port.get("anchor_name"): - LOGSTATE.set_status(url, "unknown", text=f"解析失败:{url}") - tried += 1 - continue - if not port.get("is_live"): - anchor = port.get('anchor_name', '') - LOGSTATE.set_status(url, "offline", text=f"未开播:{anchor} | {url}") - tried += 1 - continue - - # 回到锁内占用 - with self.lock: - if url in self.in_use_urls: - tried += 1 - continue - self.in_use_urls.add(url) - - anchor = port.get('anchor_name', '') - LOGSTATE.set_status(url, "online", text=f"已开播:{anchor} | {url}") - return url, port - - return None - - def release(self, url: Optional[str], worker_key: Optional[str]): - if not url: - return - with self.lock: - self.in_use_urls.discard(url) - -# =============== Worker(保持原业务逻辑框架,加入防抖与探活) =============== -class StreamWorker(threading.Thread): - def __init__(self, worker_id: int, key: str, resolution: Optional[str], coordinator: UrlCoordinator): - super().__init__(daemon=True) - self.worker_id = worker_id - self.key = key - self.worker_name = f"Worker-{worker_id}" - self.resolution = resolution - self.coordinator = coordinator - - self.current_url: Optional[str] = None - self.current_real_url: Optional[str] = None - self.current_proc: Optional[subprocess.Popen] = None - self.current_platform = "" - self.current_anchor = "" - self.record_quality_code = "OD" - - # 与原逻辑一致的参数(根据经验值略作保守,稳定优先) - self.watchdog_grace = 120 # 推流启动后宽限期 - self.watchdog_silence = 600 # 10 分钟无输出视为卡死 - self.refresh_real_url_every = 300 # 5 分钟刷新一次真实地址(token 可能过期) - self.check_interval_live = 8 # 在播时的检测间隔 - self.scan_interval_when_idle = 2 # 空闲时的扫描间隔 - - self._last_progress_print = -9999 - self._stop_event = threading.Event() - self.start_time = time.time() - - # 修复点②:新增下播防抖状态(连续多次 is_live=False 才判定) - self._offline_count = 0 - self._last_live_ts = time.time() - - # 软重启冷却(避免频繁重启) - self._last_soft_restart_ts = 0.0 - self._soft_restart_min_interval = 90.0 - - def stop(self): - """收到停止请求时,尽快停掉当前 ffmpeg。""" - self._stop_event.set() - with contextlib.suppress(Exception): - stop_ffmpeg(self.current_proc) - - def _p(self, text: str): - print(f"[{now_str()}][{self.worker_name}] {text}") - - def _soft_restart_ffmpeg(self, reason: str): - """ - 尝试“软重启”ffmpeg:仅重启进程,不释放 URL 占用,优先保持连续性。 - """ - now = time.time() - if now - self._last_soft_restart_ts < self._soft_restart_min_interval: - self._p(f"跳过软重启(冷却中): {reason}") - return False - self._last_soft_restart_ts = now - - try: - stop_ffmpeg(self.current_proc) - except Exception: - pass - - out_url, v_args, a_args = build_youtube_output_for_key(self.key, self.resolution) # 修复:用 self.resolution,非 None - headers = headers_for_platform(self.current_platform, self.current_url or "") - if not self.current_real_url: - # 尝试重新解析一遍 - new_port = asyncio.run(fetch_port_info(self.current_url, self.record_quality_code)) if self.current_url else {} - self.current_real_url = select_source_url(self.current_url or "", new_port) - if not self.current_real_url: - self._p("软重启失败:无法获取播放 URL。") - return False - - self.current_proc = launch_ffmpeg(self.current_real_url, out_url, headers, v_args, a_args, self.worker_name) - self.start_time = time.time() - self._last_progress_print = -9999 - self._p(f"已软重启 ffmpeg:{reason}") - return True - - def run(self): - self._p("启动") - try: - while not self._stop_event.is_set(): - try: - self.coordinator.refresh_if_changed() - - # ========== 已在推流:健康检测 ========== - if self.current_proc and self.current_url: - retcode = self.current_proc.poll() - if retcode is not None: - self._p(f"❌ ffmpeg 退出(code={retcode}),切换下一个源…") - stop_ffmpeg(self.current_proc) - self.coordinator.release(self.current_url, self.key) - self._clear_state() - LOGSTATE.clear_idle_flag(self.worker_name) - continue - - # 检测是否仍在直播(加入“防抖 + 交叉探活”) - still_live = True - is_live_raw = True - try: - port = asyncio.run(fetch_port_info(self.current_url, self.record_quality_code)) - is_live_raw = bool(port.get("is_live")) - except Exception as e: - logger.warning(f"[{self.worker_name}] 检测当前源异常:{e}") - is_live_raw = True # 网络/接口异常默认不下播 - - if not is_live_raw: - # 交叉探活:若当前 real_url 还能探测成功,认为仍在播 - alive_by_probe = probe_stream_url(self.current_real_url) - if alive_by_probe: - logger.info(f"[{self.worker_name}] API 返回下播但探活成功,视为仍在播。") - is_live_raw = True - - # 下播防抖:连续 N 次失败 且 距最后一次活跃已超过 M 秒 - if is_live_raw: - self._offline_count = 0 - self._last_live_ts = time.time() - still_live = True - else: - self._offline_count += 1 - if time.time() - self._last_live_ts < 120: - logger.info(f"[{self.worker_name}] 短期异常 is_live=False({self._offline_count}),已忽略") - still_live = True - elif self._offline_count < 3: - logger.info(f"[{self.worker_name}] 疑似下播 {self._offline_count}/3 次,继续观望") - still_live = True - else: - still_live = False # 多次确认才下播 - - if still_live: - elapsed = int(time.time() - self.start_time) - # 每 60s 打印一次“正在转播” - if elapsed - self._last_progress_print >= 60: - h, m = divmod(elapsed, 3600) - m, s = divmod(m, 60) - dur_str = f"{h:02d}:{m:02d}:{s:02d}" - self._p(f"⏱ 正在转播({dur_str}):{self.current_platform} | {self.current_anchor}") - self._last_progress_print = elapsed - - # 定期刷新真实播放地址(token) - try: - if elapsed >= self.refresh_real_url_every and (elapsed % self.refresh_real_url_every) < self.check_interval_live: - new_port = asyncio.run(fetch_port_info(self.current_url, self.record_quality_code)) - new_real_url = select_source_url(self.current_url, new_port) - if new_real_url and new_real_url != self.current_real_url: - self._p("🔄 刷新播放地址(token 变化),软重启 ffmpeg…") - stop_ffmpeg(self.current_proc) - out_url, v_args, a_args = build_youtube_output_for_key(self.key, self.resolution) # 修复:用 self.resolution - headers = headers_for_platform(self.current_platform, self.current_url) - self.current_proc = launch_ffmpeg(new_real_url, out_url, headers, v_args, a_args, self.worker_name) - self.current_real_url = new_real_url - self.start_time = time.time() - self._last_progress_print = -9999 - time.sleep(self.check_interval_live) - continue - except Exception as e: - logger.warning(f"[{self.worker_name}] 刷新播放地址失败:{e}") - - # 看门狗:长时间无输出 -> 先软重启,如果软重启失败再释放 - silence = time.time() - getattr(self.current_proc, "last_out_ts", 0) - if elapsed >= self.watchdog_grace and silence > self.watchdog_silence: - self._p("⚠️ 超过10分钟未见输出,尝试软重启…") - ok = self._soft_restart_ffmpeg("watchdog silence") - if not ok: - self._p("软重启失败,释放当前源并切换…") - stop_ffmpeg(self.current_proc) - self._clear_state(release=True) - LOGSTATE.clear_idle_flag(self.worker_name) - time.sleep(1) - else: - time.sleep(self.check_interval_live) - continue - - time.sleep(self.check_interval_live) - continue - else: - self._p("📴 多次确认主播下播,切换下一个源…") - stop_ffmpeg(self.current_proc) - self._clear_state(release=True) - LOGSTATE.clear_idle_flag(self.worker_name) - continue - - # ========== 未在推流:尝试分配新源 ========== - got = self.coordinator.acquire_next_live(self.key, self.record_quality_code) - if not got: - LOGSTATE.mark_idle_once(self.worker_name) - time.sleep(self.scan_interval_when_idle) - continue - - LOGSTATE.clear_idle_flag(self.worker_name) - url, port = got - real_url = select_source_url(url, port) - if not real_url: - self._p(f"未得到播放地址,跳过:{url}") - self.coordinator.release(url, self.key) - time.sleep(1) - continue - - # 新源上线前:探活(避免刚拿到 URL 就空推) - if not probe_stream_url(real_url): - self._p(f"播放 URL 探活失败(可能刚开播或接口延迟),暂缓此源:{url}") - self.coordinator.release(url, self.key) - time.sleep(1) - continue - - self.current_platform = detect_platform(url) - self.current_anchor = port.get("anchor_name", "") - headers = headers_for_platform(self.current_platform, url) - out_url, v_args, a_args = build_youtube_output_for_key(self.key, self.resolution) # 修复:用 self.resolution,非 None - self.current_proc = launch_ffmpeg(real_url, out_url, headers, v_args, a_args, self.worker_name) - self.current_url = url - self.current_real_url = real_url - self.start_time = time.time() - self._last_progress_print = -9999 - self._offline_count = 0 - self._last_live_ts = time.time() - - self._p(f"✅ 开始转播:{self.current_platform} | {self.current_anchor} | {url}") - - except Exception as e: - # 关键:任何内部异常不让线程退出 - logger.error(f"[{self.worker_name}] 内部异常:{e}") - time.sleep(2) - continue - - finally: - with contextlib.suppress(Exception): - stop_ffmpeg(self.current_proc) - self.coordinator.release(self.current_url, self.key) - self._p("已退出。") - - def _clear_state(self, release: bool = False): - if release and self.current_url: - self.coordinator.release(self.current_url, self.key) - self.current_url = None - self.current_real_url = None - self.current_proc = None - self.current_platform = "" - self.current_anchor = "" - -# =============== 启动入口(保持原行为) =============== -def check_ffmpeg_existence() -> bool: - try: - result = subprocess.run(['ffmpeg', '-version'], check=True, capture_output=True, text=True) - if result.returncode == 0: - print(result.stdout.splitlines()[0]) # 修复:统一用 stdout - except Exception: - pass - finally: - return bool(check_ffmpeg()) - -# ---- 统一优雅退出 ---- -def graceful_shutdown(signum=None, frame=None): - if not SHUTDOWN_EVENT.is_set(): - print("\n收到中断,正在停止所有 worker 与 ffmpeg …") - SHUTDOWN_EVENT.set() - # 先发 stop 给所有 worker(会内部 stop 当前 ffmpeg) - for w in list(ALL_WORKERS): - with contextlib.suppress(Exception): - w.stop() - # 再兜底停止全局登记的 ffmpeg 子进程 - with ALL_LOCK: - procs = list(ALL_PROCS) - for p in procs: - with contextlib.suppress(Exception): - stop_ffmpeg(p) - -def main(): - print("DouyinRelay", version) - print("-----------------------------------------") - - if not check_ffmpeg_existence(): - logger.error("缺少 ffmpeg,程序退出") - sys.exit(1) - - urls = parse_urls_from_file(url_config_file) - if not urls: - logger.error("URL_config.ini 中没有可用 URL。") - sys.exit(1) - - # keys(并行路数 = keys 数量);兼容单 key - keys = _yt_get_list("youtube", "keys") - if not keys: - single_key = _yt_get("youtube", "key", fallback="", cast=str) - if single_key: - keys = [single_key] - if not keys: - logger.error("youtube.ini 没有配置 [youtube].keys 或 key") - sys.exit(1) - - # 可选:每路分辨率 - per_res = _yt_get_list("youtube", "resolutions") # 如:1920x1080,1080x1920 - while per_res and len(per_res) < len(keys): - per_res.append(per_res[-1]) - - # 打印 ffmpeg 版本(贴合旧风格) - try: - vline = subprocess.run(['ffmpeg', '-version'], check=True, capture_output=True, text=True).stdout.splitlines()[0] - print(vline) - except Exception: - pass - - coordinator = UrlCoordinator(url_config_file) - - # 信号处理(Ctrl+C / kill) - with contextlib.suppress(Exception): - signal.signal(signal.SIGINT, graceful_shutdown) - with contextlib.suppress(Exception): - signal.signal(signal.SIGTERM, graceful_shutdown) - - # 启动多路(Worker 前缀区分) - workers: List[StreamWorker] = [] - for i, key in enumerate(keys, start=1): - res_for_this = per_res[i-1] if per_res else None - w = StreamWorker(worker_id=i, key=key, resolution=res_for_this, coordinator=coordinator) - w.start() - workers.append(w) - ALL_WORKERS.append(w) - - try: - while not SHUTDOWN_EVENT.is_set(): - time.sleep(0.5) - except KeyboardInterrupt: - graceful_shutdown() - finally: - graceful_shutdown() - for w in workers: - w.join(timeout=10) - print("全部 worker 已退出。") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/back/mult6 b/back/mult6 deleted file mode 100644 index 6e9cffd..0000000 --- a/back/mult6 +++ /dev/null @@ -1,1035 +0,0 @@ -# -*- encoding: utf-8 -*- - -""" -并行转推(稳定性+体验增强版,保持原业务逻辑与对外接口) -- 核心修复:降低“误判下播”的概率(抖音仍在播但 API 偶发失败) - * fetch_port_info:加入重试(3 次,指数退避),不改签名/调用处 - * “下播防抖”:只有在“连续N次 is_live=False 且最近M秒没有任何活跃迹象”才判定下播 - * 交叉验证:若 is_live=False,但当前 real_url 可探测(探活成功),则判为仍在播 -- FFmpeg 看门狗:卡顿/长时间无输出 -> 先软重启(保留 URL 占用),再必要时释放 -- 细节增强:日志更明确,解释每次判定原因;优雅退出确保无僵尸进程 -- 兼容性:不改配置文件/外部模块(spider、stream 等)的接口与行为 -- 修复:确保函数定义顺序,避免 NameError -""" - -import asyncio -import os -import sys -import signal -import time -import datetime -import re -import uuid -import threading -import subprocess -from typing import Any, List, Tuple, Optional, Dict, Set -import contextlib -import configparser -import urllib.request -import urllib.error -import io -import logging -from logging.handlers import RotatingFileHandler - -# ======================= 颜色日志(Windows Cmder 友好) ======================= -def _enable_vt_on_windows(): - """在 Windows 控制台启用 ANSI VT(Cmder/CMD/PowerShell 颜色支持)""" - if os.name != "nt": - return - try: - import msvcrt # noqa: F401 - import ctypes - kernel32 = ctypes.windll.kernel32 - handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE = -11 - mode = ctypes.c_uint32() - if kernel32.GetConsoleMode(handle, ctypes.byref(mode)): - mode.value |= 0x0004 # ENABLE_VIRTUAL_TERMINAL_PROCESSING - kernel32.SetConsoleMode(handle, mode) - except Exception: - pass - -_enable_vt_on_windows() - -class ColorLog: - # ANSI colors - RESET = "\033[0m" - GREY = "\033[90m" - RED = "\033[91m" - GREEN = "\033[92m" - YELLOW = "\033[93m" - BLUE = "\033[94m" - MAGENTA = "\033[95m" - CYAN = "\033[96m" - WHITE = "\033[97m" - - ICONS = { - "INFO": "🟢", - "WARN": "🟡", - "ERROR": "🔴", - "DEBUG": "⚪", - "OK": "✅", - "SWAP": "🔁", - "LIVE": "📡", - "OFF": "📴", - "FFMPEG":"🎬", - "WATCH": "👀", - } - - COLORS = { - "INFO": GREEN, - "OK": GREEN, - "WARN": YELLOW, - "ERROR": RED, - "DEBUG": GREY, - "SWAP": CYAN, - "LIVE": BLUE, - "OFF": RED, - "FFMPEG": MAGENTA, - "WATCH": CYAN, - } - - def __init__(self, log_file_path: str = "logs/runtime.log", max_bytes: int = 5*1024*1024, backup: int = 3): - os.makedirs(os.path.dirname(log_file_path), exist_ok=True) - - self._logger = logging.getLogger("Runtime") - self._logger.setLevel(logging.INFO) - self._logger.propagate = False - # 控制台 Handler(给 src.utils.logger 让位,这里只用于文件) - if not self._logger.handlers: - file_handler = RotatingFileHandler(log_file_path, maxBytes=max_bytes, backupCount=backup, encoding="utf-8") - fmt = logging.Formatter("%(asctime)s | %(levelname)-5s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S") - file_handler.setFormatter(fmt) - self._logger.addHandler(file_handler) - - def _stamp(self) -> str: - return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - - def _compose(self, level: str, msg: str, worker: Optional[str] = None) -> str: - icon = self.ICONS.get(level, "") - color = self.COLORS.get(level, self.WHITE) - prefix = f"[{self._stamp()}]" - if worker: - prefix += f"[{worker}]" - return f"{prefix} {color}[{icon} {level}]{self.RESET} {msg}" - - def info(self, msg: str, worker: Optional[str] = None): - text = self._compose("INFO", msg, worker) - print(text) - self._logger.info(msg) - - def ok(self, msg: str, worker: Optional[str] = None): - text = self._compose("OK", msg, worker) - print(text) - self._logger.info(msg) - - def warn(self, msg: str, worker: Optional[str] = None): - text = self._compose("WARN", msg, worker) - print(text) - self._logger.warning(msg) - - def error(self, msg: str, worker: Optional[str] = None): - text = self._compose("ERROR", msg, worker) - print(text, file=sys.stderr) - self._logger.error(msg) - - def debug(self, msg: str, worker: Optional[str] = None): - text = self._compose("DEBUG", msg, worker) - print(text) - self._logger.debug(msg) - - def swap(self, msg: str, worker: Optional[str] = None): - text = self._compose("SWAP", msg, worker) - print(text) - self._logger.info("[SWAP] " + msg) - - def live(self, msg: str, worker: Optional[str] = None): - text = self._compose("LIVE", msg, worker) - print(text) - self._logger.info("[LIVE] " + msg) - - def off(self, msg: str, worker: Optional[str] = None): - text = self._compose("OFF", msg, worker) - print(text) - self._logger.info("[OFF] " + msg) - -CLOG = ColorLog() # 全局彩色日志 - -# ======================= 这里开始是原有工程的 import =========================== -from src import spider, stream -from src.utils import logger, Color, check_md5 -from ffmpeg_install import check_ffmpeg, ffmpeg_path, current_env_path - -# =============== 重要:先定义 build_youtube_output_for_key,避免 NameError =============== -# 读取 YouTube 配置(需要在函数内部访问 _yt_get,故先声明占位;稍后会正式赋值) -_yt_cfg: Optional[configparser.ConfigParser] = None - -def _safe_int(v, lo, hi, defv): - try: - v = int(v) - if v < lo or v > hi: return defv - return v - except Exception: - return defv - -def _round_res(w: int, h: int) -> Tuple[int, int]: - # 偶数对齐,避免 1088x1920 类问题 - return (w & ~1, h & ~1) - -def _build_youtube_rtmp_url(key: str, secure: bool, ingest: str | None): - node = (ingest or "a").strip().lower() - node = node if node.isalpha() and len(node) == 1 else "a" - scheme = "rtmps" if secure else "rtmp" - host = f"{node}.rtmps.youtube.com" if secure else f"{node}.rtmp.youtube.com" - return f"{scheme}://{host}/live2/{key}" - -def build_youtube_output_for_key(key: str, resolution: Optional[str]) -> Tuple[str, list, list]: - """ - !!!此函数放在文件靠前位置,确保任何地方调用都不再出现 NameError。 - 与旧版保持一致;仅对异常进行更稳妥处理与注释增强。 - """ - if not key: - logger.error("错误: youtube.ini 未配置 key/keys") - sys.exit(1) - - secure = _yt_get("youtube", "secure", fallback=False, cast=bool) - ingest = _yt_get("youtube", "ingest", fallback="a", cast=str) - res_str = (resolution or _yt_get("youtube", "resolution", fallback="", cast=str) or "").strip() - - bv = _safe_int(_yt_get("youtube", "bitrate", fallback=5000, cast=int), 300, 20000, 5000) - ba = _safe_int(_yt_get("youtube", "audio_bitrate", fallback=128, cast=int), 64, 384, 128) - fps = _safe_int(_yt_get("youtube", "fps", fallback=30, cast=int), 10, 60, 30) - gop = _safe_int(_yt_get("youtube", "gop", fallback=2*fps, cast=int), fps, 4*fps, 2*fps) - - out_url = _build_youtube_rtmp_url(key, secure, ingest) - - v_args = [ - "-c:v", "libx264", - "-preset", "veryfast", - "-tune", "zerolatency", - "-profile:v", "high", - "-level:v", "4.2", - "-pix_fmt", "yuv420p", - "-r", str(fps), - "-g", str(gop), - "-b:v", f"{bv}k", - "-maxrate", f"{bv}k", - "-bufsize", f"{max(2*bv, 4000)}k", - ] - a_args = [ - "-c:a", "aac", - "-b:a", f"{ba}k", - "-ar", "44100", - "-ac", "2", - ] - - if res_str: - try: - w, h = map(int, res_str.lower().split("x")) - w, h = _round_res(w, h) - vf = ( - f"scale={w}:{h}:force_original_aspect_ratio=decrease," - f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2," - f"crop={w}:{h}," - f"format=yuv420p" - ) - v_args = ["-vf", vf] + v_args - except Exception: - logger.warning(f"resolution '{res_str}' 非法,忽略") - - return out_url, v_args, a_args - -# =============== 基础环境 =============== -version = "vrelay-parallel-2.1.1-pro" -os.environ['PATH'] = ffmpeg_path + os.pathsep + current_env_path -script_path = os.path.split(os.path.realpath(sys.argv[0]))[0] -config_file = f'{script_path}/config/config.ini' -url_config_file = f'{script_path}/config/URL_config.ini' -yt_ini_file = f"{script_path}/config/youtube.ini" -text_encoding = 'utf-8-sig' -color = Color() - -# ---- 全局 Worker & 进程注册(用于 Ctrl+C 干净退出) ---- -ALL_WORKERS: List["StreamWorker"] = [] -ALL_PROCS: Set[subprocess.Popen] = set() -SHUTDOWN_EVENT = threading.Event() -ALL_LOCK = threading.Lock() - -# =============== 读取 YouTube 配置 =============== -_yt_cfg = configparser.ConfigParser() -with open(yt_ini_file, "r", encoding="utf-8-sig") as f: - _yt_cfg.read_file(f) - -def _yt_get(section: str, key: str, fallback=None, cast=str): - try: - if cast is bool: - return _yt_cfg.getboolean(section, key, fallback=fallback) - if cast is int: - return _yt_cfg.getint(section, key, fallback=fallback) - return _yt_cfg.get(section, key, fallback=fallback) - except Exception: - return fallback - -def _yt_get_list(section: str, key: str) -> List[str]: - raw = _yt_get(section, key, fallback="", cast=str) or "" - parts = re.split(r"[,;\s]+", raw.strip()) - return [p for p in parts if p] - -# =============== 工具函数(原版保留,已前置到文件顶部附近) =============== -def now_str(): - return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - -# =============== 读取运行配置 =============== -def read_config_value(cfg: configparser.RawConfigParser, section: str, option: str, default_value: Any) -> Any: - try: - cfg.read(config_file, encoding=text_encoding) - if section not in cfg.sections(): - cfg.add_section(section) - return cfg.get(section, option) - except (configparser.NoSectionError, configparser.NoOptionError): - cfg.set(section, option, str(default_value)) - with open(config_file, 'w', encoding=text_encoding) as f: - cfg.write(f) - return default_value - -_CN_BOOL = {"是": True, "否": False} -cfg = configparser.RawConfigParser() -use_proxy = _CN_BOOL.get(read_config_value(cfg, '录制设置', '是否使用代理ip(是/否)', "否"), False) -proxy_addr = read_config_value(cfg, '录制设置', '代理地址', "") -proxy_addr = proxy_addr if use_proxy and proxy_addr.strip() else None -dy_cookie = read_config_value(cfg, 'Cookie', '抖音cookie', '') -tiktok_cookie = read_config_value(cfg, 'Cookie', 'tiktok_cookie', '') - -# 启动时连通性提示(仅提示,不改变逻辑) -try: - if not use_proxy: - urllib.request.urlopen("https://www.google.com/", timeout=5) - CLOG.info("你的网络似乎直连可用(无需代理)。") -except Exception: - if not use_proxy: - print("\033[93m提示:无法直连海外站,如需 TikTok 请在 config.ini 开启并填写代理地址。\033[0m") - -# =============== URL 列表 =============== -def parse_urls_from_file(path: str) -> List[str]: - urls, seen = [], set() - if not os.path.isfile(path): - os.makedirs(os.path.dirname(path), exist_ok=True) - open(path, 'w', encoding=text_encoding).close() - print("URL_config.ini 为空,请写入直播间地址后再运行。") - return urls - with open(path, "r", encoding=text_encoding, errors='ignore') as f: - for line in f: - s = line.strip() - if not s or s.startswith("#"): - continue - parts = re.split(r"[,,]\s*", s) - cand = None - for p in parts: - if '://' in p or re.match(r"^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", p): - cand = p - break - if not cand: - continue - url = cand if '://' in cand else f"https://{cand}" - if url not in seen: - seen.add(url) - urls.append(url) - return urls - -# =============== 解析与选源 =============== -def is_flv_preferred_platform(link: str) -> bool: - return any(i in link for i in ["douyin", "tiktok"]) - -def select_source_url(link: str, stream_info: dict) -> Optional[str]: - """ - 选源优先级保持不变:抖音/TikTok 优先 FLV(且避免 H265),否则 record_url/m3u8/flv。 - """ - if not stream_info: - return None - if is_flv_preferred_platform(link): - flv = stream_info.get("flv_url") - if flv: - try: - from urllib.parse import urlparse, parse_qs - qs = parse_qs(urlparse(flv).query or "") - if (qs.get("codec") or [""])[0].lower() == "h265": - logger.warning("FLV 不支持 h265,改用 HLS。") - else: - return flv - except Exception: - return flv - return stream_info.get("record_url") or stream_info.get("m3u8_url") or stream_info.get("flv_url") - -# =============== 辅助:探测播放 URL 是否可用(用于误判纠正) =============== -def _open_with_proxy(url: str, timeout: float = 5.0): - """ - 使用 urllib 在当前代理环境下打开 URL。 - 仅抓取少量字节用于“活跃性探测”,避免大流量。 - """ - handlers = [] - if proxy_addr: - handlers.append(urllib.request.ProxyHandler({"http": proxy_addr, "https": proxy_addr})) - opener = urllib.request.build_opener(*handlers) if handlers else urllib.request.build_opener() - req = urllib.request.Request( - url, - headers={ - "User-Agent": "Mozilla/5.0 (Linux; Android 11; SM-G973U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Mobile Safari/537.36", - "Accept": "*/*", - "Connection": "close", - } - ) - return opener.open(req, timeout=timeout) - -def probe_stream_url(real_url: Optional[str], timeout: float = 5.0) -> bool: - """ - 轻量级“探活”: - - m3u8: 取前若干字节并检查 "#EXTM3U" - - flv: 取前3字节是否 "FLV" - - 其它:只要 200 且有数据即可视为“活着” - 失败不抛异常,返回 False - """ - if not real_url: - return False - try: - with contextlib.closing(_open_with_proxy(real_url, timeout=timeout)) as resp: - code = getattr(resp, "status", None) or getattr(resp, "code", None) - if code and int(code) >= 400: - return False - head = resp.read(256) # 少量字节足够判定格式 - u = real_url.lower() - if ".m3u8" in u: - return b"#EXTM3U" in head.upper() - if ".flv" in u: - return head.startswith(b"FLV") - # 其它类型:有数据即认为仍在 - return bool(head) - except Exception: - return False - -# =============== 解析与选源(抖音/TikTok/自定义) =============== -# 修复点①:增强 fetch_port_info —— 重试 + 指数退避(不改签名/调用处) -async def fetch_port_info(url: str, record_quality_code: str = "OD") -> dict: - proxy = proxy_addr - delay = 1.5 # 初始退避 - for attempt in range(1, 4): # 最多 3 次 - try: - if "douyin.com/" in url: - if 'v.douyin.com' not in url and '/user/' not in url: - j = await spider.get_douyin_stream_data(url=url, proxy_addr=proxy, cookies=dy_cookie) - else: - j = await spider.get_douyin_app_stream_data(url=url, proxy_addr=proxy, cookies=dy_cookie) - port_info = await stream.get_douyin_stream_url(j, record_quality_code, proxy) - elif url.startswith("https://www.tiktok.com/"): - if proxy: - j = await spider.get_tiktok_stream_data(url=url, proxy_addr=proxy, cookies=tiktok_cookie) - port_info = await stream.get_tiktok_stream_url(j, record_quality_code, proxy) - else: - logger.error("TikTok 需要代理,请在 config.ini 开启并配置代理。") - port_info = {"anchor_name": "", "is_live": False} - elif (".m3u8" in url) or (".flv" in url): - port_info = { - "anchor_name": "自定义_" + str(uuid.uuid4())[:8], - "is_live": True, - } - if url.endswith(".flv"): - port_info["flv_url"] = url - else: - port_info["m3u8_url"] = url - else: - port_info = {"anchor_name": "", "is_live": False} - - # 返回前补充:若 is_live True 但未给出真实播放地址,也作为暂不可用(但不判下播) - if port_info.get("is_live") and not select_source_url(url, port_info): - logger.warning(f"[fetch_port_info] 在播但暂未拿到播放 URL,可能接口延迟:{url}") - return port_info - - except Exception as e: - logger.warning(f"[fetch_port_info] 第{attempt}次解析失败: {e}") - if attempt < 3: - try: - await asyncio.sleep(delay) - except Exception: - time.sleep(delay) - delay *= 2 - else: - logger.error(f"[fetch_port_info] 多次失败,返回空状态: {url}") - return {"anchor_name": "", "is_live": False} - -# =============== 启动/停止 ffmpeg =============== -def launch_ffmpeg(input_url: str, - out_url: str, - extra_headers: Optional[str], - v_args: list, - a_args: list, - worker_name: str) -> subprocess.Popen: - user_agent = ("Mozilla/5.0 (Linux; Android 11; SM-G973U) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/87.0.4280.141 Mobile Safari/537.36") - # 输入侧容错/重连/探测 - base = [ - "ffmpeg", "-nostdin", "-hide_banner", - "-stats", - "-stats_period", "60", - "-progress", "pipe:1", - "-loglevel", "info", - - # 输入容错 & 自动重连 - "-rw_timeout", "15000000", - "-timeout", "10000000", - "-reconnect", "1", - "-reconnect_streamed", "1", - "-reconnect_at_eof", "1", - "-reconnect_delay_max", "30", - - # UA/协议白名单 - "-user_agent", user_agent, - "-protocol_whitelist", "file,http,https,tcp,tls,crypto,udp,rtmp,rtp,httpproxy", - - # 缓冲 & 探测 - "-thread_queue_size", "4096", - "-analyzeduration", "10000000", - "-probesize", "10000000", - ] - - # 平台 headers(referer/origin)要在 -i 之前 - if extra_headers: - base += ["-headers", extra_headers] - - # 代理:http_proxy/https_proxy 环境变量(同时保留 -http_proxy 兼容) - env = os.environ.copy() - if proxy_addr: - env["http_proxy"] = proxy_addr - env["https_proxy"] = proxy_addr - base = ["ffmpeg", "-http_proxy", proxy_addr] + base[1:] - - # 输入 - base += ["-re", "-i", input_url] - - # 通用容错 + 时间戳修复(优化版) - base += [ - "-fflags", "+genpts+igndts+flush_packets", - "-use_wallclock_as_timestamps", "1", - "-avioflags", "direct", - "-rtbufsize", "100M", - "-err_detect", "ignore_err", - "-avoid_negative_ts", "make_zero", - ] - - # 视频/音频编码参数(使用 build_youtube_output 的结果) - v_args = v_args or [] - a_args = a_args or [] - base += v_args + a_args - - # 输出(flv 到 YT,更宽容) - base += ["-f", "flv", "-flvflags", "no_duration_filesize", out_url] - - cmd_str = " ".join(base) - # 控制台+文件日志 - CLOG.live(f"FFmpeg CMD({worker_name}): {cmd_str if len(cmd_str) < 500 else cmd_str[:500] + ' ...'}", worker=worker_name) - logger.info(f"{worker_name} 启动推流:{input_url} -> {out_url}") - - creationflags = 0 - if os.name == "nt": - creationflags = subprocess.CREATE_NEW_PROCESS_GROUP - - p = subprocess.Popen( - base, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - bufsize=0, - env=env, - creationflags=creationflags - ) - p.last_out_ts = time.time() - - # ---- 注册进程,便于 Ctrl+C 统一清理 ---- - with ALL_LOCK: - ALL_PROCS.add(p) - - def _reader(): - try: - for line in iter(p.stdout.readline, b''): - if not line: - break - text = line.decode(errors="ignore").strip() - p.last_out_ts = time.time() - - # 只保留关键统计/错误(前缀带 Worker) - if ("bitrate=" in text) or ("frame=" in text) or ("speed=" in text) or ("libx264" in text): - CLOG.info(f"[ffmpeg统计] {text}", worker=worker_name) - if "Server disconnected" in text: - CLOG.error(f"[输出异常] {text}", worker=worker_name) - elif ("not supported" in text) or ("Invalid" in text) or ("invalid" in text): - CLOG.error(f"[转码异常] {text}", worker=worker_name) - except Exception: - pass - - threading.Thread(target=_reader, daemon=True).start() - return p - -def stop_ffmpeg(p: Optional[subprocess.Popen]): - if not p: return - try: - if os.name == "nt": - if p.stdin: - with contextlib.suppress(Exception): - p.stdin.write(b"q"); p.stdin.flush() - p.wait(timeout=6) - else: - p.send_signal(signal.SIGINT) - p.wait(timeout=10) - except Exception: - with contextlib.suppress(Exception): - p.kill() - finally: - # ---- 从全局集合移除 ---- - with ALL_LOCK: - ALL_PROCS.discard(p) - -# =============== 平台 headers/识别 =============== -def headers_for_platform(platform_name: str, live_url: str) -> Optional[str]: - live_domain = '/'.join(live_url.split('/')[0:3]) - record_headers = { - 'PandaTV': 'origin:https://www.pandalive.co.kr', - 'WinkTV': 'origin:https://www.winktv.co.kr', - 'PopkonTV': 'origin:https://www.popkontv.com', - 'FlexTV': 'origin:https://www.flextv.co.kr', - '千度热播': 'referer:https://qiandurebo.com', - '17Live': 'referer:https://17.live/en/live/6302408', - '浪Live': 'referer:https://www.lang.live', - 'shopee': f'origin:{live_domain}', - 'Blued直播': 'referer:https://app.blued.cn' - } - return record_headers.get(platform_name) - -def detect_platform(url: str) -> str: - if "douyin.com" in url: return "抖音直播" - if "tiktok.com" in url: return "TikTok直播" - if url.endswith(".flv") or url.endswith(".m3u8"): return "自定义录制直播" - return "未知平台" - -# =============== 日志状态缓存(去重/限频) =============== -class LogState: - def __init__(self): - self.lock = threading.Lock() - self.url_status: Dict[str, str] = {} # url -> "unknown/offline/online/streaming" - self.no_source_worker: Dict[str, bool] = {} # worker_name -> idle printed flag - - def set_status(self, url: str, status: str, text: Optional[str] = None, prefix: str = ""): - with self.lock: - prev = self.url_status.get(url, "unknown") - if prev != status: - self.url_status[url] = status - if text: - print(f"[{now_str()}]{prefix} {text}") - - def mark_idle_once(self, worker_name: str): - with self.lock: - if not self.no_source_worker.get(worker_name, False): - CLOG.debug("当前无可用直播源,等待中…", worker=worker_name) - self.no_source_worker[worker_name] = True - - def clear_idle_flag(self, worker_name: str): - with self.lock: - self.no_source_worker[worker_name] = False - -LOGSTATE = LogState() - -# =============== URL 协调器(url 级独占,保持原顺序) =============== -class UrlCoordinator: - """ - - urls 顺序分配 - - in_use_urls: url 级别独占(防止同一 url 被不同 worker 同时占用) - """ - def __init__(self, path: str): - self.path = path - self.urls: List[str] = parse_urls_from_file(path) - self.idx: int = 0 - self.lock = threading.Lock() - self.last_md5 = check_md5(path) - self.in_use_urls: Set[str] = set() - - def refresh_if_changed(self): - try: - md5 = check_md5(self.path) - if md5 != self.last_md5: - self.urls = parse_urls_from_file(self.path) or self.urls - self.last_md5 = md5 - CLOG.info(f"已重载 URL 列表,数量:{len(self.urls)}") - except Exception as e: - logger.warning(f"URL 列表热更新失败:{e}") - - def acquire_next_live(self, worker_key: str, quality_code: str = "OD") -> Optional[Tuple[str, dict]]: - """尝试遍历一轮 URL 表,找到首个在播的;否则返回 None。""" - with self.lock: - total = len(self.urls) - if total == 0: - return None - - tried = 0 - while tried < total: - with self.lock: - url = self.urls[self.idx] - self.idx = (self.idx + 1) % total - - if url in self.in_use_urls: - tried += 1 - continue - - # 锁外解析(避免阻塞) - port = asyncio.run(fetch_port_info(url, quality_code)) - anchor = port.get('anchor_name', '') - if not port.get("anchor_name"): - LOGSTATE.set_status(url, "unknown", text=f"解析失败:{url}") - tried += 1 - continue - if not port.get("is_live"): - LOGSTATE.set_status(url, "offline", text=f"未开播:{anchor} | {url}") - tried += 1 - continue - - # 回到锁内占用 - with self.lock: - if url in self.in_use_urls: - tried += 1 - continue - self.in_use_urls.add(url) - - LOGSTATE.set_status(url, "online", text=f"已开播:{anchor} | {url}") - return url, port - - return None - - def release(self, url: Optional[str], worker_key: Optional[str]): - if not url: - return - with self.lock: - self.in_use_urls.discard(url) - -# =============== Worker(保持原业务逻辑框架,加入防抖与探活) =============== -class StreamWorker(threading.Thread): - def __init__(self, worker_id: int, key: str, resolution: Optional[str], coordinator: UrlCoordinator): - super().__init__(daemon=True) - self.worker_id = worker_id - self.key = key - self.worker_name = f"Worker-{worker_id}" - self.resolution = resolution - self.coordinator = coordinator - - self.current_url: Optional[str] = None - self.current_real_url: Optional[str] = None - self.current_proc: Optional[subprocess.Popen] = None - self.current_platform = "" - self.current_anchor = "" - self.record_quality_code = "OD" - - # 与原逻辑一致的参数(稳定优先) - self.watchdog_grace = 120 # 推流启动后宽限期 - self.watchdog_silence = 600 # 10 分钟无输出视为卡死 - self.refresh_real_url_every = 300 # 5 分钟刷新一次真实地址(token 可能过期) - self.check_interval_live = 8 # 在播时的检测间隔 - self.scan_interval_when_idle = 2 # 空闲时的扫描间隔 - - self._last_progress_print = -9999 - self._stop_event = threading.Event() - self.start_time = time.time() - - # 修复点②:新增下播防抖状态(连续多次 is_live=False 才判定) - self._offline_count = 0 - self._last_live_ts = time.time() - - # 软重启冷却(避免频繁重启) - self._last_soft_restart_ts = 0.0 - self._soft_restart_min_interval = 90.0 - - def stop(self): - """收到停止请求时,尽快停掉当前 ffmpeg。""" - self._stop_event.set() - with contextlib.suppress(Exception): - stop_ffmpeg(self.current_proc) - - def _soft_restart_ffmpeg(self, reason: str): - """ - 尝试“软重启”ffmpeg:仅重启进程,不释放 URL 占用,优先保持连续性。 - """ - now = time.time() - if now - self._last_soft_restart_ts < self._soft_restart_min_interval: - CLOG.warn(f"跳过软重启(冷却中): {reason}", worker=self.worker_name) - return False - self._last_soft_restart_ts = now - - try: - stop_ffmpeg(self.current_proc) - except Exception: - pass - - out_url, v_args, a_args = build_youtube_output_for_key(self.key, self.resolution) - headers = headers_for_platform(self.current_platform, self.current_url or "") - if not self.current_real_url: - # 尝试重新解析一遍 - new_port = asyncio.run(fetch_port_info(self.current_url, self.record_quality_code)) if self.current_url else {} - self.current_real_url = select_source_url(self.current_url or "", new_port) - if not self.current_real_url: - CLOG.error("软重启失败:无法获取播放 URL。", worker=self.worker_name) - return False - - self.current_proc = launch_ffmpeg(self.current_real_url, out_url, headers, v_args, a_args, self.worker_name) - self.start_time = time.time() - self._last_progress_print = -9999 - CLOG.ok(f"已软重启 ffmpeg:{reason}", worker=self.worker_name) - return True - - def run(self): - CLOG.ok("启动", worker=self.worker_name) - try: - while not self._stop_event.is_set(): - try: - self.coordinator.refresh_if_changed() - - # ========== 已在推流:健康检测 ========== - if self.current_proc and self.current_url: - retcode = self.current_proc.poll() - if retcode is not None: - CLOG.error(f"ffmpeg 退出(code={retcode}),切换下一个源…", worker=self.worker_name) - stop_ffmpeg(self.current_proc) - self.coordinator.release(self.current_url, self.key) - self._clear_state() - LOGSTATE.clear_idle_flag(self.worker_name) - continue - - # 检测是否仍在直播(加入“防抖 + 交叉探活”) - still_live = True - is_live_raw = True - try: - port = asyncio.run(fetch_port_info(self.current_url, self.record_quality_code)) - is_live_raw = bool(port.get("is_live")) - except Exception as e: - CLOG.warn(f"检测当前源异常:{e}", worker=self.worker_name) - is_live_raw = True # 网络/接口异常默认不下播 - - if not is_live_raw: - # 交叉探活:若当前 real_url 还能探测成功,认为仍在播 - alive_by_probe = probe_stream_url(self.current_real_url) - if alive_by_probe: - CLOG.warn("API 返回下播但探活成功,视为仍在播。", worker=self.worker_name) - is_live_raw = True - - # 下播防抖:连续 N 次失败 且 距最后一次活跃已超过 M 秒 - if is_live_raw: - self._offline_count = 0 - self._last_live_ts = time.time() - still_live = True - else: - self._offline_count += 1 - if time.time() - self._last_live_ts < 120: - CLOG.warn(f"短期异常 is_live=False({self._offline_count}),已忽略", worker=self.worker_name) - still_live = True - elif self._offline_count < 3: - CLOG.warn(f"疑似下播 {self._offline_count}/3 次,继续观望", worker=self.worker_name) - still_live = True - else: - still_live = False # 多次确认才下播 - - if still_live: - elapsed = int(time.time() - self.start_time) - # 每 60s 打印一次“正在转播” - if elapsed - self._last_progress_print >= 60: - h, m = divmod(elapsed, 3600) - m, s = divmod(m, 60) - dur_str = f"{h:02d}:{m:02d}:{s:02d}" - CLOG.live(f"正在转播({dur_str}):{self.current_platform} | {self.current_anchor}", worker=self.worker_name) - self._last_progress_print = elapsed - - # 定期刷新真实播放地址(token) - try: - if elapsed >= self.refresh_real_url_every and (elapsed % self.refresh_real_url_every) < self.check_interval_live: - new_port = asyncio.run(fetch_port_info(self.current_url, self.record_quality_code)) - new_real_url = select_source_url(self.current_url, new_port) - if new_real_url and new_real_url != self.current_real_url: - CLOG.swap("刷新播放地址(token 变化),软重启 ffmpeg…", worker=self.worker_name) - stop_ffmpeg(self.current_proc) - out_url, v_args, a_args = build_youtube_output_for_key(self.key, self.resolution) - headers = headers_for_platform(self.current_platform, self.current_url) - self.current_proc = launch_ffmpeg(new_real_url, out_url, headers, v_args, a_args, self.worker_name) - self.current_real_url = new_real_url - self.start_time = time.time() - self._last_progress_print = -9999 - time.sleep(self.check_interval_live) - continue - except Exception as e: - CLOG.warn(f"刷新播放地址失败:{e}", worker=self.worker_name) - - # 看门狗:长时间无输出 -> 先软重启,如果软重启失败再释放 - silence = time.time() - getattr(self.current_proc, "last_out_ts", 0) - if elapsed >= self.watchdog_grace and silence > self.watchdog_silence: - CLOG.warn("超过10分钟未见输出,尝试软重启…", worker=self.worker_name) - ok = self._soft_restart_ffmpeg("watchdog silence") - if not ok: - CLOG.warn("软重启失败,释放当前源并切换…", worker=self.worker_name) - stop_ffmpeg(self.current_proc) - self._clear_state(release=True) - LOGSTATE.clear_idle_flag(self.worker_name) - time.sleep(1) - else: - time.sleep(self.check_interval_live) - continue - - time.sleep(self.check_interval_live) - continue - else: - CLOG.off("多次确认主播下播,切换下一个源…", worker=self.worker_name) - stop_ffmpeg(self.current_proc) - self._clear_state(release=True) - LOGSTATE.clear_idle_flag(self.worker_name) - continue - - # ========== 未在推流:尝试分配新源 ========== - got = self.coordinator.acquire_next_live(self.key, self.record_quality_code) - if not got: - LOGSTATE.mark_idle_once(self.worker_name) - time.sleep(self.scan_interval_when_idle) - continue - - LOGSTATE.clear_idle_flag(self.worker_name) - url, port = got - real_url = select_source_url(url, port) - if not real_url: - CLOG.warn(f"未得到播放地址,跳过:{url}", worker=self.worker_name) - self.coordinator.release(url, self.key) - time.sleep(1) - continue - - # 新源上线前:探活(避免刚拿到 URL 就空推) - if not probe_stream_url(real_url): - CLOG.warn(f"播放 URL 探活失败(可能刚开播或接口延迟),暂缓此源:{url}", worker=self.worker_name) - self.coordinator.release(url, self.key) - time.sleep(1) - continue - - self.current_platform = detect_platform(url) - self.current_anchor = port.get("anchor_name", "") - headers = headers_for_platform(self.current_platform, url) - out_url, v_args, a_args = build_youtube_output_for_key(self.key, self.resolution) - self.current_proc = launch_ffmpeg(real_url, out_url, headers, v_args, a_args, self.worker_name) - self.current_url = url - self.current_real_url = real_url - self.start_time = time.time() - self._last_progress_print = -9999 - self._offline_count = 0 - self._last_live_ts = time.time() - - CLOG.ok(f"开始转播:{self.current_platform} | {self.current_anchor} | {url}", worker=self.worker_name) - - except Exception as e: - # 关键:任何内部异常不让线程退出 - CLOG.error(f"内部异常:{e}", worker=self.worker_name) - time.sleep(2) - continue - - finally: - with contextlib.suppress(Exception): - stop_ffmpeg(self.current_proc) - self.coordinator.release(self.current_url, self.key) - CLOG.off("已退出。", worker=self.worker_name) - - def _clear_state(self, release: bool = False): - if release and self.current_url: - self.coordinator.release(self.current_url, self.key) - self.current_url = None - self.current_real_url = None - self.current_proc = None - self.current_platform = "" - self.current_anchor = "" - -# =============== 启动入口(保持原行为) =============== -def check_ffmpeg_existence() -> bool: - try: - result = subprocess.run(['ffmpeg', '-version'], check=True, capture_output=True, text=True) - if result.returncode == 0: - print(result.stdout.splitlines()[0]) # 贴合旧风格:打印版本第一行 - except Exception: - pass - finally: - return bool(check_ffmpeg()) - -# ---- 统一优雅退出 ---- -def graceful_shutdown(signum=None, frame=None): - if not SHUTDOWN_EVENT.is_set(): - print("\n收到中断,正在停止所有 worker 与 ffmpeg …") - SHUTDOWN_EVENT.set() - # 先发 stop 给所有 worker(会内部 stop 当前 ffmpeg) - for w in list(ALL_WORKERS): - with contextlib.suppress(Exception): - w.stop() - # 再兜底停止全局登记的 ffmpeg 子进程 - with ALL_LOCK: - procs = list(ALL_PROCS) - for p in procs: - with contextlib.suppress(Exception): - stop_ffmpeg(p) - -def main(): - print("DouyinRelay", version) - print("-----------------------------------------") - - if not check_ffmpeg_existence(): - logger.error("缺少 ffmpeg,程序退出") - sys.exit(1) - - urls = parse_urls_from_file(url_config_file) - if not urls: - logger.error("URL_config.ini 中没有可用 URL。") - sys.exit(1) - - # keys(并行路数 = keys 数量);兼容单 key - keys = _yt_get_list("youtube", "keys") - if not keys: - single_key = _yt_get("youtube", "key", fallback="", cast=str) - if single_key: - keys = [single_key] - if not keys: - logger.error("youtube.ini 没有配置 [youtube].keys 或 key") - sys.exit(1) - - # 可选:每路分辨率 - per_res = _yt_get_list("youtube", "resolutions") # 如:1920x1080,1080x1920 - while per_res and len(per_res) < len(keys): - per_res.append(per_res[-1]) - - # 打印 ffmpeg 版本(贴合旧风格) - try: - vline = subprocess.run(['ffmpeg', '-version'], check=True, capture_output=True, text=True).stdout.splitlines()[0] - print(vline) - except Exception: - pass - - coordinator = UrlCoordinator(url_config_file) - - # 信号处理(Ctrl+C / kill) - with contextlib.suppress(Exception): - signal.signal(signal.SIGINT, graceful_shutdown) - with contextlib.suppress(Exception): - signal.signal(signal.SIGTERM, graceful_shutdown) - - # 启动多路(Worker 前缀区分) - workers: List[StreamWorker] = [] - for i, key in enumerate(keys, start=1): - res_for_this = per_res[i-1] if per_res else None - w = StreamWorker(worker_id=i, key=key, resolution=res_for_this, coordinator=coordinator) - w.start() - workers.append(w) - ALL_WORKERS.append(w) - - try: - while not SHUTDOWN_EVENT.is_set(): - time.sleep(0.5) - except KeyboardInterrupt: - graceful_shutdown() - finally: - graceful_shutdown() - for w in workers: - w.join(timeout=10) - print("全部 worker 已退出。") - -if __name__ == "__main__": - main() diff --git a/back/mult7 b/back/mult7 deleted file mode 100644 index 6e9cffd..0000000 --- a/back/mult7 +++ /dev/null @@ -1,1035 +0,0 @@ -# -*- encoding: utf-8 -*- - -""" -并行转推(稳定性+体验增强版,保持原业务逻辑与对外接口) -- 核心修复:降低“误判下播”的概率(抖音仍在播但 API 偶发失败) - * fetch_port_info:加入重试(3 次,指数退避),不改签名/调用处 - * “下播防抖”:只有在“连续N次 is_live=False 且最近M秒没有任何活跃迹象”才判定下播 - * 交叉验证:若 is_live=False,但当前 real_url 可探测(探活成功),则判为仍在播 -- FFmpeg 看门狗:卡顿/长时间无输出 -> 先软重启(保留 URL 占用),再必要时释放 -- 细节增强:日志更明确,解释每次判定原因;优雅退出确保无僵尸进程 -- 兼容性:不改配置文件/外部模块(spider、stream 等)的接口与行为 -- 修复:确保函数定义顺序,避免 NameError -""" - -import asyncio -import os -import sys -import signal -import time -import datetime -import re -import uuid -import threading -import subprocess -from typing import Any, List, Tuple, Optional, Dict, Set -import contextlib -import configparser -import urllib.request -import urllib.error -import io -import logging -from logging.handlers import RotatingFileHandler - -# ======================= 颜色日志(Windows Cmder 友好) ======================= -def _enable_vt_on_windows(): - """在 Windows 控制台启用 ANSI VT(Cmder/CMD/PowerShell 颜色支持)""" - if os.name != "nt": - return - try: - import msvcrt # noqa: F401 - import ctypes - kernel32 = ctypes.windll.kernel32 - handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE = -11 - mode = ctypes.c_uint32() - if kernel32.GetConsoleMode(handle, ctypes.byref(mode)): - mode.value |= 0x0004 # ENABLE_VIRTUAL_TERMINAL_PROCESSING - kernel32.SetConsoleMode(handle, mode) - except Exception: - pass - -_enable_vt_on_windows() - -class ColorLog: - # ANSI colors - RESET = "\033[0m" - GREY = "\033[90m" - RED = "\033[91m" - GREEN = "\033[92m" - YELLOW = "\033[93m" - BLUE = "\033[94m" - MAGENTA = "\033[95m" - CYAN = "\033[96m" - WHITE = "\033[97m" - - ICONS = { - "INFO": "🟢", - "WARN": "🟡", - "ERROR": "🔴", - "DEBUG": "⚪", - "OK": "✅", - "SWAP": "🔁", - "LIVE": "📡", - "OFF": "📴", - "FFMPEG":"🎬", - "WATCH": "👀", - } - - COLORS = { - "INFO": GREEN, - "OK": GREEN, - "WARN": YELLOW, - "ERROR": RED, - "DEBUG": GREY, - "SWAP": CYAN, - "LIVE": BLUE, - "OFF": RED, - "FFMPEG": MAGENTA, - "WATCH": CYAN, - } - - def __init__(self, log_file_path: str = "logs/runtime.log", max_bytes: int = 5*1024*1024, backup: int = 3): - os.makedirs(os.path.dirname(log_file_path), exist_ok=True) - - self._logger = logging.getLogger("Runtime") - self._logger.setLevel(logging.INFO) - self._logger.propagate = False - # 控制台 Handler(给 src.utils.logger 让位,这里只用于文件) - if not self._logger.handlers: - file_handler = RotatingFileHandler(log_file_path, maxBytes=max_bytes, backupCount=backup, encoding="utf-8") - fmt = logging.Formatter("%(asctime)s | %(levelname)-5s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S") - file_handler.setFormatter(fmt) - self._logger.addHandler(file_handler) - - def _stamp(self) -> str: - return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - - def _compose(self, level: str, msg: str, worker: Optional[str] = None) -> str: - icon = self.ICONS.get(level, "") - color = self.COLORS.get(level, self.WHITE) - prefix = f"[{self._stamp()}]" - if worker: - prefix += f"[{worker}]" - return f"{prefix} {color}[{icon} {level}]{self.RESET} {msg}" - - def info(self, msg: str, worker: Optional[str] = None): - text = self._compose("INFO", msg, worker) - print(text) - self._logger.info(msg) - - def ok(self, msg: str, worker: Optional[str] = None): - text = self._compose("OK", msg, worker) - print(text) - self._logger.info(msg) - - def warn(self, msg: str, worker: Optional[str] = None): - text = self._compose("WARN", msg, worker) - print(text) - self._logger.warning(msg) - - def error(self, msg: str, worker: Optional[str] = None): - text = self._compose("ERROR", msg, worker) - print(text, file=sys.stderr) - self._logger.error(msg) - - def debug(self, msg: str, worker: Optional[str] = None): - text = self._compose("DEBUG", msg, worker) - print(text) - self._logger.debug(msg) - - def swap(self, msg: str, worker: Optional[str] = None): - text = self._compose("SWAP", msg, worker) - print(text) - self._logger.info("[SWAP] " + msg) - - def live(self, msg: str, worker: Optional[str] = None): - text = self._compose("LIVE", msg, worker) - print(text) - self._logger.info("[LIVE] " + msg) - - def off(self, msg: str, worker: Optional[str] = None): - text = self._compose("OFF", msg, worker) - print(text) - self._logger.info("[OFF] " + msg) - -CLOG = ColorLog() # 全局彩色日志 - -# ======================= 这里开始是原有工程的 import =========================== -from src import spider, stream -from src.utils import logger, Color, check_md5 -from ffmpeg_install import check_ffmpeg, ffmpeg_path, current_env_path - -# =============== 重要:先定义 build_youtube_output_for_key,避免 NameError =============== -# 读取 YouTube 配置(需要在函数内部访问 _yt_get,故先声明占位;稍后会正式赋值) -_yt_cfg: Optional[configparser.ConfigParser] = None - -def _safe_int(v, lo, hi, defv): - try: - v = int(v) - if v < lo or v > hi: return defv - return v - except Exception: - return defv - -def _round_res(w: int, h: int) -> Tuple[int, int]: - # 偶数对齐,避免 1088x1920 类问题 - return (w & ~1, h & ~1) - -def _build_youtube_rtmp_url(key: str, secure: bool, ingest: str | None): - node = (ingest or "a").strip().lower() - node = node if node.isalpha() and len(node) == 1 else "a" - scheme = "rtmps" if secure else "rtmp" - host = f"{node}.rtmps.youtube.com" if secure else f"{node}.rtmp.youtube.com" - return f"{scheme}://{host}/live2/{key}" - -def build_youtube_output_for_key(key: str, resolution: Optional[str]) -> Tuple[str, list, list]: - """ - !!!此函数放在文件靠前位置,确保任何地方调用都不再出现 NameError。 - 与旧版保持一致;仅对异常进行更稳妥处理与注释增强。 - """ - if not key: - logger.error("错误: youtube.ini 未配置 key/keys") - sys.exit(1) - - secure = _yt_get("youtube", "secure", fallback=False, cast=bool) - ingest = _yt_get("youtube", "ingest", fallback="a", cast=str) - res_str = (resolution or _yt_get("youtube", "resolution", fallback="", cast=str) or "").strip() - - bv = _safe_int(_yt_get("youtube", "bitrate", fallback=5000, cast=int), 300, 20000, 5000) - ba = _safe_int(_yt_get("youtube", "audio_bitrate", fallback=128, cast=int), 64, 384, 128) - fps = _safe_int(_yt_get("youtube", "fps", fallback=30, cast=int), 10, 60, 30) - gop = _safe_int(_yt_get("youtube", "gop", fallback=2*fps, cast=int), fps, 4*fps, 2*fps) - - out_url = _build_youtube_rtmp_url(key, secure, ingest) - - v_args = [ - "-c:v", "libx264", - "-preset", "veryfast", - "-tune", "zerolatency", - "-profile:v", "high", - "-level:v", "4.2", - "-pix_fmt", "yuv420p", - "-r", str(fps), - "-g", str(gop), - "-b:v", f"{bv}k", - "-maxrate", f"{bv}k", - "-bufsize", f"{max(2*bv, 4000)}k", - ] - a_args = [ - "-c:a", "aac", - "-b:a", f"{ba}k", - "-ar", "44100", - "-ac", "2", - ] - - if res_str: - try: - w, h = map(int, res_str.lower().split("x")) - w, h = _round_res(w, h) - vf = ( - f"scale={w}:{h}:force_original_aspect_ratio=decrease," - f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2," - f"crop={w}:{h}," - f"format=yuv420p" - ) - v_args = ["-vf", vf] + v_args - except Exception: - logger.warning(f"resolution '{res_str}' 非法,忽略") - - return out_url, v_args, a_args - -# =============== 基础环境 =============== -version = "vrelay-parallel-2.1.1-pro" -os.environ['PATH'] = ffmpeg_path + os.pathsep + current_env_path -script_path = os.path.split(os.path.realpath(sys.argv[0]))[0] -config_file = f'{script_path}/config/config.ini' -url_config_file = f'{script_path}/config/URL_config.ini' -yt_ini_file = f"{script_path}/config/youtube.ini" -text_encoding = 'utf-8-sig' -color = Color() - -# ---- 全局 Worker & 进程注册(用于 Ctrl+C 干净退出) ---- -ALL_WORKERS: List["StreamWorker"] = [] -ALL_PROCS: Set[subprocess.Popen] = set() -SHUTDOWN_EVENT = threading.Event() -ALL_LOCK = threading.Lock() - -# =============== 读取 YouTube 配置 =============== -_yt_cfg = configparser.ConfigParser() -with open(yt_ini_file, "r", encoding="utf-8-sig") as f: - _yt_cfg.read_file(f) - -def _yt_get(section: str, key: str, fallback=None, cast=str): - try: - if cast is bool: - return _yt_cfg.getboolean(section, key, fallback=fallback) - if cast is int: - return _yt_cfg.getint(section, key, fallback=fallback) - return _yt_cfg.get(section, key, fallback=fallback) - except Exception: - return fallback - -def _yt_get_list(section: str, key: str) -> List[str]: - raw = _yt_get(section, key, fallback="", cast=str) or "" - parts = re.split(r"[,;\s]+", raw.strip()) - return [p for p in parts if p] - -# =============== 工具函数(原版保留,已前置到文件顶部附近) =============== -def now_str(): - return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - -# =============== 读取运行配置 =============== -def read_config_value(cfg: configparser.RawConfigParser, section: str, option: str, default_value: Any) -> Any: - try: - cfg.read(config_file, encoding=text_encoding) - if section not in cfg.sections(): - cfg.add_section(section) - return cfg.get(section, option) - except (configparser.NoSectionError, configparser.NoOptionError): - cfg.set(section, option, str(default_value)) - with open(config_file, 'w', encoding=text_encoding) as f: - cfg.write(f) - return default_value - -_CN_BOOL = {"是": True, "否": False} -cfg = configparser.RawConfigParser() -use_proxy = _CN_BOOL.get(read_config_value(cfg, '录制设置', '是否使用代理ip(是/否)', "否"), False) -proxy_addr = read_config_value(cfg, '录制设置', '代理地址', "") -proxy_addr = proxy_addr if use_proxy and proxy_addr.strip() else None -dy_cookie = read_config_value(cfg, 'Cookie', '抖音cookie', '') -tiktok_cookie = read_config_value(cfg, 'Cookie', 'tiktok_cookie', '') - -# 启动时连通性提示(仅提示,不改变逻辑) -try: - if not use_proxy: - urllib.request.urlopen("https://www.google.com/", timeout=5) - CLOG.info("你的网络似乎直连可用(无需代理)。") -except Exception: - if not use_proxy: - print("\033[93m提示:无法直连海外站,如需 TikTok 请在 config.ini 开启并填写代理地址。\033[0m") - -# =============== URL 列表 =============== -def parse_urls_from_file(path: str) -> List[str]: - urls, seen = [], set() - if not os.path.isfile(path): - os.makedirs(os.path.dirname(path), exist_ok=True) - open(path, 'w', encoding=text_encoding).close() - print("URL_config.ini 为空,请写入直播间地址后再运行。") - return urls - with open(path, "r", encoding=text_encoding, errors='ignore') as f: - for line in f: - s = line.strip() - if not s or s.startswith("#"): - continue - parts = re.split(r"[,,]\s*", s) - cand = None - for p in parts: - if '://' in p or re.match(r"^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", p): - cand = p - break - if not cand: - continue - url = cand if '://' in cand else f"https://{cand}" - if url not in seen: - seen.add(url) - urls.append(url) - return urls - -# =============== 解析与选源 =============== -def is_flv_preferred_platform(link: str) -> bool: - return any(i in link for i in ["douyin", "tiktok"]) - -def select_source_url(link: str, stream_info: dict) -> Optional[str]: - """ - 选源优先级保持不变:抖音/TikTok 优先 FLV(且避免 H265),否则 record_url/m3u8/flv。 - """ - if not stream_info: - return None - if is_flv_preferred_platform(link): - flv = stream_info.get("flv_url") - if flv: - try: - from urllib.parse import urlparse, parse_qs - qs = parse_qs(urlparse(flv).query or "") - if (qs.get("codec") or [""])[0].lower() == "h265": - logger.warning("FLV 不支持 h265,改用 HLS。") - else: - return flv - except Exception: - return flv - return stream_info.get("record_url") or stream_info.get("m3u8_url") or stream_info.get("flv_url") - -# =============== 辅助:探测播放 URL 是否可用(用于误判纠正) =============== -def _open_with_proxy(url: str, timeout: float = 5.0): - """ - 使用 urllib 在当前代理环境下打开 URL。 - 仅抓取少量字节用于“活跃性探测”,避免大流量。 - """ - handlers = [] - if proxy_addr: - handlers.append(urllib.request.ProxyHandler({"http": proxy_addr, "https": proxy_addr})) - opener = urllib.request.build_opener(*handlers) if handlers else urllib.request.build_opener() - req = urllib.request.Request( - url, - headers={ - "User-Agent": "Mozilla/5.0 (Linux; Android 11; SM-G973U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Mobile Safari/537.36", - "Accept": "*/*", - "Connection": "close", - } - ) - return opener.open(req, timeout=timeout) - -def probe_stream_url(real_url: Optional[str], timeout: float = 5.0) -> bool: - """ - 轻量级“探活”: - - m3u8: 取前若干字节并检查 "#EXTM3U" - - flv: 取前3字节是否 "FLV" - - 其它:只要 200 且有数据即可视为“活着” - 失败不抛异常,返回 False - """ - if not real_url: - return False - try: - with contextlib.closing(_open_with_proxy(real_url, timeout=timeout)) as resp: - code = getattr(resp, "status", None) or getattr(resp, "code", None) - if code and int(code) >= 400: - return False - head = resp.read(256) # 少量字节足够判定格式 - u = real_url.lower() - if ".m3u8" in u: - return b"#EXTM3U" in head.upper() - if ".flv" in u: - return head.startswith(b"FLV") - # 其它类型:有数据即认为仍在 - return bool(head) - except Exception: - return False - -# =============== 解析与选源(抖音/TikTok/自定义) =============== -# 修复点①:增强 fetch_port_info —— 重试 + 指数退避(不改签名/调用处) -async def fetch_port_info(url: str, record_quality_code: str = "OD") -> dict: - proxy = proxy_addr - delay = 1.5 # 初始退避 - for attempt in range(1, 4): # 最多 3 次 - try: - if "douyin.com/" in url: - if 'v.douyin.com' not in url and '/user/' not in url: - j = await spider.get_douyin_stream_data(url=url, proxy_addr=proxy, cookies=dy_cookie) - else: - j = await spider.get_douyin_app_stream_data(url=url, proxy_addr=proxy, cookies=dy_cookie) - port_info = await stream.get_douyin_stream_url(j, record_quality_code, proxy) - elif url.startswith("https://www.tiktok.com/"): - if proxy: - j = await spider.get_tiktok_stream_data(url=url, proxy_addr=proxy, cookies=tiktok_cookie) - port_info = await stream.get_tiktok_stream_url(j, record_quality_code, proxy) - else: - logger.error("TikTok 需要代理,请在 config.ini 开启并配置代理。") - port_info = {"anchor_name": "", "is_live": False} - elif (".m3u8" in url) or (".flv" in url): - port_info = { - "anchor_name": "自定义_" + str(uuid.uuid4())[:8], - "is_live": True, - } - if url.endswith(".flv"): - port_info["flv_url"] = url - else: - port_info["m3u8_url"] = url - else: - port_info = {"anchor_name": "", "is_live": False} - - # 返回前补充:若 is_live True 但未给出真实播放地址,也作为暂不可用(但不判下播) - if port_info.get("is_live") and not select_source_url(url, port_info): - logger.warning(f"[fetch_port_info] 在播但暂未拿到播放 URL,可能接口延迟:{url}") - return port_info - - except Exception as e: - logger.warning(f"[fetch_port_info] 第{attempt}次解析失败: {e}") - if attempt < 3: - try: - await asyncio.sleep(delay) - except Exception: - time.sleep(delay) - delay *= 2 - else: - logger.error(f"[fetch_port_info] 多次失败,返回空状态: {url}") - return {"anchor_name": "", "is_live": False} - -# =============== 启动/停止 ffmpeg =============== -def launch_ffmpeg(input_url: str, - out_url: str, - extra_headers: Optional[str], - v_args: list, - a_args: list, - worker_name: str) -> subprocess.Popen: - user_agent = ("Mozilla/5.0 (Linux; Android 11; SM-G973U) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/87.0.4280.141 Mobile Safari/537.36") - # 输入侧容错/重连/探测 - base = [ - "ffmpeg", "-nostdin", "-hide_banner", - "-stats", - "-stats_period", "60", - "-progress", "pipe:1", - "-loglevel", "info", - - # 输入容错 & 自动重连 - "-rw_timeout", "15000000", - "-timeout", "10000000", - "-reconnect", "1", - "-reconnect_streamed", "1", - "-reconnect_at_eof", "1", - "-reconnect_delay_max", "30", - - # UA/协议白名单 - "-user_agent", user_agent, - "-protocol_whitelist", "file,http,https,tcp,tls,crypto,udp,rtmp,rtp,httpproxy", - - # 缓冲 & 探测 - "-thread_queue_size", "4096", - "-analyzeduration", "10000000", - "-probesize", "10000000", - ] - - # 平台 headers(referer/origin)要在 -i 之前 - if extra_headers: - base += ["-headers", extra_headers] - - # 代理:http_proxy/https_proxy 环境变量(同时保留 -http_proxy 兼容) - env = os.environ.copy() - if proxy_addr: - env["http_proxy"] = proxy_addr - env["https_proxy"] = proxy_addr - base = ["ffmpeg", "-http_proxy", proxy_addr] + base[1:] - - # 输入 - base += ["-re", "-i", input_url] - - # 通用容错 + 时间戳修复(优化版) - base += [ - "-fflags", "+genpts+igndts+flush_packets", - "-use_wallclock_as_timestamps", "1", - "-avioflags", "direct", - "-rtbufsize", "100M", - "-err_detect", "ignore_err", - "-avoid_negative_ts", "make_zero", - ] - - # 视频/音频编码参数(使用 build_youtube_output 的结果) - v_args = v_args or [] - a_args = a_args or [] - base += v_args + a_args - - # 输出(flv 到 YT,更宽容) - base += ["-f", "flv", "-flvflags", "no_duration_filesize", out_url] - - cmd_str = " ".join(base) - # 控制台+文件日志 - CLOG.live(f"FFmpeg CMD({worker_name}): {cmd_str if len(cmd_str) < 500 else cmd_str[:500] + ' ...'}", worker=worker_name) - logger.info(f"{worker_name} 启动推流:{input_url} -> {out_url}") - - creationflags = 0 - if os.name == "nt": - creationflags = subprocess.CREATE_NEW_PROCESS_GROUP - - p = subprocess.Popen( - base, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - bufsize=0, - env=env, - creationflags=creationflags - ) - p.last_out_ts = time.time() - - # ---- 注册进程,便于 Ctrl+C 统一清理 ---- - with ALL_LOCK: - ALL_PROCS.add(p) - - def _reader(): - try: - for line in iter(p.stdout.readline, b''): - if not line: - break - text = line.decode(errors="ignore").strip() - p.last_out_ts = time.time() - - # 只保留关键统计/错误(前缀带 Worker) - if ("bitrate=" in text) or ("frame=" in text) or ("speed=" in text) or ("libx264" in text): - CLOG.info(f"[ffmpeg统计] {text}", worker=worker_name) - if "Server disconnected" in text: - CLOG.error(f"[输出异常] {text}", worker=worker_name) - elif ("not supported" in text) or ("Invalid" in text) or ("invalid" in text): - CLOG.error(f"[转码异常] {text}", worker=worker_name) - except Exception: - pass - - threading.Thread(target=_reader, daemon=True).start() - return p - -def stop_ffmpeg(p: Optional[subprocess.Popen]): - if not p: return - try: - if os.name == "nt": - if p.stdin: - with contextlib.suppress(Exception): - p.stdin.write(b"q"); p.stdin.flush() - p.wait(timeout=6) - else: - p.send_signal(signal.SIGINT) - p.wait(timeout=10) - except Exception: - with contextlib.suppress(Exception): - p.kill() - finally: - # ---- 从全局集合移除 ---- - with ALL_LOCK: - ALL_PROCS.discard(p) - -# =============== 平台 headers/识别 =============== -def headers_for_platform(platform_name: str, live_url: str) -> Optional[str]: - live_domain = '/'.join(live_url.split('/')[0:3]) - record_headers = { - 'PandaTV': 'origin:https://www.pandalive.co.kr', - 'WinkTV': 'origin:https://www.winktv.co.kr', - 'PopkonTV': 'origin:https://www.popkontv.com', - 'FlexTV': 'origin:https://www.flextv.co.kr', - '千度热播': 'referer:https://qiandurebo.com', - '17Live': 'referer:https://17.live/en/live/6302408', - '浪Live': 'referer:https://www.lang.live', - 'shopee': f'origin:{live_domain}', - 'Blued直播': 'referer:https://app.blued.cn' - } - return record_headers.get(platform_name) - -def detect_platform(url: str) -> str: - if "douyin.com" in url: return "抖音直播" - if "tiktok.com" in url: return "TikTok直播" - if url.endswith(".flv") or url.endswith(".m3u8"): return "自定义录制直播" - return "未知平台" - -# =============== 日志状态缓存(去重/限频) =============== -class LogState: - def __init__(self): - self.lock = threading.Lock() - self.url_status: Dict[str, str] = {} # url -> "unknown/offline/online/streaming" - self.no_source_worker: Dict[str, bool] = {} # worker_name -> idle printed flag - - def set_status(self, url: str, status: str, text: Optional[str] = None, prefix: str = ""): - with self.lock: - prev = self.url_status.get(url, "unknown") - if prev != status: - self.url_status[url] = status - if text: - print(f"[{now_str()}]{prefix} {text}") - - def mark_idle_once(self, worker_name: str): - with self.lock: - if not self.no_source_worker.get(worker_name, False): - CLOG.debug("当前无可用直播源,等待中…", worker=worker_name) - self.no_source_worker[worker_name] = True - - def clear_idle_flag(self, worker_name: str): - with self.lock: - self.no_source_worker[worker_name] = False - -LOGSTATE = LogState() - -# =============== URL 协调器(url 级独占,保持原顺序) =============== -class UrlCoordinator: - """ - - urls 顺序分配 - - in_use_urls: url 级别独占(防止同一 url 被不同 worker 同时占用) - """ - def __init__(self, path: str): - self.path = path - self.urls: List[str] = parse_urls_from_file(path) - self.idx: int = 0 - self.lock = threading.Lock() - self.last_md5 = check_md5(path) - self.in_use_urls: Set[str] = set() - - def refresh_if_changed(self): - try: - md5 = check_md5(self.path) - if md5 != self.last_md5: - self.urls = parse_urls_from_file(self.path) or self.urls - self.last_md5 = md5 - CLOG.info(f"已重载 URL 列表,数量:{len(self.urls)}") - except Exception as e: - logger.warning(f"URL 列表热更新失败:{e}") - - def acquire_next_live(self, worker_key: str, quality_code: str = "OD") -> Optional[Tuple[str, dict]]: - """尝试遍历一轮 URL 表,找到首个在播的;否则返回 None。""" - with self.lock: - total = len(self.urls) - if total == 0: - return None - - tried = 0 - while tried < total: - with self.lock: - url = self.urls[self.idx] - self.idx = (self.idx + 1) % total - - if url in self.in_use_urls: - tried += 1 - continue - - # 锁外解析(避免阻塞) - port = asyncio.run(fetch_port_info(url, quality_code)) - anchor = port.get('anchor_name', '') - if not port.get("anchor_name"): - LOGSTATE.set_status(url, "unknown", text=f"解析失败:{url}") - tried += 1 - continue - if not port.get("is_live"): - LOGSTATE.set_status(url, "offline", text=f"未开播:{anchor} | {url}") - tried += 1 - continue - - # 回到锁内占用 - with self.lock: - if url in self.in_use_urls: - tried += 1 - continue - self.in_use_urls.add(url) - - LOGSTATE.set_status(url, "online", text=f"已开播:{anchor} | {url}") - return url, port - - return None - - def release(self, url: Optional[str], worker_key: Optional[str]): - if not url: - return - with self.lock: - self.in_use_urls.discard(url) - -# =============== Worker(保持原业务逻辑框架,加入防抖与探活) =============== -class StreamWorker(threading.Thread): - def __init__(self, worker_id: int, key: str, resolution: Optional[str], coordinator: UrlCoordinator): - super().__init__(daemon=True) - self.worker_id = worker_id - self.key = key - self.worker_name = f"Worker-{worker_id}" - self.resolution = resolution - self.coordinator = coordinator - - self.current_url: Optional[str] = None - self.current_real_url: Optional[str] = None - self.current_proc: Optional[subprocess.Popen] = None - self.current_platform = "" - self.current_anchor = "" - self.record_quality_code = "OD" - - # 与原逻辑一致的参数(稳定优先) - self.watchdog_grace = 120 # 推流启动后宽限期 - self.watchdog_silence = 600 # 10 分钟无输出视为卡死 - self.refresh_real_url_every = 300 # 5 分钟刷新一次真实地址(token 可能过期) - self.check_interval_live = 8 # 在播时的检测间隔 - self.scan_interval_when_idle = 2 # 空闲时的扫描间隔 - - self._last_progress_print = -9999 - self._stop_event = threading.Event() - self.start_time = time.time() - - # 修复点②:新增下播防抖状态(连续多次 is_live=False 才判定) - self._offline_count = 0 - self._last_live_ts = time.time() - - # 软重启冷却(避免频繁重启) - self._last_soft_restart_ts = 0.0 - self._soft_restart_min_interval = 90.0 - - def stop(self): - """收到停止请求时,尽快停掉当前 ffmpeg。""" - self._stop_event.set() - with contextlib.suppress(Exception): - stop_ffmpeg(self.current_proc) - - def _soft_restart_ffmpeg(self, reason: str): - """ - 尝试“软重启”ffmpeg:仅重启进程,不释放 URL 占用,优先保持连续性。 - """ - now = time.time() - if now - self._last_soft_restart_ts < self._soft_restart_min_interval: - CLOG.warn(f"跳过软重启(冷却中): {reason}", worker=self.worker_name) - return False - self._last_soft_restart_ts = now - - try: - stop_ffmpeg(self.current_proc) - except Exception: - pass - - out_url, v_args, a_args = build_youtube_output_for_key(self.key, self.resolution) - headers = headers_for_platform(self.current_platform, self.current_url or "") - if not self.current_real_url: - # 尝试重新解析一遍 - new_port = asyncio.run(fetch_port_info(self.current_url, self.record_quality_code)) if self.current_url else {} - self.current_real_url = select_source_url(self.current_url or "", new_port) - if not self.current_real_url: - CLOG.error("软重启失败:无法获取播放 URL。", worker=self.worker_name) - return False - - self.current_proc = launch_ffmpeg(self.current_real_url, out_url, headers, v_args, a_args, self.worker_name) - self.start_time = time.time() - self._last_progress_print = -9999 - CLOG.ok(f"已软重启 ffmpeg:{reason}", worker=self.worker_name) - return True - - def run(self): - CLOG.ok("启动", worker=self.worker_name) - try: - while not self._stop_event.is_set(): - try: - self.coordinator.refresh_if_changed() - - # ========== 已在推流:健康检测 ========== - if self.current_proc and self.current_url: - retcode = self.current_proc.poll() - if retcode is not None: - CLOG.error(f"ffmpeg 退出(code={retcode}),切换下一个源…", worker=self.worker_name) - stop_ffmpeg(self.current_proc) - self.coordinator.release(self.current_url, self.key) - self._clear_state() - LOGSTATE.clear_idle_flag(self.worker_name) - continue - - # 检测是否仍在直播(加入“防抖 + 交叉探活”) - still_live = True - is_live_raw = True - try: - port = asyncio.run(fetch_port_info(self.current_url, self.record_quality_code)) - is_live_raw = bool(port.get("is_live")) - except Exception as e: - CLOG.warn(f"检测当前源异常:{e}", worker=self.worker_name) - is_live_raw = True # 网络/接口异常默认不下播 - - if not is_live_raw: - # 交叉探活:若当前 real_url 还能探测成功,认为仍在播 - alive_by_probe = probe_stream_url(self.current_real_url) - if alive_by_probe: - CLOG.warn("API 返回下播但探活成功,视为仍在播。", worker=self.worker_name) - is_live_raw = True - - # 下播防抖:连续 N 次失败 且 距最后一次活跃已超过 M 秒 - if is_live_raw: - self._offline_count = 0 - self._last_live_ts = time.time() - still_live = True - else: - self._offline_count += 1 - if time.time() - self._last_live_ts < 120: - CLOG.warn(f"短期异常 is_live=False({self._offline_count}),已忽略", worker=self.worker_name) - still_live = True - elif self._offline_count < 3: - CLOG.warn(f"疑似下播 {self._offline_count}/3 次,继续观望", worker=self.worker_name) - still_live = True - else: - still_live = False # 多次确认才下播 - - if still_live: - elapsed = int(time.time() - self.start_time) - # 每 60s 打印一次“正在转播” - if elapsed - self._last_progress_print >= 60: - h, m = divmod(elapsed, 3600) - m, s = divmod(m, 60) - dur_str = f"{h:02d}:{m:02d}:{s:02d}" - CLOG.live(f"正在转播({dur_str}):{self.current_platform} | {self.current_anchor}", worker=self.worker_name) - self._last_progress_print = elapsed - - # 定期刷新真实播放地址(token) - try: - if elapsed >= self.refresh_real_url_every and (elapsed % self.refresh_real_url_every) < self.check_interval_live: - new_port = asyncio.run(fetch_port_info(self.current_url, self.record_quality_code)) - new_real_url = select_source_url(self.current_url, new_port) - if new_real_url and new_real_url != self.current_real_url: - CLOG.swap("刷新播放地址(token 变化),软重启 ffmpeg…", worker=self.worker_name) - stop_ffmpeg(self.current_proc) - out_url, v_args, a_args = build_youtube_output_for_key(self.key, self.resolution) - headers = headers_for_platform(self.current_platform, self.current_url) - self.current_proc = launch_ffmpeg(new_real_url, out_url, headers, v_args, a_args, self.worker_name) - self.current_real_url = new_real_url - self.start_time = time.time() - self._last_progress_print = -9999 - time.sleep(self.check_interval_live) - continue - except Exception as e: - CLOG.warn(f"刷新播放地址失败:{e}", worker=self.worker_name) - - # 看门狗:长时间无输出 -> 先软重启,如果软重启失败再释放 - silence = time.time() - getattr(self.current_proc, "last_out_ts", 0) - if elapsed >= self.watchdog_grace and silence > self.watchdog_silence: - CLOG.warn("超过10分钟未见输出,尝试软重启…", worker=self.worker_name) - ok = self._soft_restart_ffmpeg("watchdog silence") - if not ok: - CLOG.warn("软重启失败,释放当前源并切换…", worker=self.worker_name) - stop_ffmpeg(self.current_proc) - self._clear_state(release=True) - LOGSTATE.clear_idle_flag(self.worker_name) - time.sleep(1) - else: - time.sleep(self.check_interval_live) - continue - - time.sleep(self.check_interval_live) - continue - else: - CLOG.off("多次确认主播下播,切换下一个源…", worker=self.worker_name) - stop_ffmpeg(self.current_proc) - self._clear_state(release=True) - LOGSTATE.clear_idle_flag(self.worker_name) - continue - - # ========== 未在推流:尝试分配新源 ========== - got = self.coordinator.acquire_next_live(self.key, self.record_quality_code) - if not got: - LOGSTATE.mark_idle_once(self.worker_name) - time.sleep(self.scan_interval_when_idle) - continue - - LOGSTATE.clear_idle_flag(self.worker_name) - url, port = got - real_url = select_source_url(url, port) - if not real_url: - CLOG.warn(f"未得到播放地址,跳过:{url}", worker=self.worker_name) - self.coordinator.release(url, self.key) - time.sleep(1) - continue - - # 新源上线前:探活(避免刚拿到 URL 就空推) - if not probe_stream_url(real_url): - CLOG.warn(f"播放 URL 探活失败(可能刚开播或接口延迟),暂缓此源:{url}", worker=self.worker_name) - self.coordinator.release(url, self.key) - time.sleep(1) - continue - - self.current_platform = detect_platform(url) - self.current_anchor = port.get("anchor_name", "") - headers = headers_for_platform(self.current_platform, url) - out_url, v_args, a_args = build_youtube_output_for_key(self.key, self.resolution) - self.current_proc = launch_ffmpeg(real_url, out_url, headers, v_args, a_args, self.worker_name) - self.current_url = url - self.current_real_url = real_url - self.start_time = time.time() - self._last_progress_print = -9999 - self._offline_count = 0 - self._last_live_ts = time.time() - - CLOG.ok(f"开始转播:{self.current_platform} | {self.current_anchor} | {url}", worker=self.worker_name) - - except Exception as e: - # 关键:任何内部异常不让线程退出 - CLOG.error(f"内部异常:{e}", worker=self.worker_name) - time.sleep(2) - continue - - finally: - with contextlib.suppress(Exception): - stop_ffmpeg(self.current_proc) - self.coordinator.release(self.current_url, self.key) - CLOG.off("已退出。", worker=self.worker_name) - - def _clear_state(self, release: bool = False): - if release and self.current_url: - self.coordinator.release(self.current_url, self.key) - self.current_url = None - self.current_real_url = None - self.current_proc = None - self.current_platform = "" - self.current_anchor = "" - -# =============== 启动入口(保持原行为) =============== -def check_ffmpeg_existence() -> bool: - try: - result = subprocess.run(['ffmpeg', '-version'], check=True, capture_output=True, text=True) - if result.returncode == 0: - print(result.stdout.splitlines()[0]) # 贴合旧风格:打印版本第一行 - except Exception: - pass - finally: - return bool(check_ffmpeg()) - -# ---- 统一优雅退出 ---- -def graceful_shutdown(signum=None, frame=None): - if not SHUTDOWN_EVENT.is_set(): - print("\n收到中断,正在停止所有 worker 与 ffmpeg …") - SHUTDOWN_EVENT.set() - # 先发 stop 给所有 worker(会内部 stop 当前 ffmpeg) - for w in list(ALL_WORKERS): - with contextlib.suppress(Exception): - w.stop() - # 再兜底停止全局登记的 ffmpeg 子进程 - with ALL_LOCK: - procs = list(ALL_PROCS) - for p in procs: - with contextlib.suppress(Exception): - stop_ffmpeg(p) - -def main(): - print("DouyinRelay", version) - print("-----------------------------------------") - - if not check_ffmpeg_existence(): - logger.error("缺少 ffmpeg,程序退出") - sys.exit(1) - - urls = parse_urls_from_file(url_config_file) - if not urls: - logger.error("URL_config.ini 中没有可用 URL。") - sys.exit(1) - - # keys(并行路数 = keys 数量);兼容单 key - keys = _yt_get_list("youtube", "keys") - if not keys: - single_key = _yt_get("youtube", "key", fallback="", cast=str) - if single_key: - keys = [single_key] - if not keys: - logger.error("youtube.ini 没有配置 [youtube].keys 或 key") - sys.exit(1) - - # 可选:每路分辨率 - per_res = _yt_get_list("youtube", "resolutions") # 如:1920x1080,1080x1920 - while per_res and len(per_res) < len(keys): - per_res.append(per_res[-1]) - - # 打印 ffmpeg 版本(贴合旧风格) - try: - vline = subprocess.run(['ffmpeg', '-version'], check=True, capture_output=True, text=True).stdout.splitlines()[0] - print(vline) - except Exception: - pass - - coordinator = UrlCoordinator(url_config_file) - - # 信号处理(Ctrl+C / kill) - with contextlib.suppress(Exception): - signal.signal(signal.SIGINT, graceful_shutdown) - with contextlib.suppress(Exception): - signal.signal(signal.SIGTERM, graceful_shutdown) - - # 启动多路(Worker 前缀区分) - workers: List[StreamWorker] = [] - for i, key in enumerate(keys, start=1): - res_for_this = per_res[i-1] if per_res else None - w = StreamWorker(worker_id=i, key=key, resolution=res_for_this, coordinator=coordinator) - w.start() - workers.append(w) - ALL_WORKERS.append(w) - - try: - while not SHUTDOWN_EVENT.is_set(): - time.sleep(0.5) - except KeyboardInterrupt: - graceful_shutdown() - finally: - graceful_shutdown() - for w in workers: - w.join(timeout=10) - print("全部 worker 已退出。") - -if __name__ == "__main__": - main() diff --git a/back/mult8 b/back/mult8 deleted file mode 100644 index 07507b0..0000000 --- a/back/mult8 +++ /dev/null @@ -1,1105 +0,0 @@ -# -*- encoding: utf-8 -*- - -""" -并行转推(稳定性+体验增强版,保持原业务逻辑与对外接口) -- 核心修复:降低“误判下播”的概率(抖音仍在播但 API 偶发失败) - * fetch_port_info:加入重试(3 次,指数退避),不改签名/调用处 - * “下播防抖”:只有在“连续N次 is_live=False 且最近M秒没有任何活跃迹象”才判定下播 - * 交叉验证:若 is_live=False,但当前 real_url 可探测(探活成功),则判为仍在播 -- FFmpeg 看门狗:卡顿/长时间无输出 -> 先软重启(保留 URL 占用),再必要时释放 -- 细节增强:日志更明确,解释每次判定原因;优雅退出确保无僵尸进程 -- 兼容性:不改配置文件/外部模块(spider、stream 等)的接口与行为 -- 修复:确保函数定义顺序,避免 NameError -""" - -import asyncio -import os -import sys -import signal -import time -import datetime -import re -import uuid -import threading -import subprocess -from typing import Any, List, Tuple, Optional, Dict, Set -import contextlib -import configparser -import urllib.request -import urllib.error -import io -import logging -from logging.handlers import RotatingFileHandler - -# ======================= 颜色日志(Windows Cmder 友好) ======================= -def _enable_vt_on_windows(): - """在 Windows 控制台启用 ANSI VT(Cmder/CMD/PowerShell 颜色支持)""" - if os.name != "nt": - return - try: - import msvcrt # noqa: F401 - import ctypes - kernel32 = ctypes.windll.kernel32 - handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE = -11 - mode = ctypes.c_uint32() - if kernel32.GetConsoleMode(handle, ctypes.byref(mode)): - mode.value |= 0x0004 # ENABLE_VIRTUAL_TERMINAL_PROCESSING - kernel32.SetConsoleMode(handle, mode) - except Exception: - pass - -_enable_vt_on_windows() - -class ColorLog: - # ANSI colors - RESET = "\033[0m" - GREY = "\033[90m" - RED = "\033[91m" - GREEN = "\033[92m" - YELLOW = "\033[93m" - BLUE = "\033[94m" - MAGENTA = "\033[95m" - CYAN = "\033[96m" - WHITE = "\033[97m" - - ICONS = { - "INFO": "🟢", - "WARN": "🟡", - "ERROR": "🔴", - "DEBUG": "⚪", - "OK": "✅", - "SWAP": "🔁", - "LIVE": "📡", - "OFF": "📴", - "FFMPEG":"🎬", - "WATCH": "👀", - } - - COLORS = { - "INFO": GREEN, - "OK": GREEN, - "WARN": YELLOW, - "ERROR": RED, - "DEBUG": GREY, - "SWAP": CYAN, - "LIVE": BLUE, - "OFF": RED, - "FFMPEG": MAGENTA, - "WATCH": CYAN, - } - - def __init__(self, log_file_path: str = "logs/runtime.log", max_bytes: int = 5*1024*1024, backup: int = 3): - os.makedirs(os.path.dirname(log_file_path), exist_ok=True) - - self._logger = logging.getLogger("Runtime") - self._logger.setLevel(logging.INFO) - self._logger.propagate = False - # 控制台 Handler(给 src.utils.logger 让位,这里只用于文件) - if not self._logger.handlers: - file_handler = RotatingFileHandler(log_file_path, maxBytes=max_bytes, backupCount=backup, encoding="utf-8") - fmt = logging.Formatter("%(asctime)s | %(levelname)-5s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S") - file_handler.setFormatter(fmt) - self._logger.addHandler(file_handler) - - def _stamp(self) -> str: - return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - - def _compose(self, level: str, msg: str, worker: Optional[str] = None) -> str: - icon = self.ICONS.get(level, "") - color = self.COLORS.get(level, self.WHITE) - prefix = f"[{self._stamp()}]" - if worker: - prefix += f"[{worker}]" - return f"{prefix} {color}[{icon} {level}]{self.RESET} {msg}" - - def info(self, msg: str, worker: Optional[str] = None): - text = self._compose("INFO", msg, worker) - print(text) - self._logger.info(msg) - - def ok(self, msg: str, worker: Optional[str] = None): - text = self._compose("OK", msg, worker) - print(text) - self._logger.info(msg) - - def warn(self, msg: str, worker: Optional[str] = None): - text = self._compose("WARN", msg, worker) - print(text) - self._logger.warning(msg) - - def error(self, msg: str, worker: Optional[str] = None): - text = self._compose("ERROR", msg, worker) - print(text, file=sys.stderr) - self._logger.error(msg) - - def debug(self, msg: str, worker: Optional[str] = None): - text = self._compose("DEBUG", msg, worker) - print(text) - self._logger.debug(msg) - - def swap(self, msg: str, worker: Optional[str] = None): - text = self._compose("SWAP", msg, worker) - print(text) - self._logger.info("[SWAP] " + msg) - - def live(self, msg: str, worker: Optional[str] = None): - text = self._compose("LIVE", msg, worker) - print(text) - self._logger.info("[LIVE] " + msg) - - def off(self, msg: str, worker: Optional[str] = None): - text = self._compose("OFF", msg, worker) - print(text) - self._logger.info("[OFF] " + msg) - -CLOG = ColorLog() # 全局彩色日志 - -# ======================= 这里开始是原有工程的 import =========================== -from src import spider, stream -from src.utils import logger, Color, check_md5 -from ffmpeg_install import check_ffmpeg, ffmpeg_path, current_env_path - -# =============== 重要:先定义 build_youtube_output_for_key,避免 NameError =============== -# 读取 YouTube 配置(需要在函数内部访问 _yt_get,故先声明占位;稍后会正式赋值) -_yt_cfg: Optional[configparser.ConfigParser] = None - -def _safe_int(v, lo, hi, defv): - try: - v = int(v) - if v < lo or v > hi: return defv - return v - except Exception: - return defv - -def _round_res(w: int, h: int) -> Tuple[int, int]: - # 偶数对齐,避免 1088x1920 类问题 - return (w & ~1, h & ~1) - -def _build_youtube_rtmp_url(key: str, secure: bool, ingest: str | None): - node = (ingest or "a").strip().lower() - node = node if node.isalpha() and len(node) == 1 else "a" - scheme = "rtmps" if secure else "rtmp" - host = f"{node}.rtmps.youtube.com" if secure else f"{node}.rtmp.youtube.com" - return f"{scheme}://{host}/live2/{key}" - -def build_youtube_output_for_key(key: str, resolution: Optional[str]) -> Tuple[str, list, list]: - """ - !!!此函数放在文件靠前位置,确保任何地方调用都不再出现 NameError。 - 与旧版保持一致;仅对异常进行更稳妥处理与注释增强。 - """ - if not key: - logger.error("错误: youtube.ini 未配置 key/keys") - sys.exit(1) - - secure = _yt_get("youtube", "secure", fallback=False, cast=bool) - ingest = _yt_get("youtube", "ingest", fallback="a", cast=str) - res_str = (resolution or _yt_get("youtube", "resolution", fallback="", cast=str) or "").strip() - - bv = _safe_int(_yt_get("youtube", "bitrate", fallback=5000, cast=int), 300, 20000, 5000) - ba = _safe_int(_yt_get("youtube", "audio_bitrate", fallback=128, cast=int), 64, 384, 128) - fps = _safe_int(_yt_get("youtube", "fps", fallback=30, cast=int), 10, 60, 30) - gop = _safe_int(_yt_get("youtube", "gop", fallback=2*fps, cast=int), fps, 4*fps, 2*fps) - - out_url = _build_youtube_rtmp_url(key, secure, ingest) - - v_args = [ - "-c:v", "libx264", - "-preset", "veryfast", - "-tune", "zerolatency", - "-profile:v", "high", - "-level:v", "4.2", - "-pix_fmt", "yuv420p", - "-r", str(fps), - "-g", str(gop), - "-b:v", f"{bv}k", - "-maxrate", f"{bv}k", - "-bufsize", f"{max(2*bv, 4000)}k", - ] - - # v_args = [ - # "-hwaccel", "qsv", # 启用硬件加速解码 - # "-c:v", "h264_qsv", # 使用 QSV H.264 解码 - # "-i", input_file, # 输入文件/流 - # "-c:v", "h264_qsv", # 使用 QSV H.264 编码 - # "-b:v", f"{bv}k", # 目标码率 - # "-maxrate", f"{bv}k", # 最大码率 - # "-bufsize", f"{max(2*bv, 4000)}k", # 缓冲区 - # "-r", str(fps), # 帧率 - # "-g", str(gop), # GOP 长度 - # "-pix_fmt", "nv12", # QSV 支持的像素格式 - # "-profile:v", "high", # High Profile - # "-level:v", "4.1", # Level 4.1,N5105 支持 - # "-f", "flv", # 输出协议(YouTube/RTMP) - # ] - - # v_args = [ - # "-c:v", "libx264", # CPU 编码 H.264 - # "-preset", "superfast", # 性能优先,速度快,CPU 占用低 - # "-tune", "zerolatency", # 低延迟模式,适合直播 - # "-profile:v", "baseline", # baseline 比 high 更轻量,N5105 CPU 更适合 - # "-level:v", "3.1", # 与 baseline 匹配,低复杂度 - # "-pix_fmt", "yuv420p", # 抖音/YouTube 通用 - # "-r", str(fps), # 帧率保持原始 - # "-g", str(gop), # GOP 与帧率匹配,避免过多 I 帧 - # "-b:v", f"{bv}k", # 目标码率 - # "-maxrate", f"{bv}k", - # "-bufsize", f"{max(2*bv, 2000)}k", # 缓冲,CPU轻量化 - # "-tune", "fastdecode", # 优化解码速度,直播客户端更流畅 - # "-x264opts", "no-mbtree:threads=4" # 禁用 mbtree 减少 CPU,固定线程数 - # ] - - a_args = [ - "-c:a", "aac", - "-b:a", f"{ba}k", - "-ar", "44100", - "-ac", "2", - ] - - if res_str: - try: - w, h = map(int, res_str.lower().split("x")) - w, h = _round_res(w, h) - vf = ( - f"scale={w}:{h}:force_original_aspect_ratio=decrease," - f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2," - f"crop={w}:{h}," - f"format=yuv420p" - ) - v_args = ["-vf", vf] + v_args - except Exception: - logger.warning(f"resolution '{res_str}' 非法,忽略") - - return out_url, v_args, a_args - -# =============== 基础环境 =============== -version = "vrelay-parallel-2.1.1-pro" -os.environ['PATH'] = ffmpeg_path + os.pathsep + current_env_path -script_path = os.path.split(os.path.realpath(sys.argv[0]))[0] -config_file = f'{script_path}/config/config.ini' -url_config_file = f'{script_path}/config/URL_config.ini' -yt_ini_file = f"{script_path}/config/youtube.ini" -text_encoding = 'utf-8-sig' -color = Color() - -# ---- 全局 Worker & 进程注册(用于 Ctrl+C 干净退出) ---- -ALL_WORKERS: List["StreamWorker"] = [] -ALL_PROCS: Set[subprocess.Popen] = set() -SHUTDOWN_EVENT = threading.Event() -ALL_LOCK = threading.Lock() - -# =============== 读取 YouTube 配置 =============== -_yt_cfg = configparser.ConfigParser() -with open(yt_ini_file, "r", encoding="utf-8-sig") as f: - _yt_cfg.read_file(f) - -def _yt_get(section: str, key: str, fallback=None, cast=str): - try: - if cast is bool: - return _yt_cfg.getboolean(section, key, fallback=fallback) - if cast is int: - return _yt_cfg.getint(section, key, fallback=fallback) - return _yt_cfg.get(section, key, fallback=fallback) - except Exception: - return fallback - -def _yt_get_list(section: str, key: str) -> List[str]: - raw = _yt_get(section, key, fallback="", cast=str) or "" - parts = re.split(r"[,;\s]+", raw.strip()) - return [p for p in parts if p] - -# =============== 工具函数(原版保留,已前置到文件顶部附近) =============== -def now_str(): - return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - -# =============== 读取运行配置 =============== -def read_config_value(cfg: configparser.RawConfigParser, section: str, option: str, default_value: Any) -> Any: - try: - cfg.read(config_file, encoding=text_encoding) - if section not in cfg.sections(): - cfg.add_section(section) - return cfg.get(section, option) - except (configparser.NoSectionError, configparser.NoOptionError): - cfg.set(section, option, str(default_value)) - with open(config_file, 'w', encoding=text_encoding) as f: - cfg.write(f) - return default_value - -_CN_BOOL = {"是": True, "否": False} -cfg = configparser.RawConfigParser() -use_proxy = _CN_BOOL.get(read_config_value(cfg, '录制设置', '是否使用代理ip(是/否)', "否"), False) -proxy_addr = read_config_value(cfg, '录制设置', '代理地址', "") -proxy_addr = proxy_addr if use_proxy and proxy_addr.strip() else None -dy_cookie = read_config_value(cfg, 'Cookie', '抖音cookie', '') -tiktok_cookie = read_config_value(cfg, 'Cookie', 'tiktok_cookie', '') - -# 启动时连通性提示(仅提示,不改变逻辑) -try: - if not use_proxy: - urllib.request.urlopen("https://www.google.com/", timeout=5) - CLOG.info("你的网络似乎直连可用(无需代理)。") -except Exception: - if not use_proxy: - print("\033[93m提示:无法直连海外站,如需 TikTok 请在 config.ini 开启并填写代理地址。\033[0m") - -# =============== URL 列表 =============== -def parse_urls_from_file(path: str) -> List[str]: - urls, seen = [], set() - if not os.path.isfile(path): - os.makedirs(os.path.dirname(path), exist_ok=True) - open(path, 'w', encoding=text_encoding).close() - print("URL_config.ini 为空,请写入直播间地址后再运行。") - return urls - with open(path, "r", encoding=text_encoding, errors='ignore') as f: - for line in f: - s = line.strip() - if not s or s.startswith("#"): - continue - parts = re.split(r"[,,]\s*", s) - cand = None - for p in parts: - if '://' in p or re.match(r"^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", p): - cand = p - break - if not cand: - continue - url = cand if '://' in cand else f"https://{cand}" - if url not in seen: - seen.add(url) - urls.append(url) - return urls - -# =============== 解析与选源 =============== -def is_flv_preferred_platform(link: str) -> bool: - return any(i in link for i in ["douyin", "tiktok"]) - -def select_source_url(link: str, stream_info: dict) -> Optional[str]: - """ - 选源优先级保持不变:抖音/TikTok 优先 FLV(且避免 H265),否则 record_url/m3u8/flv。 - """ - if not stream_info: - return None - if is_flv_preferred_platform(link): - flv = stream_info.get("flv_url") - if flv: - try: - from urllib.parse import urlparse, parse_qs - qs = parse_qs(urlparse(flv).query or "") - if (qs.get("codec") or [""])[0].lower() == "h265": - logger.warning("FLV 不支持 h265,改用 HLS。") - else: - return flv - except Exception: - return flv - return stream_info.get("record_url") or stream_info.get("m3u8_url") or stream_info.get("flv_url") - -# =============== 辅助:探测播放 URL 是否可用(用于误判纠正) =============== -def _open_with_proxy(url: str, timeout: float = 5.0): - """ - 使用 urllib 在当前代理环境下打开 URL。 - 仅抓取少量字节用于“活跃性探测”,避免大流量。 - """ - handlers = [] - if proxy_addr: - handlers.append(urllib.request.ProxyHandler({"http": proxy_addr, "https": proxy_addr})) - opener = urllib.request.build_opener(*handlers) if handlers else urllib.request.build_opener() - req = urllib.request.Request( - url, - headers={ - "User-Agent": "Mozilla/5.0 (Linux; Android 11; SM-G973U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Mobile Safari/537.36", - "Accept": "*/*", - "Connection": "close", - } - ) - return opener.open(req, timeout=timeout) - -def probe_stream_url(real_url: Optional[str], timeout: float = 5.0) -> bool: - """ - 轻量级“探活”: - - m3u8: 取前若干字节并检查 "#EXTM3U" - - flv: 取前3字节是否 "FLV" - - 其它:只要 200 且有数据即可视为“活着” - 失败不抛异常,返回 False - """ - if not real_url: - return False - try: - with contextlib.closing(_open_with_proxy(real_url, timeout=timeout)) as resp: - code = getattr(resp, "status", None) or getattr(resp, "code", None) - if code and int(code) >= 400: - return False - head = resp.read(256) # 少量字节足够判定格式 - u = real_url.lower() - if ".m3u8" in u: - return b"#EXTM3U" in head.upper() - if ".flv" in u: - return head.startswith(b"FLV") - # 其它类型:有数据即认为仍在 - return bool(head) - except Exception: - return False - -# =============== 解析与选源(抖音/TikTok/自定义) =============== -# 修复点①:增强 fetch_port_info —— 重试 + 指数退避(不改签名/调用处) -import random, json, contextlib, urllib.request, urllib.error, re, time - -async def fetch_port_info(url: str, record_quality_code: str = "OD") -> dict: - """抖音/TikTok 直播解析(带防风控与 JSON fallback)""" - proxy = proxy_addr - delay = 1.2 - for attempt in range(1, 4): - try: - if "douyin.com" in url: - # ======= 原始接口 ======= - if 'v.douyin.com' not in url and '/user/' not in url: - j = await spider.get_douyin_stream_data(url=url, proxy_addr=proxy, cookies=dy_cookie) - else: - j = await spider.get_douyin_app_stream_data(url=url, proxy_addr=proxy, cookies=dy_cookie) - port_info = await stream.get_douyin_stream_url(j, record_quality_code, proxy) - - # ======= fallback:接口空时解析 HTML 中 JSON ======= - if not port_info.get("is_live"): - handlers = [] - if proxy: - handlers.append(urllib.request.ProxyHandler({"http": proxy, "https": proxy})) - opener = urllib.request.build_opener(*handlers) if handlers else urllib.request.build_opener() - headers = { - "User-Agent": random.choice([ - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127 Safari/537.36", - "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile Safari/604.1", - ]), - "Referer": f"https://live.douyin.com/{re.sub(r'\\D','',url)}", - "Accept": "*/*", - } - req = urllib.request.Request(url, headers=headers) - with contextlib.closing(opener.open(req, timeout=6)) as resp: - html = resp.read(512*1024).decode("utf-8", errors="ignore") - - # 解析 roomStore JSON - m = re.search(r'"roomStore"\s*:\s*({.*?})\s*,\s*"userStore"', html) - if m: - try: - data = json.loads(m.group(1)) - stream_data = data.get("roomInfo", {}).get("stream_url", {}) - flv = (stream_data.get("flv_pull_url", {}).get("FULL_HD1") - or stream_data.get("flv_pull_url", {}).get("HD1")) - m3u8 = (stream_data.get("hls_pull_url", {}).get("FULL_HD1") - or stream_data.get("hls_pull_url", {}).get("HD1")) - if flv or m3u8: - port_info = {"anchor_name": "JSONFallback", "is_live": True} - if flv: port_info["flv_url"] = flv - else: port_info["m3u8_url"] = m3u8 - CLOG.warn("⚠️ JSON fallback 成功解析流地址", worker=None) - except Exception as e: - CLOG.debug(f"JSON fallback 解析失败: {e}") - - elif "tiktok.com" in url: - if proxy: - j = await spider.get_tiktok_stream_data(url=url, proxy_addr=proxy, cookies=tiktok_cookie) - port_info = await stream.get_tiktok_stream_url(j, record_quality_code, proxy) - else: - port_info = {"anchor_name": "", "is_live": False} - logger.error("TikTok 需要代理。") - - elif (".m3u8" in url) or (".flv" in url): - port_info = {"anchor_name": "自定义", "is_live": True} - port_info["flv_url" if url.endswith(".flv") else "m3u8_url"] = url - - else: - port_info = {"anchor_name": "", "is_live": False} - - return port_info - - except urllib.error.HTTPError as e: - if e.code == 403: - CLOG.warn(f"[fetch_port_info] Douyin 403 第{attempt}次延时重试") - time.sleep(delay) - delay *= 2 - continue - CLOG.error(f"[fetch_port_info] HTTP {e.code}: {url}") - break - except Exception as e: - CLOG.warn(f"[fetch_port_info] 异常: {e}") - time.sleep(delay) - delay *= 2 - - return {"anchor_name": "", "is_live": False} - -# =============== 启动/停止 ffmpeg =============== -def launch_ffmpeg(input_url: str, - out_url: str, - extra_headers: Optional[str], - v_args: list, - a_args: list, - worker_name: str) -> subprocess.Popen: - user_agent = ("Mozilla/5.0 (Linux; Android 11; SM-G973U) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/87.0.4280.141 Mobile Safari/537.36") - # 输入侧容错/重连/探测 - base = [ - "ffmpeg", "-nostdin", "-hide_banner", - "-stats", - "-stats_period", "60", - "-progress", "pipe:1", - "-loglevel", "info", - - # 输入容错 & 自动重连 - "-rw_timeout", "15000000", - "-timeout", "10000000", - "-reconnect", "1", - "-reconnect_streamed", "1", - "-reconnect_at_eof", "1", - "-reconnect_delay_max", "30", - - # UA/协议白名单 - "-user_agent", user_agent, - "-protocol_whitelist", "file,http,https,tcp,tls,crypto,udp,rtmp,rtp,httpproxy", - - # 缓冲 & 探测 - "-thread_queue_size", "4096", - "-analyzeduration", "10000000", - "-probesize", "10000000", - ] - - # 平台 headers(referer/origin)要在 -i 之前 - if extra_headers: - base += ["-headers", extra_headers] - - # 代理:http_proxy/https_proxy 环境变量(同时保留 -http_proxy 兼容) - env = os.environ.copy() - if proxy_addr: - env["http_proxy"] = proxy_addr - env["https_proxy"] = proxy_addr - base = ["ffmpeg", "-http_proxy", proxy_addr] + base[1:] - - # 输入 - base += ["-re", "-i", input_url] - - # 通用容错 + 时间戳修复(优化版) - base += [ - "-fflags", "+genpts+igndts+flush_packets", - "-use_wallclock_as_timestamps", "1", - "-avioflags", "direct", - "-rtbufsize", "100M", - "-err_detect", "ignore_err", - "-avoid_negative_ts", "make_zero", - ] - - # 视频/音频编码参数(使用 build_youtube_output 的结果) - v_args = v_args or [] - a_args = a_args or [] - base += v_args + a_args - - # 输出(flv 到 YT,更宽容) - base += ["-f", "flv", "-flvflags", "no_duration_filesize", out_url] - - cmd_str = " ".join(base) - # 控制台+文件日志 - CLOG.live(f"FFmpeg CMD({worker_name}): {cmd_str if len(cmd_str) < 500 else cmd_str[:500] + ' ...'}", worker=worker_name) - logger.info(f"{worker_name} 启动推流:{input_url} -> {out_url}") - - creationflags = 0 - if os.name == "nt": - creationflags = subprocess.CREATE_NEW_PROCESS_GROUP - - p = subprocess.Popen( - base, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - bufsize=0, - env=env, - creationflags=creationflags - ) - p.last_out_ts = time.time() - - # ---- 注册进程,便于 Ctrl+C 统一清理 ---- - with ALL_LOCK: - ALL_PROCS.add(p) - - def _reader(): - try: - for line in iter(p.stdout.readline, b''): - if not line: - break - text = line.decode(errors="ignore").strip() - p.last_out_ts = time.time() - - # 只保留关键统计/错误(前缀带 Worker) - if ("bitrate=" in text) or ("frame=" in text) or ("speed=" in text) or ("libx264" in text): - CLOG.info(f"[ffmpeg统计] {text}", worker=worker_name) - if "Server disconnected" in text: - CLOG.error(f"[输出异常] {text}", worker=worker_name) - elif ("not supported" in text) or ("Invalid" in text) or ("invalid" in text): - CLOG.error(f"[转码异常] {text}", worker=worker_name) - except Exception: - pass - - threading.Thread(target=_reader, daemon=True).start() - return p - -def stop_ffmpeg(p: Optional[subprocess.Popen]): - if not p: return - try: - if os.name == "nt": - if p.stdin: - with contextlib.suppress(Exception): - p.stdin.write(b"q"); p.stdin.flush() - p.wait(timeout=6) - else: - p.send_signal(signal.SIGINT) - p.wait(timeout=10) - except Exception: - with contextlib.suppress(Exception): - p.kill() - finally: - # ---- 从全局集合移除 ---- - with ALL_LOCK: - ALL_PROCS.discard(p) - -# =============== 平台 headers/识别 =============== -def headers_for_platform(platform_name: str, live_url: str) -> Optional[str]: - live_domain = '/'.join(live_url.split('/')[0:3]) - record_headers = { - 'PandaTV': 'origin:https://www.pandalive.co.kr', - 'WinkTV': 'origin:https://www.winktv.co.kr', - 'PopkonTV': 'origin:https://www.popkontv.com', - 'FlexTV': 'origin:https://www.flextv.co.kr', - '千度热播': 'referer:https://qiandurebo.com', - '17Live': 'referer:https://17.live/en/live/6302408', - '浪Live': 'referer:https://www.lang.live', - 'shopee': f'origin:{live_domain}', - 'Blued直播': 'referer:https://app.blued.cn' - } - return record_headers.get(platform_name) - -def detect_platform(url: str) -> str: - if "douyin.com" in url: return "抖音直播" - if "tiktok.com" in url: return "TikTok直播" - if url.endswith(".flv") or url.endswith(".m3u8"): return "自定义录制直播" - return "未知平台" - -# =============== 日志状态缓存(去重/限频) =============== -class LogState: - def __init__(self): - self.lock = threading.Lock() - self.url_status: Dict[str, str] = {} # url -> "unknown/offline/online/streaming" - self.no_source_worker: Dict[str, bool] = {} # worker_name -> idle printed flag - - def set_status(self, url: str, status: str, text: Optional[str] = None, prefix: str = ""): - with self.lock: - prev = self.url_status.get(url, "unknown") - if prev != status: - self.url_status[url] = status - if text: - print(f"[{now_str()}]{prefix} {text}") - - def mark_idle_once(self, worker_name: str): - with self.lock: - if not self.no_source_worker.get(worker_name, False): - CLOG.debug("当前无可用直播源,等待中…", worker=worker_name) - self.no_source_worker[worker_name] = True - - def clear_idle_flag(self, worker_name: str): - with self.lock: - self.no_source_worker[worker_name] = False - -LOGSTATE = LogState() - -# =============== URL 协调器(url 级独占,保持原顺序) =============== -class UrlCoordinator: - """ - - urls 顺序分配 - - in_use_urls: url 级别独占(防止同一 url 被不同 worker 同时占用) - """ - def __init__(self, path: str): - self.path = path - self.urls: List[str] = parse_urls_from_file(path) - self.idx: int = 0 - self.lock = threading.Lock() - self.last_md5 = check_md5(path) - self.in_use_urls: Set[str] = set() - - def refresh_if_changed(self): - try: - md5 = check_md5(self.path) - if md5 != self.last_md5: - self.urls = parse_urls_from_file(self.path) or self.urls - self.last_md5 = md5 - CLOG.info(f"已重载 URL 列表,数量:{len(self.urls)}") - except Exception as e: - logger.warning(f"URL 列表热更新失败:{e}") - - def acquire_next_live(self, worker_key: str, quality_code: str = "OD") -> Optional[Tuple[str, dict]]: - """尝试遍历一轮 URL 表,找到首个在播的;否则返回 None。""" - with self.lock: - total = len(self.urls) - if total == 0: - return None - - tried = 0 - while tried < total: - with self.lock: - url = self.urls[self.idx] - self.idx = (self.idx + 1) % total - - if url in self.in_use_urls: - tried += 1 - continue - - # 锁外解析(避免阻塞) - port = asyncio.run(fetch_port_info(url, quality_code)) - anchor = port.get('anchor_name', '') - if not port.get("anchor_name"): - LOGSTATE.set_status(url, "unknown", text=f"解析失败:{url}") - tried += 1 - continue - if not port.get("is_live"): - LOGSTATE.set_status(url, "offline", text=f"未开播:{anchor} | {url}") - tried += 1 - continue - - # 回到锁内占用 - with self.lock: - if url in self.in_use_urls: - tried += 1 - continue - self.in_use_urls.add(url) - - LOGSTATE.set_status(url, "online", text=f"已开播:{anchor} | {url}") - return url, port - - return None - - def release(self, url: Optional[str], worker_key: Optional[str]): - if not url: - return - with self.lock: - self.in_use_urls.discard(url) - -# =============== Worker(保持原业务逻辑框架,加入防抖与探活) =============== -class StreamWorker(threading.Thread): - def __init__(self, worker_id: int, key: str, resolution: Optional[str], coordinator: UrlCoordinator): - super().__init__(daemon=True) - self.worker_id = worker_id - self.key = key - self.worker_name = f"Worker-{worker_id}" - self.resolution = resolution - self.coordinator = coordinator - - self.current_url: Optional[str] = None - self.current_real_url: Optional[str] = None - self.current_proc: Optional[subprocess.Popen] = None - self.current_platform = "" - self.current_anchor = "" - self.record_quality_code = "OD" - - # 与原逻辑一致的参数(稳定优先) - self.watchdog_grace = 120 # 推流启动后宽限期 - self.watchdog_silence = 600 # 10 分钟无输出视为卡死 - self.refresh_real_url_every = 300 # 5 分钟刷新一次真实地址(token 可能过期) - self.check_interval_live = 8 # 在播时的检测间隔 - self.scan_interval_when_idle = 2 # 空闲时的扫描间隔 - - self._last_progress_print = -9999 - self._stop_event = threading.Event() - self.start_time = time.time() - - # 修复点②:新增下播防抖状态(连续多次 is_live=False 才判定) - self._offline_count = 0 - self._last_live_ts = time.time() - - # 软重启冷却(避免频繁重启) - self._last_soft_restart_ts = 0.0 - self._soft_restart_min_interval = 90.0 - - def stop(self): - """收到停止请求时,尽快停掉当前 ffmpeg。""" - self._stop_event.set() - with contextlib.suppress(Exception): - stop_ffmpeg(self.current_proc) - - def _soft_restart_ffmpeg(self, reason: str): - """ - 尝试“软重启”ffmpeg:仅重启进程,不释放 URL 占用,优先保持连续性。 - """ - now = time.time() - if now - self._last_soft_restart_ts < self._soft_restart_min_interval: - CLOG.warn(f"跳过软重启(冷却中): {reason}", worker=self.worker_name) - return False - self._last_soft_restart_ts = now - - try: - stop_ffmpeg(self.current_proc) - except Exception: - pass - - out_url, v_args, a_args = build_youtube_output_for_key(self.key, self.resolution) - headers = headers_for_platform(self.current_platform, self.current_url or "") - if not self.current_real_url: - # 尝试重新解析一遍 - new_port = asyncio.run(fetch_port_info(self.current_url, self.record_quality_code)) if self.current_url else {} - self.current_real_url = select_source_url(self.current_url or "", new_port) - if not self.current_real_url: - CLOG.error("软重启失败:无法获取播放 URL。", worker=self.worker_name) - return False - - self.current_proc = launch_ffmpeg(self.current_real_url, out_url, headers, v_args, a_args, self.worker_name) - self.start_time = time.time() - self._last_progress_print = -9999 - CLOG.ok(f"已软重启 ffmpeg:{reason}", worker=self.worker_name) - return True - - def run(self): - CLOG.ok("启动", worker=self.worker_name) - try: - while not self._stop_event.is_set(): - try: - self.coordinator.refresh_if_changed() - - # ========== 已在推流:健康检测 ========== - if self.current_proc and self.current_url: - retcode = self.current_proc.poll() - if retcode is not None: - CLOG.error(f"ffmpeg 退出(code={retcode}),切换下一个源…", worker=self.worker_name) - stop_ffmpeg(self.current_proc) - self.coordinator.release(self.current_url, self.key) - self._clear_state() - LOGSTATE.clear_idle_flag(self.worker_name) - continue - - # 检测是否仍在直播(加入“防抖 + 交叉探活”) - still_live = True - is_live_raw = True - try: - port = asyncio.run(fetch_port_info(self.current_url, self.record_quality_code)) - is_live_raw = bool(port.get("is_live")) - except Exception as e: - CLOG.warn(f"检测当前源异常:{e}", worker=self.worker_name) - is_live_raw = True # 网络/接口异常默认不下播 - - if not is_live_raw: - # 交叉探活:若当前 real_url 还能探测成功,认为仍在播 - alive_by_probe = probe_stream_url(self.current_real_url) - if alive_by_probe: - CLOG.warn("API 返回下播但探活成功,视为仍在播。", worker=self.worker_name) - is_live_raw = True - - # 下播防抖:连续 N 次失败 且 距最后一次活跃已超过 M 秒 - if is_live_raw: - self._offline_count = 0 - self._last_live_ts = time.time() - still_live = True - else: - self._offline_count += 1 - if time.time() - self._last_live_ts < 120: - CLOG.warn(f"短期异常 is_live=False({self._offline_count}),已忽略", worker=self.worker_name) - still_live = True - elif self._offline_count < 3: - CLOG.warn(f"疑似下播 {self._offline_count}/3 次,继续观望", worker=self.worker_name) - still_live = True - else: - still_live = False # 多次确认才下播 - - if still_live: - elapsed = int(time.time() - self.start_time) - # 每 60s 打印一次“正在转播” - if elapsed - self._last_progress_print >= 60: - h, m = divmod(elapsed, 3600) - m, s = divmod(m, 60) - dur_str = f"{h:02d}:{m:02d}:{s:02d}" - CLOG.live(f"正在转播({dur_str}):{self.current_platform} | {self.current_anchor}", worker=self.worker_name) - self._last_progress_print = elapsed - - # 定期刷新真实播放地址(token) - try: - if elapsed >= self.refresh_real_url_every and (elapsed % self.refresh_real_url_every) < self.check_interval_live: - new_port = asyncio.run(fetch_port_info(self.current_url, self.record_quality_code)) - new_real_url = select_source_url(self.current_url, new_port) - if new_real_url and new_real_url != self.current_real_url: - CLOG.swap("刷新播放地址(token 变化),软重启 ffmpeg…", worker=self.worker_name) - stop_ffmpeg(self.current_proc) - out_url, v_args, a_args = build_youtube_output_for_key(self.key, self.resolution) - headers = headers_for_platform(self.current_platform, self.current_url) - self.current_proc = launch_ffmpeg(new_real_url, out_url, headers, v_args, a_args, self.worker_name) - self.current_real_url = new_real_url - self.start_time = time.time() - self._last_progress_print = -9999 - time.sleep(self.check_interval_live) - continue - except Exception as e: - CLOG.warn(f"刷新播放地址失败:{e}", worker=self.worker_name) - - # 看门狗:长时间无输出 -> 先软重启,如果软重启失败再释放 - silence = time.time() - getattr(self.current_proc, "last_out_ts", 0) - if elapsed >= self.watchdog_grace and silence > self.watchdog_silence: - CLOG.warn("超过10分钟未见输出,尝试软重启…", worker=self.worker_name) - ok = self._soft_restart_ffmpeg("watchdog silence") - if not ok: - CLOG.warn("软重启失败,释放当前源并切换…", worker=self.worker_name) - stop_ffmpeg(self.current_proc) - self._clear_state(release=True) - LOGSTATE.clear_idle_flag(self.worker_name) - time.sleep(1) - else: - time.sleep(self.check_interval_live) - continue - - time.sleep(self.check_interval_live) - continue - else: - CLOG.off("多次确认主播下播,切换下一个源…", worker=self.worker_name) - stop_ffmpeg(self.current_proc) - self._clear_state(release=True) - LOGSTATE.clear_idle_flag(self.worker_name) - continue - - # ========== 未在推流:尝试分配新源 ========== - got = self.coordinator.acquire_next_live(self.key, self.record_quality_code) - if not got: - LOGSTATE.mark_idle_once(self.worker_name) - time.sleep(self.scan_interval_when_idle) - continue - - LOGSTATE.clear_idle_flag(self.worker_name) - url, port = got - real_url = select_source_url(url, port) - if not real_url: - CLOG.warn(f"未得到播放地址,跳过:{url}", worker=self.worker_name) - self.coordinator.release(url, self.key) - time.sleep(1) - continue - - # 新源上线前:探活(避免刚拿到 URL 就空推) - if not probe_stream_url(real_url): - CLOG.warn(f"播放 URL 探活失败(可能刚开播或接口延迟),暂缓此源:{url}", worker=self.worker_name) - self.coordinator.release(url, self.key) - time.sleep(1) - continue - - self.current_platform = detect_platform(url) - self.current_anchor = port.get("anchor_name", "") - headers = headers_for_platform(self.current_platform, url) - out_url, v_args, a_args = build_youtube_output_for_key(self.key, self.resolution) - self.current_proc = launch_ffmpeg(real_url, out_url, headers, v_args, a_args, self.worker_name) - self.current_url = url - self.current_real_url = real_url - self.start_time = time.time() - self._last_progress_print = -9999 - self._offline_count = 0 - self._last_live_ts = time.time() - - CLOG.ok(f"开始转播:{self.current_platform} | {self.current_anchor} | {url}", worker=self.worker_name) - - except Exception as e: - # 关键:任何内部异常不让线程退出 - CLOG.error(f"内部异常:{e}", worker=self.worker_name) - time.sleep(2) - continue - - finally: - with contextlib.suppress(Exception): - stop_ffmpeg(self.current_proc) - self.coordinator.release(self.current_url, self.key) - CLOG.off("已退出。", worker=self.worker_name) - - def _clear_state(self, release: bool = False): - if release and self.current_url: - self.coordinator.release(self.current_url, self.key) - self.current_url = None - self.current_real_url = None - self.current_proc = None - self.current_platform = "" - self.current_anchor = "" - -# =============== 启动入口(保持原行为) =============== -def check_ffmpeg_existence() -> bool: - try: - result = subprocess.run(['ffmpeg', '-version'], check=True, capture_output=True, text=True) - if result.returncode == 0: - print(result.stdout.splitlines()[0]) # 贴合旧风格:打印版本第一行 - except Exception: - pass - finally: - return bool(check_ffmpeg()) - -# ---- 统一优雅退出 ---- -def graceful_shutdown(signum=None, frame=None): - if not SHUTDOWN_EVENT.is_set(): - print("\n收到中断,正在停止所有 worker 与 ffmpeg …") - SHUTDOWN_EVENT.set() - # 先发 stop 给所有 worker(会内部 stop 当前 ffmpeg) - for w in list(ALL_WORKERS): - with contextlib.suppress(Exception): - w.stop() - # 再兜底停止全局登记的 ffmpeg 子进程 - with ALL_LOCK: - procs = list(ALL_PROCS) - for p in procs: - with contextlib.suppress(Exception): - stop_ffmpeg(p) - -def main(): - print("DouyinRelay", version) - print("-----------------------------------------") - - if not check_ffmpeg_existence(): - logger.error("缺少 ffmpeg,程序退出") - sys.exit(1) - - urls = parse_urls_from_file(url_config_file) - if not urls: - logger.error("URL_config.ini 中没有可用 URL。") - sys.exit(1) - - # keys(并行路数 = keys 数量);兼容单 key - keys = _yt_get_list("youtube", "keys") - if not keys: - single_key = _yt_get("youtube", "key", fallback="", cast=str) - if single_key: - keys = [single_key] - if not keys: - logger.error("youtube.ini 没有配置 [youtube].keys 或 key") - sys.exit(1) - - # 可选:每路分辨率 - per_res = _yt_get_list("youtube", "resolutions") # 如:1920x1080,1080x1920 - while per_res and len(per_res) < len(keys): - per_res.append(per_res[-1]) - - # 打印 ffmpeg 版本(贴合旧风格) - try: - vline = subprocess.run(['ffmpeg', '-version'], check=True, capture_output=True, text=True).stdout.splitlines()[0] - print(vline) - except Exception: - pass - - coordinator = UrlCoordinator(url_config_file) - - # 信号处理(Ctrl+C / kill) - with contextlib.suppress(Exception): - signal.signal(signal.SIGINT, graceful_shutdown) - with contextlib.suppress(Exception): - signal.signal(signal.SIGTERM, graceful_shutdown) - - # 启动多路(Worker 前缀区分) - workers: List[StreamWorker] = [] - for i, key in enumerate(keys, start=1): - res_for_this = per_res[i-1] if per_res else None - w = StreamWorker(worker_id=i, key=key, resolution=res_for_this, coordinator=coordinator) - w.start() - workers.append(w) - ALL_WORKERS.append(w) - - try: - while not SHUTDOWN_EVENT.is_set(): - time.sleep(0.5) - except KeyboardInterrupt: - graceful_shutdown() - finally: - graceful_shutdown() - for w in workers: - w.join(timeout=10) - print("全部 worker 已退出。") - -if __name__ == "__main__": - main() diff --git a/config/URL_config.ini b/config/URL_config.ini index cfe388a..f5b0e6e 100644 --- a/config/URL_config.ini +++ b/config/URL_config.ini @@ -1,57 +1,58 @@ -https://live.douyin.com/456576000931 -https://live.douyin.com/782322393954 -https://live.douyin.com/16531926455 -https://live.douyin.com/739670432305 -https://live.douyin.com/486789870173 -https://live.douyin.com/15652099787 -https://live.douyin.com/572442812068 -https://live.douyin.com/477961427964 -https://live.douyin.com/686507122231 -https://live.douyin.com/50627104055 -https://live.douyin.com/339454333072 -https://live.douyin.com/72212955083 -https://live.douyin.com/621118100231 -https://live.douyin.com/280422307519 -https://live.douyin.com/651293616593 -https://live.douyin.com/114111936890 -https://live.douyin.com/43665279648 -https://live.douyin.com/122383435921 -https://live.douyin.com/92876979328 -https://live.douyin.com/517715534931 -https://live.douyin.com/460525712926 -https://live.douyin.com/339454333072 -https://live.douyin.com/745606325880 -https://live.douyin.com/749687541944 -https://live.douyin.com/675343045974 -https://live.douyin.com/673565298571 -https://live.douyin.com/835571459859 -https://live.douyin.com/152358755212 -https://live.douyin.com/990825651731 -https://live.douyin.com/591442402624 -https://live.douyin.com/967381081829 -https://live.douyin.com/78012762575 -https://live.douyin.com/163813589919 -https://live.douyin.com/689189188452 -https://live.douyin.com/498368994814 -https://live.douyin.com/617848099204 -https://live.douyin.com/821525628944 -https://live.douyin.com/993102287144 -https://live.douyin.com/701547125568 -https://live.douyin.com/816130992220 -https://live.douyin.com/876468215361 -https://live.douyin.com/511335278313 -https://live.douyin.com/483160615952 -https://live.douyin.com/673565298571 -https://live.douyin.com/525514386431 -https://live.douyin.com/8687122573 -https://live.douyin.com/642534242822 -https://live.douyin.com/743565594721 -https://live.douyin.com/249578288248 -https://live.douyin.com/472140253414 -https://live.douyin.com/700846653732 -https://live.douyin.com/81849868631 -https://live.douyin.com/690114366322 -https://live.douyin.com/642534242822 -https://live.douyin.com/469980190666 -https://live.douyin.com/388066418744 -https://live.douyin.com/122383435921 \ No newline at end of file +https://live.douyin.com/456576000931,主播: 介休〖烩饕〗大块牛肉饭「摆摊」 +https://live.douyin.com/742474761291,主播: 阿诺椰子• +https://live.douyin.com/820845121901,主播: 地摊闺蜜_创意寿司_(马儿努力版) +https://live.douyin.com/980855021360,主播: 图爸摆摊卖枣糕(日照首家)教学员 +https://live.douyin.com/497155982953,主播: 想吃糖葫芦 +https://live.douyin.com/349973241990,主播: 元气满满饭团(摆摊不摆烂版) +https://live.douyin.com/814839174401,主播: 飞飞摆摊不摆烂 +https://live.douyin.com/597618993066,主播: 婷婷的茶生活 +https://live.douyin.com/782322393954,主播: 小葵(摆摊版) +https://live.douyin.com/16531926455,主播: 木黎黎 +https://live.douyin.com/739670432305,主播: 干饭007 +https://live.douyin.com/486789870173,主播: 凉皮西施—豆豆 +https://live.douyin.com/15652099787,主播: 莹莹在摆摊 +https://live.douyin.com/572442812068,主播: 小小 +https://live.douyin.com/477961427964,主播: 小鱼 +https://live.douyin.com/686507122231,主播: 上海少妇鲜果茶 +https://live.douyin.com/50627104055,主播: 冷阿姨 +https://live.douyin.com/339454333072,主播: 小安炒粉 +https://live.douyin.com/72212955083,主播: 花老板回村了 +https://live.douyin.com/280422307519,主播: 早餐妹 +https://live.douyin.com/651293616593,主播: 会發的黄 +https://live.douyin.com/114111936890,主播: 小乖早安_(小七泡面) +https://live.douyin.com/43665279648,主播: 喵喵老师 +https://live.douyin.com/122383435921,主播: 婷饱饱 +https://live.douyin.com/92876979328,主播: 福宝(野摊小馄饨) +https://live.douyin.com/517715534931,主播: 一只羊 +https://live.douyin.com/460525712926,主播: 丽姐摆摊记 +https://live.douyin.com/745606325880,主播: w、(摆摊日记_) +https://live.douyin.com/749687541944,主播: 摆摊的二师兄 +https://live.douyin.com/675343045974,主播: 张子沐的串串(你的电子榨菜) +https://live.douyin.com/673565298571,主播: 轻舟已撞大冰山(摆摊vlog) +https://live.douyin.com/835571459859,主播: 海绵饱饱 +https://live.douyin.com/152358755212,主播: 大美_红烧肉 +https://live.douyin.com/990825651731,主播: 王同学摆摊日记(蜜汁烤鸡腿) +https://live.douyin.com/591442402624,主播: 76696515302 +https://live.douyin.com/967381081829,主播: 憨憨的摆摊日记 +https://live.douyin.com/78012762575,主播: 摆摊儿去划水 +https://live.douyin.com/163813589919,主播: 不要熬夜(摆摊中) +https://live.douyin.com/498368994814,主播: 剪头发的小帆帆 +https://live.douyin.com/617848099204,主播: 果妈要努力 +https://live.douyin.com/821525628944,主播: 李熙雨⭐ +https://live.douyin.com/993102287144,主播: 小茗早餐(薪笼记助创官) +https://live.douyin.com/701547125568,主播: 悠悠包 +https://live.douyin.com/816130992220,主播: 甜昕冰冰的摆摊日记 +https://live.douyin.com/876468215361,主播: 晓晓和凡凡 +https://live.douyin.com/511335278313,主播: 秘书(摆摊休息版) +https://live.douyin.com/483160615952,主播: 小胡同学你好(饭团版) +https://live.douyin.com/525514386431,主播: 阿闯烤肉夹馍 +https://live.douyin.com/8687122573,主播: 小彤炒粉 +https://live.douyin.com/642534242822,主播: (小王_)煎饼果子 +https://live.douyin.com/743565594721,主播: 汪汪汪小妞 +https://live.douyin.com/249578288248,主播: 思思努力版 +https://live.douyin.com/472140253414,主播: 戴盘龙 +https://live.douyin.com/700846653732,主播: 三哥大锅菜_炒鸡(国基路店)官方号 +https://live.douyin.com/81849868631,主播: 纯儿²¹(休息勿跑空哦) +https://live.douyin.com/690114366322,主播: 卤味鲜(东大) +https://live.douyin.com/469980190666,主播: 茜茜柠檬茶(小米茶档_) +https://live.douyin.com/388066418744,主播: 加速姐的摆摊日记 diff --git a/config/config.ini b/config/config.ini index 6d4bac7..7ff3a2a 100644 --- a/config/config.ini +++ b/config/config.ini @@ -1,26 +1,26 @@ [录制设置] language(zh_cn/en) = zh_cn -是否跳过代理检测(是/否) = 否 +是否跳过代理检测(是/否) = 是 直播保存路径(不填则默认) = -保存文件夹是否以作者区分 = 是 +保存文件夹是否以作者区分 = 否 保存文件夹是否以时间区分 = 否 保存文件夹是否以标题区分 = 否 保存文件名是否包含标题 = 否 是否去除名称中的表情符号 = 是 -视频保存格式ts|mkv|flv|mp4|mp3音频|m4a音频 = mp4 +视频保存格式ts|mkv|flv|mp4|mp3音频|m4a音频 = ts 原画|超清|高清|标清|流畅 = 原画 -是否使用代理ip(是/否) = 是 +是否使用代理ip(是/否) = 否 代理地址 = -同一时间访问网络的线程数 = 1 -循环时间(秒) = 30 +同一时间访问网络的线程数 = 3 +循环时间(秒) = 300 排队读取网址时间(秒) = 0 是否显示循环秒数 = 否 -是否显示直播源地址 = 否 +是否显示直播源地址 = 是 分段录制是否开启 = 是 是否强制启用https录制 = 否 录制空间剩余阈值(gb) = 1.0 视频分段时间(秒) = 1800 -录制完成后自动转为mp4格式 = 是 +录制完成后自动转为mp4格式 = 否 mp4格式重新编码为h264 = 否 追加格式后删除原文件 = 是 生成时间字幕文件 = 否 @@ -30,142 +30,102 @@ mp4格式重新编码为h264 = 否 额外使用代理录制的平台(逗号分隔) = [推送配置] -直播状态推送渠道 = -钉钉推送接口链接 = -微信推送接口链接 = -bark推送接口链接 = +# 可选微信|钉钉|tg|邮箱|bark|ntfy|pushplus 可填多个 +直播状态推送渠道 = +钉钉推送接口链接 = +微信推送接口链接 = +bark推送接口链接 = bark推送中断级别 = active -bark推送铃声 = -钉钉通知@对象(填手机号) = +bark推送铃声 = +钉钉通知@对象(填手机号) = 钉钉通知@全体(是/否) = 否 -tgapi令牌 = -tg聊天id(个人或者群组id) = -smtp邮件服务器 = -是否使用smtp服务ssl加密(是/否) = -smtp邮件服务器端口 = -邮箱登录账号 = -发件人密码(授权码) = -发件人邮箱 = -发件人显示昵称 = -收件人邮箱 = +tgapi令牌 = +tg聊天id(个人或者群组id) = +smtp邮件服务器 = +是否使用SMTP服务SSL加密(是/否) = +SMTP邮件服务器端口 = +邮箱登录账号 = +发件人密码(授权码) = +发件人邮箱 = +发件人显示昵称 = +收件人邮箱 = ntfy推送地址 = https://ntfy.sh/xxxx ntfy推送标签 = tada -ntfy推送邮箱 = -pushplus推送token = -自定义推送标题 = -自定义开播推送内容 = -自定义关播推送内容 = +ntfy推送邮箱 = +pushplus推送token = +自定义推送标题 = +自定义开播推送内容 = +自定义关播推送内容 = 只推送通知不录制(是/否) = 否 直播推送检测频率(秒) = 1800 开播推送开启(是/否) = 是 -关播推送开启(是/否) = 否 -只推送通知不直播(是/否) = 否 +关播推送开启(是/否)= 否 [Cookie] +# 录制抖音必填 抖音cookie = ttwid=1%7CB1qls3GdnZhUov9o2NxOMxxYS2ff6OSvEWbv0ytbES4%7C1680522049%7C280d802d6d478e3e78d0c807f7c487e7ffec0ae4e5fdd6a0fe74c3c6af149511; my_rd=1; passport_csrf_token=3ab34460fa656183fccfb904b16ff742; passport_csrf_token_default=3ab34460fa656183fccfb904b16ff742; d_ticket=9f562383ac0547d0b561904513229d76c9c21; n_mh=hvnJEQ4Q5eiH74-84kTFUyv4VK8xtSrpRZG1AhCeFNI; store-region=cn-fj; store-region-src=uid; LOGIN_STATUS=1; __security_server_data_status=1; FORCE_LOGIN=%7B%22videoConsumedRemainSeconds%22%3A180%7D; pwa2=%223%7C0%7C3%7C0%22; download_guide=%223%2F20230729%2F0%22; volume_info=%7B%22isUserMute%22%3Afalse%2C%22isMute%22%3Afalse%2C%22volume%22%3A0.6%7D; strategyABtestKey=%221690824679.923%22; stream_recommend_feed_params=%22%7B%5C%22cookie_enabled%5C%22%3Atrue%2C%5C%22screen_width%5C%22%3A1536%2C%5C%22screen_height%5C%22%3A864%2C%5C%22browser_online%5C%22%3Atrue%2C%5C%22cpu_core_num%5C%22%3A8%2C%5C%22device_memory%5C%22%3A8%2C%5C%22downlink%5C%22%3A10%2C%5C%22effective_type%5C%22%3A%5C%224g%5C%22%2C%5C%22round_trip_time%5C%22%3A150%7D%22; VIDEO_FILTER_MEMO_SELECT=%7B%22expireTime%22%3A1691443863751%2C%22type%22%3Anull%7D; home_can_add_dy_2_desktop=%221%22; __live_version__=%221.1.1.2169%22; device_web_cpu_core=8; device_web_memory_size=8; xgplayer_user_id=346045893336; csrf_session_id=2e00356b5cd8544d17a0e66484946f28; odin_tt=724eb4dd23bc6ffaed9a1571ac4c757ef597768a70c75fef695b95845b7ffcd8b1524278c2ac31c2587996d058e03414595f0a4e856c53bd0d5e5f56dc6d82e24004dc77773e6b83ced6f80f1bb70627; __ac_nonce=064caded4009deafd8b89; __ac_signature=_02B4Z6wo00f01HLUuwwAAIDBh6tRkVLvBQBy9L-AAHiHf7; ttcid=2e9619ebbb8449eaa3d5a42d8ce88ec835; webcast_leading_last_show_time=1691016922379; webcast_leading_total_show_times=1; webcast_local_quality=sd; live_can_add_dy_2_desktop=%221%22; msToken=1JDHnVPw_9yTvzIrwb7cQj8dCMNOoesXbA_IooV8cezcOdpe4pzusZE7NB7tZn9TBXPr0ylxmv-KMs5rqbNUBHP4P7VBFUu0ZAht_BEylqrLpzgt3y5ne_38hXDOX8o=; msToken=jV_yeN1IQKUd9PlNtpL7k5vthGKcHo0dEh_QPUQhr8G3cuYv-Jbb4NnIxGDmhVOkZOCSihNpA2kvYtHiTW25XNNX_yrsv5FN8O6zm3qmCIXcEe0LywLn7oBO2gITEeg=; tt_scid=mYfqpfbDjqXrIGJuQ7q-DlQJfUSG51qG.KUdzztuGP83OjuVLXnQHjsz-BRHRJu4e986 -快手cookie = -tiktok_cookie = -虎牙cookie = -斗鱼cookie = -yy_cookie = -b站cookie = -小红书cookie = -bigo_cookie = -blued_cookie = -sooplive_cookie = -netease_cookie = -千度热播_cookie = -pandatv_cookie = -猫耳fm_cookie = -winktv_cookie = -flextv_cookie = -look_cookie = -twitcasting_cookie = -baidu_cookie = -weibo_cookie = -kugou_cookie = -twitch_cookie = -liveme_cookie = -huajiao_cookie = -liuxing_cookie = -showroom_cookie = -acfun_cookie = -changliao_cookie = -yinbo_cookie = -yingke_cookie = -zhihu_cookie = -chzzk_cookie = -haixiu_cookie = -vvxqiu_cookie = -17live_cookie = -langlive_cookie = -pplive_cookie = -6room_cookie = -lehaitv_cookie = -huamao_cookie = -shopee_cookie = -youtube_cookie = -taobao_cookie = -jd_cookie = -faceit_cookie = -migu_cookie = -lianjie_cookie = -laixiu_cookie = -picarto_cookie = +快手cookie = +tiktok_cookie = +虎牙cookie = +斗鱼cookie = +yy_cookie = +b站cookie = +小红书cookie = +bigo_cookie = +blued_cookie = +sooplive_cookie = +netease_cookie = +千度热播_cookie = +pandatv_cookie = +猫耳fm_cookie = +winktv_cookie = +flextv_cookie = +look_cookie = +twitcasting_cookie = +baidu_cookie = +weibo_cookie = +kugou_cookie = +twitch_cookie = +liveme_cookie = +huajiao_cookie = +liuxing_cookie = +showroom_cookie = +acfun_cookie = +changliao_cookie = +yinbo_cookie = +yingke_cookie = +zhihu_cookie = +chzzk_cookie = +haixiu_cookie = +vvxqiu_cookie = +17live_cookie = +langlive_cookie = +pplive_cookie = +6room_cookie = +lehaitv_cookie = +huamao_cookie = +shopee_cookie = +youtube_cookie = +taobao_cookie = +jd_cookie = +faceit_cookie = +migu_cookie = +lianjie_cookie = +laixiu_cookie = +picarto_cookie = + [Authorization] -popkontv_token = +popkontv_token = [账号密码] -sooplive账号 = -sooplive密码 = -flextv账号 = -flextv密码 = -popkontv账号 = +sooplive账号 = +sooplive密码 = +flextv账号 = +flextv密码 = +popkontv账号 = partner_code = P-00001 -popkontv密码 = +popkontv密码 = twitcasting账号类型 = normal -twitcasting账号 = -twitcasting密码 = - -[直播设置] -language(zh_cn/en) = zh_cn -是否跳过代理检测(是/否) = 否 -直播保存路径(不填则默认) = -保存文件夹是否以作者区分 = 是 -保存文件夹是否以时间区分 = 否 -保存文件夹是否以标题区分 = 否 -保存文件名是否包含标题 = 否 -是否去除名称中的表情符号 = 是 -视频保存格式ts|mkv|flv|mp4|mp3音频|m4a音频 = mp4 -原画|超清|高清|标清|流畅 = 原画 -是否使用代理ip(是/否) = 是 -代理地址 = -同一时间访问网络的线程数 = 3 -循环时间(秒) = 120 -排队读取网址时间(秒) = 0 -是否显示循环秒数 = 否 -是否显示直播源地址 = 否 -分段直播是否开启 = 否 -是否强制启用https直播 = 否 -直播空间剩余阈值(gb) = 1.0 -视频分段时间(秒) = 1800 -直播完成后自动转为mp4格式 = 否 -mp4格式重新编码为h264 = 否 -追加格式后删除原文件 = 否 -生成时间字幕文件 = 否 -是否直播完成后执行自定义脚本 = 否 -使用代理直播的平台(逗号分隔) = tiktok, soop, pandalive, winktv, flextv, popkontv, twitch, liveme, showroom, chzzk, shopee, shp, youtu, faceit -额外使用代理直播的平台(逗号分隔) = - -[监控] -启用webui(是/否) = 是 -webui监听ip = 0.0.0.0 -webui端口 = 8787 - -[稳定性] -ffmpeg长时间无输出阈值秒 = 600 -码率低重启比率(0-1) = 0.5 -软重启冷却秒 = 90 -url刷新间隔秒 = 300 - +twitcasting账号 = +twitcasting密码 = \ No newline at end of file diff --git a/config/youtube.ini b/config/youtube.ini deleted file mode 100644 index 65f316d..0000000 --- a/config/youtube.ini +++ /dev/null @@ -1,13 +0,0 @@ -[youtube] -key = x04z-564w-aks7-embw-30y4 -keys = x04z-564w-aks7-embw-30y4 -; ,qxvb-r47b-r5ju-6ud3-6k7z -secure = true -ingest = a -bitrate = 5000 -audio_bitrate = 128 -fps = 30 -gop = 60 -copy_when_safe = true -resolution = 720x1280 -single_mode = true \ No newline at end of file diff --git a/main.py b/main.py deleted file mode 100644 index 07507b0..0000000 --- a/main.py +++ /dev/null @@ -1,1105 +0,0 @@ -# -*- encoding: utf-8 -*- - -""" -并行转推(稳定性+体验增强版,保持原业务逻辑与对外接口) -- 核心修复:降低“误判下播”的概率(抖音仍在播但 API 偶发失败) - * fetch_port_info:加入重试(3 次,指数退避),不改签名/调用处 - * “下播防抖”:只有在“连续N次 is_live=False 且最近M秒没有任何活跃迹象”才判定下播 - * 交叉验证:若 is_live=False,但当前 real_url 可探测(探活成功),则判为仍在播 -- FFmpeg 看门狗:卡顿/长时间无输出 -> 先软重启(保留 URL 占用),再必要时释放 -- 细节增强:日志更明确,解释每次判定原因;优雅退出确保无僵尸进程 -- 兼容性:不改配置文件/外部模块(spider、stream 等)的接口与行为 -- 修复:确保函数定义顺序,避免 NameError -""" - -import asyncio -import os -import sys -import signal -import time -import datetime -import re -import uuid -import threading -import subprocess -from typing import Any, List, Tuple, Optional, Dict, Set -import contextlib -import configparser -import urllib.request -import urllib.error -import io -import logging -from logging.handlers import RotatingFileHandler - -# ======================= 颜色日志(Windows Cmder 友好) ======================= -def _enable_vt_on_windows(): - """在 Windows 控制台启用 ANSI VT(Cmder/CMD/PowerShell 颜色支持)""" - if os.name != "nt": - return - try: - import msvcrt # noqa: F401 - import ctypes - kernel32 = ctypes.windll.kernel32 - handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE = -11 - mode = ctypes.c_uint32() - if kernel32.GetConsoleMode(handle, ctypes.byref(mode)): - mode.value |= 0x0004 # ENABLE_VIRTUAL_TERMINAL_PROCESSING - kernel32.SetConsoleMode(handle, mode) - except Exception: - pass - -_enable_vt_on_windows() - -class ColorLog: - # ANSI colors - RESET = "\033[0m" - GREY = "\033[90m" - RED = "\033[91m" - GREEN = "\033[92m" - YELLOW = "\033[93m" - BLUE = "\033[94m" - MAGENTA = "\033[95m" - CYAN = "\033[96m" - WHITE = "\033[97m" - - ICONS = { - "INFO": "🟢", - "WARN": "🟡", - "ERROR": "🔴", - "DEBUG": "⚪", - "OK": "✅", - "SWAP": "🔁", - "LIVE": "📡", - "OFF": "📴", - "FFMPEG":"🎬", - "WATCH": "👀", - } - - COLORS = { - "INFO": GREEN, - "OK": GREEN, - "WARN": YELLOW, - "ERROR": RED, - "DEBUG": GREY, - "SWAP": CYAN, - "LIVE": BLUE, - "OFF": RED, - "FFMPEG": MAGENTA, - "WATCH": CYAN, - } - - def __init__(self, log_file_path: str = "logs/runtime.log", max_bytes: int = 5*1024*1024, backup: int = 3): - os.makedirs(os.path.dirname(log_file_path), exist_ok=True) - - self._logger = logging.getLogger("Runtime") - self._logger.setLevel(logging.INFO) - self._logger.propagate = False - # 控制台 Handler(给 src.utils.logger 让位,这里只用于文件) - if not self._logger.handlers: - file_handler = RotatingFileHandler(log_file_path, maxBytes=max_bytes, backupCount=backup, encoding="utf-8") - fmt = logging.Formatter("%(asctime)s | %(levelname)-5s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S") - file_handler.setFormatter(fmt) - self._logger.addHandler(file_handler) - - def _stamp(self) -> str: - return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - - def _compose(self, level: str, msg: str, worker: Optional[str] = None) -> str: - icon = self.ICONS.get(level, "") - color = self.COLORS.get(level, self.WHITE) - prefix = f"[{self._stamp()}]" - if worker: - prefix += f"[{worker}]" - return f"{prefix} {color}[{icon} {level}]{self.RESET} {msg}" - - def info(self, msg: str, worker: Optional[str] = None): - text = self._compose("INFO", msg, worker) - print(text) - self._logger.info(msg) - - def ok(self, msg: str, worker: Optional[str] = None): - text = self._compose("OK", msg, worker) - print(text) - self._logger.info(msg) - - def warn(self, msg: str, worker: Optional[str] = None): - text = self._compose("WARN", msg, worker) - print(text) - self._logger.warning(msg) - - def error(self, msg: str, worker: Optional[str] = None): - text = self._compose("ERROR", msg, worker) - print(text, file=sys.stderr) - self._logger.error(msg) - - def debug(self, msg: str, worker: Optional[str] = None): - text = self._compose("DEBUG", msg, worker) - print(text) - self._logger.debug(msg) - - def swap(self, msg: str, worker: Optional[str] = None): - text = self._compose("SWAP", msg, worker) - print(text) - self._logger.info("[SWAP] " + msg) - - def live(self, msg: str, worker: Optional[str] = None): - text = self._compose("LIVE", msg, worker) - print(text) - self._logger.info("[LIVE] " + msg) - - def off(self, msg: str, worker: Optional[str] = None): - text = self._compose("OFF", msg, worker) - print(text) - self._logger.info("[OFF] " + msg) - -CLOG = ColorLog() # 全局彩色日志 - -# ======================= 这里开始是原有工程的 import =========================== -from src import spider, stream -from src.utils import logger, Color, check_md5 -from ffmpeg_install import check_ffmpeg, ffmpeg_path, current_env_path - -# =============== 重要:先定义 build_youtube_output_for_key,避免 NameError =============== -# 读取 YouTube 配置(需要在函数内部访问 _yt_get,故先声明占位;稍后会正式赋值) -_yt_cfg: Optional[configparser.ConfigParser] = None - -def _safe_int(v, lo, hi, defv): - try: - v = int(v) - if v < lo or v > hi: return defv - return v - except Exception: - return defv - -def _round_res(w: int, h: int) -> Tuple[int, int]: - # 偶数对齐,避免 1088x1920 类问题 - return (w & ~1, h & ~1) - -def _build_youtube_rtmp_url(key: str, secure: bool, ingest: str | None): - node = (ingest or "a").strip().lower() - node = node if node.isalpha() and len(node) == 1 else "a" - scheme = "rtmps" if secure else "rtmp" - host = f"{node}.rtmps.youtube.com" if secure else f"{node}.rtmp.youtube.com" - return f"{scheme}://{host}/live2/{key}" - -def build_youtube_output_for_key(key: str, resolution: Optional[str]) -> Tuple[str, list, list]: - """ - !!!此函数放在文件靠前位置,确保任何地方调用都不再出现 NameError。 - 与旧版保持一致;仅对异常进行更稳妥处理与注释增强。 - """ - if not key: - logger.error("错误: youtube.ini 未配置 key/keys") - sys.exit(1) - - secure = _yt_get("youtube", "secure", fallback=False, cast=bool) - ingest = _yt_get("youtube", "ingest", fallback="a", cast=str) - res_str = (resolution or _yt_get("youtube", "resolution", fallback="", cast=str) or "").strip() - - bv = _safe_int(_yt_get("youtube", "bitrate", fallback=5000, cast=int), 300, 20000, 5000) - ba = _safe_int(_yt_get("youtube", "audio_bitrate", fallback=128, cast=int), 64, 384, 128) - fps = _safe_int(_yt_get("youtube", "fps", fallback=30, cast=int), 10, 60, 30) - gop = _safe_int(_yt_get("youtube", "gop", fallback=2*fps, cast=int), fps, 4*fps, 2*fps) - - out_url = _build_youtube_rtmp_url(key, secure, ingest) - - v_args = [ - "-c:v", "libx264", - "-preset", "veryfast", - "-tune", "zerolatency", - "-profile:v", "high", - "-level:v", "4.2", - "-pix_fmt", "yuv420p", - "-r", str(fps), - "-g", str(gop), - "-b:v", f"{bv}k", - "-maxrate", f"{bv}k", - "-bufsize", f"{max(2*bv, 4000)}k", - ] - - # v_args = [ - # "-hwaccel", "qsv", # 启用硬件加速解码 - # "-c:v", "h264_qsv", # 使用 QSV H.264 解码 - # "-i", input_file, # 输入文件/流 - # "-c:v", "h264_qsv", # 使用 QSV H.264 编码 - # "-b:v", f"{bv}k", # 目标码率 - # "-maxrate", f"{bv}k", # 最大码率 - # "-bufsize", f"{max(2*bv, 4000)}k", # 缓冲区 - # "-r", str(fps), # 帧率 - # "-g", str(gop), # GOP 长度 - # "-pix_fmt", "nv12", # QSV 支持的像素格式 - # "-profile:v", "high", # High Profile - # "-level:v", "4.1", # Level 4.1,N5105 支持 - # "-f", "flv", # 输出协议(YouTube/RTMP) - # ] - - # v_args = [ - # "-c:v", "libx264", # CPU 编码 H.264 - # "-preset", "superfast", # 性能优先,速度快,CPU 占用低 - # "-tune", "zerolatency", # 低延迟模式,适合直播 - # "-profile:v", "baseline", # baseline 比 high 更轻量,N5105 CPU 更适合 - # "-level:v", "3.1", # 与 baseline 匹配,低复杂度 - # "-pix_fmt", "yuv420p", # 抖音/YouTube 通用 - # "-r", str(fps), # 帧率保持原始 - # "-g", str(gop), # GOP 与帧率匹配,避免过多 I 帧 - # "-b:v", f"{bv}k", # 目标码率 - # "-maxrate", f"{bv}k", - # "-bufsize", f"{max(2*bv, 2000)}k", # 缓冲,CPU轻量化 - # "-tune", "fastdecode", # 优化解码速度,直播客户端更流畅 - # "-x264opts", "no-mbtree:threads=4" # 禁用 mbtree 减少 CPU,固定线程数 - # ] - - a_args = [ - "-c:a", "aac", - "-b:a", f"{ba}k", - "-ar", "44100", - "-ac", "2", - ] - - if res_str: - try: - w, h = map(int, res_str.lower().split("x")) - w, h = _round_res(w, h) - vf = ( - f"scale={w}:{h}:force_original_aspect_ratio=decrease," - f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2," - f"crop={w}:{h}," - f"format=yuv420p" - ) - v_args = ["-vf", vf] + v_args - except Exception: - logger.warning(f"resolution '{res_str}' 非法,忽略") - - return out_url, v_args, a_args - -# =============== 基础环境 =============== -version = "vrelay-parallel-2.1.1-pro" -os.environ['PATH'] = ffmpeg_path + os.pathsep + current_env_path -script_path = os.path.split(os.path.realpath(sys.argv[0]))[0] -config_file = f'{script_path}/config/config.ini' -url_config_file = f'{script_path}/config/URL_config.ini' -yt_ini_file = f"{script_path}/config/youtube.ini" -text_encoding = 'utf-8-sig' -color = Color() - -# ---- 全局 Worker & 进程注册(用于 Ctrl+C 干净退出) ---- -ALL_WORKERS: List["StreamWorker"] = [] -ALL_PROCS: Set[subprocess.Popen] = set() -SHUTDOWN_EVENT = threading.Event() -ALL_LOCK = threading.Lock() - -# =============== 读取 YouTube 配置 =============== -_yt_cfg = configparser.ConfigParser() -with open(yt_ini_file, "r", encoding="utf-8-sig") as f: - _yt_cfg.read_file(f) - -def _yt_get(section: str, key: str, fallback=None, cast=str): - try: - if cast is bool: - return _yt_cfg.getboolean(section, key, fallback=fallback) - if cast is int: - return _yt_cfg.getint(section, key, fallback=fallback) - return _yt_cfg.get(section, key, fallback=fallback) - except Exception: - return fallback - -def _yt_get_list(section: str, key: str) -> List[str]: - raw = _yt_get(section, key, fallback="", cast=str) or "" - parts = re.split(r"[,;\s]+", raw.strip()) - return [p for p in parts if p] - -# =============== 工具函数(原版保留,已前置到文件顶部附近) =============== -def now_str(): - return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - -# =============== 读取运行配置 =============== -def read_config_value(cfg: configparser.RawConfigParser, section: str, option: str, default_value: Any) -> Any: - try: - cfg.read(config_file, encoding=text_encoding) - if section not in cfg.sections(): - cfg.add_section(section) - return cfg.get(section, option) - except (configparser.NoSectionError, configparser.NoOptionError): - cfg.set(section, option, str(default_value)) - with open(config_file, 'w', encoding=text_encoding) as f: - cfg.write(f) - return default_value - -_CN_BOOL = {"是": True, "否": False} -cfg = configparser.RawConfigParser() -use_proxy = _CN_BOOL.get(read_config_value(cfg, '录制设置', '是否使用代理ip(是/否)', "否"), False) -proxy_addr = read_config_value(cfg, '录制设置', '代理地址', "") -proxy_addr = proxy_addr if use_proxy and proxy_addr.strip() else None -dy_cookie = read_config_value(cfg, 'Cookie', '抖音cookie', '') -tiktok_cookie = read_config_value(cfg, 'Cookie', 'tiktok_cookie', '') - -# 启动时连通性提示(仅提示,不改变逻辑) -try: - if not use_proxy: - urllib.request.urlopen("https://www.google.com/", timeout=5) - CLOG.info("你的网络似乎直连可用(无需代理)。") -except Exception: - if not use_proxy: - print("\033[93m提示:无法直连海外站,如需 TikTok 请在 config.ini 开启并填写代理地址。\033[0m") - -# =============== URL 列表 =============== -def parse_urls_from_file(path: str) -> List[str]: - urls, seen = [], set() - if not os.path.isfile(path): - os.makedirs(os.path.dirname(path), exist_ok=True) - open(path, 'w', encoding=text_encoding).close() - print("URL_config.ini 为空,请写入直播间地址后再运行。") - return urls - with open(path, "r", encoding=text_encoding, errors='ignore') as f: - for line in f: - s = line.strip() - if not s or s.startswith("#"): - continue - parts = re.split(r"[,,]\s*", s) - cand = None - for p in parts: - if '://' in p or re.match(r"^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", p): - cand = p - break - if not cand: - continue - url = cand if '://' in cand else f"https://{cand}" - if url not in seen: - seen.add(url) - urls.append(url) - return urls - -# =============== 解析与选源 =============== -def is_flv_preferred_platform(link: str) -> bool: - return any(i in link for i in ["douyin", "tiktok"]) - -def select_source_url(link: str, stream_info: dict) -> Optional[str]: - """ - 选源优先级保持不变:抖音/TikTok 优先 FLV(且避免 H265),否则 record_url/m3u8/flv。 - """ - if not stream_info: - return None - if is_flv_preferred_platform(link): - flv = stream_info.get("flv_url") - if flv: - try: - from urllib.parse import urlparse, parse_qs - qs = parse_qs(urlparse(flv).query or "") - if (qs.get("codec") or [""])[0].lower() == "h265": - logger.warning("FLV 不支持 h265,改用 HLS。") - else: - return flv - except Exception: - return flv - return stream_info.get("record_url") or stream_info.get("m3u8_url") or stream_info.get("flv_url") - -# =============== 辅助:探测播放 URL 是否可用(用于误判纠正) =============== -def _open_with_proxy(url: str, timeout: float = 5.0): - """ - 使用 urllib 在当前代理环境下打开 URL。 - 仅抓取少量字节用于“活跃性探测”,避免大流量。 - """ - handlers = [] - if proxy_addr: - handlers.append(urllib.request.ProxyHandler({"http": proxy_addr, "https": proxy_addr})) - opener = urllib.request.build_opener(*handlers) if handlers else urllib.request.build_opener() - req = urllib.request.Request( - url, - headers={ - "User-Agent": "Mozilla/5.0 (Linux; Android 11; SM-G973U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Mobile Safari/537.36", - "Accept": "*/*", - "Connection": "close", - } - ) - return opener.open(req, timeout=timeout) - -def probe_stream_url(real_url: Optional[str], timeout: float = 5.0) -> bool: - """ - 轻量级“探活”: - - m3u8: 取前若干字节并检查 "#EXTM3U" - - flv: 取前3字节是否 "FLV" - - 其它:只要 200 且有数据即可视为“活着” - 失败不抛异常,返回 False - """ - if not real_url: - return False - try: - with contextlib.closing(_open_with_proxy(real_url, timeout=timeout)) as resp: - code = getattr(resp, "status", None) or getattr(resp, "code", None) - if code and int(code) >= 400: - return False - head = resp.read(256) # 少量字节足够判定格式 - u = real_url.lower() - if ".m3u8" in u: - return b"#EXTM3U" in head.upper() - if ".flv" in u: - return head.startswith(b"FLV") - # 其它类型:有数据即认为仍在 - return bool(head) - except Exception: - return False - -# =============== 解析与选源(抖音/TikTok/自定义) =============== -# 修复点①:增强 fetch_port_info —— 重试 + 指数退避(不改签名/调用处) -import random, json, contextlib, urllib.request, urllib.error, re, time - -async def fetch_port_info(url: str, record_quality_code: str = "OD") -> dict: - """抖音/TikTok 直播解析(带防风控与 JSON fallback)""" - proxy = proxy_addr - delay = 1.2 - for attempt in range(1, 4): - try: - if "douyin.com" in url: - # ======= 原始接口 ======= - if 'v.douyin.com' not in url and '/user/' not in url: - j = await spider.get_douyin_stream_data(url=url, proxy_addr=proxy, cookies=dy_cookie) - else: - j = await spider.get_douyin_app_stream_data(url=url, proxy_addr=proxy, cookies=dy_cookie) - port_info = await stream.get_douyin_stream_url(j, record_quality_code, proxy) - - # ======= fallback:接口空时解析 HTML 中 JSON ======= - if not port_info.get("is_live"): - handlers = [] - if proxy: - handlers.append(urllib.request.ProxyHandler({"http": proxy, "https": proxy})) - opener = urllib.request.build_opener(*handlers) if handlers else urllib.request.build_opener() - headers = { - "User-Agent": random.choice([ - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127 Safari/537.36", - "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile Safari/604.1", - ]), - "Referer": f"https://live.douyin.com/{re.sub(r'\\D','',url)}", - "Accept": "*/*", - } - req = urllib.request.Request(url, headers=headers) - with contextlib.closing(opener.open(req, timeout=6)) as resp: - html = resp.read(512*1024).decode("utf-8", errors="ignore") - - # 解析 roomStore JSON - m = re.search(r'"roomStore"\s*:\s*({.*?})\s*,\s*"userStore"', html) - if m: - try: - data = json.loads(m.group(1)) - stream_data = data.get("roomInfo", {}).get("stream_url", {}) - flv = (stream_data.get("flv_pull_url", {}).get("FULL_HD1") - or stream_data.get("flv_pull_url", {}).get("HD1")) - m3u8 = (stream_data.get("hls_pull_url", {}).get("FULL_HD1") - or stream_data.get("hls_pull_url", {}).get("HD1")) - if flv or m3u8: - port_info = {"anchor_name": "JSONFallback", "is_live": True} - if flv: port_info["flv_url"] = flv - else: port_info["m3u8_url"] = m3u8 - CLOG.warn("⚠️ JSON fallback 成功解析流地址", worker=None) - except Exception as e: - CLOG.debug(f"JSON fallback 解析失败: {e}") - - elif "tiktok.com" in url: - if proxy: - j = await spider.get_tiktok_stream_data(url=url, proxy_addr=proxy, cookies=tiktok_cookie) - port_info = await stream.get_tiktok_stream_url(j, record_quality_code, proxy) - else: - port_info = {"anchor_name": "", "is_live": False} - logger.error("TikTok 需要代理。") - - elif (".m3u8" in url) or (".flv" in url): - port_info = {"anchor_name": "自定义", "is_live": True} - port_info["flv_url" if url.endswith(".flv") else "m3u8_url"] = url - - else: - port_info = {"anchor_name": "", "is_live": False} - - return port_info - - except urllib.error.HTTPError as e: - if e.code == 403: - CLOG.warn(f"[fetch_port_info] Douyin 403 第{attempt}次延时重试") - time.sleep(delay) - delay *= 2 - continue - CLOG.error(f"[fetch_port_info] HTTP {e.code}: {url}") - break - except Exception as e: - CLOG.warn(f"[fetch_port_info] 异常: {e}") - time.sleep(delay) - delay *= 2 - - return {"anchor_name": "", "is_live": False} - -# =============== 启动/停止 ffmpeg =============== -def launch_ffmpeg(input_url: str, - out_url: str, - extra_headers: Optional[str], - v_args: list, - a_args: list, - worker_name: str) -> subprocess.Popen: - user_agent = ("Mozilla/5.0 (Linux; Android 11; SM-G973U) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/87.0.4280.141 Mobile Safari/537.36") - # 输入侧容错/重连/探测 - base = [ - "ffmpeg", "-nostdin", "-hide_banner", - "-stats", - "-stats_period", "60", - "-progress", "pipe:1", - "-loglevel", "info", - - # 输入容错 & 自动重连 - "-rw_timeout", "15000000", - "-timeout", "10000000", - "-reconnect", "1", - "-reconnect_streamed", "1", - "-reconnect_at_eof", "1", - "-reconnect_delay_max", "30", - - # UA/协议白名单 - "-user_agent", user_agent, - "-protocol_whitelist", "file,http,https,tcp,tls,crypto,udp,rtmp,rtp,httpproxy", - - # 缓冲 & 探测 - "-thread_queue_size", "4096", - "-analyzeduration", "10000000", - "-probesize", "10000000", - ] - - # 平台 headers(referer/origin)要在 -i 之前 - if extra_headers: - base += ["-headers", extra_headers] - - # 代理:http_proxy/https_proxy 环境变量(同时保留 -http_proxy 兼容) - env = os.environ.copy() - if proxy_addr: - env["http_proxy"] = proxy_addr - env["https_proxy"] = proxy_addr - base = ["ffmpeg", "-http_proxy", proxy_addr] + base[1:] - - # 输入 - base += ["-re", "-i", input_url] - - # 通用容错 + 时间戳修复(优化版) - base += [ - "-fflags", "+genpts+igndts+flush_packets", - "-use_wallclock_as_timestamps", "1", - "-avioflags", "direct", - "-rtbufsize", "100M", - "-err_detect", "ignore_err", - "-avoid_negative_ts", "make_zero", - ] - - # 视频/音频编码参数(使用 build_youtube_output 的结果) - v_args = v_args or [] - a_args = a_args or [] - base += v_args + a_args - - # 输出(flv 到 YT,更宽容) - base += ["-f", "flv", "-flvflags", "no_duration_filesize", out_url] - - cmd_str = " ".join(base) - # 控制台+文件日志 - CLOG.live(f"FFmpeg CMD({worker_name}): {cmd_str if len(cmd_str) < 500 else cmd_str[:500] + ' ...'}", worker=worker_name) - logger.info(f"{worker_name} 启动推流:{input_url} -> {out_url}") - - creationflags = 0 - if os.name == "nt": - creationflags = subprocess.CREATE_NEW_PROCESS_GROUP - - p = subprocess.Popen( - base, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - bufsize=0, - env=env, - creationflags=creationflags - ) - p.last_out_ts = time.time() - - # ---- 注册进程,便于 Ctrl+C 统一清理 ---- - with ALL_LOCK: - ALL_PROCS.add(p) - - def _reader(): - try: - for line in iter(p.stdout.readline, b''): - if not line: - break - text = line.decode(errors="ignore").strip() - p.last_out_ts = time.time() - - # 只保留关键统计/错误(前缀带 Worker) - if ("bitrate=" in text) or ("frame=" in text) or ("speed=" in text) or ("libx264" in text): - CLOG.info(f"[ffmpeg统计] {text}", worker=worker_name) - if "Server disconnected" in text: - CLOG.error(f"[输出异常] {text}", worker=worker_name) - elif ("not supported" in text) or ("Invalid" in text) or ("invalid" in text): - CLOG.error(f"[转码异常] {text}", worker=worker_name) - except Exception: - pass - - threading.Thread(target=_reader, daemon=True).start() - return p - -def stop_ffmpeg(p: Optional[subprocess.Popen]): - if not p: return - try: - if os.name == "nt": - if p.stdin: - with contextlib.suppress(Exception): - p.stdin.write(b"q"); p.stdin.flush() - p.wait(timeout=6) - else: - p.send_signal(signal.SIGINT) - p.wait(timeout=10) - except Exception: - with contextlib.suppress(Exception): - p.kill() - finally: - # ---- 从全局集合移除 ---- - with ALL_LOCK: - ALL_PROCS.discard(p) - -# =============== 平台 headers/识别 =============== -def headers_for_platform(platform_name: str, live_url: str) -> Optional[str]: - live_domain = '/'.join(live_url.split('/')[0:3]) - record_headers = { - 'PandaTV': 'origin:https://www.pandalive.co.kr', - 'WinkTV': 'origin:https://www.winktv.co.kr', - 'PopkonTV': 'origin:https://www.popkontv.com', - 'FlexTV': 'origin:https://www.flextv.co.kr', - '千度热播': 'referer:https://qiandurebo.com', - '17Live': 'referer:https://17.live/en/live/6302408', - '浪Live': 'referer:https://www.lang.live', - 'shopee': f'origin:{live_domain}', - 'Blued直播': 'referer:https://app.blued.cn' - } - return record_headers.get(platform_name) - -def detect_platform(url: str) -> str: - if "douyin.com" in url: return "抖音直播" - if "tiktok.com" in url: return "TikTok直播" - if url.endswith(".flv") or url.endswith(".m3u8"): return "自定义录制直播" - return "未知平台" - -# =============== 日志状态缓存(去重/限频) =============== -class LogState: - def __init__(self): - self.lock = threading.Lock() - self.url_status: Dict[str, str] = {} # url -> "unknown/offline/online/streaming" - self.no_source_worker: Dict[str, bool] = {} # worker_name -> idle printed flag - - def set_status(self, url: str, status: str, text: Optional[str] = None, prefix: str = ""): - with self.lock: - prev = self.url_status.get(url, "unknown") - if prev != status: - self.url_status[url] = status - if text: - print(f"[{now_str()}]{prefix} {text}") - - def mark_idle_once(self, worker_name: str): - with self.lock: - if not self.no_source_worker.get(worker_name, False): - CLOG.debug("当前无可用直播源,等待中…", worker=worker_name) - self.no_source_worker[worker_name] = True - - def clear_idle_flag(self, worker_name: str): - with self.lock: - self.no_source_worker[worker_name] = False - -LOGSTATE = LogState() - -# =============== URL 协调器(url 级独占,保持原顺序) =============== -class UrlCoordinator: - """ - - urls 顺序分配 - - in_use_urls: url 级别独占(防止同一 url 被不同 worker 同时占用) - """ - def __init__(self, path: str): - self.path = path - self.urls: List[str] = parse_urls_from_file(path) - self.idx: int = 0 - self.lock = threading.Lock() - self.last_md5 = check_md5(path) - self.in_use_urls: Set[str] = set() - - def refresh_if_changed(self): - try: - md5 = check_md5(self.path) - if md5 != self.last_md5: - self.urls = parse_urls_from_file(self.path) or self.urls - self.last_md5 = md5 - CLOG.info(f"已重载 URL 列表,数量:{len(self.urls)}") - except Exception as e: - logger.warning(f"URL 列表热更新失败:{e}") - - def acquire_next_live(self, worker_key: str, quality_code: str = "OD") -> Optional[Tuple[str, dict]]: - """尝试遍历一轮 URL 表,找到首个在播的;否则返回 None。""" - with self.lock: - total = len(self.urls) - if total == 0: - return None - - tried = 0 - while tried < total: - with self.lock: - url = self.urls[self.idx] - self.idx = (self.idx + 1) % total - - if url in self.in_use_urls: - tried += 1 - continue - - # 锁外解析(避免阻塞) - port = asyncio.run(fetch_port_info(url, quality_code)) - anchor = port.get('anchor_name', '') - if not port.get("anchor_name"): - LOGSTATE.set_status(url, "unknown", text=f"解析失败:{url}") - tried += 1 - continue - if not port.get("is_live"): - LOGSTATE.set_status(url, "offline", text=f"未开播:{anchor} | {url}") - tried += 1 - continue - - # 回到锁内占用 - with self.lock: - if url in self.in_use_urls: - tried += 1 - continue - self.in_use_urls.add(url) - - LOGSTATE.set_status(url, "online", text=f"已开播:{anchor} | {url}") - return url, port - - return None - - def release(self, url: Optional[str], worker_key: Optional[str]): - if not url: - return - with self.lock: - self.in_use_urls.discard(url) - -# =============== Worker(保持原业务逻辑框架,加入防抖与探活) =============== -class StreamWorker(threading.Thread): - def __init__(self, worker_id: int, key: str, resolution: Optional[str], coordinator: UrlCoordinator): - super().__init__(daemon=True) - self.worker_id = worker_id - self.key = key - self.worker_name = f"Worker-{worker_id}" - self.resolution = resolution - self.coordinator = coordinator - - self.current_url: Optional[str] = None - self.current_real_url: Optional[str] = None - self.current_proc: Optional[subprocess.Popen] = None - self.current_platform = "" - self.current_anchor = "" - self.record_quality_code = "OD" - - # 与原逻辑一致的参数(稳定优先) - self.watchdog_grace = 120 # 推流启动后宽限期 - self.watchdog_silence = 600 # 10 分钟无输出视为卡死 - self.refresh_real_url_every = 300 # 5 分钟刷新一次真实地址(token 可能过期) - self.check_interval_live = 8 # 在播时的检测间隔 - self.scan_interval_when_idle = 2 # 空闲时的扫描间隔 - - self._last_progress_print = -9999 - self._stop_event = threading.Event() - self.start_time = time.time() - - # 修复点②:新增下播防抖状态(连续多次 is_live=False 才判定) - self._offline_count = 0 - self._last_live_ts = time.time() - - # 软重启冷却(避免频繁重启) - self._last_soft_restart_ts = 0.0 - self._soft_restart_min_interval = 90.0 - - def stop(self): - """收到停止请求时,尽快停掉当前 ffmpeg。""" - self._stop_event.set() - with contextlib.suppress(Exception): - stop_ffmpeg(self.current_proc) - - def _soft_restart_ffmpeg(self, reason: str): - """ - 尝试“软重启”ffmpeg:仅重启进程,不释放 URL 占用,优先保持连续性。 - """ - now = time.time() - if now - self._last_soft_restart_ts < self._soft_restart_min_interval: - CLOG.warn(f"跳过软重启(冷却中): {reason}", worker=self.worker_name) - return False - self._last_soft_restart_ts = now - - try: - stop_ffmpeg(self.current_proc) - except Exception: - pass - - out_url, v_args, a_args = build_youtube_output_for_key(self.key, self.resolution) - headers = headers_for_platform(self.current_platform, self.current_url or "") - if not self.current_real_url: - # 尝试重新解析一遍 - new_port = asyncio.run(fetch_port_info(self.current_url, self.record_quality_code)) if self.current_url else {} - self.current_real_url = select_source_url(self.current_url or "", new_port) - if not self.current_real_url: - CLOG.error("软重启失败:无法获取播放 URL。", worker=self.worker_name) - return False - - self.current_proc = launch_ffmpeg(self.current_real_url, out_url, headers, v_args, a_args, self.worker_name) - self.start_time = time.time() - self._last_progress_print = -9999 - CLOG.ok(f"已软重启 ffmpeg:{reason}", worker=self.worker_name) - return True - - def run(self): - CLOG.ok("启动", worker=self.worker_name) - try: - while not self._stop_event.is_set(): - try: - self.coordinator.refresh_if_changed() - - # ========== 已在推流:健康检测 ========== - if self.current_proc and self.current_url: - retcode = self.current_proc.poll() - if retcode is not None: - CLOG.error(f"ffmpeg 退出(code={retcode}),切换下一个源…", worker=self.worker_name) - stop_ffmpeg(self.current_proc) - self.coordinator.release(self.current_url, self.key) - self._clear_state() - LOGSTATE.clear_idle_flag(self.worker_name) - continue - - # 检测是否仍在直播(加入“防抖 + 交叉探活”) - still_live = True - is_live_raw = True - try: - port = asyncio.run(fetch_port_info(self.current_url, self.record_quality_code)) - is_live_raw = bool(port.get("is_live")) - except Exception as e: - CLOG.warn(f"检测当前源异常:{e}", worker=self.worker_name) - is_live_raw = True # 网络/接口异常默认不下播 - - if not is_live_raw: - # 交叉探活:若当前 real_url 还能探测成功,认为仍在播 - alive_by_probe = probe_stream_url(self.current_real_url) - if alive_by_probe: - CLOG.warn("API 返回下播但探活成功,视为仍在播。", worker=self.worker_name) - is_live_raw = True - - # 下播防抖:连续 N 次失败 且 距最后一次活跃已超过 M 秒 - if is_live_raw: - self._offline_count = 0 - self._last_live_ts = time.time() - still_live = True - else: - self._offline_count += 1 - if time.time() - self._last_live_ts < 120: - CLOG.warn(f"短期异常 is_live=False({self._offline_count}),已忽略", worker=self.worker_name) - still_live = True - elif self._offline_count < 3: - CLOG.warn(f"疑似下播 {self._offline_count}/3 次,继续观望", worker=self.worker_name) - still_live = True - else: - still_live = False # 多次确认才下播 - - if still_live: - elapsed = int(time.time() - self.start_time) - # 每 60s 打印一次“正在转播” - if elapsed - self._last_progress_print >= 60: - h, m = divmod(elapsed, 3600) - m, s = divmod(m, 60) - dur_str = f"{h:02d}:{m:02d}:{s:02d}" - CLOG.live(f"正在转播({dur_str}):{self.current_platform} | {self.current_anchor}", worker=self.worker_name) - self._last_progress_print = elapsed - - # 定期刷新真实播放地址(token) - try: - if elapsed >= self.refresh_real_url_every and (elapsed % self.refresh_real_url_every) < self.check_interval_live: - new_port = asyncio.run(fetch_port_info(self.current_url, self.record_quality_code)) - new_real_url = select_source_url(self.current_url, new_port) - if new_real_url and new_real_url != self.current_real_url: - CLOG.swap("刷新播放地址(token 变化),软重启 ffmpeg…", worker=self.worker_name) - stop_ffmpeg(self.current_proc) - out_url, v_args, a_args = build_youtube_output_for_key(self.key, self.resolution) - headers = headers_for_platform(self.current_platform, self.current_url) - self.current_proc = launch_ffmpeg(new_real_url, out_url, headers, v_args, a_args, self.worker_name) - self.current_real_url = new_real_url - self.start_time = time.time() - self._last_progress_print = -9999 - time.sleep(self.check_interval_live) - continue - except Exception as e: - CLOG.warn(f"刷新播放地址失败:{e}", worker=self.worker_name) - - # 看门狗:长时间无输出 -> 先软重启,如果软重启失败再释放 - silence = time.time() - getattr(self.current_proc, "last_out_ts", 0) - if elapsed >= self.watchdog_grace and silence > self.watchdog_silence: - CLOG.warn("超过10分钟未见输出,尝试软重启…", worker=self.worker_name) - ok = self._soft_restart_ffmpeg("watchdog silence") - if not ok: - CLOG.warn("软重启失败,释放当前源并切换…", worker=self.worker_name) - stop_ffmpeg(self.current_proc) - self._clear_state(release=True) - LOGSTATE.clear_idle_flag(self.worker_name) - time.sleep(1) - else: - time.sleep(self.check_interval_live) - continue - - time.sleep(self.check_interval_live) - continue - else: - CLOG.off("多次确认主播下播,切换下一个源…", worker=self.worker_name) - stop_ffmpeg(self.current_proc) - self._clear_state(release=True) - LOGSTATE.clear_idle_flag(self.worker_name) - continue - - # ========== 未在推流:尝试分配新源 ========== - got = self.coordinator.acquire_next_live(self.key, self.record_quality_code) - if not got: - LOGSTATE.mark_idle_once(self.worker_name) - time.sleep(self.scan_interval_when_idle) - continue - - LOGSTATE.clear_idle_flag(self.worker_name) - url, port = got - real_url = select_source_url(url, port) - if not real_url: - CLOG.warn(f"未得到播放地址,跳过:{url}", worker=self.worker_name) - self.coordinator.release(url, self.key) - time.sleep(1) - continue - - # 新源上线前:探活(避免刚拿到 URL 就空推) - if not probe_stream_url(real_url): - CLOG.warn(f"播放 URL 探活失败(可能刚开播或接口延迟),暂缓此源:{url}", worker=self.worker_name) - self.coordinator.release(url, self.key) - time.sleep(1) - continue - - self.current_platform = detect_platform(url) - self.current_anchor = port.get("anchor_name", "") - headers = headers_for_platform(self.current_platform, url) - out_url, v_args, a_args = build_youtube_output_for_key(self.key, self.resolution) - self.current_proc = launch_ffmpeg(real_url, out_url, headers, v_args, a_args, self.worker_name) - self.current_url = url - self.current_real_url = real_url - self.start_time = time.time() - self._last_progress_print = -9999 - self._offline_count = 0 - self._last_live_ts = time.time() - - CLOG.ok(f"开始转播:{self.current_platform} | {self.current_anchor} | {url}", worker=self.worker_name) - - except Exception as e: - # 关键:任何内部异常不让线程退出 - CLOG.error(f"内部异常:{e}", worker=self.worker_name) - time.sleep(2) - continue - - finally: - with contextlib.suppress(Exception): - stop_ffmpeg(self.current_proc) - self.coordinator.release(self.current_url, self.key) - CLOG.off("已退出。", worker=self.worker_name) - - def _clear_state(self, release: bool = False): - if release and self.current_url: - self.coordinator.release(self.current_url, self.key) - self.current_url = None - self.current_real_url = None - self.current_proc = None - self.current_platform = "" - self.current_anchor = "" - -# =============== 启动入口(保持原行为) =============== -def check_ffmpeg_existence() -> bool: - try: - result = subprocess.run(['ffmpeg', '-version'], check=True, capture_output=True, text=True) - if result.returncode == 0: - print(result.stdout.splitlines()[0]) # 贴合旧风格:打印版本第一行 - except Exception: - pass - finally: - return bool(check_ffmpeg()) - -# ---- 统一优雅退出 ---- -def graceful_shutdown(signum=None, frame=None): - if not SHUTDOWN_EVENT.is_set(): - print("\n收到中断,正在停止所有 worker 与 ffmpeg …") - SHUTDOWN_EVENT.set() - # 先发 stop 给所有 worker(会内部 stop 当前 ffmpeg) - for w in list(ALL_WORKERS): - with contextlib.suppress(Exception): - w.stop() - # 再兜底停止全局登记的 ffmpeg 子进程 - with ALL_LOCK: - procs = list(ALL_PROCS) - for p in procs: - with contextlib.suppress(Exception): - stop_ffmpeg(p) - -def main(): - print("DouyinRelay", version) - print("-----------------------------------------") - - if not check_ffmpeg_existence(): - logger.error("缺少 ffmpeg,程序退出") - sys.exit(1) - - urls = parse_urls_from_file(url_config_file) - if not urls: - logger.error("URL_config.ini 中没有可用 URL。") - sys.exit(1) - - # keys(并行路数 = keys 数量);兼容单 key - keys = _yt_get_list("youtube", "keys") - if not keys: - single_key = _yt_get("youtube", "key", fallback="", cast=str) - if single_key: - keys = [single_key] - if not keys: - logger.error("youtube.ini 没有配置 [youtube].keys 或 key") - sys.exit(1) - - # 可选:每路分辨率 - per_res = _yt_get_list("youtube", "resolutions") # 如:1920x1080,1080x1920 - while per_res and len(per_res) < len(keys): - per_res.append(per_res[-1]) - - # 打印 ffmpeg 版本(贴合旧风格) - try: - vline = subprocess.run(['ffmpeg', '-version'], check=True, capture_output=True, text=True).stdout.splitlines()[0] - print(vline) - except Exception: - pass - - coordinator = UrlCoordinator(url_config_file) - - # 信号处理(Ctrl+C / kill) - with contextlib.suppress(Exception): - signal.signal(signal.SIGINT, graceful_shutdown) - with contextlib.suppress(Exception): - signal.signal(signal.SIGTERM, graceful_shutdown) - - # 启动多路(Worker 前缀区分) - workers: List[StreamWorker] = [] - for i, key in enumerate(keys, start=1): - res_for_this = per_res[i-1] if per_res else None - w = StreamWorker(worker_id=i, key=key, resolution=res_for_this, coordinator=coordinator) - w.start() - workers.append(w) - ALL_WORKERS.append(w) - - try: - while not SHUTDOWN_EVENT.is_set(): - time.sleep(0.5) - except KeyboardInterrupt: - graceful_shutdown() - finally: - graceful_shutdown() - for w in workers: - w.join(timeout=10) - print("全部 worker 已退出。") - -if __name__ == "__main__": - main() diff --git a/msg_push.py b/msg_push.py deleted file mode 100644 index c682b20..0000000 --- a/msg_push.py +++ /dev/null @@ -1,295 +0,0 @@ -# -*- coding: utf-8 -*- - -""" -Author: Hmily -GitHub: https://github.com/ihmily -Date: 2023-09-03 19:18:36 -Update: 2025-01-23 17:16:12 -Copyright (c) 2023-2024 by Hmily, All Rights Reserved. -""" -from typing import Dict, Any -import json -import base64 -import urllib.request -import urllib.error -import smtplib -from email.header import Header -from email.mime.multipart import MIMEMultipart -from email.mime.text import MIMEText - -no_proxy_handler = urllib.request.ProxyHandler({}) -opener = urllib.request.build_opener(no_proxy_handler) -headers: Dict[str, str] = {'Content-Type': 'application/json'} - - -def dingtalk(url: str, content: str, number: str = None, is_atall: bool = False) -> Dict[str, Any]: - success = [] - error = [] - api_list = url.replace(',', ',').split(',') if url.strip() else [] - for api in api_list: - json_data = { - 'msgtype': 'text', - 'text': { - 'content': content, - }, - "at": { - "atMobiles": [ - number - ], - "isAtAll": is_atall - }, - } - try: - data = json.dumps(json_data).encode('utf-8') - req = urllib.request.Request(api, data=data, headers=headers) - response = opener.open(req, timeout=10) - json_str = response.read().decode('utf-8') - json_data = json.loads(json_str) - if json_data['errcode'] == 0: - success.append(api) - else: - error.append(api) - print(f'钉钉推送失败, 推送地址:{api}, {json_data["errmsg"]}') - except Exception as e: - error.append(api) - print(f'钉钉推送失败, 推送地址:{api}, 错误信息:{e}') - return {"success": success, "error": error} - - -def xizhi(url: str, title: str, content: str) -> Dict[str, Any]: - success = [] - error = [] - api_list = url.replace(',', ',').split(',') if url.strip() else [] - for api in api_list: - json_data = { - 'title': title, - 'content': content - } - try: - data = json.dumps(json_data).encode('utf-8') - req = urllib.request.Request(api, data=data, headers=headers) - response = opener.open(req, timeout=10) - json_str = response.read().decode('utf-8') - json_data = json.loads(json_str) - if json_data['code'] == 200: - success.append(api) - else: - error.append(api) - print(f'微信推送失败, 推送地址:{api}, 失败信息:{json_data["msg"]}') - except Exception as e: - error.append(api) - print(f'微信推送失败, 推送地址:{api}, 错误信息:{e}') - return {"success": success, "error": error} - - -def send_email(email_host: str, login_email: str, email_pass: str, sender_email: str, sender_name: str, - to_email: str, title: str, content: str, smtp_port: str = None, open_ssl: bool = True) -> Dict[str, Any]: - receivers = to_email.replace(',', ',').split(',') if to_email.strip() else [] - - try: - message = MIMEMultipart() - send_name = base64.b64encode(sender_name.encode("utf-8")).decode() - message['From'] = f'=?UTF-8?B?{send_name}?= <{sender_email}>' - message['Subject'] = Header(title, 'utf-8') - if len(receivers) == 1: - message['To'] = receivers[0] - - t_apart = MIMEText(content, 'plain', 'utf-8') - message.attach(t_apart) - - if open_ssl: - smtp_port = int(smtp_port) or 465 - smtp_obj = smtplib.SMTP_SSL(email_host, smtp_port) - else: - smtp_port = int(smtp_port) or 25 - smtp_obj = smtplib.SMTP(email_host, smtp_port) - smtp_obj.login(login_email, email_pass) - smtp_obj.sendmail(sender_email, receivers, message.as_string()) - return {"success": receivers, "error": []} - except smtplib.SMTPException as e: - print(f'邮件推送失败, 推送邮箱:{to_email}, 错误信息:{e}') - return {"success": [], "error": receivers} - - -def tg_bot(chat_id: int, token: str, content: str) -> Dict[str, Any]: - try: - json_data = { - "chat_id": chat_id, - 'text': content - } - url = f'https://api.telegram.org/bot{token}/sendMessage' - data = json.dumps(json_data).encode('utf-8') - req = urllib.request.Request(url, data=data, headers=headers) - response = urllib.request.urlopen(req, timeout=15) - json_str = response.read().decode('utf-8') - _json_data = json.loads(json_str) - return {"success": [1], "error": []} - except Exception as e: - print(f'tg推送失败, 聊天ID:{chat_id}, 错误信息:{e}') - return {"success": [], "error": [1]} - - -def bark(api: str, title: str = "message", content: str = 'test', level: str = "active", - badge: int = 1, auto_copy: int = 1, sound: str = "", icon: str = "", group: str = "", - is_archive: int = 1, url: str = "") -> Dict[str, Any]: - success = [] - error = [] - api_list = api.replace(',', ',').split(',') if api.strip() else [] - for _api in api_list: - json_data = { - "title": title, - "body": content, - "level": level, - "badge": badge, - "autoCopy": auto_copy, - "sound": sound, - "icon": icon, - "group": group, - "isArchive": is_archive, - "url": url - } - try: - data = json.dumps(json_data).encode('utf-8') - req = urllib.request.Request(_api, data=data, headers=headers) - response = opener.open(req, timeout=10) - json_str = response.read().decode("utf-8") - json_data = json.loads(json_str) - if json_data['code'] == 200: - success.append(_api) - else: - error.append(_api) - print(f'Bark推送失败, 推送地址:{_api}, 失败信息:{json_data["message"]}') - except Exception as e: - error.append(api) - print(f'Bark推送失败, 推送地址:{_api}, 错误信息:{e}') - return {"success": success, "error": error} - - -def ntfy(api: str, title: str = "message", content: str = 'test', tags: str = 'tada', priority: int = 3, - action_url: str = "", attach: str = "", filename: str = "", click: str = "", icon: str = "", - delay: str = "", email: str = "", call: str = "") -> Dict[str, Any]: - success = [] - error = [] - api_list = api.replace(',', ',').split(',') if api.strip() else [] - tags = tags.replace(',', ',').split(',') if tags else ['partying_face'] - actions = [{"action": "view", "label": "view live", "url": action_url}] if action_url else [] - for _api in api_list: - server, topic = _api.rsplit('/', maxsplit=1) - json_data = { - "topic": topic, - "title": title, - "message": content, - "tags": tags, - "priority": priority, - "attach": attach, - "filename": filename, - "click": click, - "actions": actions, - "markdown": False, - "icon": icon, - "delay": delay, - "email": email, - "call": call - } - - try: - data = json.dumps(json_data, ensure_ascii=False).encode('utf-8') - req = urllib.request.Request(server, data=data, headers=headers) - response = opener.open(req, timeout=10) - json_str = response.read().decode("utf-8") - json_data = json.loads(json_str) - if "error" not in json_data: - success.append(_api) - else: - error.append(_api) - print(f'ntfy推送失败, 推送地址:{_api}, 失败信息:{json_data["error"]}') - except urllib.error.HTTPError as e: - error.append(_api) - error_msg = e.read().decode("utf-8") - print(f'ntfy推送失败, 推送地址:{_api}, 错误信息:{json.loads(error_msg)["error"]}') - except Exception as e: - error.append(api) - print(f'ntfy推送失败, 推送地址:{_api}, 错误信息:{e}') - return {"success": success, "error": error} - - -def pushplus(token: str, title: str, content: str) -> Dict[str, Any]: - """ - PushPlus推送通知 - API文档: https://www.pushplus.plus/doc/ - """ - success = [] - error = [] - token_list = token.replace(',', ',').split(',') if token.strip() else [] - - for _token in token_list: - json_data = { - 'token': _token, - 'title': title, - 'content': content - } - - try: - url = 'https://www.pushplus.plus/send' - data = json.dumps(json_data).encode('utf-8') - req = urllib.request.Request(url, data=data, headers=headers) - response = opener.open(req, timeout=10) - json_str = response.read().decode('utf-8') - json_data = json.loads(json_str) - - if json_data.get('code') == 200: - success.append(_token) - else: - error.append(_token) - print(f'PushPlus推送失败, Token:{_token}, 失败信息:{json_data.get("msg", "未知错误")}') - except Exception as e: - error.append(_token) - print(f'PushPlus推送失败, Token:{_token}, 错误信息:{e}') - - return {"success": success, "error": error} - - -if __name__ == '__main__': - send_title = '直播通知' # 标题 - send_content = '张三 开播了!' # 推送内容 - - # 钉钉推送通知 - webhook_api = '' # 替换成自己Webhook链接,参考文档:https://open.dingtalk.com/document/robots/custom-robot-access - phone_number = '' # 被@用户的手机号码 - is_atall = '' # 是否@全体 - # dingtalk(webhook_api, send_content, phone_number) - - # 微信推送通知 - # 替换成自己的单点推送接口,获取地址:https://xz.qqoq.net/#/admin/one - # 当然也可以使用其他平台API 如server酱 使用方法一样 - xizhi_api = 'https://xizhi.qqoq.net/xxxxxxxxx.send' - # xizhi(xizhi_api, send_content) - - # telegram推送通知 - tg_token = '' # tg搜索"BotFather"获取的token值 - tg_chat_id = 000000 # tg搜索"userinfobot"获取的chat_id值,即可发送推送消息给你自己,如果下面的是群组id则发送到群 - # tg_bot(tg_chat_id, tg_token, send_content) - - # email_message( - # email_host="smtp.qq.com", - # login_email="", - # email_pass="", - # sender_email="", - # sender_name="", - # to_email="", - # title="", - # content="", - # ) - - bark_url = 'https://xxx.xxx.com/key/' - # bark(bark_url, send_title, send_content) - - ntfy( - api="https://ntfy.sh/xxxxx", - title="直播推送", - content="xxx已开播", - ) - - # PushPlus推送通知 - pushplus_token = '' # 替换成自己的PushPlus Token,获取地址:https://www.pushplus.plus/ - # pushplus(pushplus_token, send_title, send_content) diff --git a/out.html b/out.html deleted file mode 100644 index 65410d1..0000000 --- a/out.html +++ /dev/null @@ -1,207 +0,0 @@ - - - - -验证码中间页 - - - - -
-
- - - - - -
- - - \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 18f105b..0000000 --- a/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -requests>=2.31.0 -loguru>=0.7.3 -pycryptodome>=3.20.0 -distro>=1.9.0 -tqdm>=4.67.1 -httpx[http2]>=0.28.1 -PyExecJS>=1.5.1 \ No newline at end of file diff --git a/src/ab_sign.py b/src/ab_sign.py new file mode 100644 index 0000000..ad04d32 --- /dev/null +++ b/src/ab_sign.py @@ -0,0 +1,454 @@ +# -*- encoding: utf-8 -*- +import math +import time + + +def rc4_encrypt(plaintext: str, key: str) -> str: + # 初始化状态数组 + s = list(range(256)) + + # 使用密钥对状态数组进行置换 + j = 0 + for i in range(256): + j = (j + s[i] + ord(key[i % len(key)])) % 256 + s[i], s[j] = s[j], s[i] + + # 生成密钥流并加密 + i = j = 0 + result = [] + for char in plaintext: + i = (i + 1) % 256 + j = (j + s[i]) % 256 + s[i], s[j] = s[j], s[i] + t = (s[i] + s[j]) % 256 + result.append(chr(s[t] ^ ord(char))) + + return ''.join(result) + + +def left_rotate(x: int, n: int) -> int: + n %= 32 + return ((x << n) | (x >> (32 - n))) & 0xFFFFFFFF + + +def get_t_j(j: int) -> int: + if 0 <= j < 16: + return 2043430169 # 0x79CC4519 + elif 16 <= j < 64: + return 2055708042 # 0x7A879D8A + else: + raise ValueError("invalid j for constant Tj") + + +def ff_j(j: int, x: int, y: int, z: int) -> int: + if 0 <= j < 16: + return (x ^ y ^ z) & 0xFFFFFFFF + elif 16 <= j < 64: + return ((x & y) | (x & z) | (y & z)) & 0xFFFFFFFF + else: + raise ValueError("invalid j for bool function FF") + + +def gg_j(j: int, x: int, y: int, z: int) -> int: + if 0 <= j < 16: + return (x ^ y ^ z) & 0xFFFFFFFF + elif 16 <= j < 64: + return ((x & y) | (~x & z)) & 0xFFFFFFFF + else: + raise ValueError("invalid j for bool function GG") + + +class SM3: + def __init__(self): + self.reg = [] + self.chunk = [] + self.size = 0 + self.reset() + + def reset(self): + # 初始化寄存器值 - 修正为与JS版本相同的值 + self.reg = [ + 1937774191, 1226093241, 388252375, 3666478592, + 2842636476, 372324522, 3817729613, 2969243214 + ] + self.chunk = [] + self.size = 0 + + def write(self, data): + # 将输入转换为字节数组 + if isinstance(data, str): + # 直接转换为UTF-8字节列表 + a = list(data.encode('utf-8')) + else: + a = data + + self.size += len(a) + f = 64 - len(self.chunk) + + if len(a) < f: + # 如果数据长度小于剩余空间,直接添加 + self.chunk.extend(a) + else: + # 否则分块处理 + self.chunk.extend(a[:f]) + + while len(self.chunk) >= 64: + self._compress(self.chunk) + if f < len(a): + self.chunk = a[f:min(f + 64, len(a))] + else: + self.chunk = [] + f += 64 + + def _fill(self): + # 计算比特长度 + bit_length = 8 * self.size + + # 添加填充位 + padding_pos = len(self.chunk) + self.chunk.append(0x80) + padding_pos = (padding_pos + 1) % 64 + + # 如果剩余空间不足8字节,则填充到下一个块 + if 64 - padding_pos < 8: + padding_pos -= 64 + + # 填充0直到剩余8字节用于存储长度 + while padding_pos < 56: + self.chunk.append(0) + padding_pos += 1 + + # 添加消息长度(高32位) + high_bits = bit_length // 4294967296 + for i in range(4): + self.chunk.append((high_bits >> (8 * (3 - i))) & 0xFF) + + # 添加消息长度(低32位) + for i in range(4): + self.chunk.append((bit_length >> (8 * (3 - i))) & 0xFF) + + def _compress(self, data): + if len(data) < 64: + raise ValueError("compress error: not enough data") + else: + # 消息扩展 + w = [0] * 132 + + # 将字节数组转换为字 + for t in range(16): + w[t] = (data[4 * t] << 24) | (data[4 * t + 1] << 16) | (data[4 * t + 2] << 8) | data[4 * t + 3] + w[t] &= 0xFFFFFFFF + + # 消息扩展 + for j in range(16, 68): + a = w[j - 16] ^ w[j - 9] ^ left_rotate(w[j - 3], 15) + a = a ^ left_rotate(a, 15) ^ left_rotate(a, 23) + w[j] = (a ^ left_rotate(w[j - 13], 7) ^ w[j - 6]) & 0xFFFFFFFF + + # 计算w' + for j in range(64): + w[j + 68] = (w[j] ^ w[j + 4]) & 0xFFFFFFFF + + # 压缩 + a, b, c, d, e, f, g, h = self.reg + + for j in range(64): + ss1 = left_rotate((left_rotate(a, 12) + e + left_rotate(get_t_j(j), j)) & 0xFFFFFFFF, 7) + ss2 = ss1 ^ left_rotate(a, 12) + tt1 = (ff_j(j, a, b, c) + d + ss2 + w[j + 68]) & 0xFFFFFFFF + tt2 = (gg_j(j, e, f, g) + h + ss1 + w[j]) & 0xFFFFFFFF + + d = c + c = left_rotate(b, 9) + b = a + a = tt1 + h = g + g = left_rotate(f, 19) + f = e + e = (tt2 ^ left_rotate(tt2, 9) ^ left_rotate(tt2, 17)) & 0xFFFFFFFF + + # 更新寄存器 + self.reg[0] ^= a + self.reg[1] ^= b + self.reg[2] ^= c + self.reg[3] ^= d + self.reg[4] ^= e + self.reg[5] ^= f + self.reg[6] ^= g + self.reg[7] ^= h + + def sum(self, data=None, output_format=None): + """ + 计算哈希值 + """ + # 如果提供了输入,则重置并写入 + if data is not None: + self.reset() + self.write(data) + + self._fill() + + # 分块压缩 + for f in range(0, len(self.chunk), 64): + self._compress(self.chunk[f:f + 64]) + + if output_format == 'hex': + # 十六进制输出 + result = ''.join(f'{val:08x}' for val in self.reg) + else: + # 字节数组输出 + result = [] + for f in range(8): + c = self.reg[f] + result.append((c >> 24) & 0xFF) + result.append((c >> 16) & 0xFF) + result.append((c >> 8) & 0xFF) + result.append(c & 0xFF) + + self.reset() + return result + + +def result_encrypt(long_str: str, num: str | None = None) -> str: + # 魔改base64编码表 + encoding_tables = { + "s0": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=", + "s1": "Dkdpgh4ZKsQB80/Mfvw36XI1R25+WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=", + "s2": "Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=", + "s3": "ckdp1h4ZKsUB80/Mfvw36XIgR25+WQAlEi7NLboqYTOPuzmFjJnryx9HVGDaStCe", + "s4": "Dkdpgh2ZmsQB80/MfvV36XI1R45-WUAlEixNLwoqYTOPuzKFjJnry79HbGcaStCe" + } + + # 位移常量 + masks = [16515072, 258048, 4032, 63] # 对应 0, 1, 2 的掩码,添加63作为第四个掩码 + shifts = [18, 12, 6, 0] # 对应的位移量 + + encoding_table = encoding_tables[num] + + result = "" + round_num = 0 + long_int = get_long_int(round_num, long_str) + + total_chars = math.ceil(len(long_str) / 3 * 4) + + for i in range(total_chars): + # 每4个字符处理一组3字节 + if i // 4 != round_num: + round_num += 1 + long_int = get_long_int(round_num, long_str) + + # 计算当前位置的索引 + index = i % 4 + + # 使用掩码和位移提取6位值 + char_index = (long_int & masks[index]) >> shifts[index] + + result += encoding_table[char_index] + + return result + + +def get_long_int(round_num: int, long_str: str) -> int: + round_num = round_num * 3 + + # 获取字符串中的字符,如果超出范围则使用0 + char1 = ord(long_str[round_num]) if round_num < len(long_str) else 0 + char2 = ord(long_str[round_num + 1]) if round_num + 1 < len(long_str) else 0 + char3 = ord(long_str[round_num + 2]) if round_num + 2 < len(long_str) else 0 + + return (char1 << 16) | (char2 << 8) | char3 + + +def gener_random(random_num: int, option: list[int]) -> list[int]: + byte1 = random_num & 255 + byte2 = (random_num >> 8) & 255 + + return [ + (byte1 & 170) | (option[0] & 85), # 偶数位与option[0]的奇数位合并 + (byte1 & 85) | (option[0] & 170), # 奇数位与option[0]的偶数位合并 + (byte2 & 170) | (option[1] & 85), # 偶数位与option[1]的奇数位合并 + (byte2 & 85) | (option[1] & 170), # 奇数位与option[1]的偶数位合并 + ] + + +def generate_random_str() -> str: + """ + 生成随机字符串 + + Returns: + 随机字符串 + """ + # 使用与JS版本相同的固定随机值 + random_values = [0.123456789, 0.987654321, 0.555555555] + + # 生成三组随机字节并合并 + random_bytes = [] + random_bytes.extend(gener_random(int(random_values[0] * 10000), [3, 45])) + random_bytes.extend(gener_random(int(random_values[1] * 10000), [1, 0])) + random_bytes.extend(gener_random(int(random_values[2] * 10000), [1, 5])) + + return ''.join(chr(b) for b in random_bytes) + + +def generate_rc4_bb_str(url_search_params: str, user_agent: str, window_env_str: str, + suffix: str = "cus", arguments: list[int] | None = None) -> str: + if arguments is None: + arguments = [0, 1, 14] + + sm3 = SM3() + start_time = int(time.time() * 1000) + + # 三次加密处理 + # 1: url_search_params两次sm3之的结果 + url_search_params_list = sm3.sum(sm3.sum(url_search_params + suffix)) + # 2: 对后缀两次sm3之的结果 + cus = sm3.sum(sm3.sum(suffix)) + # 3: 对ua处理之后的结果 + ua_key = chr(0) + chr(1) + chr(14) # [1/256, 1, 14] + ua = sm3.sum(result_encrypt( + rc4_encrypt(user_agent, ua_key), + "s3" + )) + + end_time = start_time + 100 + + # 构建配置对象 + b = { + 8: 3, + 10: end_time, + 15: { + "aid": 6383, + "pageId": 110624, + "boe": False, + "ddrt": 7, + "paths": { + "include": [{} for _ in range(7)], + "exclude": [] + }, + "track": { + "mode": 0, + "delay": 300, + "paths": [] + }, + "dump": True, + "rpU": "hwj" + }, + 16: start_time, + 18: 44, + 19: [1, 0, 1, 5], + } + + def split_to_bytes(num: int) -> list[int]: + return [ + (num >> 24) & 255, + (num >> 16) & 255, + (num >> 8) & 255, + num & 255 + ] + + # 处理时间戳 + start_time_bytes = split_to_bytes(b[16]) + b[20] = start_time_bytes[0] + b[21] = start_time_bytes[1] + b[22] = start_time_bytes[2] + b[23] = start_time_bytes[3] + b[24] = int(b[16] / 256 / 256 / 256 / 256) & 255 + b[25] = int(b[16] / 256 / 256 / 256 / 256 / 256) & 255 + + # 处理Arguments参数 + arg0_bytes = split_to_bytes(arguments[0]) + b[26] = arg0_bytes[0] + b[27] = arg0_bytes[1] + b[28] = arg0_bytes[2] + b[29] = arg0_bytes[3] + + b[30] = int(arguments[1] / 256) & 255 + b[31] = (arguments[1] % 256) & 255 + + arg1_bytes = split_to_bytes(arguments[1]) + b[32] = arg1_bytes[0] + b[33] = arg1_bytes[1] + + arg2_bytes = split_to_bytes(arguments[2]) + b[34] = arg2_bytes[0] + b[35] = arg2_bytes[1] + b[36] = arg2_bytes[2] + b[37] = arg2_bytes[3] + + # 处理加密结果 + b[38] = url_search_params_list[21] + b[39] = url_search_params_list[22] + b[40] = cus[21] + b[41] = cus[22] + b[42] = ua[23] + b[43] = ua[24] + + # 处理结束时间 + end_time_bytes = split_to_bytes(b[10]) + b[44] = end_time_bytes[0] + b[45] = end_time_bytes[1] + b[46] = end_time_bytes[2] + b[47] = end_time_bytes[3] + b[48] = b[8] + b[49] = int(b[10] / 256 / 256 / 256 / 256) & 255 + b[50] = int(b[10] / 256 / 256 / 256 / 256 / 256) & 255 + + # 处理配置项 + b[51] = b[15]['pageId'] + + page_id_bytes = split_to_bytes(b[15]['pageId']) + b[52] = page_id_bytes[0] + b[53] = page_id_bytes[1] + b[54] = page_id_bytes[2] + b[55] = page_id_bytes[3] + + b[56] = b[15]['aid'] + b[57] = b[15]['aid'] & 255 + b[58] = (b[15]['aid'] >> 8) & 255 + b[59] = (b[15]['aid'] >> 16) & 255 + b[60] = (b[15]['aid'] >> 24) & 255 + + # 处理环境信息 + window_env_list = [ord(char) for char in window_env_str] + b[64] = len(window_env_list) + b[65] = b[64] & 255 + b[66] = (b[64] >> 8) & 255 + + b[69] = 0 + b[70] = 0 + b[71] = 0 + + # 计算校验和 + b[72] = b[18] ^ b[20] ^ b[26] ^ b[30] ^ b[38] ^ b[40] ^ b[42] ^ b[21] ^ b[27] ^ b[31] ^ \ + b[35] ^ b[39] ^ b[41] ^ b[43] ^ b[22] ^ b[28] ^ b[32] ^ b[36] ^ b[23] ^ b[29] ^ \ + b[33] ^ b[37] ^ b[44] ^ b[45] ^ b[46] ^ b[47] ^ b[48] ^ b[49] ^ b[50] ^ b[24] ^ \ + b[25] ^ b[52] ^ b[53] ^ b[54] ^ b[55] ^ b[57] ^ b[58] ^ b[59] ^ b[60] ^ b[65] ^ \ + b[66] ^ b[70] ^ b[71] + + # 构建最终字节数组 + bb = [ + b[18], b[20], b[52], b[26], b[30], b[34], b[58], b[38], b[40], b[53], b[42], b[21], + b[27], b[54], b[55], b[31], b[35], b[57], b[39], b[41], b[43], b[22], b[28], b[32], + b[60], b[36], b[23], b[29], b[33], b[37], b[44], b[45], b[59], b[46], b[47], b[48], + b[49], b[50], b[24], b[25], b[65], b[66], b[70], b[71] + ] + bb.extend(window_env_list) + bb.append(b[72]) + + return rc4_encrypt( + ''.join(chr(byte) for byte in bb), + chr(121) + ) + + +def ab_sign(url_search_params: str, user_agent: str) -> str: + window_env_str = "1920|1080|1920|1040|0|30|0|0|1872|92|1920|1040|1857|92|1|24|Win32" + + # 1. 生成随机字符串前缀 + # 2. 生成RC4加密的主体部分 + # 3. 对结果进行最终加密并添加等号后缀 + return result_encrypt( + generate_random_str() + + generate_rc4_bb_str(url_search_params, user_agent, window_env_str), + "s4" + ) + "=" diff --git a/src/spider.py b/src/spider.py index 2b5c682..b0a9304 100644 --- a/src/spider.py +++ b/src/spider.py @@ -4,7 +4,7 @@ Author: Hmily GitHub: https://github.com/ihmily Date: 2023-07-15 23:15:00 -Update: 2025-07-19 17:43:00 +Update: 2025-10-23 18:28:00 Copyright (c) 2023-2025 by Hmily, All Rights Reserved. Function: Get live stream data. """ @@ -29,6 +29,7 @@ from .utils import trace_error_decorator, generate_random_string from .logger import script_path from .room import get_sec_user_id, get_unique_id, UnsupportedUrlError from .http_clients.async_http import async_req +from .ab_sign import ab_sign ssl_context = ssl.create_default_context() @@ -64,10 +65,87 @@ async def get_play_url_list(m3u8: str, proxy: OptionalStr = None, header: Option return play_url_list +async def get_douyin_web_stream_data(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None): + headers = { + 'cookie': 'ttwid=1%7C2iDIYVmjzMcpZ20fcaFde0VghXAA3NaNXE_SLR68IyE%7C1761045455' + '%7Cab35197d5cfb21df6cbb2fa7ef1c9262206b062c315b9d04da746d0b37dfbc7d', + 'referer': 'https://live.douyin.com/335354047186', + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/116.0.5845.97 Safari/537.36 Core/1.116.567.400 QQBrowser/19.7.6764.400', + } + if cookies: + headers['cookie'] = cookies + + try: + web_rid = url.split('?')[0].split('live.douyin.com/')[-1] + params = { + "aid": "6383", + "app_name": "douyin_web", + "live_id": "1", + "device_platform": "web", + "language": "zh-CN", + "browser_language": "zh-CN", + "browser_platform": "Win32", + "browser_name": "Chrome", + "browser_version": "116.0.0.0", + "web_rid": web_rid, + 'msToken': '', + } + + api = f'https://live.douyin.com/webcast/room/web/enter/?{urllib.parse.urlencode(params)}' + a_bogus = ab_sign(urllib.parse.urlparse(api).query, headers['user-agent']) + api += "&a_bogus=" + a_bogus + try: + json_str = await async_req(url=api, proxy_addr=proxy_addr, headers=headers) + if not json_str: + raise Exception("it triggered risk control") + json_data = json.loads(json_str)['data'] + if not json_data['data']: + raise Exception(f"{url} VR live is not supported") + room_data = json_data['data'][0] + room_data['anchor_name'] = json_data['user']['nickname'] + except Exception as e: + raise Exception(f"Douyin web data fetch error, because {e}.") + + if room_data['status'] == 2: + if 'stream_url' not in room_data: + raise RuntimeError( + "The live streaming type or gameplay is not supported on the computer side yet, please use the " + "app to share the link for recording." + ) + live_core_sdk_data = room_data['stream_url']['live_core_sdk_data'] + pull_datas = room_data['stream_url']['pull_datas'] + if live_core_sdk_data: + if pull_datas: + key = list(pull_datas.keys())[0] + json_str = pull_datas[key]['stream_data'] + else: + json_str = live_core_sdk_data['pull_data']['stream_data'] + json_data = json.loads(json_str) + if 'origin' in json_data['data']: + stream_data = live_core_sdk_data['pull_data']['stream_data'] + origin_data = json.loads(stream_data)['data']['origin']['main'] + sdk_params = json.loads(origin_data['sdk_params']) + origin_hls_codec = sdk_params.get('VCodec') or '' + + origin_url_list = json_data['data']['origin']['main'] + origin_m3u8 = {'ORIGIN': origin_url_list["hls"] + '&codec=' + origin_hls_codec} + origin_flv = {'ORIGIN': origin_url_list["flv"] + '&codec=' + origin_hls_codec} + hls_pull_url_map = room_data['stream_url']['hls_pull_url_map'] + flv_pull_url = room_data['stream_url']['flv_pull_url'] + room_data['stream_url']['hls_pull_url_map'] = {**origin_m3u8, **hls_pull_url_map} + room_data['stream_url']['flv_pull_url'] = {**origin_flv, **flv_pull_url} + except Exception as e: + print(f"Error message: {e} Error line: {e.__traceback__.tb_lineno}") + room_data = {'anchor_name': ""} + return room_data + + @trace_error_decorator async def get_douyin_app_stream_data(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict: headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/141.0.0.0 Safari/537.36 Edg/141.0.0.0', 'Accept-Language': 'zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2', 'Referer': 'https://live.douyin.com/', 'Cookie': 'ttwid=1%7CB1qls3GdnZhUov9o2NxOMxxYS2ff6OSvEWbv0ytbES4%7C1680522049%7C280d802d6d478e3e78d0c807f7c487e7ffec0ae4e5fdd6a0fe74c3c6af149511; my_rd=1; passport_csrf_token=3ab34460fa656183fccfb904b16ff742; passport_csrf_token_default=3ab34460fa656183fccfb904b16ff742; d_ticket=9f562383ac0547d0b561904513229d76c9c21; n_mh=hvnJEQ4Q5eiH74-84kTFUyv4VK8xtSrpRZG1AhCeFNI; store-region=cn-fj; store-region-src=uid; LOGIN_STATUS=1; __security_server_data_status=1; FORCE_LOGIN=%7B%22videoConsumedRemainSeconds%22%3A180%7D; pwa2=%223%7C0%7C3%7C0%22; download_guide=%223%2F20230729%2F0%22; volume_info=%7B%22isUserMute%22%3Afalse%2C%22isMute%22%3Afalse%2C%22volume%22%3A0.6%7D; strategyABtestKey=%221690824679.923%22; stream_recommend_feed_params=%22%7B%5C%22cookie_enabled%5C%22%3Atrue%2C%5C%22screen_width%5C%22%3A1536%2C%5C%22screen_height%5C%22%3A864%2C%5C%22browser_online%5C%22%3Atrue%2C%5C%22cpu_core_num%5C%22%3A8%2C%5C%22device_memory%5C%22%3A8%2C%5C%22downlink%5C%22%3A10%2C%5C%22effective_type%5C%22%3A%5C%224g%5C%22%2C%5C%22round_trip_time%5C%22%3A150%7D%22; VIDEO_FILTER_MEMO_SELECT=%7B%22expireTime%22%3A1691443863751%2C%22type%22%3Anull%7D; home_can_add_dy_2_desktop=%221%22; __live_version__=%221.1.1.2169%22; device_web_cpu_core=8; device_web_memory_size=8; xgplayer_user_id=346045893336; csrf_session_id=2e00356b5cd8544d17a0e66484946f28; odin_tt=724eb4dd23bc6ffaed9a1571ac4c757ef597768a70c75fef695b95845b7ffcd8b1524278c2ac31c2587996d058e03414595f0a4e856c53bd0d5e5f56dc6d82e24004dc77773e6b83ced6f80f1bb70627; __ac_nonce=064caded4009deafd8b89; __ac_signature=_02B4Z6wo00f01HLUuwwAAIDBh6tRkVLvBQBy9L-AAHiHf7; ttcid=2e9619ebbb8449eaa3d5a42d8ce88ec835; webcast_leading_last_show_time=1691016922379; webcast_leading_total_show_times=1; webcast_local_quality=sd; live_can_add_dy_2_desktop=%221%22; msToken=1JDHnVPw_9yTvzIrwb7cQj8dCMNOoesXbA_IooV8cezcOdpe4pzusZE7NB7tZn9TBXPr0ylxmv-KMs5rqbNUBHP4P7VBFUu0ZAht_BEylqrLpzgt3y5ne_38hXDOX8o=; msToken=jV_yeN1IQKUd9PlNtpL7k5vthGKcHo0dEh_QPUQhr8G3cuYv-Jbb4NnIxGDmhVOkZOCSihNpA2kvYtHiTW25XNNX_yrsv5FN8O6zm3qmCIXcEe0LywLn7oBO2gITEeg=; tt_scid=mYfqpfbDjqXrIGJuQ7q-DlQJfUSG51qG.KUdzztuGP83OjuVLXnQHjsz-BRHRJu4e986' @@ -77,7 +155,7 @@ async def get_douyin_app_stream_data(url: str, proxy_addr: OptionalStr = None, c async def get_app_data(room_id: str, sec_uid: str) -> dict: app_params = { - "verifyFp": "verify_lxj5zv70_7szNlAB7_pxNY_48Vh_ALKF_GA1Uf3yteoOY", + "verifyFp": "verify_hwj52020_7szNlAB7_pxNY_48Vh_ALKF_GA1Uf3yteoOY", "type_id": "0", "live_id": "1", "room_id": room_id, @@ -86,36 +164,25 @@ async def get_douyin_app_stream_data(url: str, proxy_addr: OptionalStr = None, c "app_id": "1128" } api2 = f'https://webcast.amemv.com/webcast/room/reflow/info/?{urllib.parse.urlencode(app_params)}' - json_str2 = await async_req(url=api2, proxy_addr=proxy_addr, headers=headers) - json_data2 = json.loads(json_str2)['data'] - room_data2 = json_data2['room'] - room_data2['anchor_name'] = room_data2['owner']['nickname'] - return room_data2 + a_bogus = ab_sign(urllib.parse.urlparse(api2).query, headers['User-Agent']) + api2 += "&a_bogus=" + a_bogus + try: + json_str2 = await async_req(url=api2, proxy_addr=proxy_addr, headers=headers) + if not json_str2: + raise Exception("it triggered risk control") + json_data2 = json.loads(json_str2)['data'] + if not json_data2.get('room'): + raise Exception(f"{url} VR live is not supported") + room_data2 = json_data2['room'] + room_data2['anchor_name'] = room_data2['owner']['nickname'] + return room_data2 + except Exception as e: + raise Exception(f"Douyin app data fetch error, because {e}.") try: web_rid = url.split('?')[0].split('live.douyin.com/') if len(web_rid) > 1: - web_rid = web_rid[1] - params = { - "aid": "6383", - "app_name": "douyin_web", - "live_id": "1", - "device_platform": "web", - "language": "zh-CN", - "browser_language": "zh-CN", - "browser_platform": "Win32", - "browser_name": "Chrome", - "browser_version": "116.0.0.0", - "web_rid": web_rid, - 'msToken': '', - 'a_bogus': '' - - } - api = f'https://live.douyin.com/webcast/room/web/enter/?{urllib.parse.urlencode(params)}' - json_str = await async_req(url=api, proxy_addr=proxy_addr, headers=headers) - json_data = json.loads(json_str)['data'] - room_data = json_data['data'][0] - room_data['anchor_name'] = json_data['user']['nickname'] + return await get_douyin_web_stream_data(url, proxy_addr, cookies) else: try: data = await get_sec_user_id(url, proxy_addr=proxy_addr) @@ -218,18 +285,20 @@ async def get_douyin_stream_data(url: str, proxy_addr: OptionalStr = None, cooki @trace_error_decorator async def get_tiktok_stream_data(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict | None: headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0', - 'Cookie': 'ttwid=1%7CM-rF193sJugKuNz2RGNt-rh6pAAR9IMceUSzlDnPCNI%7C1683274418%7Cf726d4947f2fc37fecc7aeb0cdaee52892244d04efde6f8a8edd2bb168263269; tiktok_webapp_theme=light; tt_chain_token=VWkygAWDlm1cFg/k8whmOg==; passport_csrf_token=6e422c5a7991f8cec7033a8082921510; passport_csrf_token_default=6e422c5a7991f8cec7033a8082921510; d_ticket=f8c267d4af4523c97be1ccb355e9991e2ae06; odin_tt=320b5f386cdc23f347be018e588873db7f7aea4ea5d1813681c3fbc018ea025dde957b94f74146dbc0e3612426b865ccb95ec8abe4ee36cca65f15dbffec0deff7b0e69e8ea536d46e0f82a4fc37d211; cmpl_token=AgQQAPNSF-RO0rT04baWtZ0T_jUjl4fVP4PZYM2QPw; uid_tt=319b558dbba684bb1557206c92089cd113a875526a89aee30595925d804b81c7; uid_tt_ss=319b558dbba684bb1557206c92089cd113a875526a89aee30595925d804b81c7; sid_tt=ad5e736f4bedb2f6d42ccd849e706b1d; sessionid=ad5e736f4bedb2f6d42ccd849e706b1d; sessionid_ss=ad5e736f4bedb2f6d42ccd849e706b1d; store-idc=useast5; store-country-code=us; store-country-code-src=uid; tt-target-idc=useast5; tt-target-idc-sign=qXNk0bb1pDQ0FbCNF120Pl9WWMLZg9Edv5PkfyCbS4lIk5ieW5tfLP7XWROnN0mEaSlc5hg6Oji1pF-yz_3ZXnUiNMrA9wNMPvI6D9IFKKVmq555aQzwPIGHv0aQC5dNRgKo5Z5LBkgxUMWEojTKclq2_L8lBciw0IGdhFm_XyVJtbqbBKKgybGDLzK8ZyxF4Jl_cYRXaDlshZjc38JdS6wruDueRSHe7YvNbjxCnApEFUv-OwJANSPU_4rvcqpVhq3JI2VCCfw-cs_4MFIPCDOKisk5EhAo2JlHh3VF7_CLuv80FXg_7ZqQ2pJeMOog294rqxwbbQhl3ATvjQV_JsWyUsMd9zwqecpylrPvtySI2u1qfoggx1owLrrUynee1R48QlanLQnTNW_z1WpmZBgVJqgEGLwFoVOmRzJuFFNj8vIqdjM2nDSdWqX8_wX3wplohkzkPSFPfZgjzGnQX28krhgTytLt7BXYty5dpfGtsdb11WOFHM6MZ9R9uLVB; sid_guard=ad5e736f4bedb2f6d42ccd849e706b1d%7C1690990657%7C15525213%7CMon%2C+29-Jan-2024+08%3A11%3A10+GMT; sid_ucp_v1=1.0.0-KGM3YzgwYjZhODgyYWI1NjIwNTA0NjBmOWUxMGRhMjIzYTI2YjMxNDUKGAiqiJ30keKD5WQQwfCppgYYsws4AkDsBxAEGgd1c2Vhc3Q1IiBhZDVlNzM2ZjRiZWRiMmY2ZDQyY2NkODQ5ZTcwNmIxZA; ssid_ucp_v1=1.0.0-KGM3YzgwYjZhODgyYWI1NjIwNTA0NjBmOWUxMGRhMjIzYTI2YjMxNDUKGAiqiJ30keKD5WQQwfCppgYYsws4AkDsBxAEGgd1c2Vhc3Q1IiBhZDVlNzM2ZjRiZWRiMmY2ZDQyY2NkODQ5ZTcwNmIxZA; tt_csrf_token=dD0EIH8q-pe3qDQsCyyD1jLN6KizJDRjOEyk; __tea_cache_tokens_1988={%22_type_%22:%22default%22%2C%22user_unique_id%22:%227229608516049831425%22%2C%22timestamp%22:1683274422659}; ttwid=1%7CM-rF193sJugKuNz2RGNt-rh6pAAR9IMceUSzlDnPCNI%7C1694002151%7Cd89b77afc809b1a610661a9d1c2784d80ebef9efdd166f06de0d28e27f7e4efe; msToken=KfJAVZ7r9D_QVeQlYAUZzDFbc1Yx-nZz6GF33eOxgd8KlqvTg1lF9bMXW7gFV-qW4MCgUwnBIhbiwU9kdaSpgHJCk-PABsHCtTO5J3qC4oCTsrXQ1_E0XtbqiE4OVLZ_jdF1EYWgKNPT2SnwGkQ=; msToken=KfJAVZ7r9D_QVeQlYAUZzDFbc1Yx-nZz6GF33eOxgd8KlqvTg1lF9bMXW7gFV-qW4MCgUwnBIhbiwU9kdaSpgHJCk-PABsHCtTO5J3qC4oCTsrXQ1_E0XtbqiE4OVLZ_jdF1EYWgKNPT2SnwGkQ=' + 'referer': 'https://www.tiktok.com/', + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/141.0.0.0 Safari/537.36', + 'cookie': cookies or '1%7Cz7FKki38aKyy7i-BC9rEDwcrVvjcLcFEL6QIeqldoy4%7C1761302831%7C6c1461e9f1f980cbe0404c5190' + '5177d5d53bbd822e1bf66128887d942c9c3e2f' } - if cookies: - headers['Cookie'] = cookies + for i in range(3): html_str = await async_req(url=url, proxy_addr=proxy_addr, headers=headers, abroad=True, http2=False) time.sleep(1) if "We regret to inform you that we have discontinued operating TikTok" in html_str: msg = re.search('

\n\\s+(We regret to inform you that we have discontinu.*?)\\.\n\\s+

', html_str) raise ConnectionError( - f"Your proxy node's regional network is blocked from accessing TikTok; please switch to a node in " + "Your proxy node's regional network is blocked from accessing TikTok; please switch to a node in " f"another region to access. {msg.group(1) if msg else ''}" ) if 'UNEXPECTED_EOF_WHILE_READING' not in html_str: @@ -935,6 +1004,76 @@ async def get_sooplive_tk(url: str, rtype: str, proxy_addr: OptionalStr = None, return f"{bj_name}-{bj_id}", json_data['CHANNEL']['BNO'] +def get_soop_headers(cookies): + headers = { + 'client-id': str(uuid.uuid4()), + 'user-agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, ' + 'like Gecko) Version/18.5 Mobile/15E148 Safari/604.1 Edg/141.0.0.0', + } + if cookies: + headers['cookie'] = cookies + return headers + + +async def _get_soop_channel_info_global(bj_id, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> str: + headers = get_soop_headers(cookies) + api = 'https://api.sooplive.com/v2/channel/info/' + str(bj_id) + json_str = await async_req(api, proxy_addr=proxy_addr, headers=headers) + json_data = json.loads(json_str) + nickname = json_data['data']['streamerChannelInfo']['nickname'] + channelId = json_data['data']['streamerChannelInfo']['channelId'] + anchor_name = f"{nickname}-{channelId}" + return anchor_name + + +async def _get_soop_stream_info_global(bj_id, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> tuple: + headers = get_soop_headers(cookies) + api = 'https://api.sooplive.com/v2/stream/info/' + str(bj_id) + json_str = await async_req(api, proxy_addr=proxy_addr, headers=headers) + json_data = json.loads(json_str) + status = json_data['data']['isStream'] + title = json_data['data']['title'] + return status, title + + +async def _fetch_web_stream_data_global(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict: + split_url = url.split('/') + bj_id = split_url[3] if len(split_url) < 6 else split_url[5] + anchor_name = await _get_soop_channel_info_global(bj_id) + result = {"anchor_name": anchor_name or '', "is_live": False, "live_url": url} + status, title = await _get_soop_stream_info_global(bj_id) + if not status: + return result + else: + async def _get_url_list(m3u8: str) -> list[str]: + headers = { + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/141.0.0.0 Safari/537.36 Edg/141.0.0.0', + } + if cookies: + headers['cookie'] = cookies + resp = await async_req(url=m3u8, proxy_addr=proxy_addr, headers=headers) + play_url_list = [] + url_prefix = '/'.join(m3u8.split('/')[0:3]) + for i in resp.split('\n'): + if not i.startswith('#') and i.strip(): + play_url_list.append(url_prefix + i.strip()) + bandwidth_pattern = re.compile(r'BANDWIDTH=(\d+)') + bandwidth_list = bandwidth_pattern.findall(resp) + url_to_bandwidth = {purl: int(bandwidth) for bandwidth, purl in zip(bandwidth_list, play_url_list)} + play_url_list = sorted(play_url_list, key=lambda purl: url_to_bandwidth[purl], reverse=True) + return play_url_list + + m3u8_url = 'https://global-media.sooplive.com/live/' + str(bj_id) + '/master.m3u8' + result |= { + 'is_live': True, + 'title': title, + 'm3u8_url': m3u8_url, + 'play_url_list': await _get_url_list(m3u8_url) + } + return result + + @trace_error_decorator async def get_sooplive_stream_data( url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None, @@ -949,6 +1088,9 @@ async def get_sooplive_stream_data( if cookies: headers['Cookie'] = cookies + if "sooplive.com" in url: + return await _fetch_web_stream_data_global(url, proxy_addr, cookies) + split_url = url.split('/') bj_id = split_url[3] if len(split_url) < 6 else split_url[5]