From 9e8ef1d28d3e2d5c2ecf24f5cfb49dd603eb5eeb Mon Sep 17 00:00:00 2001 From: eric Date: Mon, 1 Dec 2025 16:18:28 +0800 Subject: [PATCH] 's' --- README.md | 679 ++++++++++++++ StopRecording.vbs | 69 ++ main.py | 2255 +++++++++++++++++++++++++++++++++++++++++++++ msg_push.py | 295 ++++++ pyproject.toml | 23 + requirements.txt | 7 + robust_relay.py | 227 +++++ wending | 111 +++ 8 files changed, 3666 insertions(+) create mode 100644 README.md create mode 100644 StopRecording.vbs create mode 100644 main.py create mode 100644 msg_push.py create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 robust_relay.py create mode 100644 wending diff --git a/README.md b/README.md new file mode 100644 index 0000000..8e0a31b --- /dev/null +++ b/README.md @@ -0,0 +1,679 @@ +![video_spider](https://socialify.git.ci/ihmily/DouyinLiveRecorder/image?font=Inter&forks=1&language=1&owner=1&pattern=Circuit%20Board&stargazers=1&theme=Light) + +## 💡简介 +[![Python Version](https://img.shields.io/badge/python-3.11.6-blue.svg)](https://www.python.org/downloads/release/python-3116/) +[![Supported Platforms](https://img.shields.io/badge/platforms-Windows%20%7C%20Linux-blue.svg)](https://github.com/ihmily/DouyinLiveRecorder) +[![Docker Pulls](https://img.shields.io/docker/pulls/ihmily/douyin-live-recorder?label=Docker%20Pulls&color=blue&logo=docker)](https://hub.docker.com/r/ihmily/douyin-live-recorder/tags) +![GitHub issues](https://img.shields.io/github/issues/ihmily/DouyinLiveRecorder.svg) +[![Latest Release](https://img.shields.io/github/v/release/ihmily/DouyinLiveRecorder)](https://github.com/ihmily/DouyinLiveRecorder/releases/latest) +[![Downloads](https://img.shields.io/github/downloads/ihmily/DouyinLiveRecorder/total)](https://github.com/ihmily/DouyinLiveRecorder/releases/latest) + +一款**简易**的可循环值守的直播录制工具,基于FFmpeg实现多平台直播源录制,支持自定义配置录制以及直播状态推送。 + + + +## 😺已支持平台 + +- [x] 抖音 +- [x] TikTok +- [x] 快手 +- [x] 虎牙 +- [x] 斗鱼 +- [x] YY +- [x] B站 +- [x] 小红书 +- [x] bigo +- [x] blued +- [x] SOOP(原AfreecaTV) +- [x] 网易cc +- [x] 千度热播 +- [x] PandaTV +- [x] 猫耳FM +- [x] Look直播 +- [x] WinkTV +- [x] TTingLive(原Flextv) +- [x] PopkonTV +- [x] TwitCasting +- [x] 百度直播 +- [x] 微博直播 +- [x] 酷狗直播 +- [x] TwitchTV +- [x] LiveMe +- [x] 花椒直播 +- [x] 流星直播 +- [x] ShowRoom +- [x] Acfun +- [x] 映客直播 +- [x] 音播直播 +- [x] 知乎直播 +- [x] CHZZK +- [x] 嗨秀直播 +- [x] vv星球直播 +- [x] 17Live +- [x] 浪Live +- [x] 畅聊直播 +- [x] 飘飘直播 +- [x] 六间房直播 +- [x] 乐嗨直播 +- [x] 花猫直播 +- [x] Shopee +- [x] Youtube +- [x] 淘宝 +- [x] 京东 +- [x] Faceit +- [x] 咪咕 +- [x] 连接直播 +- [x] 来秀直播 +- [x] Picarto +- [ ] 更多平台正在更新中 + + + +## 🎈项目结构 + +``` +. +└── DouyinLiveRecorder/ + ├── /config -> (config record) + ├── /logs -> (save runing log file) + ├── /backup_config -> (backup file) + ├── /douyinliverecorder -> (package) + ├── initializer.py-> (check and install nodejs) + ├── spider.py-> (get live data) + ├── stream.py-> (get live stream address) + ├── utils.py -> (contains utility functions) + ├── logger.py -> (logger handdle) + ├── room.py -> (get room info) + ├── ab_sign.py-> (generate dy token) + ├── /javascript -> (some decrypt code) + ├── main.py -> (main file) + ├── ffmpeg_install.py -> (ffmpeg install script) + ├── demo.py -> (call package test demo) + ├── msg_push.py -> (send live status update message) + ├── ffmpeg.exe -> (record video) + ├── index.html -> (play m3u8 and flv video) + ├── requirements.txt -> (library dependencies) + ├── docker-compose.yaml -> (Container Orchestration File) + ├── Dockerfile -> (Application Build Recipe) + ├── StopRecording.vbs -> (stop recording script on Windows) + ... +``` + + + +## 🌱使用说明 + +- 对于只想使用录制软件的小白用户,进入[Releases](https://github.com/ihmily/DouyinLiveRecorder/releases) 中下载最新发布的 zip压缩包即可,里面有打包好的录制软件。(有些电脑可能会报毒,直接忽略即可,如果下载时被浏览器屏蔽,请更换浏览器下载) + +- 压缩包解压后,在 `config` 文件夹内的 `URL_config.ini` 中添加录制直播间地址,一行一个直播间地址。如果要自定义配置录制,可以修改`config.ini` 文件,推荐将录制格式修改为`ts`。 +- 以上步骤都做好后,就可以运行`DouyinLiveRecorder.exe` 程序进行录制了。录制的视频文件保存在同目录下的 `downloads` 文件夹内。 + +- 另外,如果需要录制TikTok、AfreecaTV等海外平台,请在配置文件中设置开启代理并添加proxy_addr链接 如:`127.0.0.1:7890` (这只是示例地址,具体根据实际填写)。 + +- 假如`URL_config.ini`文件中添加的直播间地址,有个别直播间暂时不想录制又不想移除链接,可以在对应直播间的链接开头加上`#`,那么将停止该直播间的监测以及录制。 + +- 软件默认录制清晰度为 `原画` ,如果要单独设置某个直播间的录制画质,可以在添加直播间地址时前面加上画质即可,如`超清,https://live.douyin.com/745964462470` 记得中间要有`,` 分隔。 + +- 如果要长时间挂着软件循环监测直播,最好循环时间设置长一点(咱也不差没录制到的那几分钟),避免因请求频繁导致被官方封禁IP 。 + +- 要停止直播录制,Windows平台可执行StopRecording.vbs脚本文件,或者在录制界面使用 `Ctrl+C ` 组合键中断录制,若要停止其中某个直播间的录制,可在`URL_config.ini`文件中的地址前加#,会自动停止对应直播间的录制并正常保存已录制的视频。 +- 最后,欢迎右上角给本项目一个star,同时也非常乐意大家提交pr。 + +  + +直播间链接示例: + +``` +抖音: +https://live.douyin.com/745964462470 +https://v.douyin.com/iQFeBnt/ +https://live.douyin.com/yall1102 (链接+抖音号) +https://v.douyin.com/CeiU5cbX (主播主页地址) + +TikTok: +https://www.tiktok.com/@pearlgaga88/live + +快手: +https://live.kuaishou.com/u/yall1102 + +虎牙: +https://www.huya.com/52333 + +斗鱼: +https://www.douyu.com/3637778?dyshid= +https://www.douyu.com/topic/wzDBLS6?rid=4921614&dyshid= + +YY: +https://www.yy.com/22490906/22490906 + +B站: +https://live.bilibili.com/320 + +小红书(直播间分享地址): +http://xhslink.com/xpJpfM + +bigo直播: +https://www.bigo.tv/cn/716418802 + +buled直播: +https://app.blued.cn/live?id=Mp6G2R + +SOOP: +https://play.sooplive.co.kr/sw7love + +网易cc: +https://cc.163.com/583946984 + +千度热播: +https://qiandurebo.com/web/video.php?roomnumber=33333 + +PandaTV: +https://www.pandalive.co.kr/live/play/bara0109 + +猫耳FM: +https://fm.missevan.com/live/868895007 + +Look直播: +https://look.163.com/live?id=65108820&position=3 + +WinkTV: +https://www.winktv.co.kr/live/play/anjer1004 + +FlexTV(TTinglive):: +https://www.flextv.co.kr/channels/593127/live + +PopkonTV: +https://www.popkontv.com/live/view?castId=wjfal007&partnerCode=P-00117 +https://www.popkontv.com/channel/notices?mcid=wjfal007&mcPartnerCode=P-00117 + +TwitCasting: +https://twitcasting.tv/c:uonq + +百度直播: +https://live.baidu.com/m/media/pclive/pchome/live.html?room_id=9175031377&tab_category + +微博直播: +https://weibo.com/l/wblive/p/show/1022:2321325026370190442592 + +酷狗直播: +https://fanxing2.kugou.com/50428671?refer=2177&sourceFrom= + +TwitchTV: +https://www.twitch.tv/gamerbee + +LiveMe: +https://www.liveme.com/zh/v/17141543493018047815/index.html + +花椒直播: +https://www.huajiao.com/l/345096174 + +流星直播: +https://www.7u66.com/100960 + +ShowRoom: +https://www.showroom-live.com/room/profile?room_id=480206 (主播主页地址) + +Acfun: +https://live.acfun.cn/live/179922 + +映客直播: +https://www.inke.cn/liveroom/index.html?uid=22954469&id=1720860391070904 + +音播直播: +https://live.ybw1666.com/800002949 + +知乎直播: +https://www.zhihu.com/people/ac3a467005c5d20381a82230101308e9 (主播主页地址) + +CHZZK: +https://chzzk.naver.com/live/458f6ec20b034f49e0fc6d03921646d2 + +嗨秀直播: +https://www.haixiutv.com/6095106 + +VV星球直播: +https://h5webcdn-pro.vvxqiu.com//activity/videoShare/videoShare.html?h5Server=https://h5p.vvxqiu.com&roomId=LP115924473&platformId=vvstar + +17Live: +https://17.live/en/live/6302408 + +浪Live: +https://www.lang.live/en-US/room/3349463 + +畅聊直播: +https://live.tlclw.com/106188 + +飘飘直播: +https://m.pp.weimipopo.com/live/preview.html?uid=91648673&anchorUid=91625862&app=plpl + +六间房直播: +https://v.6.cn/634435 + +乐嗨直播: +https://www.lehaitv.com/8059096 + +花猫直播: +https://h.catshow168.com/live/preview.html?uid=19066357&anchorUid=18895331 + +Shopee: +https://sg.shp.ee/GmpXeuf?uid=1006401066&session=802458 + +Youtube: +https://www.youtube.com/watch?v=cS6zS5hi1w0 + +淘宝(需cookie): +https://tbzb.taobao.com/live?liveId=532359023188 +https://m.tb.cn/h.TWp0HTd + +京东: +https://3.cn/28MLBy-E + +Faceit: +https://www.faceit.com/zh/players/Compl1/stream + +连接直播: +https://show.lailianjie.com/10000258 + +咪咕直播: +https://www.miguvideo.com/p/live/120000541321 + +来秀直播: +https://www.imkktv.com/h5/share/video.html?uid=1845195&roomId=1710496 + +Picarto: +https://www.picarto.tv/cuteavalanche +``` + +  + +## 🎃源码运行 +使用源码运行,可参考下面的步骤。 + +1.首先拉取或手动下载本仓库项目代码 + +```bash +git clone https://github.com/ihmily/DouyinLiveRecorder.git +``` + +2.进入项目文件夹,安装依赖 + +```bash +cd DouyinLiveRecorder +``` + +> [!TIP] +> - 不论你是否已安装 **Python>=3.10** 环境, 都推荐使用 [**uv**](https://github.com/astral-sh/uv) 运行, 因为它可以自动管理虚拟环境和方便地管理 **Python** 版本, **不过这完全是可选的**
+> 使用以下命令安装 +> ```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 镜像源:
+> 在 `pip` 命令使用 `-i` 参数指定, 如 `pip3 install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple`
+> 或者在 `uv` 命令 `--index` 选项指定, 如 `uv sync --index https://pypi.tuna.tsinghua.edu.cn/simple` + +
+ + 如果已安装 Python>=3.10 环境 + + - :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 + ``` + +
+ +
+ + 如果未安装 Python>=3.10 环境 + + 你可以使用 [**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 + ``` + +
+ +3.安装[FFmpeg](https://ffmpeg.org/download.html#build-linux),如果是Windows系统,这一步可跳过。对于Linux系统,执行以下命令安装 + +CentOS执行 + +```bash +yum install epel-release +yum install ffmpeg +``` + +Ubuntu则执行 + +```bash +apt update +apt install ffmpeg +``` + +macOS 执行 + +**如果已经安装 Homebrew 请跳过这一步** + +```bash +/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" +``` + +```bash +brew install ffmpeg +``` + +4.运行程序 + +```python +python main.py + +``` +或 + +```bash +uv run main.py +``` + +其中Linux系统请使用`python3 main.py` 运行。 + +  +## 🐋容器运行 + +在运行命令之前,请确保您的机器上安装了 [Docker](https://docs.docker.com/get-docker/) 和 [Docker Compose](https://docs.docker.com/compose/install/) + +1.快速启动 + +最简单方法是运行项目中的 [docker-compose.yaml](https://github.com/ihmily/DouyinLiveRecorder/blob/main/docker-compose.yaml) 文件,只需简单执行以下命令: + +```bash +docker-compose up +``` + +可选 `-d` 在后台运行。 + + + +2.构建镜像(可选) + +如果你只想简单的运行程序,则不需要做这一步。Docker镜像仓库中代码版本可能不是最新的,如果要运行本仓库主分支最新代码,可以本地自定义构建,通过修改 [docker-compose.yaml](https://github.com/ihmily/DouyinLiveRecorder/blob/main/docker-compose.yaml) 文件,如将镜像名修改为 `douyin-live-recorder:latest`,并取消 `# build: .` 注释,然后再执行 + +```bash +docker build -t douyin-live-recorder:latest . +docker-compose up +``` + +或者直接使用下面命令进行构建并启动 + +```bash +docker-compose -f docker-compose.yaml up +``` + + + +3.停止容器实例 + +```bash +docker-compose stop +``` + + + +4.注意事项 + +①在docker容器内运行本程序之前,请先在配置文件中添加要录制的直播间地址。 + +②在容器内时,如果手动中断容器运行停止录制,会导致正在录制的视频文件损坏! + +**无论哪种运行方式,为避免手动中断或者异常中断导致录制的视频文件损坏的情况,推荐使用 `ts` 格式保存**。 + +  + +## 🤖相关项目 + +- StreamCap: https://github.com/ihmily/StreamCap +- streamget: https://github.com/ihmily/streamget + +  + +## ❤️贡献者 + +   [![Hmily](https://github.com/ihmily.png?size=50)](https://github.com/ihmily) +[![iridescentGray](https://github.com/iridescentGray.png?size=50)](https://github.com/iridescentGray) +[![annidy](https://github.com/annidy.png?size=50)](https://github.com/annidy) +[![wwkk2580](https://github.com/wwkk2580.png?size=50)](https://github.com/wwkk2580) +[![missuo](https://github.com/missuo.png?size=50)](https://github.com/missuo) +xueli12 +kaine1973 +yinruiqing +Max-Tortoise +[![justdoiting](https://github.com/justdoiting.png?size=50)](https://github.com/justdoiting) +[![dhbxs](https://github.com/dhbxs.png?size=50)](https://github.com/dhbxs) +[![wujiyu115](https://github.com/wujiyu115.png?size=50)](https://github.com/wujiyu115) +[![zhanghao333](https://github.com/zhanghao333.png?size=50)](https://github.com/zhanghao333) +gyc0123 + +   [![HoratioShaw](https://github.com/HoratioShaw.png?size=50)](https://github.com/HoratioShaw) +[![nov30th](https://github.com/nov30th.png?size=50)](https://github.com/nov30th) +[![727155455](https://github.com/727155455.png?size=50)](https://github.com/727155455) +[![nixingshiguang](https://github.com/nixingshiguang.png?size=50)](https://github.com/nixingshiguang) +[![1411430556](https://github.com/1411430556.png?size=50)](https://github.com/1411430556) +[![Ovear](https://github.com/Ovear.png?size=50)](https://github.com/Ovear) +  + +## ⏳提交日志 + +- 20251024 + - 修复抖音风控无法获取数据问题 + + - 新增soop.com录制支持 + + - 修复bigo录制 + +- 20250127 + - 新增淘宝、京东、faceit直播录制 + - 修复小红书直播流录制以及转码问题 + - 修复畅聊、VV星球、flexTV直播录制 + - 修复批量微信直播推送 + - 新增email发送ssl和port配置 + - 新增强制转h264配置 + - 更新ffmpeg版本 + - 重构包为异步函数! + +- 20241130 + - 新增shopee、youtube直播录制 + - 新增支持自定义m3u8、flv地址录制 + - 新增自定义执行脚本,支持python、bat、bash等 + - 修复YY直播、花椒直播和小红书直播录制 + - 修复b站标题获取错误 + - 修复log日志错误 +- 20241030 + - 新增嗨秀直播、vv星球直播、17Live、浪Live、SOOP、畅聊直播(原时光直播)、飘飘直播、六间房直播、乐嗨直播、花猫直播等10个平台直播录制 + - 修复小红书直播录制,支持小红书作者主页地址录制直播 + - 新增支持ntfy消息推送,以及新增支持批量推送多个地址(逗号分隔多个推送地址) + - 修复Liveme直播录制、twitch直播录制 + - 新增Windows平台一键停止录制VB脚本程序 +- 20241005 + - 新增邮箱和Bark推送 + - 新增直播注释停止录制 + - 优化分段录制 + - 重构部分代码 +- 20240928 + - 新增知乎直播、CHZZK直播录制 + - 修复音播直播录制 +- 20240903 + - 新增抖音双屏录制、音播直播录制 + - 修复PandaTV、bigo直播录制 +- 20240713 + - 新增映客直播录制 +- 20240705 + - 新增时光直播录制 +- 20240701 + - 修复虎牙直播录制2分钟断流问题 + - 新增自定义直播推送内容 +- 20240621 + - 新增Acfun、ShowRoom直播录制 + - 修复微博录制、新增直播源线路 + - 修复斗鱼直播60帧录制 + - 修复酷狗直播录制 + - 修复TikTok部分无法解析直播源 + - 修复抖音无法录制连麦直播 +- 20240510 + - 修复部分虎牙直播间录制错误 +- 20240508 + - 修复花椒直播录制 + - 更改文件路径解析方式 [@kaine1973](https://github.com/kaine1973) +- 20240506 + - 修复抖音录制画质解析bug + - 修复虎牙录制 60帧最高画质问题 + - 新增流星直播录制 +- 20240427 + - 新增LiveMe、花椒直播录制 +- 20240425 + - 新增TwitchTV直播录制 +- 20240424 + - 新增酷狗直播录制、优化PopkonTV直播录制 +- 20240423 + - 新增百度直播录制、微博直播录制 + - 修复斗鱼录制直播回放的问题 + - 新增直播源地址显示以及输出到日志文件设置 +- 20240311 + - 修复海外平台录制bug,增加画质选择,增强录制稳定性 + - 修复虎牙录制bug (虎牙`一起看`频道 有特殊限制,有时无法录制) +- 20240309 + - 修复虎牙直播、小红书直播和B站直播录制 + - 新增5个直播平台录制,包括winktv、flextv、look、popkontv、twitcasting + - 新增部分海外平台账号密码配置,实现自动登录并更新配置文件中的cookie + - 新增自定义配置需要使用代理录制的平台 + - 新增只推送开播消息不进行录制设置 + - 修复了一些bug +- 20240209 + - 优化AfreecaTV录制,新增账号密码登录获取cookie以及持久保存 + - 修复了小红书直播因官方更新直播域名,导致无法录制直播的问题 + - 修复了更新URL配置文件的bug + - 最后,祝大家新年快乐! + +
点击展开更多提交日志 + +- 20240129 + - 新增猫耳FM直播录制 +- 20240127 + - 新增千度热播直播录制、新增pandaTV(韩国)直播录制 + - 新增telegram直播状态消息推送,修复了某些bug + - 新增自定义设置不同直播间的录制画质(即每个直播间录制画质可不同) + - 修改录制视频保存路径为 `downloads` 文件夹,并且分平台进行保存。 +- 20240114 + - 新增网易cc直播录制,优化ffmpeg参数,修改AfreecaTV输入直播地址格式 + - 修改日志记录器 @[iridescentGray](https://github.com/iridescentGray) +- 20240102 + - 修复Linux上运行,新增docker配置文件 +- 20231210 + - 修复录制分段bug,修复bigo录制检测bug + - 新增自定义修改录制主播名 + - 新增AfreecaTV直播录制,修复某些可能会发生的bug +- 20231207 + - 新增blued直播录制,修复YY直播录制,新增直播结束消息推送 +- 20231206 + - 新增bigo直播录制 +- 20231203 + - 新增小红书直播录制(全网首发),目前小红书官方没有切换清晰度功能,因此直播录制也只有默认画质 + - 小红书录制暂时无法循环监测,每次主播开启直播,都要重新获取一次链接 + - 获取链接的方式为 将直播间转发到微信,在微信中打开后,复制页面的链接。 +- 20231030 + - 本次更新只是进行修复,没时间新增功能。 + - 欢迎各位大佬提pr 帮忙更新维护 +- 20230930 + - 新增抖音从接口获取直播流,增强稳定性 + - 修改快手获取直播流的方式,改用从官方接口获取 + - 祝大家中秋节快乐! +- 20230919 + - 修复了快手版本更新后录制出错的问题,增加了其自动获取cookie(~~稳定性未知~~) + - 修复了TikTok显示正在直播但不进行录制的问题 +- 20230907 + - 修复了因抖音官方更新了版本导致的录制出错以及短链接转换出错 + - 修复B站无法录制原画视频的bug + - 修改了配置文件字段,新增各平台自定义设置Cookie +- 20230903 + - 修复了TikTok录制时报644无法录制的问题 + - 新增直播状态推送到钉钉和微信的功能,如有需要请看 [设置推送教程](https://d04vqdiqwr3.feishu.cn/docx/XFPwdDDvfobbzlxhmMYcvouynDh?from=from_copylink) + - 最近比较忙,其他问题有时间再更新 +- 20230816 + - 修复斗鱼直播(官方更新了字段)和快手直播录制出错的问题 +- 20230814 + - 新增B站直播录制 + - 写了一个在线播放M3U8和FLV视频的网页源码,打开即可食用 +- 20230812 + - 新增YY直播录制 +- 20230808 + - 修复主播重新开播无法再次录制的问题 +- 20230807 + - 新增了斗鱼直播录制 + - 修复显示录制完成之后会重新开始录制的问题 +- 20230805 + - 新增了虎牙直播录制,其暂时只能用flv视频流进行录制 + - Web API 新增了快手和虎牙这两个平台的直播流解析(TikTok要代理) +- 20230804 + - 新增了快手直播录制,优化了部分代码 + - 上传了一个自动化获取抖音直播间页面Cookie的代码,可以用于录制 +- 20230803 + - 通宵更新 + - 新增了国际版抖音TikTok的直播录制,去除冗余 简化了部分代码 +- 20230724 + - 新增了一个通过抖音直播间地址获取直播视频流链接的API接口,上传即可用 +
+   + +## 有问题可以提issue, 我会在这里持续添加更多直播平台的录制 欢迎Star +#### diff --git a/StopRecording.vbs b/StopRecording.vbs new file mode 100644 index 0000000..f6c47c1 --- /dev/null +++ b/StopRecording.vbs @@ -0,0 +1,69 @@ +'********************************************************************************************/ +'* File Name : StopRecording.vbs +'* Created Date : 2024-10-15 01:50:30 +'* Author : Hmily +'* GitHub : http://github.com/ihmily +'* Description : This script is designed to terminate the process of live recording +'********************************************************************************************/ + +Dim objWMIService, colProcesses, objProcess +Dim intResponse +strComputer = "." +On Error Resume Next +intResponse = MsgBox("ȷҪкֱ̨¼ƽ", vbYesNo + vbQuestion, "ȷϽ") + +If intResponse = vbYes Then + Set objWMIService = GetObject("winmgmts:\\" & strComputer & "\root\cimv2") + If Err.Number <> 0 Then + Err.Clear + End If + + Set colProcesses = objWMIService.ExecQuery("Select * from Win32_Process Where Name = 'ffmpeg.exe'") + Set colProcesses2 = objWMIService.ExecQuery("Select * from Win32_Process Where Name = 'pythonw.exe'") + Set colProcesses3 = objWMIService.ExecQuery("Select * from Win32_Process Where Name = 'DouyinLiveRecorder.exe'") + If Err.Number <> 0 Then + Err.Clear + End If + + If Not objWMIService Is Nothing And Not colProcesses Is Nothing And Not colProcesses2 Is Nothing Then + If colProcesses2.Count = 0 And colProcesses3.Count = 0 Then + MsgBox "ûҵ¼ƳĽ", vbExclamation, "ʾϢ" + WScript.Quit(1) + Else + For Each objProcess in colProcesses + objProcess.Terminate() + If Err.Number <> 0 Then + objShell.Run "taskkill /f /im " & objProcess.Name, 0, True + Err.Clear + End If + Next + End If + Else + objShell.Run "taskkill /f /im " & objProcess.Name, 0, True + End If + MsgBox "ѳɹ¼ֱḶ̌" & vbCrLf & "رմ˴30Զֹͣ¼Ƴ", vbInformation, "ʾϢ" + + WScript.Sleep 10000 + If colProcesses3.Count <> 0 Then + Set colProcesses_ = colProcesses3 + Else + Set colProcesses_ = colProcesses2 + End If + For Each objProcess in colProcesses_ + objProcess.Terminate() + If Err.Number <> 0 Then + objShell.Run "taskkill /f /im " & objProcess.Name, 0, True + Err.Clear + End If + Next +Else + MsgBox "ȡ¼Ʋ", vbExclamation, "ʾϢ" +End If + +On Error GoTo 0 +Set objWMIService = Nothing +Set colProcesses = Nothing +Set colProcesses2 = Nothing +Set colProcesses3 = Nothing +Set objProcess = Nothing +Set objShell = Nothing \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..c764715 --- /dev/null +++ b/main.py @@ -0,0 +1,2255 @@ +# -*- encoding: utf-8 -*- + +""" +Author: Hmily +GitHub: https://github.com/ihmily +Date: 2023-07-17 23:52:05 +Update: 2025-10-23 19:48:05 +Copyright (c) 2023-2025 by Hmily, All Rights Reserved. +Function: Record live stream video. +""" +import asyncio +import os +import sys +import builtins +import subprocess +import signal +import threading +import time +import datetime +import re +import shutil +import random +import uuid +from pathlib import Path +import urllib.request +from urllib.error import URLError, HTTPError +from typing import Any +import configparser +import httpx +from src import spider, stream +from src.proxy import ProxyDetector +from src.utils import logger +from src import utils +from msg_push import ( + dingtalk, xizhi, tg_bot, send_email, bark, ntfy, pushplus +) +from ffmpeg_install import ( + check_ffmpeg, ffmpeg_path, current_env_path +) + + + + +version = "v4.0.7" +platforms = ("\n国内站点:抖音|快手|虎牙|斗鱼|YY|B站|小红书|bigo|blued|网易CC|千度热播|猫耳FM|Look|TwitCasting|百度|微博|" + "酷狗|花椒|流星|Acfun|畅聊|映客|音播|知乎|嗨秀|VV星球|17Live|浪Live|漂漂|六间房|乐嗨|花猫|淘宝|京东|咪咕|连接|来秀" + "\n海外站点:TikTok|SOOP|PandaTV|WinkTV|FlexTV|PopkonTV|TwitchTV|LiveMe|ShowRoom|CHZZK|Shopee|" + "Youtube|Faceit|Picarto") + +recording = set() +error_count = 0 +pre_max_request = 10 +max_request_lock = threading.Lock() +error_window = [] +error_window_size = 10 +error_threshold = 5 +monitoring = 0 +running_list = [] +url_tuples_list = [] +url_comments = [] +text_no_repeat_url = [] +create_var = locals() +first_start = True +exit_recording = False +need_update_line_list = [] +first_run = True +not_record_list = [] +start_display_time = datetime.datetime.now() +global_proxy = False +recording_time_list = {} +script_path = os.path.split(os.path.realpath(sys.argv[0]))[0] +config_file = f'{script_path}/config/config.ini' +url_config_file = f'{script_path}/config/URL_config.ini' +backup_dir = f'{script_path}/backup_config' +text_encoding = 'utf-8-sig' +rstr = r"[\/\\\:\*\??\"\<\>\|&#.。,, ~!· ]" +default_path = f'{script_path}/downloads' +os.makedirs(default_path, exist_ok=True) +file_update_lock = threading.Lock() +os_type = os.name +clear_command = "cls" if os_type == 'nt' else "clear" +color_obj = utils.Color() +os.environ['PATH'] = ffmpeg_path + os.pathsep + current_env_path + + +def signal_handler(_signal, _frame): + sys.exit(0) + + +signal.signal(signal.SIGTERM, signal_handler) + + +def display_info() -> None: + global start_display_time + time.sleep(5) + while True: + try: + sys.stdout.flush() + time.sleep(5) + if Path(sys.executable).name != 'pythonw.exe': + os.system(clear_command) + print(f"\r共监测{monitoring}个直播中", end=" | ") + print(f"同一时间访问网络的线程数: {max_request}", end=" | ") + print(f"是否开启代理录制: {'是' if use_proxy else '否'}", end=" | ") + if split_video_by_time: + print(f"录制分段开启: {split_time}秒", end=" | ") + else: + print("录制分段开启: 否", end=" | ") + if create_time_file: + print("是否生成时间文件: 是", end=" | ") + print(f"录制视频质量为: {video_record_quality}", end=" | ") + print(f"录制视频格式为: {video_save_type}", end=" | ") + print(f"目前瞬时错误数为: {error_count}", end=" | ") + now = time.strftime("%H:%M:%S", time.localtime()) + print(f"当前时间: {now}") + + if len(recording) == 0: + time.sleep(5) + if monitoring == 0: + print("\r没有正在监测和录制的直播") + else: + print(f"\r没有正在录制的直播 循环监测间隔时间:{delay_default}秒") + else: + now_time = datetime.datetime.now() + print("x" * 60) + no_repeat_recording = list(set(recording)) + print(f"正在录制{len(no_repeat_recording)}个直播: ") + for recording_live in no_repeat_recording: + rt, qa = recording_time_list[recording_live] + have_record_time = now_time - rt + print(f"{recording_live}[{qa}] 正在录制中 {str(have_record_time).split('.')[0]}") + + # print('\n本软件已运行:'+str(now_time - start_display_time).split('.')[0]) + print("x" * 60) + start_display_time = now_time + except Exception as e: + logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}") + + +def update_file(file_path: str, old_str: str, new_str: str, start_str: str = None) -> str | None: + if old_str == new_str and start_str is None: + return old_str + with file_update_lock: + file_data = [] + with open(file_path, "r", encoding=text_encoding) as f: + try: + for text_line in f: + if old_str in text_line: + text_line = text_line.replace(old_str, new_str) + if start_str: + text_line = f'{start_str}{text_line}' + if text_line not in file_data: + file_data.append(text_line) + except RuntimeError as e: + logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}") + if ini_URL_content: + with open(file_path, "w", encoding=text_encoding) as f2: + f2.write(ini_URL_content) + return old_str + if file_data: + with open(file_path, "w", encoding=text_encoding) as f: + f.write(''.join(file_data)) + return new_str + + +def delete_line(file_path: str, del_line: str, delete_all: bool = False) -> None: + with file_update_lock: + with open(file_path, 'r+', encoding=text_encoding) as f: + lines = f.readlines() + f.seek(0) + f.truncate() + skip_line = False + for txt_line in lines: + if del_line in txt_line: + if delete_all or not skip_line: + skip_line = True + continue + else: + skip_line = False + f.write(txt_line) + + +def get_startup_info(system_type: str): + if system_type == 'nt': + startup_info = subprocess.STARTUPINFO() + startup_info.dwFlags |= subprocess.STARTF_USESHOWWINDOW + else: + startup_info = None + return startup_info + + +def segment_video(converts_file_path: str, segment_save_file_path: str, segment_format: str, segment_time: str, + is_original_delete: bool = True) -> None: + try: + if os.path.exists(converts_file_path) and os.path.getsize(converts_file_path) > 0: + ffmpeg_command = [ + "ffmpeg", + "-i", converts_file_path, + "-c:v", "copy", + "-c:a", "copy", + "-map", "0", + "-f", "segment", + "-segment_time", segment_time, + "-segment_format", segment_format, + "-reset_timestamps", "1", + "-movflags", "+frag_keyframe+empty_moov", + segment_save_file_path, + ] + _output = subprocess.check_output( + ffmpeg_command, stderr=subprocess.STDOUT, startupinfo=get_startup_info(os_type) + ) + if is_original_delete: + time.sleep(1) + if os.path.exists(converts_file_path): + os.remove(converts_file_path) + except subprocess.CalledProcessError as e: + logger.error(f'Error occurred during conversion: {e}') + except Exception as e: + logger.error(f'An unknown error occurred: {e}') + + +def converts_mp4(converts_file_path: str, is_original_delete: bool = True) -> None: + try: + if os.path.exists(converts_file_path) and os.path.getsize(converts_file_path) > 0: + if converts_to_h264: + color_obj.print_colored("正在转码为MP4格式并重新编码为h264\n", color_obj.YELLOW) + ffmpeg_command = [ + "ffmpeg", "-i", converts_file_path, + "-c:v", "libx264", + "-preset", "veryfast", + "-crf", "23", + "-vf", "format=yuv420p", + "-c:a", "copy", + "-f", "mp4", converts_file_path.rsplit('.', maxsplit=1)[0] + ".mp4", + ] + else: + color_obj.print_colored("正在转码为MP4格式\n", color_obj.YELLOW) + ffmpeg_command = [ + "ffmpeg", "-i", converts_file_path, + "-c:v", "copy", + "-c:a", "copy", + "-f", "mp4", converts_file_path.rsplit('.', maxsplit=1)[0] + ".mp4", + ] + _output = subprocess.check_output( + ffmpeg_command, stderr=subprocess.STDOUT, startupinfo=get_startup_info(os_type) + ) + if is_original_delete: + time.sleep(1) + if os.path.exists(converts_file_path): + os.remove(converts_file_path) + except subprocess.CalledProcessError as e: + logger.error(f'Error occurred during conversion: {e}') + except Exception as e: + logger.error(f'An unknown error occurred: {e}') + + +def converts_m4a(converts_file_path: str, is_original_delete: bool = True) -> None: + try: + if os.path.exists(converts_file_path) and os.path.getsize(converts_file_path) > 0: + _output = subprocess.check_output([ + "ffmpeg", "-i", converts_file_path, + "-n", "-vn", + "-c:a", "aac", "-bsf:a", "aac_adtstoasc", "-ab", "320k", + converts_file_path.rsplit('.', maxsplit=1)[0] + ".m4a", + ], stderr=subprocess.STDOUT, startupinfo=get_startup_info(os_type)) + if is_original_delete: + time.sleep(1) + if os.path.exists(converts_file_path): + os.remove(converts_file_path) + except subprocess.CalledProcessError as e: + logger.error(f'Error occurred during conversion: {e}') + except Exception as e: + logger.error(f'An unknown error occurred: {e}') + + +def generate_subtitles(record_name: str, ass_filename: str, sub_format: str = 'srt') -> None: + index_time = 0 + today = datetime.datetime.now() + re_datatime = today.strftime('%Y-%m-%d %H:%M:%S') + + def transform_int_to_time(seconds: int) -> str: + m, s = divmod(seconds, 60) + h, m = divmod(m, 60) + return f"{h:02d}:{m:02d}:{s:02d}" + + while True: + index_time += 1 + txt = str(index_time) + "\n" + transform_int_to_time(index_time) + ',000 --> ' + transform_int_to_time( + index_time + 1) + ',000' + "\n" + str(re_datatime) + "\n\n" + + with open(f"{ass_filename}.{sub_format.lower()}", 'a', encoding=text_encoding) as f: + f.write(txt) + + if record_name not in recording: + return + time.sleep(1) + today = datetime.datetime.now() + re_datatime = today.strftime('%Y-%m-%d %H:%M:%S') + + +def adjust_max_request() -> None: + global max_request, error_count, pre_max_request, error_window + preset = max_request + + while True: + time.sleep(5) + with max_request_lock: + if error_window: + error_rate = sum(error_window) / len(error_window) + else: + error_rate = 0 + + if error_rate > error_threshold: + max_request = max(1, max_request - 1) + elif error_rate < error_threshold / 2 and max_request < preset: + max_request += 1 + else: + pass + + if pre_max_request != max_request: + pre_max_request = max_request + print(f"\r同一时间访问网络的线程数动态改为 {max_request}") + + error_window.append(error_count) + if len(error_window) > error_window_size: + error_window.pop(0) + error_count = 0 + + +def push_message(record_name: str, live_url: str, content: str) -> None: + msg_title = push_message_title.strip() or "直播间状态更新通知" + push_functions = { + '微信': lambda: xizhi(xizhi_api_url, msg_title, content), + '钉钉': lambda: dingtalk(dingtalk_api_url, content, dingtalk_phone_num, dingtalk_is_atall), + '邮箱': lambda: send_email( + email_host, login_email, email_password, sender_email, sender_name, + to_email, msg_title, content, smtp_port, open_smtp_ssl + ), + 'TG': lambda: tg_bot(tg_chat_id, tg_token, content), + 'BARK': lambda: bark( + bark_msg_api, title=msg_title, content=content, level=bark_msg_level, sound=bark_msg_ring + ), + 'NTFY': lambda: ntfy( + ntfy_api, title=msg_title, content=content, tags=ntfy_tags, action_url=live_url, email=ntfy_email + ), + 'PUSHPLUS': lambda: pushplus(pushplus_token, msg_title, content), + } + + for platform, func in push_functions.items(): + if platform in live_status_push.upper(): + try: + result = func() + print(f'提示信息:已经将[{record_name}]直播状态消息推送至你的{platform},' + f' 成功{len(result["success"])}, 失败{len(result["error"])}') + except Exception as e: + color_obj.print_colored(f"直播消息推送到{platform}失败: {e}", color_obj.RED) + + +def run_script(command: str) -> None: + try: + process = subprocess.Popen( + command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, startupinfo=get_startup_info(os_type) + ) + stdout, stderr = process.communicate() + stdout_decoded = stdout.decode('utf-8') + stderr_decoded = stderr.decode('utf-8') + if stdout_decoded.strip(): + print(stdout_decoded) + if stderr_decoded.strip(): + print(stderr_decoded) + except PermissionError as e: + logger.error(e) + logger.error('脚本无执行权限!, 若是Linux环境, 请先执行:chmod +x your_script.sh 授予脚本可执行权限') + except OSError as e: + logger.error(e) + logger.error('Please add `#!/bin/bash` at the beginning of your bash script file.') + + +def clear_record_info(record_name: str, record_url: str) -> None: + global monitoring + recording.discard(record_name) + if record_url in url_comments and record_url in running_list: + running_list.remove(record_url) + monitoring -= 1 + color_obj.print_colored(f"[{record_name}]已经从录制列表中移除\n", color_obj.YELLOW) + + +def direct_download_stream(source_url: str, save_path: str, record_name: str, live_url: str, platform: str) -> bool: + try: + with open(save_path, 'wb') as f: + client = httpx.Client(timeout=None) + + headers = {} + header_params = get_record_headers(platform, live_url) + if header_params: + key, value = header_params.split(":", 1) + headers[key] = value + + with client.stream('GET', source_url, headers=headers, follow_redirects=True) as response: + if response.status_code != 200: + logger.error(f"请求直播流失败,状态码: {response.status_code}") + return False + + downloaded = 0 + chunk_size = 1024 * 16 + + for chunk in response.iter_bytes(chunk_size): + if live_url in url_comments or exit_recording: + color_obj.print_colored(f"[{record_name}]录制时已被注释或请求停止,下载中断", color_obj.YELLOW) + clear_record_info(record_name, live_url) + return False + + if chunk: + f.write(chunk) + downloaded += len(chunk) + print() + return True + except Exception as e: + logger.error(f"FLV下载错误: {e} 发生错误的行数: {e.__traceback__.tb_lineno}") + return False + + +def check_subprocess(record_name: str, record_url: str, ffmpeg_command: list, save_type: str, + script_command: str | None = None) -> bool: + save_file_path = ffmpeg_command[-1] + process = subprocess.Popen( + ffmpeg_command, stdin=subprocess.PIPE, stderr=subprocess.STDOUT, startupinfo=get_startup_info(os_type) + ) + + subs_file_path = save_file_path.rsplit('.', maxsplit=1)[0] + subs_thread_name = f'subs_{Path(subs_file_path).name}' + if create_time_file and not split_video_by_time and '音频' not in save_type: + create_var[subs_thread_name] = threading.Thread( + target=generate_subtitles, args=(record_name, subs_file_path) + ) + create_var[subs_thread_name].daemon = True + create_var[subs_thread_name].start() + + while process.poll() is None: + if record_url in url_comments or exit_recording: + color_obj.print_colored(f"[{record_name}]录制时已被注释,本条线程将会退出", color_obj.YELLOW) + clear_record_info(record_name, record_url) + # process.terminate() + if os.name == 'nt': + if process.stdin: + process.stdin.write(b'q') + process.stdin.close() + else: + process.send_signal(signal.SIGINT) + process.wait() + return True + time.sleep(1) + + return_code = process.returncode + stop_time = time.strftime('%Y-%m-%d %H:%M:%S') + if return_code == 0: + if converts_to_mp4 and save_type == 'TS': + if split_video_by_time: + file_paths = utils.get_file_paths(os.path.dirname(save_file_path)) + prefix = os.path.basename(save_file_path).rsplit('_', maxsplit=1)[0] + for path in file_paths: + if prefix in path: + threading.Thread(target=converts_mp4, args=(path, delete_origin_file)).start() + else: + threading.Thread(target=converts_mp4, args=(save_file_path, delete_origin_file)).start() + print(f"\n{record_name} {stop_time} 直播录制完成\n") + + if script_command: + logger.debug("开始执行脚本命令!") + if "python" in script_command: + params = [ + f'--record_name "{record_name}"', + f'--save_file_path "{save_file_path}"', + f'--save_type {save_type}', + f'--split_video_by_time {split_video_by_time}', + f'--converts_to_mp4 {converts_to_mp4}', + ] + else: + params = [ + f'"{record_name.split(" ", maxsplit=1)[-1]}"', + f'"{save_file_path}"', + save_type, + f'split_video_by_time:{split_video_by_time}', + f'converts_to_mp4:{converts_to_mp4}' + ] + script_command = script_command.strip() + ' ' + ' '.join(params) + run_script(script_command) + logger.debug("脚本命令执行结束!") + + else: + color_obj.print_colored(f"\n{record_name} {stop_time} 直播录制出错,返回码: {return_code}\n", color_obj.RED) + + recording.discard(record_name) + return False + + +def clean_name(input_text): + cleaned_name = re.sub(rstr, "_", input_text.strip()).strip('_') + cleaned_name = cleaned_name.replace("(", "(").replace(")", ")") + if clean_emoji: + cleaned_name = utils.remove_emojis(cleaned_name, '_').strip('_') + return cleaned_name or '空白昵称' + + +def get_quality_code(qn): + QUALITY_MAPPING = { + "原画": "OD", + "蓝光": "BD", + "超清": "UHD", + "高清": "HD", + "标清": "SD", + "流畅": "LD" + } + return QUALITY_MAPPING.get(qn) + + +def get_record_headers(platform, live_url): + live_domain = '/'.join(live_url.split('/')[0:3]) + record_headers = { + 'PandaTV': 'origin:https://www.pandalive.co.kr', + 'WinkTV': 'origin:https://www.winktv.co.kr', + 'PopkonTV': 'origin:https://www.popkontv.com', + 'FlexTV': 'origin:https://www.flextv.co.kr', + '千度热播': 'referer:https://qiandurebo.com', + '17Live': 'referer:https://17.live/en/live/6302408', + '浪Live': 'referer:https://www.lang.live', + 'shopee': f'origin:{live_domain}', + 'Blued直播': 'referer:https://app.blued.cn' + } + return record_headers.get(platform) + + +def is_flv_preferred_platform(link): + return any(i in link for i in ["douyin", "tiktok"]) + + +def select_source_url(link, stream_info): + if is_flv_preferred_platform(link): + codec = utils.get_query_params(stream_info.get('flv_url'), "codec") + if codec and codec[0] == 'h265': + logger.warning("FLV is not supported for h265 codec, use HLS source instead") + else: + return stream_info.get('flv_url') + + return stream_info.get('record_url') + + +def start_record(url_data: tuple, count_variable: int = -1) -> None: + global error_count + + while True: + try: + record_finished = False + run_once = False + start_pushed = False + new_record_url = '' + count_time = time.time() + retry = 0 + record_quality_zh, record_url, anchor_name = url_data + record_quality = get_quality_code(record_quality_zh) + proxy_address = proxy_addr + platform = '未知平台' + live_domain = '/'.join(record_url.split('/')[0:3]) + + if proxy_addr: + proxy_address = None + for platform in enable_proxy_platform_list: + if platform and platform.strip() in record_url: + proxy_address = proxy_addr + break + + if not proxy_address: + if extra_enable_proxy_platform_list: + for pt in extra_enable_proxy_platform_list: + if pt and pt.strip() in record_url: + proxy_address = proxy_addr_bak or None + + # print(f'\r代理地址:{proxy_address}') + # print(f'\r全局代理:{global_proxy}') + while True: + try: + port_info = [] + if record_url.find("douyin.com/") > -1: + platform = '抖音直播' + with semaphore: + if 'v.douyin.com' not in record_url and '/user/' not in record_url: + json_data = asyncio.run(spider.get_douyin_web_stream_data( + url=record_url, + proxy_addr=proxy_address, + cookies=dy_cookie)) + else: + json_data = asyncio.run(spider.get_douyin_app_stream_data( + url=record_url, + proxy_addr=proxy_address, + cookies=dy_cookie)) + port_info = asyncio.run( + stream.get_douyin_stream_url(json_data, record_quality, proxy_address)) + + elif record_url.find("https://www.tiktok.com/") > -1: + platform = 'TikTok直播' + with semaphore: + if global_proxy or proxy_address: + json_data = asyncio.run(spider.get_tiktok_stream_data( + url=record_url, + proxy_addr=proxy_address, + cookies=tiktok_cookie)) + port_info = asyncio.run( + stream.get_tiktok_stream_url(json_data, record_quality, proxy_address)) + else: + logger.error("错误信息: 网络异常,请检查网络是否能正常访问TikTok平台") + + elif record_url.find("https://live.kuaishou.com/") > -1: + platform = '快手直播' + with semaphore: + json_data = asyncio.run(spider.get_kuaishou_stream_data( + url=record_url, + proxy_addr=proxy_address, + cookies=ks_cookie)) + port_info = asyncio.run(stream.get_kuaishou_stream_url(json_data, record_quality)) + + elif record_url.find("https://www.huya.com/") > -1: + platform = '虎牙直播' + with semaphore: + if record_quality not in ['OD', 'BD', 'UHD']: + json_data = asyncio.run(spider.get_huya_stream_data( + url=record_url, + proxy_addr=proxy_address, + cookies=hy_cookie)) + port_info = asyncio.run(stream.get_huya_stream_url(json_data, record_quality)) + else: + port_info = asyncio.run(spider.get_huya_app_stream_url( + url=record_url, + proxy_addr=proxy_address, + cookies=hy_cookie + )) + + elif record_url.find("https://www.douyu.com/") > -1: + platform = '斗鱼直播' + with semaphore: + json_data = asyncio.run(spider.get_douyu_info_data( + url=record_url, proxy_addr=proxy_address, cookies=douyu_cookie)) + port_info = asyncio.run(stream.get_douyu_stream_url( + json_data, video_quality=record_quality, cookies=douyu_cookie, proxy_addr=proxy_address + )) + + elif record_url.find("https://www.yy.com/") > -1: + platform = 'YY直播' + with semaphore: + json_data = asyncio.run(spider.get_yy_stream_data( + url=record_url, proxy_addr=proxy_address, cookies=yy_cookie)) + port_info = asyncio.run(stream.get_yy_stream_url(json_data)) + + elif record_url.find("https://live.bilibili.com/") > -1: + platform = 'B站直播' + with semaphore: + json_data = asyncio.run(spider.get_bilibili_room_info( + url=record_url, proxy_addr=proxy_address, cookies=bili_cookie)) + port_info = asyncio.run(stream.get_bilibili_stream_url( + json_data, video_quality=record_quality, cookies=bili_cookie, proxy_addr=proxy_address)) + + elif record_url.find("http://xhslink.com/") > -1 or \ + record_url.find("https://www.xiaohongshu.com/") > -1: + platform = '小红书直播' + with semaphore: + port_info = asyncio.run(spider.get_xhs_stream_url( + record_url, proxy_addr=proxy_address, cookies=xhs_cookie)) + retry += 1 + + elif record_url.find("www.bigo.tv/") > -1 or record_url.find("slink.bigovideo.tv/") > -1: + platform = 'Bigo直播' + with semaphore: + port_info = asyncio.run(spider.get_bigo_stream_url( + record_url, proxy_addr=proxy_address, cookies=bigo_cookie)) + + elif record_url.find("https://app.blued.cn/") > -1: + platform = 'Blued直播' + with semaphore: + port_info = asyncio.run(spider.get_blued_stream_url( + record_url, proxy_addr=proxy_address, cookies=blued_cookie)) + + elif record_url.find("sooplive.co.kr/") > -1 or record_url.find("sooplive.com/") > -1: + platform = 'SOOP' + with semaphore: + if global_proxy or proxy_address: + json_data = asyncio.run(spider.get_sooplive_stream_data( + url=record_url, proxy_addr=proxy_address, + cookies=sooplive_cookie, + username=sooplive_username, + password=sooplive_password + )) + if json_data and json_data.get('new_cookies'): + utils.update_config( + config_file, 'Cookie', 'sooplive_cookie', json_data['new_cookies'] + ) + port_info = asyncio.run(stream.get_stream_url(json_data, record_quality, spec=True)) + else: + logger.error("错误信息: 网络异常,请检查本网络是否能正常访问SOOP平台") + + elif record_url.find("cc.163.com/") > -1: + platform = '网易CC直播' + with semaphore: + json_data = asyncio.run(spider.get_netease_stream_data( + url=record_url, cookies=netease_cookie)) + port_info = asyncio.run(stream.get_netease_stream_url(json_data, record_quality)) + + elif record_url.find("qiandurebo.com/") > -1: + platform = '千度热播' + with semaphore: + port_info = asyncio.run(spider.get_qiandurebo_stream_data( + url=record_url, proxy_addr=proxy_address, cookies=qiandurebo_cookie)) + + elif record_url.find("www.pandalive.co.kr/") > -1: + platform = 'PandaTV' + with semaphore: + if global_proxy or proxy_address: + json_data = asyncio.run(spider.get_pandatv_stream_data( + url=record_url, + proxy_addr=proxy_address, + cookies=pandatv_cookie + )) + port_info = asyncio.run(stream.get_stream_url(json_data, record_quality, spec=True)) + else: + logger.error("错误信息: 网络异常,请检查本网络是否能正常访问PandaTV直播平台") + + elif record_url.find("fm.missevan.com/") > -1: + platform = '猫耳FM直播' + with semaphore: + port_info = asyncio.run(spider.get_maoerfm_stream_url( + url=record_url, proxy_addr=proxy_address, cookies=maoerfm_cookie)) + + elif record_url.find("www.winktv.co.kr/") > -1: + platform = 'WinkTV' + with semaphore: + if global_proxy or proxy_address: + json_data = asyncio.run(spider.get_winktv_stream_data( + url=record_url, + proxy_addr=proxy_address, + cookies=winktv_cookie)) + port_info = asyncio.run(stream.get_stream_url(json_data, record_quality, spec=True)) + else: + logger.error("错误信息: 网络异常,请检查本网络是否能正常访问WinkTV直播平台") + + elif record_url.find("www.flextv.co.kr/") > -1 or record_url.find("www.ttinglive.com/") > -1: + platform = 'FlexTV' + with semaphore: + if global_proxy or proxy_address: + json_data = asyncio.run(spider.get_flextv_stream_data( + url=record_url, + proxy_addr=proxy_address, + cookies=flextv_cookie, + username=flextv_username, + password=flextv_password + )) + if json_data and json_data.get('new_cookies'): + utils.update_config( + config_file, 'Cookie', 'flextv_cookie', json_data['new_cookies'] + ) + if 'play_url_list' in json_data: + port_info = asyncio.run(stream.get_stream_url(json_data, record_quality, spec=True)) + else: + port_info = json_data + else: + logger.error("错误信息: 网络异常,请检查本网络是否能正常访问FlexTV直播平台") + + elif record_url.find("look.163.com/") > -1: + platform = 'Look直播' + with semaphore: + port_info = asyncio.run(spider.get_looklive_stream_url( + url=record_url, proxy_addr=proxy_address, cookies=look_cookie + )) + + elif record_url.find("www.popkontv.com/") > -1: + platform = 'PopkonTV' + with semaphore: + if global_proxy or proxy_address: + port_info = asyncio.run(spider.get_popkontv_stream_url( + url=record_url, + proxy_addr=proxy_address, + access_token=popkontv_access_token, + username=popkontv_username, + password=popkontv_password, + partner_code=popkontv_partner_code + )) + if port_info and port_info.get('new_token'): + utils.update_config( + file_path=config_file, section='Authorization', key='popkontv_token', + new_value=port_info['new_token'] + ) + + else: + logger.error("错误信息: 网络异常,请检查本网络是否能正常访问PopkonTV直播平台") + + elif record_url.find("twitcasting.tv/") > -1: + platform = 'TwitCasting' + with semaphore: + json_data = asyncio.run(spider.get_twitcasting_stream_url( + url=record_url, + proxy_addr=proxy_address, + cookies=twitcasting_cookie, + account_type=twitcasting_account_type, + username=twitcasting_username, + password=twitcasting_password + )) + port_info = asyncio.run(stream.get_stream_url(json_data, record_quality, spec=False)) + + if port_info and port_info.get('new_cookies'): + utils.update_config( + file_path=config_file, section='Cookie', key='twitcasting_cookie', + new_value=port_info['new_cookies'] + ) + + elif record_url.find("live.baidu.com/") > -1: + platform = '百度直播' + with semaphore: + json_data = asyncio.run(spider.get_baidu_stream_data( + url=record_url, + proxy_addr=proxy_address, + cookies=baidu_cookie)) + port_info = asyncio.run(stream.get_stream_url(json_data, record_quality)) + + elif record_url.find("weibo.com/") > -1: + platform = '微博直播' + with semaphore: + json_data = asyncio.run(spider.get_weibo_stream_data( + url=record_url, proxy_addr=proxy_address, cookies=weibo_cookie)) + port_info = asyncio.run(stream.get_stream_url( + json_data, record_quality, hls_extra_key='m3u8_url')) + + elif record_url.find("kugou.com/") > -1: + platform = '酷狗直播' + with semaphore: + port_info = asyncio.run(spider.get_kugou_stream_url( + url=record_url, proxy_addr=proxy_address, cookies=kugou_cookie)) + + elif record_url.find("www.twitch.tv/") > -1: + platform = 'TwitchTV' + with semaphore: + if global_proxy or proxy_address: + json_data = asyncio.run(spider.get_twitchtv_stream_data( + url=record_url, + proxy_addr=proxy_address, + cookies=twitch_cookie + )) + port_info = asyncio.run(stream.get_stream_url(json_data, record_quality, spec=True)) + else: + logger.error("错误信息: 网络异常,请检查本网络是否能正常访问TwitchTV直播平台") + + elif record_url.find("www.liveme.com/") > -1: + if global_proxy or proxy_address: + platform = 'LiveMe' + with semaphore: + port_info = asyncio.run(spider.get_liveme_stream_url( + url=record_url, proxy_addr=proxy_address, cookies=liveme_cookie)) + else: + logger.error("错误信息: 网络异常,请检查本网络是否能正常访问LiveMe直播平台") + + elif record_url.find("www.huajiao.com/") > -1: + platform = '花椒直播' + with semaphore: + port_info = asyncio.run(spider.get_huajiao_stream_url( + url=record_url, proxy_addr=proxy_address, cookies=huajiao_cookie)) + + elif record_url.find("7u66.com/") > -1: + platform = '流星直播' + with semaphore: + port_info = asyncio.run(spider.get_liuxing_stream_url( + url=record_url, proxy_addr=proxy_address, cookies=liuxing_cookie)) + + elif record_url.find("showroom-live.com/") > -1: + platform = 'ShowRoom' + with semaphore: + json_data = asyncio.run(spider.get_showroom_stream_data( + url=record_url, proxy_addr=proxy_address, cookies=showroom_cookie)) + port_info = asyncio.run(stream.get_stream_url(json_data, record_quality, spec=True)) + + elif record_url.find("live.acfun.cn/") > -1 or record_url.find("m.acfun.cn/") > -1: + platform = 'Acfun' + with semaphore: + json_data = asyncio.run(spider.get_acfun_stream_data( + url=record_url, proxy_addr=proxy_address, cookies=acfun_cookie)) + port_info = asyncio.run(stream.get_stream_url( + json_data, record_quality, url_type='flv', flv_extra_key='url')) + + elif record_url.find("live.tlclw.com/") > -1: + platform = '畅聊直播' + with semaphore: + port_info = asyncio.run(spider.get_changliao_stream_url( + url=record_url, proxy_addr=proxy_address, cookies=changliao_cookie)) + + elif record_url.find("ybw1666.com/") > -1: + platform = '音播直播' + with semaphore: + port_info = asyncio.run(spider.get_yinbo_stream_url( + url=record_url, proxy_addr=proxy_address, cookies=yinbo_cookie)) + + elif record_url.find("www.inke.cn/") > -1: + platform = '映客直播' + with semaphore: + port_info = asyncio.run(spider.get_yingke_stream_url( + url=record_url, proxy_addr=proxy_address, cookies=yingke_cookie)) + + elif record_url.find("www.zhihu.com/") > -1: + platform = '知乎直播' + with semaphore: + port_info = asyncio.run(spider.get_zhihu_stream_url( + url=record_url, proxy_addr=proxy_address, cookies=zhihu_cookie)) + + elif record_url.find("chzzk.naver.com/") > -1: + platform = 'CHZZK' + with semaphore: + json_data = asyncio.run(spider.get_chzzk_stream_data( + url=record_url, proxy_addr=proxy_address, cookies=chzzk_cookie)) + port_info = asyncio.run(stream.get_stream_url(json_data, record_quality, spec=True)) + + elif record_url.find("www.haixiutv.com/") > -1: + platform = '嗨秀直播' + with semaphore: + port_info = asyncio.run(spider.get_haixiu_stream_url( + url=record_url, proxy_addr=proxy_address, cookies=haixiu_cookie)) + + elif record_url.find("vvxqiu.com/") > -1: + platform = 'VV星球' + with semaphore: + port_info = asyncio.run(spider.get_vvxqiu_stream_url( + url=record_url, proxy_addr=proxy_address, cookies=vvxqiu_cookie)) + + elif record_url.find("17.live/") > -1: + platform = '17Live' + with semaphore: + port_info = asyncio.run(spider.get_17live_stream_url( + url=record_url, proxy_addr=proxy_address, cookies=yiqilive_cookie)) + + elif record_url.find("www.lang.live/") > -1: + platform = '浪Live' + with semaphore: + port_info = asyncio.run(spider.get_langlive_stream_url( + url=record_url, proxy_addr=proxy_address, cookies=langlive_cookie)) + + elif record_url.find("m.pp.weimipopo.com/") > -1: + platform = '漂漂直播' + with semaphore: + port_info = asyncio.run(spider.get_pplive_stream_url( + url=record_url, proxy_addr=proxy_address, cookies=pplive_cookie)) + + elif record_url.find(".6.cn/") > -1: + platform = '六间房直播' + with semaphore: + port_info = asyncio.run(spider.get_6room_stream_url( + url=record_url, proxy_addr=proxy_address, cookies=six_room_cookie)) + + elif record_url.find("lehaitv.com/") > -1: + platform = '乐嗨直播' + with semaphore: + port_info = asyncio.run(spider.get_haixiu_stream_url( + url=record_url, proxy_addr=proxy_address, cookies=lehaitv_cookie)) + + elif record_url.find("h.catshow168.com/") > -1: + platform = '花猫直播' + with semaphore: + port_info = asyncio.run(spider.get_pplive_stream_url( + url=record_url, proxy_addr=proxy_address, cookies=huamao_cookie)) + + elif record_url.find("live.shopee") > -1 or record_url.find("shp.ee/") > -1: + platform = 'shopee' + with semaphore: + port_info = asyncio.run(spider.get_shopee_stream_url( + url=record_url, proxy_addr=proxy_address, cookies=shopee_cookie)) + if port_info.get('uid'): + new_record_url = record_url.split('?')[0] + '?' + str(port_info['uid']) + + elif record_url.find("www.youtube.com/") > -1 or record_url.find("youtu.be/") > -1: + platform = 'Youtube' + with semaphore: + json_data = asyncio.run(spider.get_youtube_stream_url( + url=record_url, proxy_addr=proxy_address, cookies=youtube_cookie)) + port_info = asyncio.run(stream.get_stream_url(json_data, record_quality, spec=True)) + + elif record_url.find("tb.cn") > -1: + platform = '淘宝直播' + with semaphore: + json_data = asyncio.run(spider.get_taobao_stream_url( + url=record_url, proxy_addr=proxy_address, cookies=taobao_cookie)) + port_info = asyncio.run(stream.get_stream_url( + json_data, record_quality, + url_type='all', hls_extra_key='hlsUrl', flv_extra_key='flvUrl' + )) + + elif record_url.find("3.cn") > -1 or record_url.find("m.jd.com") > -1: + platform = '京东直播' + with semaphore: + port_info = asyncio.run(spider.get_jd_stream_url( + url=record_url, proxy_addr=proxy_address, cookies=jd_cookie)) + + elif record_url.find("faceit.com/") > -1: + platform = 'faceit' + with semaphore: + if global_proxy or proxy_address: + with semaphore: + json_data = asyncio.run(spider.get_faceit_stream_data( + url=record_url, proxy_addr=proxy_address, cookies=faceit_cookie)) + port_info = asyncio.run(stream.get_stream_url(json_data, record_quality, spec=True)) + else: + logger.error("错误信息: 网络异常,请检查本网络是否能正常访问faceit直播平台") + + elif record_url.find("www.miguvideo.com") > -1 or record_url.find("m.miguvideo.com") > -1: + platform = '咪咕直播' + with semaphore: + port_info = asyncio.run(spider.get_migu_stream_url( + url=record_url, proxy_addr=proxy_address, cookies=migu_cookie)) + + elif record_url.find("show.lailianjie.com") > -1: + platform = '连接直播' + with semaphore: + port_info = asyncio.run(spider.get_lianjie_stream_url( + url=record_url, proxy_addr=proxy_address, cookies=lianjie_cookie)) + + elif record_url.find("www.imkktv.com") > -1: + platform = '来秀直播' + with semaphore: + port_info = asyncio.run(spider.get_laixiu_stream_url( + url=record_url, proxy_addr=proxy_address, cookies=laixiu_cookie)) + + elif record_url.find("www.picarto.tv") > -1: + platform = 'Picarto' + with semaphore: + port_info = asyncio.run(spider.get_picarto_stream_url( + url=record_url, proxy_addr=proxy_address, cookies=picarto_cookie)) + + elif record_url.find(".m3u8") > -1 or record_url.find(".flv") > -1: + platform = '自定义录制直播' + port_info = { + "anchor_name": platform + '_' + str(uuid.uuid4())[:8], + "is_live": True, + "record_url": record_url, + } + if '.flv' in record_url: + port_info['flv_url'] = record_url + else: + port_info['m3u8_url'] = record_url + + else: + logger.error(f'{record_url} {platform}直播地址') + return + + if anchor_name: + if '主播:' in anchor_name: + anchor_split: list = anchor_name.split('主播:') + if len(anchor_split) > 1 and anchor_split[1].strip(): + anchor_name = anchor_split[1].strip() + else: + anchor_name = port_info.get("anchor_name", '') + else: + anchor_name = port_info.get("anchor_name", '') + + if not port_info.get("anchor_name", ''): + print(f'序号{count_variable} 网址内容获取失败,进行重试中...获取失败的地址是:{url_data}') + with max_request_lock: + error_count += 1 + error_window.append(1) + else: + anchor_name = clean_name(anchor_name) + record_name = f'序号{count_variable} {anchor_name}' + + if record_url in url_comments: + print(f"[{anchor_name}]已被注释,本条线程将会退出") + clear_record_info(record_name, record_url) + return + + if not url_data[-1] and run_once is False: + if new_record_url: + need_update_line_list.append( + f'{record_url}|{new_record_url},主播: {anchor_name.strip()}') + not_record_list.append(new_record_url) + else: + need_update_line_list.append(f'{record_url}|{record_url},主播: {anchor_name.strip()}') + run_once = True + + push_at = datetime.datetime.today().strftime('%Y-%m-%d %H:%M:%S') + if port_info['is_live'] is False: + print(f"\r{record_name} 等待直播... ") + + if start_pushed: + if over_show_push: + push_content = "直播间状态更新:[直播间名称] 直播已结束!时间:[时间]" + if over_push_message_text: + push_content = over_push_message_text + + push_content = (push_content.replace('[直播间名称]', record_name). + replace('[时间]', push_at)) + threading.Thread( + target=push_message, + args=(record_name, record_url, push_content.replace(r'\n', '\n')), + daemon=True + ).start() + start_pushed = False + + else: + content = f"\r{record_name} 正在直播中..." + print(content) + + if live_status_push and not start_pushed: + if begin_show_push: + push_content = "直播间状态更新:[直播间名称] 正在直播中,时间:[时间]" + if begin_push_message_text: + push_content = begin_push_message_text + + push_content = (push_content.replace('[直播间名称]', record_name). + replace('[时间]', push_at)) + threading.Thread( + target=push_message, + args=(record_name, record_url, push_content.replace(r'\n', '\n')), + daemon=True + ).start() + start_pushed = True + + if disable_record: + time.sleep(push_check_seconds) + continue + + real_url = select_source_url(record_url, port_info) + full_path = f'{default_path}/{platform}' + if real_url: + now = datetime.datetime.today().strftime("%Y-%m-%d_%H-%M-%S") + live_title = port_info.get('title') + title_in_name = '' + if live_title: + live_title = clean_name(live_title) + title_in_name = live_title + '_' if filename_by_title else '' + + try: + if len(video_save_path) > 0: + if not video_save_path.endswith(('/', '\\')): + full_path = f'{video_save_path}/{platform}' + else: + full_path = f'{video_save_path}{platform}' + + full_path = full_path.replace("\\", '/') + if folder_by_author: + full_path = f'{full_path}/{anchor_name}' + if folder_by_time: + full_path = f'{full_path}/{now[:10]}' + if folder_by_title and port_info.get('title'): + if folder_by_time: + full_path = f'{full_path}/{live_title}_{anchor_name}' + else: + full_path = f'{full_path}/{now[:10]}_{live_title}' + if not os.path.exists(full_path): + os.makedirs(full_path) + except Exception as e: + logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}") + + if platform != '自定义录制直播': + if enable_https_recording and real_url.startswith("http://"): + real_url = real_url.replace("http://", "https://") + + http_record_list = ['shopee', "migu"] + if platform in http_record_list: + real_url = real_url.replace("https://", "http://") + + user_agent = ("Mozilla/5.0 (Linux; Android 11; SAMSUNG SM-G973U) AppleWebKit/537.36 (" + "KHTML, like Gecko) SamsungBrowser/14.2 Chrome/87.0.4280.141 Mobile " + "Safari/537.36") + + rw_timeout = "15000000" + analyzeduration = "20000000" + probesize = "10000000" + bufsize = "8000k" + max_muxing_queue_size = "1024" + for pt_host in overseas_platform_host: + if pt_host in record_url: + rw_timeout = "50000000" + analyzeduration = "40000000" + probesize = "20000000" + bufsize = "15000k" + max_muxing_queue_size = "2048" + break + + ffmpeg_command = [ + 'ffmpeg', "-y", + "-v", "verbose", + "-rw_timeout", rw_timeout, + "-loglevel", "error", + "-hide_banner", + "-user_agent", user_agent, + "-protocol_whitelist", "rtmp,crypto,file,http,https,tcp,tls,udp,rtp,httpproxy", + "-thread_queue_size", "1024", + "-analyzeduration", analyzeduration, + "-probesize", probesize, + "-fflags", "+discardcorrupt", + "-re", "-i", real_url, + "-bufsize", bufsize, + "-sn", "-dn", + "-reconnect_delay_max", "60", + "-reconnect_streamed", "-reconnect_at_eof", + "-max_muxing_queue_size", max_muxing_queue_size, + "-correct_ts_overflow", "1", + "-avoid_negative_ts", "1" + ] + + headers = get_record_headers(platform, record_url) + if headers: + ffmpeg_command.insert(11, "-headers") + ffmpeg_command.insert(12, headers) + + if proxy_address: + ffmpeg_command.insert(1, "-http_proxy") + ffmpeg_command.insert(2, proxy_address) + # 检查是否已有直播在录制 + if len(recording) > 0: + # 如果已有直播在录制,跳过这次录制 + return # 或者 continue,如果在循环中 + recording.add(record_name) + start_record_time = datetime.datetime.now() + recording_time_list[record_name] = [start_record_time, record_quality_zh] + rec_info = f"\r{anchor_name} 准备开始录制视频: {full_path}" + if show_url: + re_plat = ('WinkTV', 'PandaTV', 'ShowRoom', 'CHZZK', 'Youtube') + if platform in re_plat: + logger.info( + f"{platform} | {anchor_name} | 直播源地址: {port_info.get('m3u8_url')}") + else: + logger.info( + f"{platform} | {anchor_name} | 直播源地址: {real_url}") + + only_flv_record = False + only_flv_platform_list = ['shopee', '花椒直播'] + if platform in only_flv_platform_list: + logger.debug(f"提示: {platform} 将强制使用FLV格式录制") + only_flv_record = True + + only_audio_record = False + only_audio_platform_list = ['猫耳FM直播', 'Look直播'] + if platform in only_audio_platform_list: + only_audio_record = True + + record_save_type = video_save_type + + if is_flv_preferred_platform(record_url) and port_info.get('flv_url'): + codec = utils.get_query_params(port_info['flv_url'], "codec") + if codec and codec[0] == 'h265': + logger.warning("FLV is not supported for h265 codec, use TS format instead") + record_save_type = "TS" + + if only_audio_record or any(i in record_save_type for i in ['MP3', 'M4A']): + try: + now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime()) + extension = "mp3" if "m4a" not in record_save_type.lower() else "m4a" + name_format = "_%03d" if split_video_by_time else "" + save_file_path = (f"{full_path}/{anchor_name}_{title_in_name}{now}" + f"{name_format}.{extension}") + + if split_video_by_time: + print(f'\r{anchor_name} 准备开始录制音频: {save_file_path}') + + if "MP3" in record_save_type: + command = [ + "-map", "0:a", + "-c:a", "libmp3lame", + "-ab", "320k", + "-f", "segment", + "-segment_time", split_time, + "-reset_timestamps", "1", + save_file_path, + ] + else: + command = [ + "-map", "0:a", + "-c:a", "aac", + "-bsf:a", "aac_adtstoasc", + "-ab", "320k", + "-f", "segment", + "-segment_time", split_time, + "-segment_format", 'mpegts', + "-reset_timestamps", "1", + save_file_path, + ] + + else: + if "MP3" in record_save_type: + command = [ + "-map", "0:a", + "-c:a", "libmp3lame", + "-ab", "320k", + save_file_path, + ] + + else: + command = [ + "-map", "0:a", + "-c:a", "aac", + "-bsf:a", "aac_adtstoasc", + "-ab", "320k", + "-movflags", "+faststart", + save_file_path, + ] + + ffmpeg_command.extend(command) + comment_end = check_subprocess( + record_name, + record_url, + ffmpeg_command, + record_save_type, + custom_script + ) + if comment_end: + return + + except subprocess.CalledProcessError as e: + logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}") + with max_request_lock: + error_count += 1 + error_window.append(1) + + + + # if only_flv_record: + if False: + + logger.info(f"Use Direct Downloader to Download FLV Stream: {record_url}") + filename = anchor_name + f'_{title_in_name}' + now + '.flv' + save_file_path = f'{full_path}/{filename}' + print(f'{rec_info}/{filename}') + + subs_file_path = save_file_path.rsplit('.', maxsplit=1)[0] + subs_thread_name = f'subs_{Path(subs_file_path).name}' + if create_time_file: + create_var[subs_thread_name] = threading.Thread( + target=generate_subtitles, args=(record_name, subs_file_path) + ) + create_var[subs_thread_name].daemon = True + create_var[subs_thread_name].start() + + try: + flv_url = port_info.get('flv_url') + if flv_url: + recording.add(record_name) + start_record_time = datetime.datetime.now() + recording_time_list[record_name] = [start_record_time, record_quality_zh] + + download_success = direct_download_stream( + flv_url, save_file_path, record_name, record_url, platform + ) + + if download_success: + record_finished = True + print( + f"\n{anchor_name} {time.strftime('%Y-%m-%d %H:%M:%S')} 直播录制完成\n") + + recording.discard(record_name) + else: + logger.debug("未找到FLV直播流,跳过录制") + except Exception as e: + clear_record_info(record_name, record_url) + color_obj.print_colored( + f"\n{anchor_name} {time.strftime('%Y-%m-%d %H:%M:%S')} 直播录制出错,请检查网络\n", + color_obj.RED) + logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}") + with max_request_lock: + error_count += 1 + error_window.append(1) + + elif record_save_type == "FLV": + filename = anchor_name + f'_{title_in_name}' + now + ".flv" + print(f'{rec_info}/{filename}') + save_file_path = full_path + '/' + filename + + try: + if split_video_by_time: + now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime()) + save_file_path = f"{full_path}/{anchor_name}_{title_in_name}{now}_%03d.flv" + command = [ + "-map", "0", + "-c:v", "copy", + "-c:a", "copy", + "-bsf:a", "aac_adtstoasc", + "-f", "segment", + "-segment_time", split_time, + "-segment_format", "flv", + "-reset_timestamps", "1", + save_file_path + ] + + else: + command = [ + "-map", "0", + "-c:v", "copy", + "-c:a", "copy", + "-bsf:a", "aac_adtstoasc", + "-f", "flv", + "{path}".format(path=save_file_path), + ] + ffmpeg_command.extend(command) + + comment_end = check_subprocess( + record_name, + record_url, + ffmpeg_command, + record_save_type, + custom_script + ) + if comment_end: + return + + except subprocess.CalledProcessError as e: + logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}") + with max_request_lock: + error_count += 1 + error_window.append(1) + + try: + if converts_to_mp4: + seg_file_path = f"{full_path}/{anchor_name}_{title_in_name}{now}_%03d.mp4" + if split_video_by_time: + segment_video( + save_file_path, seg_file_path, + segment_format='mp4', segment_time=split_time, + is_original_delete=delete_origin_file + ) + else: + threading.Thread( + target=converts_mp4, + args=(save_file_path, delete_origin_file) + ).start() + + else: + seg_file_path = f"{full_path}/{anchor_name}_{title_in_name}{now}_%03d.flv" + if split_video_by_time: + segment_video( + save_file_path, seg_file_path, + segment_format='flv', segment_time=split_time, + is_original_delete=delete_origin_file + ) + except Exception as e: + logger.error(f"转码失败: {e} ") + + elif record_save_type == "MKV": + filename = anchor_name + f'_{title_in_name}' + now + ".mkv" + print(f'{rec_info}/{filename}') + save_file_path = full_path + '/' + filename + + try: + if split_video_by_time: + now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime()) + save_file_path = f"{full_path}/{anchor_name}_{title_in_name}{now}_%03d.mkv" + command = [ + "-flags", "global_header", + "-c:v", "copy", + "-c:a", "aac", + "-map", "0", + "-f", "segment", + "-segment_time", split_time, + "-segment_format", "matroska", + "-reset_timestamps", "1", + save_file_path, + ] + + else: + command = [ + "-flags", "global_header", + "-map", "0", + "-c:v", "copy", + "-c:a", "copy", + "-f", "matroska", + "{path}".format(path=save_file_path), + ] + ffmpeg_command.extend(command) + + comment_end = check_subprocess( + record_name, + record_url, + ffmpeg_command, + record_save_type, + custom_script + ) + if comment_end: + return + + except subprocess.CalledProcessError as e: + logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}") + with max_request_lock: + error_count += 1 + error_window.append(1) + + elif record_save_type == "MP4": + filename = anchor_name + f'_{title_in_name}' + now + ".mp4" + print(f'{rec_info}/{filename}') + save_file_path = full_path + '/' + filename + + try: + if split_video_by_time: + now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime()) + save_file_path = f"{full_path}/{anchor_name}_{title_in_name}{now}_%03d.mp4" + command = [ + "-c:v", "copy", + "-c:a", "aac", + "-map", "0", + "-f", "segment", + "-segment_time", split_time, + "-segment_format", "mp4", + "-reset_timestamps", "1", + "-movflags", "+frag_keyframe+empty_moov", + save_file_path, + ] + + else: + command = [ + "-map", "0", + "-c:v", "copy", + "-c:a", "copy", + "-f", "mp4", + save_file_path, + ] + + ffmpeg_command.extend(command) + comment_end = check_subprocess( + record_name, + record_url, + ffmpeg_command, + record_save_type, + custom_script + ) + if comment_end: + return + + except subprocess.CalledProcessError as e: + logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}") + with max_request_lock: + error_count += 1 + error_window.append(1) + + else: + + + import platform + # ====================== 抖音→YouTube 转推 2025终极永不死版 ====================== + try: + # 直播标题带时间戳 + timestamp_str = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime()) + title_suffix = f"_{title_in_name}" if title_in_name else "" + stream_title = f"{anchor_name}{title_suffix}_{timestamp_str}" + + # YouTube 推流地址(务必换成你自己的!!!) + YT_STREAM_KEY = "x04z-564w-aks7-embw-30y4" + youtube_rtmp = f"rtmp://a.rtmp.youtube.com/live2/{YT_STREAM_KEY}" + print(f"{rec_info} 开始推流到 YouTube: {stream_title}") + + # 自动检测并使用 NVENC(有N卡就硬件加速) + codec_v = "libx264" + preset_v = "veryfast" + tune_param = ["-tune", "zerolatency"] + try: + if platform.system().lower() in ["windows", "linux"]: + check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=8) + if check.returncode == 0: + enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=8) + if "h264_nvenc" in enc.stdout: + codec_v = "h264_nvenc" + preset_v = "p5" + tune_param = ["-tune", "ll", "-rc", "vbr", "-cq", "23"] + print("检测到 NVIDIA GPU → 使用 NVENC 硬件加速") + except: + pass + + # 最强最稳的 FFmpeg 参数(千万不要随便改) + ffmpeg_command = [ + "ffmpeg", + "-rw_timeout", "15000000", + "-reconnect", "1", + "-reconnect_at_eof", "1", + "-reconnect_streamed", "1", + "-reconnect_delay_max", "10", + "-fflags", "+genpts+discardcorrupt+igndts", + "-err_detect", "ignore_err", + "-thread_queue_size", "4096", + "-i", real_url, + "-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black", + "-c:v", codec_v, "-preset", preset_v, *tune_param, + "-b:v", "2500k", "-maxrate", "2500k", "-bufsize", "5000k", + "-g", "50", "-r", "25", "-pix_fmt", "yuv420p", "-bf", "0", + "-c:a", "aac", "-b:a", "128k", "-ar", "44100", "-ac", "2", + "-af", "aresample=async=1:first_pts=0", + "-flags", "+low_delay+global_header", + "-fflags", "+genpts+nobuffer", + "-threads", "8", + "-f", "flv", youtube_rtmp + ] + + proc = None + + # 主循环:无限重启,直到主播真下播 + while True: + try: + # 确保上一次的 ffmpeg 彻底死了 + if proc and proc.poll() is None: + proc.terminate() + try: proc.wait(timeout=5) + except: proc.kill() + + print(f"[{datetime.datetime.now():%H:%M:%S}] 启动YouTube推流 → {stream_title}") + proc = subprocess.Popen( + ffmpeg_command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + universal_newlines=True + ) + + # 每次启动都重置监控变量 + proc.last_out_ts = time.time() + last_frame_time = time.time() + no_frame_restart_count = 0 + + # 读取 ffmpeg 输出核心循环 + while True: + line = proc.stderr.readline() + if line == '' and proc.poll() is not None: + break + if not line: + time.sleep(0.01) + continue + + line = line.strip() + if line: + proc.last_out_ts = time.time() + + # 收到新帧就清零计数(关键!防止误杀) + if "frame=" in line or "fps=" in line: + last_frame_time = time.time() + no_frame_restart_count = 0 + + print(f"FFmpeg: {line}") + + line_lower = line.lower() + + # 致命错误 = 主播真下播(最准) + if any(kw in line_lower for kw in [ + "403 forbidden", "401 unauthorized", "members only", + "private video", "live stream ended", "ingestion error" + ]): + print("【致命错误】检测到主播已下播或被封,停止推流") + if proc and proc.poll() is None: + proc.kill() + raise SystemExit + + # YouTube 救命机制(12秒无新帧就砍) + if time.time() - time.time() > 25: # 启动25秒后开始判断 + if time.time() - last_frame_time > 12: + no_frame_restart_count += 1 + print(f"【YouTube救命】已 {time.time()-last_frame_time:.1f}s 无新帧,连续第 {no_frame_restart_count} 次重启") + + if no_frame_restart_count >= 5: # 连续5次救不回 = 真下播 + print("【真下播确认】连续5次无新帧失败,主播已下播,停止推流") + if proc and proc.poll() is None: + proc.kill() + raise SystemExit + + if proc and proc.poll() is None: + proc.kill() + continue # 跳到外层重新启动 + + # 20秒完全没日志 = 进程僵死 + if time.time() - time.time() > 20: + if time.time() - proc.last_out_ts > 20: + print("【真死流】20秒无任何输出,强制重启") + if proc and proc.poll() is None: + proc.kill() + continue + + except SystemExit: + # 主动退出信号:主播真下播 + break + except Exception as e: + print(f"推流异常: {e},5秒后重试...") + time.sleep(5) + continue + + # 最终清理(主播下播后执行) + if proc and proc.poll() is None: + proc.kill() + + try: recording.discard(record_name) + except: pass + try: recording_time_list.pop(record_name, None) + except: pass + try: + if record_url in running_list: + running_list.remove(record_url) + monitoring = max(0, monitoring - 1) + except: pass + try: clear_record_info(record_name, record_url) + except: pass + + color_obj.print_colored(f"[{record_name}] YouTube推流已彻底停止并清理完毕\n", color_obj.GREEN) + + except Exception as e: + print(f"最外层异常: {e}") + try: + if 'proc' in locals() and proc and proc.poll() is None: + proc.kill() + except: pass + color_obj.print_colored(f"[{record_name}] 异常退出但已清理完毕\n", color_obj.RED) + + count_time = time.time() + + except Exception as e: + logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}") + with max_request_lock: + error_count += 1 + error_window.append(1) + + num = random.randint(-5, 5) + delay_default + if num < 0: + num = 0 + x = num + + if error_count > 20: + x = x + 60 + color_obj.print_colored("\r瞬时错误太多,延迟加60秒", color_obj.YELLOW) + + # 这里是.如果录制结束后,循环时间会暂时变成30s后检测一遍. 这样一定程度上防止主播卡顿造成少录 + # 当30秒过后检测一遍后. 会回归正常设置的循环秒数 + if record_finished: + count_time_end = time.time() - count_time + if count_time_end < 60: + x = 30 + record_finished = False + + else: + x = num + + # 这里是正常循环 + while x: + x = x - 1 + if loop_time: + print(f'\r{anchor_name}循环等待{x}秒 ', end="") + time.sleep(1) + if loop_time: + print('\r检测直播间中...', end="") + except Exception as e: + logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}") + with max_request_lock: + error_count += 1 + error_window.append(1) + time.sleep(2) + + +def backup_file(file_path: str, backup_dir_path: str, limit_counts: int = 6) -> None: + try: + if not os.path.exists(backup_dir_path): + os.makedirs(backup_dir_path) + + timestamp = datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S') + backup_file_name = os.path.basename(file_path) + '_' + timestamp + backup_file_path = os.path.join(backup_dir_path, backup_file_name).replace("\\", "/") + shutil.copy2(file_path, backup_file_path) + + files = os.listdir(backup_dir_path) + _files = [f for f in files if f.startswith(os.path.basename(file_path))] + _files.sort(key=lambda x: os.path.getmtime(os.path.join(backup_dir_path, x))) + + while len(_files) > limit_counts: + oldest_file = _files[0] + os.remove(os.path.join(backup_dir_path, oldest_file)) + _files = _files[1:] + + except Exception as e: + logger.error(f'\r备份配置文件 {file_path} 失败:{str(e)}') + + +def backup_file_start() -> None: + config_md5 = '' + url_config_md5 = '' + + while True: + try: + if os.path.exists(config_file): + new_config_md5 = utils.check_md5(config_file) + if new_config_md5 != config_md5: + backup_file(config_file, backup_dir) + config_md5 = new_config_md5 + + if os.path.exists(url_config_file): + new_url_config_md5 = utils.check_md5(url_config_file) + if new_url_config_md5 != url_config_md5: + backup_file(url_config_file, backup_dir) + url_config_md5 = new_url_config_md5 + time.sleep(600) + except Exception as e: + logger.error(f"备份配置文件失败, 错误信息: {e}") + + +def check_ffmpeg_existence() -> bool: + try: + result = subprocess.run(['ffmpeg', '-version'], check=True, capture_output=True, text=True) + if result.returncode == 0: + lines = result.stdout.splitlines() + version_line = lines[0] + built_line = lines[1] + print(version_line) + print(built_line) + except subprocess.CalledProcessError as e: + logger.error(e) + except FileNotFoundError: + pass + finally: + if check_ffmpeg(): + time.sleep(1) + return True + return False + + +# --------------------------初始化程序------------------------------------- +print("-----------------------------------------------------") +print("| DouyinLiveRecorder |") +print("-----------------------------------------------------") + +print(f"版本号: {version}") +print("GitHub: https://github.com/ihmily/DouyinLiveRecorder") +print(f'支持平台: {platforms}') +print('.....................................................') +if not check_ffmpeg_existence(): + logger.error("缺少ffmpeg无法进行录制,程序退出") + sys.exit(1) +os.makedirs(os.path.dirname(config_file), exist_ok=True) +t3 = threading.Thread(target=backup_file_start, args=(), daemon=True) +t3.start() +utils.remove_duplicate_lines(url_config_file) + + +def read_config_value(config_parser: configparser.RawConfigParser, section: str, option: str, default_value: Any) \ + -> Any: + try: + + config_parser.read(config_file, encoding=text_encoding) + if '录制设置' not in config_parser.sections(): + config_parser.add_section('录制设置') + if '推送配置' not in config_parser.sections(): + config_parser.add_section('推送配置') + if 'Cookie' not in config_parser.sections(): + config_parser.add_section('Cookie') + if 'Authorization' not in config_parser.sections(): + config_parser.add_section('Authorization') + if '账号密码' not in config_parser.sections(): + config_parser.add_section('账号密码') + return config_parser.get(section, option) + except (configparser.NoSectionError, configparser.NoOptionError): + config_parser.set(section, option, str(default_value)) + with open(config_file, 'w', encoding=text_encoding) as f: + config_parser.write(f) + return default_value + + +options = {"是": True, "否": False} +config = configparser.RawConfigParser() +language = read_config_value(config, '录制设置', 'language(zh_cn/en)', "zh_cn") +skip_proxy_check = options.get(read_config_value(config, '录制设置', '是否跳过代理检测(是/否)', "否"), False) +if language and 'en' not in language.lower(): + from i18n import translated_print + + builtins.print = translated_print + +try: + if skip_proxy_check: + global_proxy = True + else: + print('系统代理检测中,请耐心等待...') + response_g = urllib.request.urlopen("https://www.google.com/", timeout=15) + global_proxy = True + print('\r全局/规则网络代理已开启√') + pd = ProxyDetector() + if pd.is_proxy_enabled(): + proxy_info = pd.get_proxy_info() + print("System Proxy: http://{}:{}".format(proxy_info.ip, proxy_info.port)) +except HTTPError as err: + print(f"HTTP error occurred: {err.code} - {err.reason}") +except URLError: + color_obj.print_colored("INFO:未检测到全局/规则网络代理,请检查代理配置(若无需录制海外直播请忽略此条提示)", + color_obj.YELLOW) +except Exception as err: + print("An unexpected error occurred:", err) + +while True: + + try: + if not os.path.isfile(config_file): + with open(config_file, 'w', encoding=text_encoding) as file: + pass + + ini_URL_content = '' + if os.path.isfile(url_config_file): + with open(url_config_file, 'r', encoding=text_encoding) as file: + ini_URL_content = file.read().strip() + + if not ini_URL_content.strip(): + input_url = input('请输入要录制的主播直播间网址(尽量使用PC网页端的直播间地址):\n') + with open(url_config_file, 'w', encoding=text_encoding) as file: + file.write(input_url) + except OSError as err: + logger.error(f"发生 I/O 错误: {err}") + + video_save_path = read_config_value(config, '录制设置', '直播保存路径(不填则默认)', "") + folder_by_author = options.get(read_config_value(config, '录制设置', '保存文件夹是否以作者区分', "是"), False) + folder_by_time = options.get(read_config_value(config, '录制设置', '保存文件夹是否以时间区分', "否"), False) + folder_by_title = options.get(read_config_value(config, '录制设置', '保存文件夹是否以标题区分', "否"), False) + filename_by_title = options.get(read_config_value(config, '录制设置', '保存文件名是否包含标题', "否"), False) + clean_emoji = options.get(read_config_value(config, '录制设置', '是否去除名称中的表情符号', "是"), True) + video_save_type = read_config_value(config, '录制设置', '视频保存格式ts|mkv|flv|mp4|mp3音频|m4a音频', "ts") + video_record_quality = read_config_value(config, '录制设置', '原画|超清|高清|标清|流畅', "原画") + use_proxy = options.get(read_config_value(config, '录制设置', '是否使用代理ip(是/否)', "是"), False) + proxy_addr_bak = read_config_value(config, '录制设置', '代理地址', "") + proxy_addr = None if not use_proxy else proxy_addr_bak + max_request = int(read_config_value(config, '录制设置', '同一时间访问网络的线程数', 3)) + semaphore = threading.Semaphore(max_request) + delay_default = int(read_config_value(config, '录制设置', '循环时间(秒)', 120)) + local_delay_default = int(read_config_value(config, '录制设置', '排队读取网址时间(秒)', 0)) + loop_time = options.get(read_config_value(config, '录制设置', '是否显示循环秒数', "否"), False) + show_url = options.get(read_config_value(config, '录制设置', '是否显示直播源地址', "否"), False) + split_video_by_time = options.get(read_config_value(config, '录制设置', '分段录制是否开启', "否"), False) + enable_https_recording = options.get(read_config_value(config, '录制设置', '是否强制启用https录制', "否"), False) + disk_space_limit = float(read_config_value(config, '录制设置', '录制空间剩余阈值(gb)', 1.0)) + split_time = str(read_config_value(config, '录制设置', '视频分段时间(秒)', 1800)) + converts_to_mp4 = options.get(read_config_value(config, '录制设置', '录制完成后自动转为mp4格式', "否"), False) + converts_to_h264 = options.get(read_config_value(config, '录制设置', 'mp4格式重新编码为h264', "否"), False) + delete_origin_file = options.get(read_config_value(config, '录制设置', '追加格式后删除原文件', "否"), False) + create_time_file = options.get(read_config_value(config, '录制设置', '生成时间字幕文件', "否"), False) + is_run_script = options.get(read_config_value(config, '录制设置', '是否录制完成后执行自定义脚本', "否"), False) + custom_script = read_config_value(config, '录制设置', '自定义脚本执行命令', "") if is_run_script else None + enable_proxy_platform = read_config_value( + config, '录制设置', '使用代理录制的平台(逗号分隔)', + 'tiktok, soop, pandalive, winktv, flextv, popkontv, twitch, liveme, showroom, chzzk, shopee, shp, youtu, faceit' + ) + enable_proxy_platform_list = enable_proxy_platform.replace(',', ',').split(',') if enable_proxy_platform else None + extra_enable_proxy = read_config_value(config, '录制设置', '额外使用代理录制的平台(逗号分隔)', '') + extra_enable_proxy_platform_list = extra_enable_proxy.replace(',', ',').split(',') if extra_enable_proxy else None + live_status_push = read_config_value(config, '推送配置', '直播状态推送渠道', "") + dingtalk_api_url = read_config_value(config, '推送配置', '钉钉推送接口链接', "") + xizhi_api_url = read_config_value(config, '推送配置', '微信推送接口链接', "") + bark_msg_api = read_config_value(config, '推送配置', 'bark推送接口链接', "") + bark_msg_level = read_config_value(config, '推送配置', 'bark推送中断级别', "active") + bark_msg_ring = read_config_value(config, '推送配置', 'bark推送铃声', "bell") + dingtalk_phone_num = read_config_value(config, '推送配置', '钉钉通知@对象(填手机号)', "") + dingtalk_is_atall = options.get(read_config_value(config, '推送配置', '钉钉通知@全体(是/否)', "否"), False) + tg_token = read_config_value(config, '推送配置', 'tgapi令牌', "") + tg_chat_id = read_config_value(config, '推送配置', 'tg聊天id(个人或者群组id)', "") + email_host = read_config_value(config, '推送配置', 'SMTP邮件服务器', "") + open_smtp_ssl = options.get(read_config_value(config, '推送配置', '是否使用SMTP服务SSL加密(是/否)', "是"), True) + smtp_port = read_config_value(config, '推送配置', 'SMTP邮件服务器端口', "") + login_email = read_config_value(config, '推送配置', '邮箱登录账号', "") + email_password = read_config_value(config, '推送配置', '发件人密码(授权码)', "") + sender_email = read_config_value(config, '推送配置', '发件人邮箱', "") + sender_name = read_config_value(config, '推送配置', '发件人显示昵称', "") + to_email = read_config_value(config, '推送配置', '收件人邮箱', "") + ntfy_api = read_config_value(config, '推送配置', 'ntfy推送地址', "") + ntfy_tags = read_config_value(config, '推送配置', 'ntfy推送标签', "tada") + ntfy_email = read_config_value(config, '推送配置', 'ntfy推送邮箱', "") + pushplus_token = read_config_value(config, '推送配置', 'pushplus推送token', "") + push_message_title = read_config_value(config, '推送配置', '自定义推送标题', "直播间状态更新通知") + begin_push_message_text = read_config_value(config, '推送配置', '自定义开播推送内容', "") + over_push_message_text = read_config_value(config, '推送配置', '自定义关播推送内容', "") + disable_record = options.get(read_config_value(config, '推送配置', '只推送通知不录制(是/否)', "否"), False) + push_check_seconds = int(read_config_value(config, '推送配置', '直播推送检测频率(秒)', 1800)) + begin_show_push = options.get(read_config_value(config, '推送配置', '开播推送开启(是/否)', "是"), True) + over_show_push = options.get(read_config_value(config, '推送配置', '关播推送开启(是/否)', "否"), False) + sooplive_username = read_config_value(config, '账号密码', 'sooplive账号', '') + sooplive_password = read_config_value(config, '账号密码', 'sooplive密码', '') + flextv_username = read_config_value(config, '账号密码', 'flextv账号', '') + flextv_password = read_config_value(config, '账号密码', 'flextv密码', '') + popkontv_username = read_config_value(config, '账号密码', 'popkontv账号', '') + popkontv_partner_code = read_config_value(config, '账号密码', 'partner_code', 'P-00001') + popkontv_password = read_config_value(config, '账号密码', 'popkontv密码', '') + twitcasting_account_type = read_config_value(config, '账号密码', 'twitcasting账号类型', 'normal') + twitcasting_username = read_config_value(config, '账号密码', 'twitcasting账号', '') + twitcasting_password = read_config_value(config, '账号密码', 'twitcasting密码', '') + popkontv_access_token = read_config_value(config, 'Authorization', 'popkontv_token', '') + dy_cookie = read_config_value(config, 'Cookie', '抖音cookie', '') + ks_cookie = read_config_value(config, 'Cookie', '快手cookie', '') + tiktok_cookie = read_config_value(config, 'Cookie', 'tiktok_cookie', '') + hy_cookie = read_config_value(config, 'Cookie', '虎牙cookie', '') + douyu_cookie = read_config_value(config, 'Cookie', '斗鱼cookie', '') + yy_cookie = read_config_value(config, 'Cookie', 'yy_cookie', '') + bili_cookie = read_config_value(config, 'Cookie', 'B站cookie', '') + xhs_cookie = read_config_value(config, 'Cookie', '小红书cookie', '') + bigo_cookie = read_config_value(config, 'Cookie', 'bigo_cookie', '') + blued_cookie = read_config_value(config, 'Cookie', 'blued_cookie', '') + sooplive_cookie = read_config_value(config, 'Cookie', 'sooplive_cookie', '') + netease_cookie = read_config_value(config, 'Cookie', 'netease_cookie', '') + qiandurebo_cookie = read_config_value(config, 'Cookie', '千度热播_cookie', '') + pandatv_cookie = read_config_value(config, 'Cookie', 'pandatv_cookie', '') + maoerfm_cookie = read_config_value(config, 'Cookie', '猫耳fm_cookie', '') + winktv_cookie = read_config_value(config, 'Cookie', 'winktv_cookie', '') + flextv_cookie = read_config_value(config, 'Cookie', 'flextv_cookie', '') + look_cookie = read_config_value(config, 'Cookie', 'look_cookie', '') + twitcasting_cookie = read_config_value(config, 'Cookie', 'twitcasting_cookie', '') + baidu_cookie = read_config_value(config, 'Cookie', 'baidu_cookie', '') + weibo_cookie = read_config_value(config, 'Cookie', 'weibo_cookie', '') + kugou_cookie = read_config_value(config, 'Cookie', 'kugou_cookie', '') + twitch_cookie = read_config_value(config, 'Cookie', 'twitch_cookie', '') + liveme_cookie = read_config_value(config, 'Cookie', 'liveme_cookie', '') + huajiao_cookie = read_config_value(config, 'Cookie', 'huajiao_cookie', '') + liuxing_cookie = read_config_value(config, 'Cookie', 'liuxing_cookie', '') + showroom_cookie = read_config_value(config, 'Cookie', 'showroom_cookie', '') + acfun_cookie = read_config_value(config, 'Cookie', 'acfun_cookie', '') + changliao_cookie = read_config_value(config, 'Cookie', 'changliao_cookie', '') + yinbo_cookie = read_config_value(config, 'Cookie', 'yinbo_cookie', '') + yingke_cookie = read_config_value(config, 'Cookie', 'yingke_cookie', '') + zhihu_cookie = read_config_value(config, 'Cookie', 'zhihu_cookie', '') + chzzk_cookie = read_config_value(config, 'Cookie', 'chzzk_cookie', '') + haixiu_cookie = read_config_value(config, 'Cookie', 'haixiu_cookie', '') + vvxqiu_cookie = read_config_value(config, 'Cookie', 'vvxqiu_cookie', '') + yiqilive_cookie = read_config_value(config, 'Cookie', '17live_cookie', '') + langlive_cookie = read_config_value(config, 'Cookie', 'langlive_cookie', '') + pplive_cookie = read_config_value(config, 'Cookie', 'pplive_cookie', '') + six_room_cookie = read_config_value(config, 'Cookie', '6room_cookie', '') + lehaitv_cookie = read_config_value(config, 'Cookie', 'lehaitv_cookie', '') + huamao_cookie = read_config_value(config, 'Cookie', 'huamao_cookie', '') + shopee_cookie = read_config_value(config, 'Cookie', 'shopee_cookie', '') + youtube_cookie = read_config_value(config, 'Cookie', 'youtube_cookie', '') + taobao_cookie = read_config_value(config, 'Cookie', 'taobao_cookie', '') + jd_cookie = read_config_value(config, 'Cookie', 'jd_cookie', '') + faceit_cookie = read_config_value(config, 'Cookie', 'faceit_cookie', '') + migu_cookie = read_config_value(config, 'Cookie', 'migu_cookie', '') + lianjie_cookie = read_config_value(config, 'Cookie', 'lianjie_cookie', '') + laixiu_cookie = read_config_value(config, 'Cookie', 'laixiu_cookie', '') + picarto_cookie = read_config_value(config, 'Cookie', 'picarto_cookie', '') + + video_save_type_list = ("FLV", "MKV", "TS", "MP4", "MP3音频", "M4A音频", "MP3", "M4A") + if video_save_type and video_save_type.upper() in video_save_type_list: + video_save_type = video_save_type.upper() + else: + video_save_type = "TS" + + check_path = video_save_path or default_path + if utils.check_disk_capacity(check_path, show=first_run) < disk_space_limit: + exit_recording = True + if not recording: + logger.warning(f"Disk space remaining is below {disk_space_limit} GB. " + f"Exiting program due to the disk space limit being reached.") + sys.exit(-1) + + + def contains_url(string: str) -> bool: + pattern = r"(https?://)?(www\.)?[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+(:\d+)?(/.*)?" + return re.search(pattern, string) is not None + + + try: + url_comments, line_list, url_line_list = [[] for _ in range(3)] + with (open(url_config_file, "r", encoding=text_encoding, errors='ignore') as file): + for origin_line in file: + if origin_line in line_list: + delete_line(url_config_file, origin_line) + line_list.append(origin_line) + line = origin_line.strip() + if len(line) < 18: + continue + + line_spilt = line.split('主播: ') + if len(line_spilt) > 2: + line = update_file(url_config_file, line, f'{line_spilt[0]}主播: {line_spilt[-1]}') + + is_comment_line = line.startswith("#") + if is_comment_line: + line = line.lstrip('#') + + if re.search('[,,]', line): + split_line = re.split('[,,]', line) + else: + split_line = [line, ''] + + if len(split_line) == 1: + url = split_line[0] + quality, name = [video_record_quality, ''] + elif len(split_line) == 2: + if contains_url(split_line[0]): + quality = video_record_quality + url, name = split_line + else: + quality, url = split_line + name = '' + else: + quality, url, name = split_line + + if quality not in ("原画", "蓝光", "超清", "高清", "标清", "流畅"): + quality = '原画' + + if url not in url_line_list: + url_line_list.append(url) + else: + delete_line(url_config_file, origin_line) + + url = 'https://' + url if '://' not in url else url + url_host = url.split('/')[2] + + platform_host = [ + 'live.douyin.com', + 'v.douyin.com', + 'www.douyin.com', + 'live.kuaishou.com', + 'www.huya.com', + 'www.douyu.com', + 'www.yy.com', + 'live.bilibili.com', + 'www.redelight.cn', + 'www.xiaohongshu.com', + 'xhslink.com', + 'www.bigo.tv', + 'slink.bigovideo.tv', + 'app.blued.cn', + 'cc.163.com', + 'qiandurebo.com', + 'fm.missevan.com', + 'look.163.com', + 'twitcasting.tv', + 'live.baidu.com', + 'weibo.com', + 'fanxing.kugou.com', + 'fanxing2.kugou.com', + 'mfanxing.kugou.com', + 'www.huajiao.com', + 'www.7u66.com', + 'wap.7u66.com', + 'live.acfun.cn', + 'm.acfun.cn', + 'live.tlclw.com', + 'wap.tlclw.com', + 'live.ybw1666.com', + 'wap.ybw1666.com', + 'www.inke.cn', + 'www.zhihu.com', + 'www.haixiutv.com', + "h5webcdnp.vvxqiu.com", + "17.live", + 'www.lang.live', + "m.pp.weimipopo.com", + "v.6.cn", + "m.6.cn", + 'www.lehaitv.com', + 'h.catshow168.com', + 'e.tb.cn', + 'huodong.m.taobao.com', + '3.cn', + 'eco.m.jd.com', + 'www.miguvideo.com', + 'm.miguvideo.com', + 'show.lailianjie.com', + 'www.imkktv.com', + 'www.picarto.tv' + ] + overseas_platform_host = [ + 'www.tiktok.com', + 'play.sooplive.co.kr', + 'm.sooplive.co.kr', + 'www.sooplive.com', + 'm.sooplive.com', + 'www.pandalive.co.kr', + 'www.winktv.co.kr', + 'www.flextv.co.kr', + 'www.ttinglive.com', + 'www.popkontv.com', + 'www.twitch.tv', + 'www.liveme.com', + 'www.showroom-live.com', + 'chzzk.naver.com', + 'm.chzzk.naver.com', + 'live.shopee.', + '.shp.ee', + 'www.youtube.com', + 'youtu.be', + 'www.faceit.com' + ] + + platform_host.extend(overseas_platform_host) + clean_url_host_list = ( + "live.douyin.com", + "live.bilibili.com", + "www.huajiao.com", + "www.zhihu.com", + "www.huya.com", + "chzzk.naver.com", + "www.liveme.com", + "www.haixiutv.com", + "v.6.cn", + "m.6.cn", + 'www.lehaitv.com' + ) + + if 'live.shopee.' in url_host or '.shp.ee' in url_host: + url_host = 'live.shopee.' if 'live.shopee.' in url_host else '.shp.ee' + + if url_host in platform_host or any(ext in url for ext in (".flv", ".m3u8")): + if url_host in clean_url_host_list: + url = update_file(url_config_file, old_str=url, new_str=url.split('?')[0]) + + if 'xiaohongshu' in url: + host_id = re.search('&host_id=(.*?)(?=&|$)', url) + if host_id: + new_url = url.split('?')[0] + f'?host_id={host_id.group(1)}' + url = update_file(url_config_file, old_str=url, new_str=new_url) + + url_comments = [i for i in url_comments if url not in i] + if is_comment_line: + url_comments.append(url) + else: + new_line = (quality, url, name) + url_tuples_list.append(new_line) + else: + if not origin_line.startswith('#'): + color_obj.print_colored(f"\r{origin_line.strip()} 本行包含未知链接.此条跳过", color_obj.YELLOW) + update_file(url_config_file, old_str=origin_line, new_str=origin_line, start_str='#') + + while len(need_update_line_list): + a = need_update_line_list.pop() + replace_words = a.split('|') + if replace_words[0] != replace_words[1]: + if replace_words[1].startswith("#"): + start_with = '#' + new_word = replace_words[1][1:] + else: + start_with = None + new_word = replace_words[1] + update_file(url_config_file, old_str=replace_words[0], new_str=new_word, start_str=start_with) + + text_no_repeat_url = list(set(url_tuples_list)) + + if len(text_no_repeat_url) > 0: + for url_tuple in text_no_repeat_url: + monitoring = len(running_list) + + if url_tuple[1] in not_record_list: + continue + if url_tuple[1] not in running_list: + print(f"\r{'新增' if not first_start else '传入'}地址: {url_tuple[1]}") + monitoring += 1 + args = [url_tuple, monitoring] + create_var[f'thread_{monitoring}'] = threading.Thread(target=start_record, args=args) + create_var[f'thread_{monitoring}'].daemon = True + create_var[f'thread_{monitoring}'].start() + running_list.append(url_tuple[1]) + time.sleep(local_delay_default) + url_tuples_list = [] + first_start = False + + except Exception as err: + logger.error(f"错误信息: {err} 发生错误的行数: {err.__traceback__.tb_lineno}") + + if first_run: + t = threading.Thread(target=display_info, args=(), daemon=True) + t.start() + t2 = threading.Thread(target=adjust_max_request, args=(), daemon=True) + t2.start() + first_run = False + + time.sleep(3) \ No newline at end of file diff --git a/msg_push.py b/msg_push.py new file mode 100644 index 0000000..c682b20 --- /dev/null +++ b/msg_push.py @@ -0,0 +1,295 @@ +# -*- coding: utf-8 -*- + +""" +Author: Hmily +GitHub: https://github.com/ihmily +Date: 2023-09-03 19:18:36 +Update: 2025-01-23 17:16:12 +Copyright (c) 2023-2024 by Hmily, All Rights Reserved. +""" +from typing import Dict, Any +import json +import base64 +import urllib.request +import urllib.error +import smtplib +from email.header import Header +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +no_proxy_handler = urllib.request.ProxyHandler({}) +opener = urllib.request.build_opener(no_proxy_handler) +headers: Dict[str, str] = {'Content-Type': 'application/json'} + + +def dingtalk(url: str, content: str, number: str = None, is_atall: bool = False) -> Dict[str, Any]: + success = [] + error = [] + api_list = url.replace(',', ',').split(',') if url.strip() else [] + for api in api_list: + json_data = { + 'msgtype': 'text', + 'text': { + 'content': content, + }, + "at": { + "atMobiles": [ + number + ], + "isAtAll": is_atall + }, + } + try: + data = json.dumps(json_data).encode('utf-8') + req = urllib.request.Request(api, data=data, headers=headers) + response = opener.open(req, timeout=10) + json_str = response.read().decode('utf-8') + json_data = json.loads(json_str) + if json_data['errcode'] == 0: + success.append(api) + else: + error.append(api) + print(f'钉钉推送失败, 推送地址:{api}, {json_data["errmsg"]}') + except Exception as e: + error.append(api) + print(f'钉钉推送失败, 推送地址:{api}, 错误信息:{e}') + return {"success": success, "error": error} + + +def xizhi(url: str, title: str, content: str) -> Dict[str, Any]: + success = [] + error = [] + api_list = url.replace(',', ',').split(',') if url.strip() else [] + for api in api_list: + json_data = { + 'title': title, + 'content': content + } + try: + data = json.dumps(json_data).encode('utf-8') + req = urllib.request.Request(api, data=data, headers=headers) + response = opener.open(req, timeout=10) + json_str = response.read().decode('utf-8') + json_data = json.loads(json_str) + if json_data['code'] == 200: + success.append(api) + else: + error.append(api) + print(f'微信推送失败, 推送地址:{api}, 失败信息:{json_data["msg"]}') + except Exception as e: + error.append(api) + print(f'微信推送失败, 推送地址:{api}, 错误信息:{e}') + return {"success": success, "error": error} + + +def send_email(email_host: str, login_email: str, email_pass: str, sender_email: str, sender_name: str, + to_email: str, title: str, content: str, smtp_port: str = None, open_ssl: bool = True) -> Dict[str, Any]: + receivers = to_email.replace(',', ',').split(',') if to_email.strip() else [] + + try: + message = MIMEMultipart() + send_name = base64.b64encode(sender_name.encode("utf-8")).decode() + message['From'] = f'=?UTF-8?B?{send_name}?= <{sender_email}>' + message['Subject'] = Header(title, 'utf-8') + if len(receivers) == 1: + message['To'] = receivers[0] + + t_apart = MIMEText(content, 'plain', 'utf-8') + message.attach(t_apart) + + if open_ssl: + smtp_port = int(smtp_port) or 465 + smtp_obj = smtplib.SMTP_SSL(email_host, smtp_port) + else: + smtp_port = int(smtp_port) or 25 + smtp_obj = smtplib.SMTP(email_host, smtp_port) + smtp_obj.login(login_email, email_pass) + smtp_obj.sendmail(sender_email, receivers, message.as_string()) + return {"success": receivers, "error": []} + except smtplib.SMTPException as e: + print(f'邮件推送失败, 推送邮箱:{to_email}, 错误信息:{e}') + return {"success": [], "error": receivers} + + +def tg_bot(chat_id: int, token: str, content: str) -> Dict[str, Any]: + try: + json_data = { + "chat_id": chat_id, + 'text': content + } + url = f'https://api.telegram.org/bot{token}/sendMessage' + data = json.dumps(json_data).encode('utf-8') + req = urllib.request.Request(url, data=data, headers=headers) + response = urllib.request.urlopen(req, timeout=15) + json_str = response.read().decode('utf-8') + _json_data = json.loads(json_str) + return {"success": [1], "error": []} + except Exception as e: + print(f'tg推送失败, 聊天ID:{chat_id}, 错误信息:{e}') + return {"success": [], "error": [1]} + + +def bark(api: str, title: str = "message", content: str = 'test', level: str = "active", + badge: int = 1, auto_copy: int = 1, sound: str = "", icon: str = "", group: str = "", + is_archive: int = 1, url: str = "") -> Dict[str, Any]: + success = [] + error = [] + api_list = api.replace(',', ',').split(',') if api.strip() else [] + for _api in api_list: + json_data = { + "title": title, + "body": content, + "level": level, + "badge": badge, + "autoCopy": auto_copy, + "sound": sound, + "icon": icon, + "group": group, + "isArchive": is_archive, + "url": url + } + try: + data = json.dumps(json_data).encode('utf-8') + req = urllib.request.Request(_api, data=data, headers=headers) + response = opener.open(req, timeout=10) + json_str = response.read().decode("utf-8") + json_data = json.loads(json_str) + if json_data['code'] == 200: + success.append(_api) + else: + error.append(_api) + print(f'Bark推送失败, 推送地址:{_api}, 失败信息:{json_data["message"]}') + except Exception as e: + error.append(api) + print(f'Bark推送失败, 推送地址:{_api}, 错误信息:{e}') + return {"success": success, "error": error} + + +def ntfy(api: str, title: str = "message", content: str = 'test', tags: str = 'tada', priority: int = 3, + action_url: str = "", attach: str = "", filename: str = "", click: str = "", icon: str = "", + delay: str = "", email: str = "", call: str = "") -> Dict[str, Any]: + success = [] + error = [] + api_list = api.replace(',', ',').split(',') if api.strip() else [] + tags = tags.replace(',', ',').split(',') if tags else ['partying_face'] + actions = [{"action": "view", "label": "view live", "url": action_url}] if action_url else [] + for _api in api_list: + server, topic = _api.rsplit('/', maxsplit=1) + json_data = { + "topic": topic, + "title": title, + "message": content, + "tags": tags, + "priority": priority, + "attach": attach, + "filename": filename, + "click": click, + "actions": actions, + "markdown": False, + "icon": icon, + "delay": delay, + "email": email, + "call": call + } + + try: + data = json.dumps(json_data, ensure_ascii=False).encode('utf-8') + req = urllib.request.Request(server, data=data, headers=headers) + response = opener.open(req, timeout=10) + json_str = response.read().decode("utf-8") + json_data = json.loads(json_str) + if "error" not in json_data: + success.append(_api) + else: + error.append(_api) + print(f'ntfy推送失败, 推送地址:{_api}, 失败信息:{json_data["error"]}') + except urllib.error.HTTPError as e: + error.append(_api) + error_msg = e.read().decode("utf-8") + print(f'ntfy推送失败, 推送地址:{_api}, 错误信息:{json.loads(error_msg)["error"]}') + except Exception as e: + error.append(api) + print(f'ntfy推送失败, 推送地址:{_api}, 错误信息:{e}') + return {"success": success, "error": error} + + +def pushplus(token: str, title: str, content: str) -> Dict[str, Any]: + """ + PushPlus推送通知 + API文档: https://www.pushplus.plus/doc/ + """ + success = [] + error = [] + token_list = token.replace(',', ',').split(',') if token.strip() else [] + + for _token in token_list: + json_data = { + 'token': _token, + 'title': title, + 'content': content + } + + try: + url = 'https://www.pushplus.plus/send' + data = json.dumps(json_data).encode('utf-8') + req = urllib.request.Request(url, data=data, headers=headers) + response = opener.open(req, timeout=10) + json_str = response.read().decode('utf-8') + json_data = json.loads(json_str) + + if json_data.get('code') == 200: + success.append(_token) + else: + error.append(_token) + print(f'PushPlus推送失败, Token:{_token}, 失败信息:{json_data.get("msg", "未知错误")}') + except Exception as e: + error.append(_token) + print(f'PushPlus推送失败, Token:{_token}, 错误信息:{e}') + + return {"success": success, "error": error} + + +if __name__ == '__main__': + send_title = '直播通知' # 标题 + send_content = '张三 开播了!' # 推送内容 + + # 钉钉推送通知 + webhook_api = '' # 替换成自己Webhook链接,参考文档:https://open.dingtalk.com/document/robots/custom-robot-access + phone_number = '' # 被@用户的手机号码 + is_atall = '' # 是否@全体 + # dingtalk(webhook_api, send_content, phone_number) + + # 微信推送通知 + # 替换成自己的单点推送接口,获取地址:https://xz.qqoq.net/#/admin/one + # 当然也可以使用其他平台API 如server酱 使用方法一样 + xizhi_api = 'https://xizhi.qqoq.net/xxxxxxxxx.send' + # xizhi(xizhi_api, send_content) + + # telegram推送通知 + tg_token = '' # tg搜索"BotFather"获取的token值 + tg_chat_id = 000000 # tg搜索"userinfobot"获取的chat_id值,即可发送推送消息给你自己,如果下面的是群组id则发送到群 + # tg_bot(tg_chat_id, tg_token, send_content) + + # email_message( + # email_host="smtp.qq.com", + # login_email="", + # email_pass="", + # sender_email="", + # sender_name="", + # to_email="", + # title="", + # content="", + # ) + + bark_url = 'https://xxx.xxx.com/key/' + # bark(bark_url, send_title, send_content) + + ntfy( + api="https://ntfy.sh/xxxxx", + title="直播推送", + content="xxx已开播", + ) + + # PushPlus推送通知 + pushplus_token = '' # 替换成自己的PushPlus Token,获取地址:https://www.pushplus.plus/ + # pushplus(pushplus_token, send_title, send_content) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b2340a6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "DouyinLiveRecorder" +version = "4.0.7" +description = "可循环值守和多人录制的直播录制软件, 支持抖音、TikTok、Youtube、快手、虎牙、斗鱼、B站、小红书、pandatv、sooplive、flextv、popkontv、twitcasting、winktv、百度、微博、酷狗、17Live、Twitch、Acfun、CHZZK、shopee等40+平台直播录制" +readme = "README.md" +authors = [{name = "Hmily"}] +license = { text = "MIT" } +requires-python = ">=3.10" +dependencies = [ + "requests>=2.31.0", + "loguru>=0.7.3", + "pycryptodome>=3.20.0", + "distro>=1.9.0", + "tqdm>=4.67.1", + "httpx[http2]>=0.28.1", + "PyExecJS>=1.5.1" +] + +[project.urls] +"Homepage" = "https://github.com/ihmily/DouyinLiveRecorder" +"Documentation" = "https://github.com/ihmily/DouyinLiveRecorder" +"Repository" = "https://github.com/ihmily/DouyinLiveRecorder" +"Issues" = "https://github.com/ihmily/DouyinLiveRecorder/issues" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..18f105b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +requests>=2.31.0 +loguru>=0.7.3 +pycryptodome>=3.20.0 +distro>=1.9.0 +tqdm>=4.67.1 +httpx[http2]>=0.28.1 +PyExecJS>=1.5.1 \ No newline at end of file diff --git a/robust_relay.py b/robust_relay.py new file mode 100644 index 0000000..000d753 --- /dev/null +++ b/robust_relay.py @@ -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() \ No newline at end of file diff --git a/wending b/wending new file mode 100644 index 0000000..d706019 --- /dev/null +++ b/wending @@ -0,0 +1,111 @@ + + try: + if split_video_by_time: + now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime()) + print(f"{rec_info} 开始推流到 YouTube: {anchor_name}_{title_in_name}_{now}") + + YT_STREAM_KEY = "ue78-1c3e-mr9g-14mz-9r4z" + youtube_rtmp = f"rtmp://a.rtmp.youtube.com/live2/{YT_STREAM_KEY}" + + # ------------------------ + # NVENC 检测 + # ------------------------ + codec_v = "libx264" + preset_v = "veryfast" + tune_param = ["-tune", "zerolatency"] + + try: + if platform.system().lower() in ["windows", "linux"]: + check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=10) + if check.returncode == 0: + enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], + capture_output=True, text=True, timeout=10) + if "h264_nvenc" in enc.stdout: + codec_v = "h264_nvenc" + preset_v = "p5" + tune_param = ["-tune", "ll", "-rc", "vbr"] + print("检测到 NVIDIA GPU → 使用 NVENC 硬件加速") + except: + pass # 异常自动降级 + + # ------------------------ + # FFmpeg 命令(稳定推流) + # ------------------------ + ffmpeg_command = [ + "ffmpeg", + "-re", + "-i", real_url, + + "-vf", "scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black", + + "-c:v", codec_v, + "-preset", preset_v, + *tune_param, + + "-b:v", "2500k", + "-maxrate", "2500k", + "-bufsize", "5000k", + "-g", "50", + "-r", "25", + "-pix_fmt", "yuv420p", + "-bf", "0", + + "-c:a", "aac", + "-b:a", "128k", + "-ar", "44100", + "-ac", "2", + "-af", "aresample=async=1:first_pts=0", + + "-flags", "+low_delay", + "-fflags", "+genpts", + "-thread_queue_size", "512", + "-threads", "6", + + "-f", "flv", + youtube_rtmp + ] + + # ------------------------ + # 永不断流重连逻辑 + # ------------------------ + proc = None + while True: + try: + if proc and proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=8) + except: + proc.kill() + + print(f"{anchor_name}_{title_in_name}_{now} → 启动 YouTube 推流...") + proc = subprocess.Popen( + ffmpeg_command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + # 打印 FFmpeg 错误日志,便于排查 + while True: + line = proc.stderr.readline() + if not line: + break + line = line.strip() + if line: + print(f"FFmpeg: {line}") + if proc.poll() is not None: + break + + code = proc.returncode + if code != 0: + print(f"FFmpeg 退出(code={code}),5秒后重连...") + time.sleep(5) + + except Exception as e: + print(f"启动 FFmpeg 异常: {e}") + time.sleep(5) + + except Exception as e: + print(f"外层异常: {e}") \ No newline at end of file