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]