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 @@
-
-
-## 💡简介
-[](https://www.python.org/downloads/release/python-3116/)
-[](https://github.com/ihmily/DouyinLiveRecorder)
-[](https://hub.docker.com/r/ihmily/douyin-live-recorder/tags)
-
-[](https://github.com/ihmily/DouyinLiveRecorder/releases/latest)
-[](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` 格式保存**。
-
-
-
-## ❤️贡献者
-
- [](https://github.com/ihmily)
-[](https://github.com/iridescentGray)
-[](https://github.com/annidy)
-[](https://github.com/wwkk2580)
-[](https://github.com/missuo)
-
-
-
-
-[](https://github.com/justdoiting)
-[](https://github.com/dhbxs)
-[](https://github.com/wujiyu115)
-[](https://github.com/zhanghao333)
-
-
- [](https://github.com/HoratioShaw)
-[](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]