Compare commits
146 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b3c6cbee4 | ||
|
|
243b9d7acb | ||
|
|
58a0cac7db | ||
|
|
504877a969 | ||
|
|
cc552d49f0 | ||
|
|
a4b7edd153 | ||
|
|
746da3d0b6 | ||
|
|
0b2ceb4e8c | ||
|
|
587c0e10b4 | ||
|
|
77969c85fe | ||
|
|
f0b1f9cf47 | ||
|
|
219a8cef61 | ||
|
|
fd3d952ab8 | ||
|
|
ab6a6cac65 | ||
|
|
032c75fb6f | ||
|
|
950cb0ae4b | ||
|
|
9700404418 | ||
|
|
1da3383735 | ||
|
|
b850d1a8bc | ||
|
|
b6c5185a59 | ||
|
|
4030a4dc28 | ||
|
|
00bcaee9fa | ||
|
|
54daccea6b | ||
|
|
4eac033e93 | ||
|
|
17900701f1 | ||
|
|
033b76bda5 | ||
|
|
bf6b827e1f | ||
|
|
b4183ed85b | ||
|
|
f0823c7593 | ||
|
|
326ab8aea2 | ||
|
|
3842833ad0 | ||
|
|
9e589e6263 | ||
|
|
7090f4481a | ||
|
|
eafcc2b7d0 | ||
|
|
e510e66c13 | ||
|
|
d86e8e339d | ||
|
|
ba4aaa1b65 | ||
|
|
475e15bc16 | ||
|
|
31e77d5203 | ||
|
|
6c383ee489 | ||
|
|
9d3ec4fa2c | ||
|
|
7402773a3e | ||
|
|
140790c4a4 | ||
|
|
1bdf41ac3f | ||
|
|
6590a9c98d | ||
|
|
07383d84ec | ||
|
|
0e679a4d7a | ||
|
|
abfa3a29c1 | ||
|
|
dc4c1c514b | ||
|
|
5722f2f6b3 | ||
|
|
d03001d446 | ||
|
|
87964c66ba | ||
|
|
b071b638fc | ||
|
|
b502cdd8c9 | ||
|
|
8b1e504701 | ||
|
|
d80eb256e9 | ||
|
|
313d705ce9 | ||
|
|
187f7a2ce8 | ||
|
|
7ade49e46d | ||
|
|
3ccbf3d973 | ||
|
|
6fbd86bf43 | ||
|
|
20c1bbd12c | ||
|
|
1595659dcf | ||
|
|
c041570eef | ||
|
|
0806545014 | ||
|
|
50b61d75fc | ||
|
|
b349f8f405 | ||
|
|
9263110c08 | ||
|
|
6ea44eede3 | ||
|
|
f7536f21e2 | ||
|
|
2c6ad31ba7 | ||
|
|
a5d3dd98fe | ||
|
|
2375331973 | ||
|
|
041a402a73 | ||
|
|
a7b7761e78 | ||
|
|
7ac4c7ff90 | ||
|
|
d4158e7f1a | ||
|
|
8953e2f4d4 | ||
|
|
88d54c1679 | ||
|
|
c630787719 | ||
|
|
67bacc1c42 | ||
|
|
53f2c77421 | ||
|
|
9107995c02 | ||
|
|
3ff4facd3d | ||
|
|
34bb2b661e | ||
|
|
6aeb1462dd | ||
|
|
0c7437f9cc | ||
|
|
9b23d9872b | ||
|
|
303ece39c0 | ||
|
|
7c21e80eb6 | ||
|
|
b4e76d74ca | ||
|
|
bfc64b873a | ||
|
|
bc2651c801 | ||
|
|
7c47c73d71 | ||
|
|
c6e2ea2e77 | ||
|
|
4e1b408e27 | ||
|
|
4a27659c99 | ||
|
|
ab8511de5a | ||
|
|
90c3f193aa | ||
|
|
f4a0c06ff6 | ||
|
|
2cb81ebb03 | ||
|
|
26db3d41ce | ||
|
|
ddadef2287 | ||
|
|
c717efe8c4 | ||
|
|
90bb33c9af | ||
|
|
1ef411a2d0 | ||
|
|
27ad068712 | ||
|
|
dd6d9f7377 | ||
|
|
b00663aa6c | ||
|
|
8d3f950303 | ||
|
|
6d505c1452 | ||
|
|
c059f62d38 | ||
|
|
eaa3d67d16 | ||
|
|
b2af356c22 | ||
|
|
31e7c67425 | ||
|
|
1d1fff7979 | ||
|
|
6430ce1f61 | ||
|
|
f10549ffae | ||
|
|
667dc47188 | ||
|
|
511576aa97 | ||
|
|
9609e4caa1 | ||
|
|
63bf62df84 | ||
|
|
d12a35d249 | ||
|
|
3b7785ec30 | ||
|
|
350eb6d798 | ||
|
|
3accbd7941 | ||
|
|
997b147589 | ||
|
|
9e8ef1d28d | ||
|
|
962b17ea67 | ||
|
|
6d7f3b874f | ||
|
|
db04131a9c | ||
|
|
5d0134b86e | ||
|
|
019f68a91a | ||
|
|
3c92b691db | ||
|
|
717b831e12 | ||
|
|
4c6b040887 | ||
|
|
617fdcb158 | ||
|
|
0067152f69 | ||
|
|
34990a29de | ||
|
|
d507ccff4c | ||
|
|
f9bf0b8d6b | ||
|
|
d808eb1bdd | ||
|
|
1e9a0759e1 | ||
|
|
da57df6a93 | ||
|
|
32df67b284 | ||
|
|
7a0207f8ee |
@@ -1,6 +1,13 @@
|
||||
.github/workflows/build-image.yml
|
||||
.git
|
||||
.gitignore
|
||||
.dockerignore
|
||||
README.md
|
||||
LICENSE
|
||||
.github
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
.venv
|
||||
venv
|
||||
node_modules
|
||||
web-console/node_modules
|
||||
web-console/.next
|
||||
*.log
|
||||
.process_runner
|
||||
.cursor
|
||||
mobile
|
||||
|
||||
91
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
@@ -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
|
||||
91
.github/ISSUE_TEMPLATE/bug_en.yml
vendored
Normal file
@@ -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
|
||||
38
.github/ISSUE_TEMPLATE/feature.yml
vendored
Normal file
@@ -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: |
|
||||
我需要这个功能是因为...
|
||||
37
.github/ISSUE_TEMPLATE/feature_en.yml
vendored
Normal file
@@ -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...
|
||||
63
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
@@ -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
|
||||
63
.github/ISSUE_TEMPLATE/question_en.yml
vendored
Normal file
@@ -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
|
||||
56
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
### 📜 标题(Title)
|
||||
|
||||
**请提供这个Pull Request中提议的更改的简洁描述:**
|
||||
<!-- Please provide a succinct description of the changes proposed in this pull request:. -->
|
||||
|
||||
-
|
||||
|
||||
### 🔍 描述(Description)
|
||||
|
||||
**请描述这个PR做了什么/为什么这些更改是必要的:**
|
||||
<!-- Please describe what this PR does / why these changes are necessary: -->
|
||||
|
||||
-
|
||||
|
||||
### 📝 类型(Type of Change)
|
||||
|
||||
**这个PR引入了哪种类型的更改?(请勾选所有适用的选项)**
|
||||
<!-- What type of change does this PR introduce? (Check all that apply) -->
|
||||
|
||||
- [ ] 修复Bug <!-- Bugfix -->
|
||||
- [ ] 新功能 <!-- Feature -->
|
||||
- [ ] 代码风格更新(格式化,局部变量) <!-- Code style update (formatting, local variables) -->
|
||||
- [ ] 重构(改进代码结构) <!-- Refactoring (improving code structure) -->
|
||||
- [ ] 构建相关更改(依赖项,构建脚本等) <!-- Build-related changes (dependencies, build scripts, etc.) -->
|
||||
- [ ] 其他:_请描述_ <!-- Other: _Please describe_ -->
|
||||
|
||||
### 🏗️ 测试(Testing)
|
||||
|
||||
**请描述您已经进行的测试:**
|
||||
<!-- Please describe the tests you've done: -->
|
||||
|
||||
-
|
||||
|
||||
**如果适用,请提供测试更改的说明:**
|
||||
<!-- If applicable, provide instructions for testing your changes -->
|
||||
|
||||
-
|
||||
|
||||
### 📋 检查清单(Checklist)
|
||||
|
||||
在您创建这个PR之前,请确保以下所有框都被勾选,方法是在每个框中放置一个`x`:
|
||||
<!-- Before you create this PR, please ensure the following boxes are checked by placing an `x` in each box: -->
|
||||
|
||||
- [ ] 我已经阅读了**贡献指南**文档 <!-- I have read the **CONTRIBUTING** document. -->
|
||||
- [ ] 我的更改没有产生新的警告 <!-- My changes generate no new warnings. -->
|
||||
- [ ] 我已经添加了覆盖我更改的测试 <!-- I have added tests to cover my changes.. -->
|
||||
- [ ] 我已经相应地更新了文档(如果适用) <!-- I have updated the documentation accordingly (if applicable). -->
|
||||
- [ ] 我遵循了这个项目的代码风格 <!-- I have followed the code style of this project. -->
|
||||
|
||||
**注意:** 这个PR在所有复选框被勾选之前不会被合并。
|
||||
<!-- **Note:** This PR will not be merged until all checkboxes are ticked. -->
|
||||
|
||||
---
|
||||
|
||||
**感谢您的贡献!**
|
||||
<!-- Thank you for your contribution! -->
|
||||
54
.github/workflows/build-image.yml
vendored
Normal file
@@ -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
|
||||
15
.github/workflows/issue-translator.yml
vendored
Normal file
@@ -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.
|
||||
65
.github/workflows/sync.yml
vendored
Normal file
@@ -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
|
||||
16
.gitignore
vendored
@@ -10,7 +10,7 @@ __pycache__/
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
# dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
@@ -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.
|
||||
@@ -165,3 +165,15 @@ cython_debug/
|
||||
#.idea/
|
||||
|
||||
backup_config/
|
||||
|
||||
# 根目录 npm(concurrently 等)
|
||||
node_modules/
|
||||
|
||||
# Next 开发缓存(静态导出目录 web-console/out 可纳入版本库以便无 Node 环境直接部署)
|
||||
web-console/.next/
|
||||
|
||||
# 无 PM2 时的本地进程状态
|
||||
.process_runner/
|
||||
|
||||
# 本机端口/主机覆盖(从 config/runtime.env.example 复制)
|
||||
config/runtime.env
|
||||
|
||||
33
Dockerfile
@@ -1,19 +1,28 @@
|
||||
FROM python:3.11-slim
|
||||
# 生产镜像:ffmpeg + 多阶段构建 Next 静态页 + Python API(默认端口 8101,减少与同机 SRS/代理 冲突)
|
||||
# docker compose up --build
|
||||
FROM node:20-bookworm-slim AS console
|
||||
WORKDIR /src/web-console
|
||||
COPY web-console/package.json web-console/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY web-console/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM python:3.12-slim-bookworm
|
||||
WORKDIR /app
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PORT=8101
|
||||
|
||||
COPY . /app
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y curl gnupg && \
|
||||
curl -sL https://deb.nodesource.com/setup_20.x | bash - && \
|
||||
apt-get install -y nodejs
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& ffmpeg -version
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y ffmpeg tzdata && \
|
||||
ln -fs /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
|
||||
dpkg-reconfigure -f noninteractive tzdata
|
||||
COPY . .
|
||||
COPY --from=console /src/web-console/out ./web-console/out
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
EXPOSE 8101
|
||||
CMD ["sh", "-c", "exec python -m uvicorn web:app --host 0.0.0.0 --port ${PORT}"]
|
||||
|
||||
115
README.md
@@ -84,6 +84,7 @@
|
||||
├── utils.py -> (contains utility functions)
|
||||
├── logger.py -> (logger handdle)
|
||||
├── room.py -> (get room info)
|
||||
├── ab_sign.py-> (generate dy token)
|
||||
├── /javascript -> (some decrypt code)
|
||||
├── main.py -> (main file)
|
||||
├── ffmpeg_install.py -> (ffmpeg install script)
|
||||
@@ -261,6 +262,7 @@ Youtube:
|
||||
https://www.youtube.com/watch?v=cS6zS5hi1w0
|
||||
|
||||
淘宝(需cookie):
|
||||
https://tbzb.taobao.com/live?liveId=532359023188
|
||||
https://m.tb.cn/h.TWp0HTd
|
||||
|
||||
京东:
|
||||
@@ -285,7 +287,7 @@ https://www.picarto.tv/cuteavalanche
|
||||
 
|
||||
|
||||
## 🎃源码运行
|
||||
使用源码运行,前提要有**Python>=3.10**环境,如果没有请先自行安装Python,再执行下面步骤。
|
||||
使用源码运行,可参考下面的步骤。
|
||||
|
||||
1.首先拉取或手动下载本仓库项目代码
|
||||
|
||||
@@ -297,9 +299,94 @@ git clone https://github.com/ihmily/DouyinLiveRecorder.git
|
||||
|
||||
```bash
|
||||
cd DouyinLiveRecorder
|
||||
pip3 install -r requirements.txt
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> - 不论你是否已安装 **Python>=3.10** 环境, 都推荐使用 [**uv**](https://github.com/astral-sh/uv) 运行, 因为它可以自动管理虚拟环境和方便地管理 **Python** 版本, **不过这完全是可选的**<br />
|
||||
> 使用以下命令安装
|
||||
> ```bash
|
||||
> # 在 macOS 和 Linux 上安装 uv
|
||||
> curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
> ```
|
||||
> ```powershell
|
||||
> # 在 Windows 上安装 uv
|
||||
> powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
|
||||
> ```
|
||||
> - 如果安装依赖速度太慢, 你可以考虑使用国内 pip 镜像源:<br />
|
||||
> 在 `pip` 命令使用 `-i` 参数指定, 如 `pip3 install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple`<br />
|
||||
> 或者在 `uv` 命令 `--index` 选项指定, 如 `uv sync --index https://pypi.tuna.tsinghua.edu.cn/simple`
|
||||
|
||||
<details>
|
||||
|
||||
<summary>如果已安装 <b>Python>=3.10</b> 环境</summary>
|
||||
|
||||
- :white_check_mark: 在虚拟环境中安装 (推荐)
|
||||
|
||||
1. 创建虚拟环境
|
||||
|
||||
- 使用系统已安装的 Python, 不使用 uv
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
```
|
||||
|
||||
- 使用 uv, 默认使用系统 Python, 你可以添加 `--python` 选项指定 Python 版本而不使用系统 Python [uv官方文档](https://docs.astral.sh/uv/concepts/python-versions/)
|
||||
|
||||
```bash
|
||||
uv venv
|
||||
```
|
||||
|
||||
2. 在终端激活虚拟环境 (在未安装 uv 或你想要手动激活虚拟环境时执行, 若已安装 uv, 可以跳过这一步, uv 会自动激活并使用虚拟环境)
|
||||
|
||||
**Bash** 中
|
||||
```bash
|
||||
source .venv/Scripts/activate
|
||||
```
|
||||
|
||||
**Powershell** 中
|
||||
```powershell
|
||||
.venv\Scripts\activate.ps1
|
||||
```
|
||||
|
||||
**Windows CMD** 中
|
||||
```bat
|
||||
.venv\Scripts\activate.bat
|
||||
```
|
||||
|
||||
3. 安装依赖
|
||||
|
||||
```bash
|
||||
# 使用 pip (若安装太慢或失败, 可使用 `-i` 指定镜像源)
|
||||
pip3 install -U pip && pip3 install -r requirements.txt
|
||||
# 或者使用 uv (可使用 `--index` 指定镜像源)
|
||||
uv sync
|
||||
# 或者
|
||||
uv pip sync requirements.txt
|
||||
```
|
||||
|
||||
- :x: 在系统 Python 环境中安装 (不推荐)
|
||||
|
||||
```bash
|
||||
pip3 install -U pip && pip3 install -r requirements.txt
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary>如果未安装 <b>Python>=3.10</b> 环境</summary>
|
||||
|
||||
你可以使用 [**uv**](https://github.com/astral-sh/uv) 安装依赖
|
||||
|
||||
```bash
|
||||
# uv 将使用 3.10 及以上的最新 python 发行版自动创建并使用虚拟环境, 可使用 --python 选项指定 python 版本, 参见 https://docs.astral.sh/uv/reference/cli/#uv-sync--python 和 https://docs.astral.sh/uv/reference/cli/#uv-pip-sync--python
|
||||
uv sync
|
||||
# 或
|
||||
uv pip sync requirements.txt
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
3.安装[FFmpeg](https://ffmpeg.org/download.html#build-linux),如果是Windows系统,这一步可跳过。对于Linux系统,执行以下命令安装
|
||||
|
||||
CentOS执行
|
||||
@@ -332,6 +419,12 @@ brew install ffmpeg
|
||||
|
||||
```python
|
||||
python main.py
|
||||
|
||||
```
|
||||
或
|
||||
|
||||
```bash
|
||||
uv run main.py
|
||||
```
|
||||
|
||||
其中Linux系统请使用`python3 main.py` 运行。
|
||||
@@ -388,6 +481,13 @@ docker-compose stop
|
||||
|
||||
 
|
||||
|
||||
## 🤖相关项目
|
||||
|
||||
- StreamCap: https://github.com/ihmily/StreamCap
|
||||
- streamget: https://github.com/ihmily/streamget
|
||||
|
||||
 
|
||||
|
||||
## ❤️贡献者
|
||||
|
||||
   [](https://github.com/ihmily)
|
||||
@@ -407,10 +507,21 @@ docker-compose stop
|
||||
|
||||
   [](https://github.com/HoratioShaw)
|
||||
[](https://github.com/nov30th)
|
||||
[](https://github.com/727155455)
|
||||
[](https://github.com/nixingshiguang)
|
||||
[](https://github.com/1411430556)
|
||||
[](https://github.com/Ovear)
|
||||
 
|
||||
|
||||
## ⏳提交日志
|
||||
|
||||
- 20251024
|
||||
- 修复抖音风控无法获取数据问题
|
||||
|
||||
- 新增soop.com录制支持
|
||||
|
||||
- 修复bigo录制
|
||||
|
||||
- 20250127
|
||||
- 新增淘宝、京东、faceit直播录制
|
||||
- 修复小红书直播流录制以及转码问题
|
||||
|
||||
1
arnndn-models
Submodule
30
audio
Normal file
@@ -0,0 +1,30 @@
|
||||
gpt的
|
||||
"-af",
|
||||
"arnndn=m=./arnndn-models/cb.rnnn:mix=0.88," # 人声增强
|
||||
"afftdn=nf=-24:tn=1," # FFT 降噪,抹平音乐中频
|
||||
"highpass=f=110,lowpass=f=4800," # 切掉低音鼓/高频镲
|
||||
"equalizer=f=250:width_type=o:width=2:g=-9," # 衰减低中频,音乐基础弱化
|
||||
"equalizer=f=800:width_type=o:width=1.5:g=-6," # 再衰减中频,人声外区域
|
||||
"atempo=1.03," # 轻微加速 3%,节奏轻微错位
|
||||
"asetrate=48000*1.02,aresample=48000," # 音高上移 ~1/2 半音
|
||||
"afreqshift=shift=20," # 整体频率偏移 20Hz,破坏指纹
|
||||
"acompressor=threshold=-28dB:ratio=5:attack=8:release=80:makeup=5," # 压缩动态,音乐更“扁”
|
||||
"volume=1.15," # 补偿整体音量
|
||||
"aresample=async=1:first_pts=0" # 保证音视频同步
|
||||
|
||||
|
||||
|
||||
grok的
|
||||
"-af", "arnndn=m=./arnndn-models/cb.rnnn:mix=0.88,"
|
||||
"afftdn=nf=-24:tn=1," # 强FFT降噪,进一步抹平音乐中频
|
||||
"highpass=f=110,lowpass=f=4800," # 切掉低音鼓/高频镲,音乐能量大减
|
||||
"equalizer=f=250:width_type=o:width=2:g=-9," # 衰减低中频(很多音乐基础)
|
||||
"equalizer=f=800:width_type=o:width=1.5:g=-6," # 再衰减中频人声外区域
|
||||
"atempo=1.06," # 轻微加速6%,节奏错位
|
||||
"asetrate=48000*1.04,aresample=48000," # 音高上移约3/4半音
|
||||
"aphaser=type=t:decay=0.5:speed=0.4:delay=2," # 相位扫,谱图模糊
|
||||
"flanger=delay=4:depth=3:regen=35:width=60:speed=0.35," # 轻法兰杰,增加金属/扫频感
|
||||
"afreqshift=shift=45," # 整体频率偏移45Hz,进一步乱指纹
|
||||
"acompressor=threshold=-28dB:ratio=5:attack=8:release=80:makeup=5," # 压缩动态,让音乐更“扁”
|
||||
"volume=1.15," # 补偿整体音量损失
|
||||
"aresample=async=1:first_pts=0"
|
||||
227
backup/robust_relay.py
Normal file
@@ -0,0 +1,227 @@
|
||||
# robust_relay.py
|
||||
# 高稳定性 YouTube 转播模块(防误判下播 + 看门狗 + 自动重连 + token刷新)
|
||||
# 作者:融合 A+B 代码精华,专为抖音/抖音国际版长期无人值守转播设计
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
import threading
|
||||
import os
|
||||
import signal
|
||||
import datetime
|
||||
from typing import List, Optional, Callable
|
||||
|
||||
# ==================== 配置常量(可全局覆盖) ====================
|
||||
WATCHDOG_SILENCE = 600 # 10分钟无输出 → 软重启
|
||||
WATCHDOG_GRACE = 120 # 启动宽限期
|
||||
REFRESH_URL_EVERY = 300 # 每5分钟强制刷新 real_url(防token过期)
|
||||
OFFLINE_DEBOUNCE = 3 # 连续3次API返回下播才认为真下播
|
||||
OFFLINE_WINDOW = 120 # 最近120秒有活跃就忽略下播信号
|
||||
SOFT_RESTART_COOLDOWN = 90 # 软重启最小间隔
|
||||
|
||||
# ==================== 轻量探活 ====================
|
||||
def _probe_alive(url: str, timeout: float = 5.0) -> bool:
|
||||
if not url:
|
||||
return False
|
||||
try:
|
||||
import urllib.request, contextlib
|
||||
opener = urllib.request.build_opener()
|
||||
if hasattr(urllib.request, 'getproxies'):
|
||||
proxy = urllib.request.getproxies().get('http')
|
||||
if proxy:
|
||||
opener.add_handler(urllib.request.ProxyHandler({"http": proxy, "https": proxy}))
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}, method="HEAD")
|
||||
with contextlib.closing(opener.open(req, timeout=timeout)) as r:
|
||||
if r.code >= 400:
|
||||
return False
|
||||
head = r.read(256)
|
||||
if url.lower().endswith(".m3u8"):
|
||||
return b"#EXTM3U" in head.upper()
|
||||
if url.lower().endswith(".flv"):
|
||||
return head.startswith(b"FLV")
|
||||
return len(head) > 0
|
||||
except:
|
||||
return False
|
||||
|
||||
# ==================== 增强版 ffmpeg 启动器 ====================
|
||||
def _launch_ffmpeg(
|
||||
input_url: str,
|
||||
youtube_rtmp: str,
|
||||
anchor_name: str,
|
||||
logger: Callable[[str], None],
|
||||
extra_cmd: List[str] = None
|
||||
) -> subprocess.Popen:
|
||||
|
||||
cmd = [
|
||||
"ffmpeg", "-nostdin", "-hide_banner", "-stats", "-stats_period", "60", "-loglevel", "info",
|
||||
|
||||
# 输入端超强重连
|
||||
"-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_at_eof", "1",
|
||||
"-reconnect_delay_max", "30", "-rw_timeout", "20000000",
|
||||
|
||||
# 极致低延迟 + 时间戳修复
|
||||
"-fflags", "+genpts+igndts+flush_packets", "-flags", "+global_header",
|
||||
"-thread_queue_size", "4096", "-analyzeduration", "0", "-probesize", "32",
|
||||
|
||||
"-i", input_url,
|
||||
|
||||
# copy 模式(质量无损)
|
||||
"-c:v", "copy", "-c:a", "copy", "-bsf:a", "aac_adtstoasc",
|
||||
"-map", "0:v?", "-map", "0:a?",
|
||||
|
||||
# YouTube 强制参数
|
||||
"-f", "mpegts", "-g", "2", "-rtmp_buffer", "1000", "-rtmp_live", "live",
|
||||
"-max_delay", "0", "-tune", "zerolatency", "-avoid_negative_ts", "make_zero",
|
||||
"-muxdelay", "0", "-muxpreload", "0",
|
||||
"-fflags", "+nobuffer+flush_packets", "-avioflags", "direct",
|
||||
]
|
||||
|
||||
if extra_cmd:
|
||||
cmd.extend(extra_cmd)
|
||||
|
||||
cmd.append(youtube_rtmp)
|
||||
|
||||
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
bufsize=0,
|
||||
creationflags=creationflags
|
||||
)
|
||||
|
||||
# 用于看门狗
|
||||
proc.last_output_ts = time.time()
|
||||
|
||||
def _reader():
|
||||
try:
|
||||
for line in proc.stdout: # type: ignore
|
||||
if not line:
|
||||
break
|
||||
text = line.decode(errors="ignore").rstrip()
|
||||
proc.last_output_ts = time.time()
|
||||
if any(k in text for k in ["bitrate=", "frame=", "speed=", "fps="]):
|
||||
logger(f"[{anchor_name}] {text}")
|
||||
except:
|
||||
pass
|
||||
threading.Thread(target=_reader, daemon=True).start()
|
||||
|
||||
return proc
|
||||
|
||||
# ==================== 主函数:高稳定性转播(替代原 check_subprocess) ====================
|
||||
def robust_youtube_relay(
|
||||
anchor_name: str,
|
||||
real_url: str,
|
||||
youtube_rtmp: str,
|
||||
logger: Callable[[str], None],
|
||||
get_latest_stream_url: Callable[[], Optional[str]], # 用于刷新 real_url(如有token过期)
|
||||
is_stream_still_live: Callable[[], bool], # 检查主播是否在播(API)
|
||||
extra_ffmpeg_args: List[str] = None, # 可选:加 -f segment 分段保存
|
||||
heartbeat_callback: Callable[[int], None] = None # 可选:每分钟回调已运行秒数
|
||||
) -> bool:
|
||||
"""
|
||||
返回值:
|
||||
True = 主播已下播(正常结束)
|
||||
False = 发生未知错误(建议计数错误次数)
|
||||
"""
|
||||
proc: Optional[subprocess.Popen] = None
|
||||
start_time = time.time()
|
||||
last_refresh = 0.0
|
||||
last_soft_restart = 0.0
|
||||
offline_count = 0
|
||||
last_live_ts = time.time()
|
||||
|
||||
try:
|
||||
proc = _launch_ffmpeg(real_url, youtube_rtmp, anchor_name, logger, extra_ffmpeg_args)
|
||||
logger(f"[{anchor_name}] 已开始高稳定性转播 → {youtube_rtmp.split('/')[-1]}")
|
||||
|
||||
while True:
|
||||
now = time.time()
|
||||
elapsed = int(now - start_time)
|
||||
silence = now - proc.last_output_ts if proc else 0
|
||||
|
||||
# 1. 进程意外退出 → 重启
|
||||
if proc and proc.poll() is not None:
|
||||
logger(f"[{anchor_name}] ffmpeg 意外退出,3秒后重启...")
|
||||
time.sleep(3)
|
||||
proc = _launch_ffmpeg(real_url, youtube_rtmp, anchor_name, logger, extra_ffmpeg_args)
|
||||
start_time = time.time()
|
||||
continue
|
||||
|
||||
# 2. 看门狗:长时间无输出 → 软重启
|
||||
if elapsed > WATCHDOG_GRACE and silence > WATCHDOG_SILENCE:
|
||||
if now - last_soft_restart > SOFT_RESTART_COOLDOWN:
|
||||
logger(f"[{anchor_name}] 检测到卡顿({silence:.0f}s无输出),软重启 ffmpeg...")
|
||||
last_soft_restart = now
|
||||
if proc and proc.stdin:
|
||||
proc.stdin.write(b'q')
|
||||
proc.stdin.flush()
|
||||
time.sleep(4)
|
||||
proc = _launch_ffmpeg(real_url, youtube_rtmp, anchor_name, logger, extra_ffmpeg_args)
|
||||
start_time = time.time()
|
||||
continue
|
||||
|
||||
# 3. 定期刷新播放地址(防token过期)
|
||||
if now - last_refresh > REFRESH_URL_EVERY:
|
||||
new_url = get_latest_stream_url()
|
||||
if new_url and new_url != real_url:
|
||||
logger(f"[{anchor_name}] 检测到新的播放地址,切换中...")
|
||||
real_url = new_url
|
||||
if proc and proc.stdin:
|
||||
proc.stdin.write(b'q')
|
||||
time.sleep(3)
|
||||
proc = _launch_ffmpeg(real_url, youtube_rtmp, anchor_name, logger, extra_ffmpeg_args)
|
||||
start_time = time.time()
|
||||
last_refresh = now
|
||||
|
||||
# 4. 下播防抖判定(核心防误判)
|
||||
try:
|
||||
still_live = is_stream_still_live()
|
||||
except:
|
||||
still_live = True # 接口异常不判下播
|
||||
|
||||
if not still_live:
|
||||
# 交叉验证:流还能访问 → 忽略API误判
|
||||
if _probe_alive(real_url):
|
||||
logger(f"[{anchor_name}] API判下播但流仍活跃,忽略本次误判")
|
||||
still_live = True
|
||||
last_live_ts = now
|
||||
|
||||
if still_live:
|
||||
offline_count = 0
|
||||
last_live_ts = now
|
||||
else:
|
||||
if now - last_live_ts < OFFLINE_WINDOW:
|
||||
logger(f"[{anchor_name}] 短期掉线,忽略")
|
||||
else:
|
||||
offline_count += 1
|
||||
if offline_count >= OFFLINE_DEBOUNCE:
|
||||
logger(f"[{anchor_name}] 连续{offline_count}次确认下播,结束转播")
|
||||
if proc and proc.stdin:
|
||||
proc.stdin.write(b'q')
|
||||
return True
|
||||
else:
|
||||
logger(f"[{anchor_name}] 疑似下播 {offline_count}/{OFFLINE_DEBOUNCE},继续观察")
|
||||
|
||||
# 5. 心跳日志 & 回调
|
||||
if elapsed % 60 < 2:
|
||||
h, m = divmod(elapsed // 60, 60)
|
||||
logger(f"[{anchor_name}] 正在转播 {h:02d}:{m:02d}")
|
||||
if heartbeat_callback:
|
||||
heartbeat_callback(elapsed)
|
||||
|
||||
time.sleep(8)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger(f"[{anchor_name}] 手动终止转播")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger(f"[{anchor_name}] 严重错误: {e}")
|
||||
return False
|
||||
finally:
|
||||
if proc:
|
||||
try:
|
||||
proc.stdin.write(b'q') if proc.stdin else None
|
||||
proc.wait(timeout=8)
|
||||
except:
|
||||
proc.kill()
|
||||
@@ -1 +1,39 @@
|
||||
https://live.douyin.com/956616126418,主播: 糕冷小橘
|
||||
https://live.douyin.com/925673220466,主播: 兜有米麻糍
|
||||
https://live.douyin.com/745606325880,主播: w、(摆摊日记_)
|
||||
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,主播: 加速姐的摆摊日记
|
||||
https://live.douyin.com/661694696687,主播: 小米摆摊记
|
||||
https://live.douyin.com/228132412943,主播: 00后摆摊小可爱
|
||||
https://live.douyin.com/876677865786,主播: 牛哄哄
|
||||
https://live.douyin.com/573735790640,主播: 小董卤串(餐饮)
|
||||
https://live.douyin.com/260608582882,主播: 阿伟在炒饭(90後炒饭)
|
||||
https://live.douyin.com/210443559964,主播: 小小诺
|
||||
https://live.douyin.com/305363702471,主播: 嘉嘉在摆摊
|
||||
https://live.douyin.com/143239713951,主播: ɞ_大馋丫头的烤肠
|
||||
@@ -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
|
||||
循环时间(秒) = 10
|
||||
同一时间访问网络的线程数 = 3
|
||||
循环时间(秒) = 300
|
||||
排队读取网址时间(秒) = 0
|
||||
是否显示循环秒数 = 否
|
||||
是否显示直播源地址 = 否
|
||||
是否显示直播源地址 = 是
|
||||
分段录制是否开启 = 是
|
||||
是否强制启用https录制 = 否
|
||||
录制空间剩余阈值(gb) = 1.0
|
||||
视频分段时间(秒) = 1800
|
||||
录制完成后自动转为mp4格式 = 是
|
||||
录制完成后自动转为mp4格式 = 否
|
||||
mp4格式重新编码为h264 = 否
|
||||
追加格式后删除原文件 = 是
|
||||
生成时间字幕文件 = 否
|
||||
|
||||
5
config/easytier_network.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"networkName": "lr_f976289ba590",
|
||||
"networkSecret": "5f86ed227e8cfcf7d8b70b287502b682aa8d3ca0b0c560ed",
|
||||
"peerUrls": []
|
||||
}
|
||||
10
config/google_oauth.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"redirect_uri": "http://127.0.0.1:8001/youtube/oauth/callback",
|
||||
"web": {
|
||||
"client_id": "305371487651-iuqafultpqp0rvitojr70560k1foet0n.apps.googleusercontent.com",
|
||||
"client_secret": "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",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"redirect_uris": ["http://127.0.0.1:8001/youtube/oauth/callback"]
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
https://live.douyin.com/956616126418
|
||||
11
config/runtime.env.example
Normal file
@@ -0,0 +1,11 @@
|
||||
# 复制本文件为同目录下的 runtime.env 后生效(已加入 .gitignore,不会提交)
|
||||
# 启动器 scripts/launch.py 会自动加载 runtime.env 中的键(不覆盖已存在的环境变量)
|
||||
|
||||
# 局域网内任意浏览器访问控制台时,请保持 0.0.0.0
|
||||
HOST=0.0.0.0
|
||||
|
||||
# Web 控制台(FastAPI + 静态页)端口;与同机 SRS / Clash / Neko 等冲突时请改成未占用端口,例如 8101、9200
|
||||
PORT=8001
|
||||
|
||||
# 开发模式时 Next 端口(一般无需改)
|
||||
# NEXT_DEV_PORT=3000
|
||||
1
config/web2_api_token.txt
Normal file
@@ -0,0 +1 @@
|
||||
9d0103f49a3fea59311c014b9468e3ad2bd14d4f8334a7ec
|
||||
1
config/web2_bind_host.txt
Normal file
@@ -0,0 +1 @@
|
||||
0.0.0.0
|
||||
@@ -1,10 +1,13 @@
|
||||
[youtube]
|
||||
key = ue78-1c3e-mr9g-14mz-9r4z
|
||||
secure = false ; true=rtmps, false=rtmp
|
||||
bitrate = 5000 ; 视频码率(kbps)
|
||||
audio_bitrate = 128 ; 音频码率(kbps)
|
||||
fps = 30
|
||||
gop = 60
|
||||
resolution = ; 为空=不改分辨率;如 720x1280 或 1280x720
|
||||
ingest = a ; 谷歌分区 a/b/c...,留空默认 a
|
||||
copy_when_safe = true ; 源满足 h264+aac 且参数合规时尝试 -c copy
|
||||
# 必填:YouTube 直播推流 key(在 YouTube 工作室 → 创建 → 流式传输 获取)
|
||||
; key = qxvb-r47b-r5ju-6ud3-6k7z
|
||||
key = x04z-564w-aks7-embw-30y4
|
||||
|
||||
# 可选:是否使用 RTMPS 加密推流,填 否 则用 RTMP(默认 RTMPS)
|
||||
; rtmps = 否
|
||||
|
||||
# 可选:视频比特率 kbps,YouTube 推荐 2500,可填 3000-4500 提高画质
|
||||
; bitrate = 2500
|
||||
|
||||
# 可选:软编 speed 长期小于 1 时开启——轻量音频链(无 arnndn/长 EQ),减轻 CPU。填 1 或 是
|
||||
fast_audio = 1
|
||||
6
dev.bat
Normal file
@@ -0,0 +1,6 @@
|
||||
@echo off
|
||||
cd /d "%~dp0"
|
||||
if not defined PORT set "PORT=8001"
|
||||
echo [dev] API+Next 开发模式 PORT=%PORT%
|
||||
echo [dev] 浏览器打开 http://localhost:3000
|
||||
python scripts\launch.py --dev --port %PORT%
|
||||
8
dev.sh
Normal file
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# 开发:API(热重载) + Next dev;浏览器打开 http://localhost:3000
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
export PORT="${PORT:-8001}"
|
||||
PY=python3
|
||||
command -v python3 >/dev/null 2>&1 || PY=python
|
||||
exec "$PY" scripts/launch.py --dev --port "$PORT"
|
||||
10
docker-compose.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
# 控制台默认 8101(与 SRS 8080/1985、Clash 7890 等错开);改端口请同步修改左侧宿主机映射
|
||||
services:
|
||||
live-console:
|
||||
build: .
|
||||
ports:
|
||||
- "${LIVE_CONSOLE_PORT:-8101}:8101"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
HOST: "0.0.0.0"
|
||||
PORT: "8101"
|
||||
119
docs/REALTIME_AUDIO_DEMUCS.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# 实时音频处理最佳方案(Demucs + RTX 3060)
|
||||
|
||||
## 一、方案对比
|
||||
|
||||
| 方案 | 版权规避率 | 延迟 | 算力 | 复杂度 |
|
||||
|------|------------|------|------|--------|
|
||||
| **FFmpeg 纯滤波**(当前) | 60-70% | 0 | 低 | 低 |
|
||||
| **Demucs 人声分离** | 90%+ | 4-8s | RTX 3060 | 中高 |
|
||||
|
||||
---
|
||||
|
||||
## 二、推荐架构:Demucs 实时管道
|
||||
|
||||
```
|
||||
抖音流 ─┬─ FFmpeg 解复用 ─┬─ 视频 ─────────────────────────────┐
|
||||
│ │ │
|
||||
│ └─ 音频 ─→ Python Demucs ─→ 人声 ────┼─→ FFmpeg 复用 ─→ YouTube RTMP
|
||||
│ (分块处理) │
|
||||
└────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 核心参数(RTX 3060 12GB)
|
||||
|
||||
| 参数 | 推荐值 | 说明 |
|
||||
|------|--------|------|
|
||||
| **模型** | `htdemucs`(单模型,非 _ft) | 速度最快,质量够用 |
|
||||
| **chunk 长度** | 2.5-3 秒 | 延迟 ≈ 2×chunk ≈ 5-6s |
|
||||
| **overlap** | 0.25 | 减少接缝,质量更好 |
|
||||
| **shifts** | 0 | 降低算力,实时必需 |
|
||||
| **segment** | 7.8(默认)或更小 | 显存不足时减小 |
|
||||
|
||||
### RTX 3060 算力
|
||||
|
||||
- 约 13 TFLOPS,高于 Demucs 实时所需 2 TFLOPS
|
||||
- 12GB 显存足够 htdemucs(约 6-8GB)
|
||||
- 预期:**2-4× 实时速度**,可满足直播
|
||||
|
||||
---
|
||||
|
||||
## 三、实现方式
|
||||
|
||||
### 方式 A:Python 自建管道(推荐)
|
||||
|
||||
```python
|
||||
# 伪代码流程
|
||||
# 1. FFmpeg 从抖音拉流,输出: -vn -acodec pcm_s16le -ar 44100 -ac 1 pipe:1
|
||||
# 2. 主进程读取音频块(2.5s = 110250 samples @ 44.1kHz)
|
||||
# 3. 调用 demucs.separate_audio(model, chunk) 得到 vocals
|
||||
# 4. 人声写入 pipe,FFmpeg 另一进程读取并与视频复用推流
|
||||
```
|
||||
|
||||
**依赖**:
|
||||
```
|
||||
demucs
|
||||
torch
|
||||
torchaudio
|
||||
```
|
||||
|
||||
**模型加载**:
|
||||
```python
|
||||
from demucs.pretrained import get_model
|
||||
model = get_model('htdemucs') # 单模型,非 htdemucs_ft
|
||||
```
|
||||
|
||||
### 方式 B:GStreamer(若可用)
|
||||
|
||||
GStreamer 1.28+ 内置 demucs 元素,CPU 约 8× 实时:
|
||||
|
||||
```bash
|
||||
# 需安装 gst-plugins-rs (含 demucs)
|
||||
gst-launch-1.0 uridecodebin uri=抖音流 ! audioconvert ! demucs ! ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、分块处理要点
|
||||
|
||||
1. **重叠**:chunk 之间 25% 重叠,避免接缝
|
||||
2. **队列**:双缓冲,一块处理时另一块接收
|
||||
3. **采样率**:Demucs 默认 44100 Hz,与 FFmpeg 输出一致
|
||||
4. **延迟**:总延迟 ≈ 2 × chunk 长度(约 5-6 秒可接受)
|
||||
|
||||
---
|
||||
|
||||
## 五、快速验证(离线测试)
|
||||
|
||||
```bash
|
||||
# 安装
|
||||
pip install demucs torch torchaudio
|
||||
|
||||
# 测试 30 秒音频分离速度
|
||||
demucs -n htdemucs --two-stems=vocals -j 1 test_30s.mp3 -o output/
|
||||
|
||||
# 若 30s 在 10s 内完成 → 可实时
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、与当前方案的关系
|
||||
|
||||
- **当前**:`douyin_youtube_ffplay.py` 纯 FFmpeg,60-70% 规避率
|
||||
- **升级**:可新增 `douyin_youtube_demucs.py`,集成 Demucs 管道
|
||||
- **切换**:通过 config 选择 `mode = ffmpeg` 或 `mode = demucs`
|
||||
|
||||
---
|
||||
|
||||
## 七、配置建议(config/youtube.ini)
|
||||
|
||||
```ini
|
||||
[youtube]
|
||||
key = xxx
|
||||
|
||||
# 音频模式: ffmpeg | demucs
|
||||
; audio_mode = ffmpeg
|
||||
|
||||
# Demucs 专用
|
||||
; demucs_chunk = 2.5
|
||||
; demucs_model = htdemucs
|
||||
```
|
||||
50
docs/YOUTUBE_LIVE_SEO.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# YouTube Live 转播优化与 SEO 指南
|
||||
|
||||
## 一、推流技术参数(已自动优化)
|
||||
|
||||
- **分辨率**:720p 竖屏 (720×1280)
|
||||
- **比特率**:4.5 Mbps(可在 config/youtube.ini 的 bitrate 调整)
|
||||
- **关键帧**:2 秒(符合 YouTube 推荐)
|
||||
- **音频**:AAC 128kbps 48kHz 立体声
|
||||
- **协议**:RTMPS 加密推流(推荐)
|
||||
- **防版权**:开源音频处理链(降噪 + 带通 + EQ + 相位扰动)
|
||||
|
||||
## 二、YouTube 直播 SEO 与推荐权重
|
||||
|
||||
直播标题、描述、标签在 **YouTube 工作室** 创建直播时设置,推流程序无法修改。以下为提升推荐权重的建议:
|
||||
|
||||
### 标题(前 60 字最关键)
|
||||
|
||||
- 主关键词放前面,如:`LIVE 摆摊日常 | 路边直播`
|
||||
- 可加实时感词汇:LIVE、直播、实时、正在直播
|
||||
- 避免堆砌关键词,保持可读性
|
||||
|
||||
### 描述(前 125 字影响搜索)
|
||||
|
||||
- 首段包含主关键词和内容概要
|
||||
- 可说明:转播自抖音、直播内容类型、时间等
|
||||
|
||||
### 标签
|
||||
|
||||
- 5–15 个相关标签,如:live stream, 摆摊, 路边直播, 抖音转播
|
||||
- 与标题、描述语义一致
|
||||
|
||||
### 算法相关因素
|
||||
|
||||
- **观看时长**:用户停留越久,推荐越高
|
||||
- **互动**:点赞、评论、分享
|
||||
- **画质与音质**:清晰稳定的流有助于留存
|
||||
|
||||
## 三、config/youtube.ini 配置说明
|
||||
|
||||
| 配置项 | 说明 | 示例 |
|
||||
|--------|------|------|
|
||||
| key | 必填,YouTube stream key | key = xxxx-xxxx-xxxx |
|
||||
| rtmps | 是否用 RTMPS(默认是) | rtmps = 否 |
|
||||
| bitrate | 视频比特率 kbps | bitrate = 4500 |
|
||||
|
||||
## 四、稳定性建议
|
||||
|
||||
1. 网络:上行带宽 ≥ 6 Mbps,建议有线
|
||||
2. 推流前在 YouTube 工作室完成标题、描述、标签设置
|
||||
3. 定期检查 config/youtube.ini 中的 stream key 是否有效
|
||||
368
douyin_youtube_ffplay.py
Normal file
@@ -0,0 +1,368 @@
|
||||
# douyin_youtube_ffplay.py
|
||||
import queue
|
||||
import unicodedata
|
||||
import platform
|
||||
import traceback
|
||||
import time
|
||||
import datetime
|
||||
import subprocess
|
||||
import threading
|
||||
import re
|
||||
import os
|
||||
import configparser
|
||||
|
||||
|
||||
class StreamEnded(Exception):
|
||||
"""主播真实下播的专用异常"""
|
||||
pass
|
||||
|
||||
|
||||
def sanitize_title(text: str, max_len: int = 120) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
text = unicodedata.normalize("NFKC", text)
|
||||
text = ''.join(ch for ch in text if unicodedata.category(ch)[0] != 'C')
|
||||
text = ''.join(ch for ch in text if ord(ch) <= 0xFFFF)
|
||||
return text.strip()[:max_len]
|
||||
|
||||
|
||||
def _stderr_reader_thread(proc, q):
|
||||
try:
|
||||
while True:
|
||||
line = proc.stderr.readline()
|
||||
if not line:
|
||||
break
|
||||
q.put(line)
|
||||
except Exception as e:
|
||||
try:
|
||||
q.put(f"__ERR__READER__:{e}")
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
q.put(None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def cleanup_proc(proc):
|
||||
try:
|
||||
if proc and proc.poll() is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
except Exception:
|
||||
if proc.poll() is None:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def start_douyin_youtube_ffplay(
|
||||
title: str,
|
||||
anchor_name: str,
|
||||
real_url: str,
|
||||
rec_info: str,
|
||||
record_name: str,
|
||||
recording: set,
|
||||
recording_time_list: dict,
|
||||
running_list: list,
|
||||
monitoring: list,
|
||||
clear_record_info,
|
||||
color_obj,
|
||||
):
|
||||
"""
|
||||
抖音 → YouTube 推流主函数(单路推流)
|
||||
只从 config/youtube.ini 读取一个 stream key(取第一个有效值)
|
||||
"""
|
||||
|
||||
# ====================== 读取 YouTube 配置 ======================
|
||||
config_file = "config/youtube.ini"
|
||||
rtmps_base = "rtmps://a.rtmp.youtube.com/live2/"
|
||||
rtmp_base = "rtmp://a.rtmp.youtube.com/live2/"
|
||||
use_rtmps = True
|
||||
# 720p 竖屏 YouTube 建议 4–6 Mbps,偏低易触发「数据不足/卡顿」提示
|
||||
b_v, maxrate, bufsize = "4000k", "4500k", "8000k"
|
||||
stream_key = None
|
||||
fast_audio = False # config: fast_audio=1 可跳过 arnndn+长 EQ,显著减轻 CPU
|
||||
|
||||
if os.path.exists(config_file):
|
||||
config = configparser.ConfigParser()
|
||||
config.read(config_file, encoding="utf-8")
|
||||
if config.has_section("youtube"):
|
||||
for k, v in config.items("youtube"):
|
||||
v = (v or "").strip()
|
||||
if k == "rtmps" and v.lower() in ("0", "false", "否"):
|
||||
use_rtmps = False
|
||||
elif k == "fast_audio" and v.lower() in ("1", "true", "yes", "是", "开", "on"):
|
||||
fast_audio = True
|
||||
elif k == "bitrate" and v:
|
||||
try:
|
||||
n = int(v)
|
||||
b_v, maxrate, bufsize = f"{n}k", f"{min(n * 12 // 10, 8000)}k", f"{n * 2}k"
|
||||
except ValueError:
|
||||
pass
|
||||
elif (k == "key" or k == "stream_key") and v and not v.startswith("#"):
|
||||
stream_key = v
|
||||
if stream_key:
|
||||
if stream_key.startswith("rtmp://") or stream_key.startswith("rtmps://"):
|
||||
youtube_rtmp = stream_key
|
||||
else:
|
||||
youtube_rtmp = (rtmps_base if use_rtmps else rtmp_base) + stream_key
|
||||
print(f"[INFO] 从 {config_file} 读取 YouTube 推流地址")
|
||||
else:
|
||||
raise ValueError(f"请在 {config_file} 中配置 key=你的stream_key")
|
||||
|
||||
# ====================== 常量配置 ======================
|
||||
MAX_RETRY_DELAY = 90
|
||||
INITIAL_RETRY_DELAY = 3
|
||||
STDERR_QUEUE_TIMEOUT = 1.0
|
||||
NO_FRAME_TIMEOUT = 120
|
||||
START_CHECK_AFTER = 25
|
||||
STALE_OUTPUT_SECONDS = 40
|
||||
RECONNECT_DELAY_MAX = 10
|
||||
|
||||
END_KEYWORDS = [
|
||||
"connection reset by peer",
|
||||
"failed to write trailer",
|
||||
"invalid data found",
|
||||
"server returned 403",
|
||||
"server returned 404",
|
||||
"server returned 500",
|
||||
"ingestion error",
|
||||
"access denied",
|
||||
"packet too short",
|
||||
"client disconnected",
|
||||
"live has ended",
|
||||
"no route to host",
|
||||
"connection timed out",
|
||||
"connection refused",
|
||||
"error writing output",
|
||||
"broken pipe",
|
||||
"i/o error",
|
||||
]
|
||||
|
||||
douyin_headers = (
|
||||
"User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15\r\n"
|
||||
"Referer: https://live.douyin.com/\r\n"
|
||||
"Origin: https://live.douyin.com\r\n"
|
||||
)
|
||||
|
||||
# ====================== 标题处理 ======================
|
||||
timestamp_str = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
|
||||
clean_title = sanitize_title(title)
|
||||
stream_title = sanitize_title(f"{anchor_name}{('_' + clean_title) if clean_title else ''}_{timestamp_str}")
|
||||
print(f"[INFO] {rec_info} 开始推流到 YouTube: {stream_title}")
|
||||
|
||||
# ====================== NVENC 检测(符合 YouTube 推荐比特率)======================
|
||||
codec_v = "libx264"
|
||||
# ultrafast+zerolatency:在软编下尽量让 speed≥1.0;画质略降、直播可接受
|
||||
video_args = ["-preset", "ultrafast", "-tune", "zerolatency", "-profile:v", "high", "-level", "4.0",
|
||||
"-b:v", b_v, "-maxrate", maxrate, "-bufsize", bufsize]
|
||||
try:
|
||||
if platform.system().lower() in ["windows", "linux"]:
|
||||
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=5)
|
||||
if check.returncode == 0:
|
||||
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=5)
|
||||
if "h264_nvenc" in (enc.stdout or ""):
|
||||
codec_v = "h264_nvenc"
|
||||
video_args = ["-profile:v", "high", "-level", "4.0",
|
||||
"-tune", "ll", "-rc", "cbr", "-b:v", b_v,
|
||||
"-maxrate", maxrate, "-bufsize", bufsize]
|
||||
print("[INFO] 检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
|
||||
except Exception as e:
|
||||
print(f"[WARN] NVENC 检测失败,回退软件编码: {e}")
|
||||
|
||||
# ====================== 开源音频处理:背景音乐弱化 + 防版权 ======================
|
||||
_script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
_ffmpeg_cwd = None
|
||||
if fast_audio:
|
||||
print("[INFO] fast_audio=开:轻量音频链(无 arnndn/长 EQ),减轻 CPU 以利 speed≥1.0")
|
||||
_af_chain = "afftdn=nf=-26:tn=1,aresample=async=1:first_pts=0"
|
||||
else:
|
||||
# 优先 RNNoise(arnndn),无模型则用 afftdn;aphaser 相位扰动可干扰 Content ID 指纹
|
||||
_arnndn_paths = [
|
||||
os.path.join(_script_dir, "arnndn-models", "cb.rnnn"),
|
||||
os.path.join(os.path.dirname(_script_dir), "arnndn-models", "cb.rnnn"),
|
||||
"arnndn-models/cb.rnnn",
|
||||
]
|
||||
_arnndn_model = None
|
||||
for _p in _arnndn_paths:
|
||||
if os.path.isfile(_p):
|
||||
_arnndn_model = os.path.normpath(_p)
|
||||
break
|
||||
if _arnndn_model:
|
||||
print(f"[INFO] 使用 RNNoise 降噪模型: {_arnndn_model}")
|
||||
_arnndn_base = os.path.dirname(os.path.dirname(os.path.abspath(_arnndn_model)))
|
||||
_af_denoise = "arnndn=m=arnndn-models/cb.rnnn:mix=0.82,"
|
||||
_ffmpeg_cwd = _arnndn_base
|
||||
else:
|
||||
print("[INFO] 未找到 arnndn 模型,使用 afftdn 降噪(可克隆 richardpl/arnndn-models 到 arnndn-models/)")
|
||||
_af_denoise = "afftdn=nf=-26:tn=1,"
|
||||
|
||||
# 强化版:收紧带通 + 频率偏移 + 音高微调 + 多段EQ + 相位扰动(纯 FFmpeg 理论上限 ~70%)
|
||||
_af_chain = (
|
||||
_af_denoise
|
||||
+ "highpass=f=200,lowpass=f=3800,"
|
||||
+ "equalizer=f=150:width_type=o:width=2.5:g=-14,"
|
||||
+ "equalizer=f=350:width_type=o:width=2:g=-10,"
|
||||
+ "equalizer=f=600:width_type=o:width=1.8:g=-8,"
|
||||
+ "equalizer=f=1000:width_type=o:width=1.5:g=-6,"
|
||||
+ "equalizer=f=2500:width_type=o:width=1.2:g=-4,"
|
||||
+ "acompressor=threshold=-26dB:ratio=5:attack=8:release=80:makeup=5,"
|
||||
+ "afreqshift=shift=22,"
|
||||
+ "asetrate=48000*1.035,aresample=48000,"
|
||||
+ "aphaser=type=t:decay=0.4:delay=3:speed=0.7,"
|
||||
+ "volume=1.12,"
|
||||
+ "aresample=async=1:first_pts=0"
|
||||
)
|
||||
|
||||
# 720p 竖屏,符合 YouTube 推荐:4–6 Mbps、2s 关键帧、AAC 128k
|
||||
_video_mid = ["-vsync", "cfr", "-g", "60", "-keyint_min", "60", "-r", "30",
|
||||
"-bf", "0", "-sc_threshold", "0"]
|
||||
if codec_v == "libx264":
|
||||
_video_mid += ["-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0"]
|
||||
# -re:按源时间戳节奏拉流,避免突发过快;不用 realtime/arealtime(输入抖动时易让 YouTube 判为数据不足)
|
||||
ffmpeg_command = [
|
||||
"ffmpeg", "-y", "-loglevel", "info", "-nostdin",
|
||||
"-stats", "-stats_period", "1",
|
||||
"-rw_timeout", "50000000",
|
||||
"-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
|
||||
"-reconnect_delay_max", str(RECONNECT_DELAY_MAX),
|
||||
"-fflags", "+genpts+discardcorrupt",
|
||||
"-err_detect", "ignore_err",
|
||||
"-max_delay", "500000",
|
||||
"-thread_queue_size", "2048",
|
||||
"-filter_threads", "4",
|
||||
"-headers", douyin_headers,
|
||||
"-re",
|
||||
"-i", real_url,
|
||||
# bilinear 比 lanczos 轻很多,利于 speed 稳定在 ≥1.0x
|
||||
"-vf", "fps=30,scale=720:1280:force_original_aspect_ratio=decrease:flags=bilinear,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
|
||||
"-c:v", codec_v,
|
||||
*video_args,
|
||||
*_video_mid,
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2",
|
||||
"-af", _af_chain,
|
||||
"-flags", "+global_header",
|
||||
"-flvflags", "no_duration_filesize",
|
||||
"-f", "flv", youtube_rtmp
|
||||
]
|
||||
|
||||
proc = None
|
||||
stderr_q = None
|
||||
reader_t = None
|
||||
retry_delay = INITIAL_RETRY_DELAY
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
if retry_delay > INITIAL_RETRY_DELAY:
|
||||
print(f"[WARN] 防风控等待 {retry_delay}s 后重新连接抖音源...")
|
||||
time.sleep(retry_delay)
|
||||
|
||||
cleanup_proc(proc)
|
||||
stderr_q = queue.Queue()
|
||||
|
||||
print(f"[INFO][{datetime.datetime.now():%H:%M:%S}] 启动 YouTube 推流 → {stream_title}")
|
||||
_popen_kw = dict(
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
universal_newlines=True,
|
||||
)
|
||||
if _ffmpeg_cwd:
|
||||
_popen_kw["cwd"] = _ffmpeg_cwd
|
||||
proc = subprocess.Popen(ffmpeg_command, **_popen_kw)
|
||||
proc.last_out_ts = time.time()
|
||||
|
||||
reader_t = threading.Thread(target=_stderr_reader_thread, args=(proc, stderr_q))
|
||||
reader_t.daemon = True
|
||||
reader_t.start()
|
||||
|
||||
start_time = last_frame_time = time.time()
|
||||
|
||||
while True:
|
||||
try:
|
||||
try:
|
||||
line = stderr_q.get(timeout=STDERR_QUEUE_TIMEOUT)
|
||||
except queue.Empty:
|
||||
if time.time() - getattr(proc, "last_out_ts", time.time()) > STALE_OUTPUT_SECONDS:
|
||||
print("[WARN] 长时间无任何日志,强制重启进程")
|
||||
cleanup_proc(proc)
|
||||
break
|
||||
continue
|
||||
|
||||
if line is None:
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
continue
|
||||
|
||||
line = line.strip()
|
||||
if line.startswith("__ERR__READER__"):
|
||||
print(f"[ERROR] stderr reader 异常: {line}")
|
||||
continue
|
||||
|
||||
if line:
|
||||
proc.last_out_ts = time.time()
|
||||
print(f"[FFmpeg] {line}")
|
||||
line_lower = line.lower()
|
||||
|
||||
if any(kw in line_lower for kw in END_KEYWORDS):
|
||||
print("[ERROR] 检测到主播真下播或被明确拒绝,停止推流")
|
||||
cleanup_proc(proc)
|
||||
raise StreamEnded()
|
||||
|
||||
if re.search(r'frame=\s*\d+', line_lower) or "fps=" in line_lower:
|
||||
last_frame_time = time.time()
|
||||
|
||||
if time.time() - start_time > START_CHECK_AFTER:
|
||||
if time.time() - last_frame_time > NO_FRAME_TIMEOUT:
|
||||
print(f"[WARN] 连续 {NO_FRAME_TIMEOUT}s 无新帧,判定主播已下播")
|
||||
cleanup_proc(proc)
|
||||
raise StreamEnded()
|
||||
|
||||
except StreamEnded:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 内层异常: {e}")
|
||||
traceback.print_exc()
|
||||
cleanup_proc(proc)
|
||||
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
|
||||
break
|
||||
|
||||
continue
|
||||
|
||||
except StreamEnded:
|
||||
print(f"[INFO] 主播已下播,停止 YouTube 转推 → {record_name}")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 外层异常: {e}")
|
||||
traceback.print_exc()
|
||||
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
print(f"[FATAL] YouTube 推流最外层异常: {e}")
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
cleanup_proc(proc)
|
||||
|
||||
try:
|
||||
recording.discard(record_name)
|
||||
recording_time_list.pop(record_name, None)
|
||||
if real_url in running_list:
|
||||
running_list.remove(real_url)
|
||||
monitoring[0] = max(0, monitoring[0] - 1)
|
||||
clear_record_info(record_name, real_url)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
color_obj.print_colored(f"[{record_name}] YouTube 推流已彻底停止并清理完毕\n", color_obj.GREEN)
|
||||
26
ecosystem.config.cjs
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* PM2:在项目根执行 `pm2 start ecosystem.config.cjs` 后 `pm2 save`
|
||||
* web 使用 scripts/launch.py:若缺少 web-console/out 会自动 npm build 再起 uvicorn
|
||||
*/
|
||||
const path = require("path");
|
||||
const root = __dirname;
|
||||
const py = process.env.PYTHON || "python3";
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: "web",
|
||||
cwd: root,
|
||||
script: path.join(root, "scripts", "launch.py"),
|
||||
interpreter: py,
|
||||
env: {
|
||||
HOST: "0.0.0.0",
|
||||
PORT: process.env.PORT || "8001",
|
||||
},
|
||||
},
|
||||
// { name: "youtube", cwd: root, script: py, args: "youtube.py" },
|
||||
// { name: "tiktok", cwd: root, script: py, args: "tiktok.py" },
|
||||
// Linux: { name: "obs", cwd: root, script: "bash", args: `-lc ${JSON.stringify(path.join(root, "obs.sh"))}` },
|
||||
// Win: { name: "obs", cwd: root, script: "cmd.exe", args: "/c obs.bat" },
|
||||
],
|
||||
};
|
||||
533
index.html
@@ -1,215 +1,338 @@
|
||||
<!--
|
||||
Project: DouyinLiveRecorder
|
||||
Author: Hmily
|
||||
Build: 2023.08.14 - 20:24:05
|
||||
GitHub Project URL: https://github.com/ihmily/DouyinLiveRecorder
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="referrer" content="never">
|
||||
<title>M3U8 视频播放器</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest/dist/hls.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/flv.js@1.6.2/dist/flv.min.js"></script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Roboto', Arial, sans-serif;
|
||||
background-color: #1a237e;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: #ffffff;
|
||||
background-image: linear-gradient(120deg, #1a237e 0%, #283593 50%, #4a148c 100%);
|
||||
}
|
||||
|
||||
|
||||
.container {
|
||||
max-width: 640px;
|
||||
width: 80%;
|
||||
padding: 20px;
|
||||
background-color: #ffffff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0px 0px 5px 0px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
#videoPlayer {
|
||||
width: 100%;
|
||||
height: 0;
|
||||
padding-bottom: 56.25%;
|
||||
position: relative;
|
||||
background-color: #000;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0px 0px 10px 0px rgba(0,0,0,0.15);
|
||||
display: none;
|
||||
}
|
||||
|
||||
video {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#videoUrlInput{
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 10px 0;
|
||||
padding: 8px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #ccc;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#playButton {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background-color: #283593;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
border-radius: 5px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
margin: 0 0 10px 0;
|
||||
box-shadow: 0px 2px 4px 0px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
#playButton:hover {
|
||||
background-color: #1a237e;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-top: 20px;
|
||||
line-height: 1.4;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
background-color: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
|
||||
box-shadow: 0px 0px 5px 0px rgba(0,0,0,0.15);
|
||||
display: block;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
p{
|
||||
color: black;
|
||||
}
|
||||
|
||||
a.no_style {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
@media screen and (max-width: 768px) {
|
||||
.container {
|
||||
width: 90%;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
margin-top:30px;
|
||||
}
|
||||
body {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
#videoUrlInput{
|
||||
|
||||
margin-top: 30px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
</style>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>多进程录制控制台</title>
|
||||
<style>
|
||||
:root{--bg:#1a237e;--accent:#283593;--text:#fff;--card:#fff;--card-text:#000}
|
||||
@media(prefers-color-scheme:dark){:root{--card:#1e1e1e;--card-text:#fff}}
|
||||
body{margin:0;font-family:system-ui,-apple-system,sans-serif;background:linear-gradient(135deg,var(--bg),#283593,#4a148c);color:var(--text);min-height:100vh;display:flex;justify-content:center;padding:20px;box-sizing:border-box}
|
||||
.container{max-width:720px;width:100%;background:var(--card);color:var(--card-text);border-radius:12px;padding:24px;box-shadow:0 8px 32px rgba(0,0,0,.4)}
|
||||
h1{margin:0 0 24px;font-size:1.6em;display:flex;align-items:center;gap:12px}
|
||||
#processSelect{padding:8px;border-radius:8px;border:1px solid #ddd;font-size:1em}
|
||||
#statusIndicator{font-size:1.2em;font-weight:600}
|
||||
h2{margin:24px 0 12px;font-size:1.4em}
|
||||
h3{margin:16px 0 8px;font-size:1.2em}
|
||||
input,button,textarea{width:100%;padding:12px;margin:8px 0;border-radius:8px;border:none;box-sizing:border-box;font-size:1em}
|
||||
input,textarea{background:#f5f5f5;color:#000;border:1px solid #ddd;font-family:monospace;overflow:hidden;resize:none;min-height:100px}
|
||||
button{background:var(--accent);color:#fff;cursor:pointer;font-weight:600;transition:.2s}
|
||||
button:hover{background:#1a237e}
|
||||
button.loading{opacity:.7;pointer-events:none}
|
||||
button.loading::after{content:" ⏳";animation:spin 1s linear infinite}
|
||||
#pm2Buttons{display:grid;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));gap:8px;margin:12px 0}
|
||||
#pm2Log{background:#000;color:#0f0;padding:12px;height:320px;overflow-y:auto;font-family:monospace;font-size:.9em;border-radius:8px;white-space:pre-wrap;word-break:break-all}
|
||||
.configButtons{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:8px;margin:12px 0}
|
||||
.configStatus{color:#0f0;margin-top:8px;font-family:monospace;font-size:0.9em}
|
||||
#obsAddress{background:#f0f0f0;padding:12px;border-radius:8px;font-family:monospace;font-size:1.1em;margin:8px 0;word-break:break-all}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<input type="text" id="videoUrlInput" placeholder="请输入 M3U8或者FLV 视频链接">
|
||||
<button id="playButton">播放视频</button>
|
||||
<div id="videoPlayer">
|
||||
<video controls></video>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p><strong>说明</strong><p>
|
||||
<p>M3U8文件格式</p>
|
||||
<p>M3U8文件是采用UTF-8编码格式的M3U文件。M3U文件本身是一个纯文本索引文件,其核心功能是记录多媒体文件链接。当用户打开此类文件时,播放软件会根据索引查找相应的音视频文件网络地址,然后进行在线播放。</p>
|
||||
<p>M3U最初设计用于播放音频文件,例如MP3。但随着时间推移,更多的播放器和软件开始使用M3U来播放视频文件列表,同时也支持在线流媒体音频源的指定。目前,许多播放器和软件都兼容M3U文件格式。</p>
|
||||
<p>FLV文件格式(Flash Video Format)是Adobe公司开发的一种专门用于网页视频播放的文件格式。FLV格式的视频文件通常用于播放短视频和在线流媒体,可以嵌入到网页中供用户观看。FLV视频通常由Adobe Flash Player播放器播放,而其他第三方播放器也支持此格式。</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© 2023 <a href='https://github.com/ihmily/DouyinLiveRecorder' class="no_style" target="_blank">Hmily</a>. All rights reserved.</p>
|
||||
</div>
|
||||
<script>
|
||||
function httpToHttps(url) {
|
||||
if (url.startsWith("http://")) {
|
||||
return url.replace("http://", "https://");
|
||||
}
|
||||
return url;
|
||||
}
|
||||
function playVideo() {
|
||||
let videoUrl = document.getElementById('videoUrlInput').value;
|
||||
const video = document.querySelector('#videoPlayer video');
|
||||
const description = document.querySelector('.description');
|
||||
if (videoUrl == ''){
|
||||
alert('请输入视频链接');
|
||||
return;
|
||||
}
|
||||
videoUrl = httpToHttps(videoUrl);
|
||||
if (videoUrl.includes('.m3u8')) {
|
||||
videoPlayer.style.display = 'block';
|
||||
description.style.display = 'none';
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls();
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||
hls.loadSource(videoUrl);
|
||||
});
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = videoUrl;
|
||||
} else {
|
||||
alert('M3U8 格式不受您的浏览器支持。');
|
||||
console.error('M3U8 格式不受您的浏览器支持。');
|
||||
return;
|
||||
}
|
||||
} else if (videoUrl.includes('.flv')) {
|
||||
if (flvjs.isSupported()) {
|
||||
const flvPlayer = flvjs.createPlayer({
|
||||
type: 'flv',
|
||||
url: videoUrl
|
||||
});
|
||||
flvPlayer.attachMediaElement(video);
|
||||
flvPlayer.load();
|
||||
flvPlayer.play();
|
||||
} else {
|
||||
alert('FLV 格式不受您的浏览器支持。');
|
||||
console.error('FLV 格式不受您的浏览器支持。');
|
||||
return;
|
||||
}
|
||||
<div class="container">
|
||||
<h1>
|
||||
进程:
|
||||
<select id="processSelect"></select>
|
||||
<span id="statusIndicator">⚪ 加载中...</span>
|
||||
</h1>
|
||||
<p id="processScriptNote" style="font-size:0.88em;margin:-12px 0 20px;opacity:0.88;font-family:ui-monospace,monospace"></p>
|
||||
|
||||
videoPlayer.style.display = 'block';
|
||||
description.style.display = 'none';
|
||||
} else {
|
||||
console.error('不支持播放该视频格式。');
|
||||
alert('不支持播放该视频格式。');
|
||||
<!-- YouTube 专属配置(仅 youtube 时显示) -->
|
||||
<div id="youtubeOnlySection" style="display:none;">
|
||||
<h2>YouTube 配置编辑 (youtube.ini)</h2>
|
||||
<div class="configEditor">
|
||||
<h3>youtube.ini</h3>
|
||||
<textarea id="youtubeConfig"></textarea>
|
||||
<div class="configButtons">
|
||||
<button id="loadYoutubeBtn">加载配置</button>
|
||||
<button id="saveYoutubeBtn">保存配置</button>
|
||||
</div>
|
||||
<pre class="configStatus" id="youtubeStatus">就绪</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- URL_config.ini 共享配置(youtube 或 tiktok 时显示) -->
|
||||
<div id="urlConfigSection" style="display:none;">
|
||||
<h2>URL 配置编辑 (URL_config.ini)</h2>
|
||||
<div class="configEditor">
|
||||
<h3>URL_config.ini</h3>
|
||||
<textarea id="urlConfig"></textarea>
|
||||
<div class="configButtons">
|
||||
<button id="loadUrlBtn">加载配置</button>
|
||||
<button id="saveUrlBtn">保存配置</button>
|
||||
</div>
|
||||
<pre class="configStatus" id="urlStatus">就绪</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OBS 推流地址区(仅 obs 时显示) -->
|
||||
<div id="obsSection" style="display:none;">
|
||||
<h2>OBS 直播推流地址</h2>
|
||||
<p>请在 OBS → 设置 → 推流 → 服务选择“自定义”,服务器填写以下地址:</p>
|
||||
<div id="obsAddress">srt://live.local:10080/live/obs</div>
|
||||
<p>密钥(Stream Key)留空即可。</p>
|
||||
</div>
|
||||
|
||||
<h2>PM2 进程控制</h2>
|
||||
<div id="pm2Buttons">
|
||||
<button data-action="start">启动</button>
|
||||
<button data-action="stop">停止</button>
|
||||
<button data-action="restart">重启</button>
|
||||
<button data-action="status">手动刷新</button>
|
||||
</div>
|
||||
<h2>实时日志(自动每秒刷新)</h2>
|
||||
<pre id="pm2Log">正在加载状态与日志...</pre>
|
||||
</div>
|
||||
<script>
|
||||
/** 进程列表来自 GET /process_monitor(web.py 扫描根目录);PM2 名 = 脚本主文件名(无扩展名)。 */
|
||||
let PROCESS_MONITOR = [];
|
||||
|
||||
function pm2NameFromScript(script) {
|
||||
const base = String(script).split(/[/\\]/).pop() || '';
|
||||
const i = base.lastIndexOf('.');
|
||||
return i > 0 ? base.slice(0, i) : base;
|
||||
}
|
||||
|
||||
function isYoutubeScript(script) {
|
||||
if (!/\.py$/i.test(script)) return false;
|
||||
var s = pm2NameFromScript(script).toLowerCase();
|
||||
return s.startsWith('youtube') || s.startsWith('douyin_youtube');
|
||||
}
|
||||
function isTiktokScript(script) {
|
||||
return /\.py$/i.test(script) && pm2NameFromScript(script).startsWith('tiktok');
|
||||
}
|
||||
function isObsScript(script) {
|
||||
return pm2NameFromScript(script).startsWith('obs');
|
||||
}
|
||||
|
||||
async function loadProcessMonitor() {
|
||||
const res = await fetch('/process_monitor');
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const data = await res.json();
|
||||
const entries = data.entries || [];
|
||||
PROCESS_MONITOR = entries.map((e) => ({
|
||||
script: e.script,
|
||||
label: e.label || e.script,
|
||||
pm2: e.pm2 || pm2NameFromScript(e.script),
|
||||
}));
|
||||
}
|
||||
|
||||
function getEntryByPm2(name) {
|
||||
return PROCESS_MONITOR.find((e) => e.pm2 === name) || null;
|
||||
}
|
||||
|
||||
function fillProcessSelect() {
|
||||
processSelect.innerHTML = '';
|
||||
PROCESS_MONITOR.forEach((e) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = e.pm2;
|
||||
opt.textContent = `${e.label}(${e.script})`;
|
||||
processSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
const logEl = document.getElementById('pm2Log');
|
||||
const statusIndicator = document.getElementById('statusIndicator');
|
||||
const processSelect = document.getElementById('processSelect');
|
||||
let currentProcess = '';
|
||||
|
||||
// 各专属区域
|
||||
const youtubeOnlySection = document.getElementById('youtubeOnlySection');
|
||||
const urlConfigSection = document.getElementById('urlConfigSection');
|
||||
const obsSection = document.getElementById('obsSection');
|
||||
|
||||
// youtube.ini 编辑(youtube 专属)
|
||||
const youtubeConfig = document.getElementById('youtubeConfig');
|
||||
const loadYoutubeBtn = document.getElementById('loadYoutubeBtn');
|
||||
const saveYoutubeBtn = document.getElementById('saveYoutubeBtn');
|
||||
const youtubeStatus = document.getElementById('youtubeStatus');
|
||||
|
||||
// URL_config.ini 编辑(youtube 和 tiktok 共享)
|
||||
const urlConfig = document.getElementById('urlConfig');
|
||||
const loadUrlBtn = document.getElementById('loadUrlBtn');
|
||||
const saveUrlBtn = document.getElementById('saveUrlBtn');
|
||||
const urlStatus = document.getElementById('urlStatus');
|
||||
|
||||
function refreshProcessScriptNote() {
|
||||
const el = document.getElementById('processScriptNote');
|
||||
const ent = getEntryByPm2(currentProcess);
|
||||
el.textContent = ent ? `脚本: ${ent.script} · PM2 名: ${ent.pm2}` : '';
|
||||
}
|
||||
|
||||
processSelect.addEventListener('change', () => {
|
||||
currentProcess = processSelect.value;
|
||||
refreshProcessScriptNote();
|
||||
fetchStatus();
|
||||
updateSectionsVisibility();
|
||||
});
|
||||
|
||||
function updateSectionsVisibility() {
|
||||
const ent = getEntryByPm2(currentProcess);
|
||||
const s = ent ? ent.script : '';
|
||||
const isYoutube = ent && isYoutubeScript(s);
|
||||
const isTiktok = ent && isTiktokScript(s);
|
||||
const isObs = ent && isObsScript(s);
|
||||
|
||||
youtubeOnlySection.style.display = isYoutube ? 'block' : 'none';
|
||||
urlConfigSection.style.display = (isYoutube || isTiktok) ? 'block' : 'none';
|
||||
obsSection.style.display = isObs ? 'block' : 'none';
|
||||
|
||||
if (isYoutube) loadYoutubeConfig();
|
||||
if (isYoutube || isTiktok) loadUrlConfig();
|
||||
}
|
||||
|
||||
// textarea 高度自适应
|
||||
function autoResize(textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = (textarea.scrollHeight) + 'px';
|
||||
}
|
||||
[youtubeConfig, urlConfig].forEach(ta => {
|
||||
ta.addEventListener('input', () => autoResize(ta));
|
||||
autoResize(ta);
|
||||
});
|
||||
|
||||
// 通用加载/保存
|
||||
async function loadConfig(endpoint, textarea, statusEl) {
|
||||
statusEl.textContent = '加载中...';
|
||||
try {
|
||||
const res = await fetch(`${endpoint}?process=${currentProcess}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
textarea.value = data.content || '';
|
||||
autoResize(textarea);
|
||||
statusEl.textContent = '加载成功';
|
||||
} catch (err) {
|
||||
statusEl.textContent = `加载失败: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig(endpoint, textarea, statusEl, saveBtn) {
|
||||
statusEl.textContent = '保存中...';
|
||||
const content = textarea.value;
|
||||
try {
|
||||
const res = await fetch(`${endpoint}?process=${currentProcess}`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({content})
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
statusEl.textContent = data.message || '保存成功';
|
||||
} catch (err) {
|
||||
statusEl.textContent = `保存失败: ${err.message}`;
|
||||
} finally {
|
||||
if (saveBtn) saveBtn.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
|
||||
// youtube.ini(仅 youtube)
|
||||
function loadYoutubeConfig() { loadConfig('/get_config', youtubeConfig, youtubeStatus); }
|
||||
function saveYoutubeConfig() {
|
||||
saveYoutubeBtn.classList.add('loading');
|
||||
saveConfig('/save_config', youtubeConfig, youtubeStatus, saveYoutubeBtn);
|
||||
}
|
||||
loadYoutubeBtn.onclick = loadYoutubeConfig;
|
||||
saveYoutubeBtn.onclick = saveYoutubeConfig;
|
||||
|
||||
// URL_config.ini(youtube 和 tiktok 共享)
|
||||
function loadUrlConfig() { loadConfig('/get_url_config', urlConfig, urlStatus); }
|
||||
function saveUrlConfig() {
|
||||
saveUrlBtn.classList.add('loading');
|
||||
saveConfig('/save_url_config', urlConfig, urlStatus, saveUrlBtn);
|
||||
}
|
||||
loadUrlBtn.onclick = loadUrlConfig;
|
||||
saveUrlBtn.onclick = saveUrlConfig;
|
||||
|
||||
function scrollLog(){logEl.scrollTop = logEl.scrollHeight}
|
||||
|
||||
function updateStatus(process_status){
|
||||
let emoji = '⚪', text = '未知';
|
||||
if(process_status === 'online'){ emoji = '🟢'; text = '运行中'; }
|
||||
else if(process_status === 'stopped'){ emoji = '🔴'; text = '已停止'; }
|
||||
else if(process_status === 'errored'){ emoji = '🔴'; text = '错误'; }
|
||||
else if(process_status === 'launching' || process_status === 'starting'){ emoji = '🟡'; text = '启动中'; }
|
||||
else if(process_status === 'waiting restart'){ emoji = '🟡'; text = '等待重启'; }
|
||||
else if(process_status === 'not_found'){ emoji = '❌'; text = '未注册到 PM2'; }
|
||||
statusIndicator.innerHTML = `${emoji} ${text}`;
|
||||
}
|
||||
|
||||
async function pm2Action(action){
|
||||
const btn = document.querySelector(`[data-action="${action}"]`);
|
||||
if(btn) btn.classList.add('loading');
|
||||
const url = `/${action}?process=${currentProcess}`;
|
||||
try{
|
||||
const res = await fetch(url);
|
||||
if(!res.ok) throw new Error('网络错误');
|
||||
const data = await res.json();
|
||||
if(action === 'status'){
|
||||
updateStatus(data.process_status);
|
||||
let logText = `【PM2 全状态表格】(${new Date().toLocaleTimeString()})\n${data.raw_status}\n\n`;
|
||||
if(data.process_status === 'not_found'){
|
||||
const ent = getEntryByPm2(currentProcess);
|
||||
const hint = ent ? `${ent.script}(PM2 名: ${ent.pm2})` : currentProcess;
|
||||
logText += `【提示】未在 PM2 中找到: ${hint}\n`;
|
||||
}else{
|
||||
logText += `【实时输出日志】`;
|
||||
if(data.recent_log.includes('不存在') || data.recent_log.includes('无日志路径')){
|
||||
logText += `\n${data.recent_log}`;
|
||||
}else{
|
||||
logText += `(最近 ${data.recent_log.trim().split('\n').length} 行)\n${data.recent_log || '无新输出'}`;
|
||||
}
|
||||
logText += `\n\n【实时错误日志】`;
|
||||
if(data.recent_error.includes('不存在') || data.recent_error.includes('无日志路径')){
|
||||
logText += `\n${data.recent_error}`;
|
||||
}else{
|
||||
logText += `(最近 ${data.recent_error.trim().split('\n').length} 行)\n${data.recent_error || '无错误'}`;
|
||||
}
|
||||
}
|
||||
logEl.textContent = logText;
|
||||
scrollLog();
|
||||
}else{
|
||||
const ent = getEntryByPm2(currentProcess);
|
||||
const who = ent ? `${ent.script} (${ent.pm2})` : currentProcess;
|
||||
logEl.textContent = `操作: ${action} → ${who}\n${data.output || '成功'}\n\n正在刷新状态...`;
|
||||
scrollLog();
|
||||
setTimeout(fetchStatus, 1500);
|
||||
}
|
||||
}catch(err){
|
||||
logEl.textContent = `请求失败: ${err.message}`;
|
||||
scrollLog();
|
||||
updateStatus('unknown');
|
||||
}finally{
|
||||
if(btn) btn.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('playButton').addEventListener('click', playVideo);
|
||||
</script>
|
||||
</div>
|
||||
async function fetchStatus(){ pm2Action('status'); }
|
||||
|
||||
// 按钮事件委托
|
||||
document.getElementById('pm2Buttons').addEventListener('click', e=>{
|
||||
const btn = e.target.closest('button');
|
||||
if(!btn) return;
|
||||
const action = btn.dataset.action;
|
||||
if(action && action !== 'status') pm2Action(action);
|
||||
});
|
||||
|
||||
(async function boot() {
|
||||
try {
|
||||
await loadProcessMonitor();
|
||||
} catch (e) {
|
||||
logEl.textContent = '无法加载 /process_monitor(请用本服务打开的页面访问,或检查 web.py / uvicorn 是否已启动)\n' + (e && e.message ? e.message : '');
|
||||
statusIndicator.textContent = '❌ 未连接';
|
||||
return;
|
||||
}
|
||||
if (!PROCESS_MONITOR.length) {
|
||||
logEl.textContent = '未发现任何入口脚本(项目根应有 tiktok*.py / youtube*.py / douyin_youtube*.py / obs 脚本,且含 web.py)';
|
||||
return;
|
||||
}
|
||||
fillProcessSelect();
|
||||
currentProcess = PROCESS_MONITOR[0].pm2;
|
||||
processSelect.value = currentProcess;
|
||||
refreshProcessScriptNote();
|
||||
setInterval(fetchStatus, 1000);
|
||||
fetchStatus();
|
||||
updateSectionsVisibility();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 73 KiB |
16
mobile/android/.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
# OSX
|
||||
#
|
||||
.DS_Store
|
||||
|
||||
# Android/IntelliJ
|
||||
#
|
||||
build/
|
||||
.idea
|
||||
.gradle
|
||||
local.properties
|
||||
*.iml
|
||||
*.hprof
|
||||
.cxx/
|
||||
|
||||
# Bundle artifacts
|
||||
*.jsbundle
|
||||
182
mobile/android/app/build.gradle
Normal file
@@ -0,0 +1,182 @@
|
||||
apply plugin: "com.android.application"
|
||||
apply plugin: "org.jetbrains.kotlin.android"
|
||||
apply plugin: "com.facebook.react"
|
||||
|
||||
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
|
||||
|
||||
/**
|
||||
* This is the configuration block to customize your React Native Android app.
|
||||
* By default you don't need to apply any configuration, just uncomment the lines you need.
|
||||
*/
|
||||
react {
|
||||
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
|
||||
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
|
||||
hermesCommand = new File(["node", "--print", "require.resolve('hermes-compiler/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/hermesc/%OS-BIN%/hermesc"
|
||||
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
|
||||
|
||||
enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean()
|
||||
// Use Expo CLI to bundle the app, this ensures the Metro config
|
||||
// works correctly with Expo projects.
|
||||
cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
|
||||
bundleCommand = "export:embed"
|
||||
|
||||
/* Folders */
|
||||
// The root of your project, i.e. where "package.json" lives. Default is '../..'
|
||||
// root = file("../../")
|
||||
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native
|
||||
// reactNativeDir = file("../../node_modules/react-native")
|
||||
// The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
|
||||
// codegenDir = file("../../node_modules/@react-native/codegen")
|
||||
|
||||
/* Variants */
|
||||
// The list of variants to that are debuggable. For those we're going to
|
||||
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
|
||||
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
|
||||
// debuggableVariants = ["liteDebug", "prodDebug"]
|
||||
|
||||
/* Bundling */
|
||||
// A list containing the node command and its flags. Default is just 'node'.
|
||||
// nodeExecutableAndArgs = ["node"]
|
||||
|
||||
//
|
||||
// The path to the CLI configuration file. Default is empty.
|
||||
// bundleConfig = file(../rn-cli.config.js)
|
||||
//
|
||||
// The name of the generated asset file containing your JS bundle
|
||||
// bundleAssetName = "MyApplication.android.bundle"
|
||||
//
|
||||
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
|
||||
// entryFile = file("../js/MyApplication.android.js")
|
||||
//
|
||||
// A list of extra flags to pass to the 'bundle' commands.
|
||||
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
|
||||
// extraPackagerArgs = []
|
||||
|
||||
/* Hermes Commands */
|
||||
// The hermes compiler command to run. By default it is 'hermesc'
|
||||
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
|
||||
//
|
||||
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
|
||||
// hermesFlags = ["-O", "-output-source-map"]
|
||||
|
||||
/* Autolinking */
|
||||
autolinkLibrariesWithApp()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set this to true in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization).
|
||||
*/
|
||||
def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
|
||||
|
||||
/**
|
||||
* The preferred build flavor of JavaScriptCore (JSC)
|
||||
*
|
||||
* For example, to use the international variant, you can use:
|
||||
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
|
||||
*
|
||||
* The international variant includes ICU i18n library and necessary data
|
||||
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
|
||||
* give correct results when using with locales other than en-US. Note that
|
||||
* this variant is about 6MiB larger per architecture than default.
|
||||
*/
|
||||
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
|
||||
|
||||
android {
|
||||
ndkVersion rootProject.ext.ndkVersion
|
||||
|
||||
buildToolsVersion rootProject.ext.buildToolsVersion
|
||||
compileSdk rootProject.ext.compileSdkVersion
|
||||
|
||||
namespace 'com.douyin.recorder.remote'
|
||||
defaultConfig {
|
||||
applicationId 'com.douyin.recorder.remote'
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0.0"
|
||||
|
||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||
}
|
||||
signingConfigs {
|
||||
debug {
|
||||
storeFile file('debug.keystore')
|
||||
storePassword 'android'
|
||||
keyAlias 'androiddebugkey'
|
||||
keyPassword 'android'
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
release {
|
||||
// Caution! In production, you need to generate your own keystore file.
|
||||
// see https://reactnative.dev/docs/signed-apk-android.
|
||||
signingConfig signingConfigs.debug
|
||||
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
|
||||
shrinkResources enableShrinkResources.toBoolean()
|
||||
minifyEnabled enableMinifyInReleaseBuilds
|
||||
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
||||
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
|
||||
crunchPngs enablePngCrunchInRelease.toBoolean()
|
||||
}
|
||||
}
|
||||
packagingOptions {
|
||||
jniLibs {
|
||||
def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false'
|
||||
useLegacyPackaging enableLegacyPackaging.toBoolean()
|
||||
}
|
||||
}
|
||||
androidResources {
|
||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
}
|
||||
|
||||
// Apply static values from `gradle.properties` to the `android.packagingOptions`
|
||||
// Accepts values in comma delimited lists, example:
|
||||
// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
|
||||
["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop ->
|
||||
// Split option: 'foo,bar' -> ['foo', 'bar']
|
||||
def options = (findProperty("android.packagingOptions.$prop") ?: "").split(",");
|
||||
// Trim all elements in place.
|
||||
for (i in 0..<options.size()) options[i] = options[i].trim();
|
||||
// `[] - ""` is essentially `[""].filter(Boolean)` removing all empty strings.
|
||||
options -= ""
|
||||
|
||||
if (options.length > 0) {
|
||||
println "android.packagingOptions.$prop += $options ($options.length)"
|
||||
// Ex: android.packagingOptions.pickFirsts += '**/SCCS/**'
|
||||
options.each {
|
||||
android.packagingOptions[prop] += it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// The version of react-native is set by the React Native Gradle Plugin
|
||||
implementation("com.facebook.react:react-android")
|
||||
|
||||
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
|
||||
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
|
||||
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
|
||||
|
||||
if (isGifEnabled) {
|
||||
// For animated gif support
|
||||
implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}")
|
||||
}
|
||||
|
||||
if (isWebpEnabled) {
|
||||
// For webp support
|
||||
implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}")
|
||||
if (isWebpAnimatedEnabled) {
|
||||
// Animated webp support
|
||||
implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}")
|
||||
}
|
||||
}
|
||||
|
||||
if (hermesEnabled.toBoolean()) {
|
||||
implementation("com.facebook.react:hermes-android")
|
||||
} else {
|
||||
implementation jscFlavor
|
||||
}
|
||||
}
|
||||
BIN
mobile/android/app/debug.keystore
Normal file
14
mobile/android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# By default, the flags in this file are appended to flags specified
|
||||
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
|
||||
# You can edit the include path and order by changing the proguardFiles
|
||||
# directive in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# react-native-reanimated
|
||||
-keep class com.swmansion.reanimated.** { *; }
|
||||
-keep class com.facebook.react.turbomodule.** { *; }
|
||||
|
||||
# Add any project specific keep options here:
|
||||
7
mobile/android/app/src/debug/AndroidManifest.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
|
||||
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
|
||||
</manifest>
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
|
||||
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
|
||||
</manifest>
|
||||
33
mobile/android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,33 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:replace="android:maxSdkVersion"/>
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:replace="android:maxSdkVersion"/>
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="https"/>
|
||||
</intent>
|
||||
</queries>
|
||||
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false">
|
||||
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
|
||||
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode|smallestScreenSize" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="mobile"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.douyin.recorder.remote
|
||||
import expo.modules.splashscreen.SplashScreenManager
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
|
||||
import com.facebook.react.ReactActivity
|
||||
import com.facebook.react.ReactActivityDelegate
|
||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
|
||||
import com.facebook.react.defaults.DefaultReactActivityDelegate
|
||||
|
||||
import expo.modules.ReactActivityDelegateWrapper
|
||||
|
||||
class MainActivity : ReactActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Set the theme to AppTheme BEFORE onCreate to support
|
||||
// coloring the background, status bar, and navigation bar.
|
||||
// This is required for expo-splash-screen.
|
||||
// setTheme(R.style.AppTheme);
|
||||
// @generated begin expo-splashscreen - expo prebuild (DO NOT MODIFY) sync-f3ff59a738c56c9a6119210cb55f0b613eb8b6af
|
||||
SplashScreenManager.registerOnActivity(this)
|
||||
// @generated end expo-splashscreen
|
||||
super.onCreate(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the name of the main component registered from JavaScript. This is used to schedule
|
||||
* rendering of the component.
|
||||
*/
|
||||
override fun getMainComponentName(): String = "main"
|
||||
|
||||
/**
|
||||
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
|
||||
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
|
||||
*/
|
||||
override fun createReactActivityDelegate(): ReactActivityDelegate {
|
||||
return ReactActivityDelegateWrapper(
|
||||
this,
|
||||
BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
|
||||
object : DefaultReactActivityDelegate(
|
||||
this,
|
||||
mainComponentName,
|
||||
fabricEnabled
|
||||
){})
|
||||
}
|
||||
|
||||
/**
|
||||
* Align the back button behavior with Android S
|
||||
* where moving root activities to background instead of finishing activities.
|
||||
* @see <a href="https://developer.android.com/reference/android/app/Activity#onBackPressed()">onBackPressed</a>
|
||||
*/
|
||||
override fun invokeDefaultOnBackPressed() {
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
|
||||
if (!moveTaskToBack(false)) {
|
||||
// For non-root activities, use the default implementation to finish them.
|
||||
super.invokeDefaultOnBackPressed()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Use the default back button implementation on Android S
|
||||
// because it's doing more than [Activity.moveTaskToBack] in fact.
|
||||
super.invokeDefaultOnBackPressed()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.douyin.recorder.remote
|
||||
|
||||
import android.app.Application
|
||||
import android.content.res.Configuration
|
||||
|
||||
import com.facebook.react.PackageList
|
||||
import com.facebook.react.ReactApplication
|
||||
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.ReactHost
|
||||
import com.facebook.react.common.ReleaseLevel
|
||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
|
||||
|
||||
import expo.modules.ApplicationLifecycleDispatcher
|
||||
import expo.modules.ExpoReactHostFactory
|
||||
|
||||
class MainApplication : Application(), ReactApplication {
|
||||
|
||||
override val reactHost: ReactHost by lazy {
|
||||
ExpoReactHostFactory.getDefaultReactHost(
|
||||
context = applicationContext,
|
||||
packageList =
|
||||
PackageList(this).packages.apply {
|
||||
// Packages that cannot be autolinked yet can be added manually here, for example:
|
||||
// add(MyReactNativePackage())
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
DefaultNewArchitectureEntryPoint.releaseLevel = try {
|
||||
ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
|
||||
} catch (e: IllegalArgumentException) {
|
||||
ReleaseLevel.STABLE
|
||||
}
|
||||
loadReactNative(this)
|
||||
ApplicationLifecycleDispatcher.onApplicationCreate(this)
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 65 KiB |
@@ -0,0 +1,6 @@
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/splashscreen_background"/>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splashscreen_logo"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Copyright (C) 2014 The Android Open Source Project
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
|
||||
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
|
||||
android:insetTop="@dimen/abc_edit_text_inset_top_material"
|
||||
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material"
|
||||
>
|
||||
|
||||
<selector>
|
||||
<!--
|
||||
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
|
||||
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
|
||||
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'
|
||||
|
||||
<item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
|
||||
|
||||
For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
|
||||
-->
|
||||
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
|
||||
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
|
||||
</selector>
|
||||
|
||||
</inset>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||
</adaptive-icon>
|
||||
BIN
mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
BIN
mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.8 KiB |