17 Commits

Author SHA1 Message Date
eric
e0b3ac43fc 's' 2026-03-28 18:38:41 -05:00
eric
8001baabf8 's' 2026-03-28 18:10:43 -05:00
eric
513c2b7b8c 's' 2026-03-28 17:49:04 -05:00
eric
e050afbac1 's' 2026-03-28 17:34:28 -05:00
eric
46fb194a02 's' 2026-03-28 17:16:38 -05:00
eric
37ab25911a 's' 2026-03-28 16:27:42 -05:00
eric
6f79f259a1 's' 2026-03-28 15:21:30 -05:00
eric
d74f3046a3 's' 2026-03-28 15:08:59 -05:00
eric
92ecc14e43 s' 2026-03-28 14:16:42 -05:00
eric
8053c4cb6d 's' 2026-03-28 13:27:53 -05:00
eric
d34ecab19c chore: remove unrelated mobile/ tree from repo
Made-with: Cursor
2026-03-28 12:32:50 -05:00
eric
ec5dc7f31e feat(ui): auto-refresh overview/modules, quick config, HW re-probe, link-tile layout; deploy: npm ci+build, restart live-console
Made-with: Cursor
2026-03-28 12:32:20 -05:00
eric
d9eceb2169 fix: stop ignoring scripts/linux/lib; track common.sh (neko + ws-scrcpy git url)
Made-with: Cursor
2026-03-28 12:19:33 -05:00
eric
5a75f18887 feat: ws-scrcpy/NeKo/Chromium hub links, light theme, ECharts, H5 nav, optional Neko Docker
Made-with: Cursor
2026-03-28 12:19:08 -05:00
eric
9b6570d06e Safer ffmpeg kill, POST process/service actions, ARM encoder from hardware.env, filter_threads scaling
Made-with: Cursor
2026-03-28 12:03:13 -05:00
eric
f4794e720a Add Paramiko deploy/verify scripts for ARM board; gitignore deploy logs
Made-with: Cursor
2026-03-28 12:02:44 -05:00
eric
b84f9b751c 's' 2026-03-28 08:39:12 -05:00
210 changed files with 12252 additions and 2537 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.sh text eol=lf

11
.gitignore vendored
View File

@@ -14,8 +14,9 @@ develop-eggs/
downloads/
eggs/
.eggs/
lib/
lib64/
# 仅忽略仓库根目录的 lib勿用 lib/ 否则会误伤 scripts/linux/lib
/lib/
/lib64/
parts/
sdist/
var/
@@ -172,8 +173,14 @@ node_modules/
# Next 开发缓存(静态导出目录 web-console/out 可纳入版本库以便无 Node 环境直接部署)
web-console/.next/
# Paramiko 部署本地日志(可能含敏感命令回显)
.deploy_logs/
# 无 PM2 时的本地进程状态
.process_runner/
# 本机端口/主机覆盖(从 config/runtime.env.example 复制)
config/runtime.env
config/system-stack.env
config/hardware.env
config/hardware-profile.json

View File

@@ -1,5 +1,57 @@
![video_spider](https://socialify.git.ci/ihmily/DouyinLiveRecorder/image?font=Inter&forks=1&language=1&owner=1&pattern=Circuit%20Board&stargazers=1&theme=Light)
## Linux Super Hub Profiles
This fork now includes a modular Linux deployment layout for ARM and X86 nodes.
### Live Platform四层架构
- **配置中心**`/opt/live/config`(模板见 `live-platform/config/defaults/`,开发时回退到仓库内同路径)
- **一键脚本**`./install-core.sh`(系统 + 基础服务,与业务解耦)、`./install-live.sh`(仅无人直播业务)、`./doctor.sh`(体检)
- **统一 API**`GET /hub/dashboard``GET /hub/config` 等(见 [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)
- **产品级 Web**:根路径 `/` 为 Live Hub 控制中心;`/console` 为高级全功能页
- `install-hub.sh`: reusable Linux super hub with `live.local`, ShellCrash, WebTTY, Cockpit, File Browser, Homepage and Netdata
- `install-business.sh`: Douyin / YouTube / HDMI business module with ffmpeg, mpv, ADB, Chromium, web-scrcpy and SRS
- `install-all.sh`: installs both layers without tightly coupling them
Detailed docs:
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)
- [`docs/install-profiles.md`](docs/install-profiles.md)
- [`docs/super-hub-architecture.md`](docs/super-hub-architecture.md)
### Default LAN Entrypoints
After `install-hub.sh` or `install-all.sh`, the default service map is:
- **`http://live.local`** (port 80, via **Caddy** `live-edge.service`) → same UI/API as the control plane
- `http://live.local:8001` for direct access to FastAPI (no edge proxy)
- `http://live.local/shellcrash` or `:8001/shellcrash` — ShellCrash 编辑器
- `http://live.local/android` or `:8001/android` — 安卓中心
- `http://live.local:3080` — Homepage 聚合(默认不再占用 80
- `http://live.local:7681` for WebTTY
- `http://live.local:8082` for File Browser with `live / 12345678`
- `http://live.local:19999` for Netdata
### ShellCrash Web Control
ShellCrash is no longer treated as a jump back to the terminal menu. The control plane now exposes:
- direct service start, stop, and restart
- managed roots for `cfg`, `yaml`, `json`, `list`, and `env` files
- in-browser create, edit, delete, and import-or-replace flows for YAML and related config files
### Android Device Center
The Android panel now combines multiple layers instead of showing only raw `web-scrcpy`:
- ADB device discovery and overview
- screenshot-based preview with tap, double tap, and long press
- package search and app launch / stop / clear actions
- UI node extraction with quick jump actions
- embedded raw `web-scrcpy` for full interactive fallback
## 💡简介
[![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)

1
backup/__init__.py Normal file
View File

@@ -0,0 +1 @@
# 使 backup 成为显式包,便于 from backup.msg_push import ...

View File

@@ -80,7 +80,7 @@ def _launch_ffmpeg(
cmd.append(youtube_rtmp)
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
creationflags = 0
proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,

BIN
codex-live-stack.tgz Normal file

Binary file not shown.

View File

@@ -0,0 +1,14 @@
# 复制为 config/hardware.env 或安装后由 scripts/hardware_probe.py --write 生成
# 抖音→YouTube 推流douyin_youtube_ffplay.py在 Linux 上会读取本文件中的 LIVE_VIDEO_ENCODER。
#
# —— ARMRK3588 等常见h264_rkmpp需 /dev/mpp_service 与 ffmpeg 含 rkmpp
# —— ARM 通用 V4L2h264_v4l2m2m
# —— x86_64 Intel/AMD 核显VA-APIh264_vaapi需 /dev/dri render 节点)
# —— x86_64 Intel Quick Synch264_qsv需 ffmpeg 编译带 libmfx/oneVPL
# —— NVIDIA一般无需写此处推流脚本会自行检测 nvidia-smi + h264_nvenc
#
# LIVE_VIDEO_ENCODER=h264_v4l2m2m
# LIVE_HW_DECODER=cpu
# LIVE_HDMI_PLAYER=mpv-drm
# LIVE_BROWSER_HWACCEL=auto
# LIVE_STABILITY_MODE=safe

View File

@@ -0,0 +1,114 @@
# Copy to config/system-stack.env before first Linux deployment when you need custom values.
# Profile model:
# - install-hub.sh: base Linux super hub, web admin, ShellCrash, monitoring, beginner tools
# - install-business.sh: Douyin / YouTube / HDMI business module only
# - install-all.sh: hub first, then business module
# LAN identity
HOSTNAME_ALIAS=live
# Shared control plane
PORT=8001
# Caddy 将 :80 反代到 PORThttp://live.local 无端口验收)
ENABLE_LIVE_EDGE_PROXY=1
# Hub web services
WEBTTY_PORT=7681
# 默认 3080避免与 Caddy 占用 80live.local 无端口访问控制台)
HOMEPAGE_PORT=3080
FILEBROWSER_PORT=8082
NETDATA_PORT=19999
# Business streaming services
SRS_HTTP_PORT=8080
SRS_API_PORT=1985
SRS_RTMP_PORT=1935
SRS_SRT_PORT=10080
SRS_WEBRTC_PORT=8088
SRS_WEBRTC_UDP_PORT=8000
# ShellCrash
SHELLCRASH_DIR=/etc/ShellCrash
# Browser / Android lab
BROWSER_PROFILE_DIR=/var/lib/live-browser/profile
BROWSER_START_URL=https://www.tiktok.com/live
BROWSER_REMOTE_DEBUG_PORT=9222
BROWSER_LOCALE=zh-CN
# WebTTY credentials
WEBTTY_USER=live
WEBTTY_PASS=12345678
# File Browser credentials
FILEBROWSER_USER=live
FILEBROWSER_PASS=12345678
# Wi-Fi onboarding
WIFI_CONNECTION_NAME=live-wifi
WIFI_SSID=live
WIFI_PSK=12345678
DISABLE_IPV6=1
# ws-scrcpy 克隆地址(可改为 NetrisTV 上游https://github.com/NetrisTV/ws-scrcpy.git
# ANDROID_PANEL_GIT_URL=https://github.com/baixin1228/web-scrcpy.git
# 可选Docker Neko 浏览器桌面m1k1o/neko与 SRS 8080 错开端口
# 策略/启动参数对齐 x86 参考机:油猴强制安装、扩展白名单、用户数据持久化目录见 NEKO_PROFILE_HOST_DIR
# WebRTC 画面NEKO_WEBRTC_NAT1TO1 必须为本机在局域网中的 IP如 192.168.x.x与参考机填网关 IP 同理
# 设为 1 才会安装/启动 Neko 容器;不需要浏览器桌面可改回 0
ENABLE_NEKO=1
NEKO_HTTP_PORT=9200
# Neko 浏览器登录口令(用户 / 管理)
NEKO_USER_PASS=live
NEKO_ADMIN_PASS=admin
# 第二套 Neko 桌面FirefoxGoogle 无官方 ARM Chrome此为多架构 Firefox。与 Chromium 共用上面口令。
ENABLE_NEKO_FF=1
NEKO_FF_HTTP_PORT=9201
NEKO_FF_WEBRTC_EPR=52200-52300
NEKO_FF_WEBRTC_UDP_RANGE=52200-52300
NEKO_FF_DESKTOP_SCREEN=1920x1080@30
NEKO_FF_PROFILE_HOST_DIR=
# 持久化 Chromium 配置油猴、YouTube 登录、脚本);可从参考机 rsync 整个目录覆盖以迁移
NEKO_PROFILE_HOST_DIR=
NEKO_SHM_SIZE=2gb
NEKO_DESKTOP_SCREEN=1920x1080@30
NEKO_WEBRTC_EPR=52000-52100
NEKO_WEBRTC_UDP_RANGE=52000-52100
NEKO_WEBRTC_ICELITE=1
NEKO_WEBRTC_NAT1TO1=
NEKO_PLUGINS_ENABLED=true
# Android gateway mode仅双网口直连安卓板时需要单网口留空 ANDROID_DOWNLINK_IF 即可,控制台为「跳过」)
ANDROID_GATEWAY_NAME=live-android-gateway
ANDROID_UPLINK_IF=eth0
ANDROID_DOWNLINK_IF=
ANDROID_GATEWAY_CIDR=192.168.51.1/24
ANDROID_PANEL_PORT=5000
ANDROID_PANEL_DIR=/opt/live-modules/web-scrcpy
ANDROID_PANEL_VIDEO_BIT_RATE=1024000
# Optional modules
# Shared
ENABLE_SHELLCRASH=1
ENABLE_WEBTTY=1
ENABLE_BROWSER=1
ENABLE_MDNS=1
ENABLE_WIFI_PROFILE=1
ENABLE_ANDROID_PANEL=1
# Hub profile defaults
ENABLE_COCKPIT=1
ENABLE_FILEBROWSER=1
ENABLE_HOMEPAGE=1
ENABLE_NETDATA=1
# Business profile defaults
ENABLE_SRS=1
# Future modules (recommended next wave, not yet wired into the core installer)
# ENABLE_CADDY=0
# ENABLE_WG_EASY=0
# ENABLE_FRP=0
# ENABLE_MATRIX=0
# ENABLE_UPTIME_KUMA=0
# ENABLE_N8N=0

8
dev.sh
View File

@@ -3,6 +3,10 @@
set -euo pipefail
cd "$(dirname "$0")"
export PORT="${PORT:-8001}"
PY=python3
command -v python3 >/dev/null 2>&1 || PY=python
if [[ -x ".venv/bin/python" ]]; then
PY=".venv/bin/python"
else
PY=python3
command -v python3 >/dev/null 2>&1 || PY=python
fi
exec "$PY" scripts/launch.py --dev --port "$PORT"

45
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,45 @@
# Live Platform 四层架构
## 分层
| 层 | 职责 | 代码/路径 |
|----|------|-----------|
| Core OS | 用户、SSH、mDNS、WiFi、WebTTY、Docker、systemd、日志 | `scripts/linux/install_hub.sh` 及系统包 |
| Infra Service | ShellCrash、Caddy、Netdata、SRS、Neko、ws-scrcpy、FRP、WireGuard 占位 | `services/*``scripts/linux/service_ctl.sh` |
| Business | 无人直播 douyinyoutubeFFmpeg/HDMI/推流),可脱离控制台运行 | 仓库根业务脚本、`install-live.sh` |
| Web Console | 仅通过 API 与配置中心「控制」,不实现业务算法 | `web.py``web-console``/hub/*` API |
## 配置中心
- 安装目标:`/opt/live/config/`
- 默认模板:`live-platform/config/defaults/`
- 文件:`system.json``network.json``services/*.json``business/*.json`
- 生成兼容层:`live-platform/tools/render_stack_env.py``/opt/live/generated/system-stack.env`(供现有 shell 读取,逐步淘汰分散 `.env`
## 一键脚本(幂等)
| 脚本 | 作用 |
|------|------|
| `./install-core.sh` | 同步 JSON → `/opt/live/config`,生成 env调用 `install_hub.sh`(无业务) |
| `./install-live.sh` | 仅业务模块,调用 `install_business.sh` |
| `./doctor.sh` | 网络、端口、adb、ffmpeg、docker、API 健康检查 |
## API
- `GET /hub/dashboard`:快照 + 服务 + 安卓 + 直播进程状态
- `GET /hub/config`:配置中心只读聚合
- `POST /hub/business/douyinyoutube`:业务元数据(如抖音 URL 声明)
- 既有:`/service_action``/android/*``/process_monitor``/save_url_config`
## 前端
- `/``LiveControlApp` 产品壳(总览、服务、直播、安卓、网络、设置)
- `/console`:高级遗留控制台(全功能)
- 开发:`middleware``/hub` 代理到本机 FastAPI
## live.local无端口验收
- **`install_hub.sh` 末尾**执行 **`install_live_edge_proxy`**:安装/启用 **`live-edge.service`Caddy**,将 **`http://live.local`:80** 反代到 **`127.0.0.1:${PORT}`**(默认 8001
- **Homepage** 默认改绑 **`HOMEPAGE_PORT=3080`**,避免与 Caddy 抢 80。
- 仅更新反代:`sudo bash scripts/linux/reinstall_live_edge.sh`
- `ENABLE_LIVE_EDGE_PROXY=0` 可关闭(`system-stack.env`)。

84
docs/install-profiles.md Normal file
View File

@@ -0,0 +1,84 @@
# Linux Install Profiles
## Quick Start
Use one of these entrypoints from the repo root on Debian, Ubuntu, or Armbian:
```bash
sudo bash install-hub.sh
sudo bash install-business.sh
sudo bash install-all.sh
```
## What Each Script Does
### `install-hub.sh`
Use this when the Linux device should become the reusable super hub first.
- Creates and refreshes the `live` account
- Installs the shared control plane runtime
- Enables `live.local`
- Creates the default Wi-Fi profile `live / 12345678` when `wlan0` exists
- Installs ShellCrash, WebTTY, Cockpit, File Browser, Homepage, and Netdata
- Installs the Android web panel based on `web-scrcpy`
- Builds the web console and enables `live-console.service`
### `install-business.sh`
Use this when the device should only run the Douyin or YouTube business stack.
- Creates and refreshes the `live` account
- Installs the shared control plane runtime
- Installs ffmpeg, mpv, ADB, Chromium
- Installs the Android web panel based on `web-scrcpy`
- Starts SRS
- Builds the web console and enables `live-console.service`
- Generates the hardware profile for adaptive ARM or X86 defaults
### PM2单频道与多频道 ProLinux ARM / x86_64
- **单频道**:在项目根启用一个业务进程即可,例如取消注释 `ecosystem.config.cjs` 里的 `youtube``tiktok`,执行 `pm2 start ecosystem.config.cjs && pm2 save`
- **多频道 Pro**:每路直播需要**独立的** `config/youtube.ini``config/URL_config.ini`(或整份独立工作目录)。为第二路及以后各复制一份目录(或符号链接脚本外的配置),在 PM2 里为每个 app 设置不同的 `cwd``name` 与 Web 控制台 Pro 线路建议名一致(如 `youtube2__<channelId>`)。
- **硬件编码**`sudo python3 scripts/hardware_probe.py --write` 会写入 `config/hardware.env``hardware-profile.json`;推流侧可选 `LIVE_VIDEO_ENCODER`RKMPP / V4L2M2M / VA-API / QSV 等,见 `config/hardware.env.example`。NVIDIA 一般由 `douyin_youtube_ffplay.py` 自动走 NVENC。
- **音频链**:与 GitLab 分支 `ffmpegAudioRMFoode``douyin_youtube_ffplay.py` 的 FFmpeg 处理思路一致(`fast_audio`、arnndn、EQ 等);当前主线在 Linux 上还包含板载编码探测与推流稳定性增强,以前端无需对齐 Electron 为准。
### `install-all.sh`
Use this when the node should be both the super hub and the livestream business node.
- Runs `install-hub.sh`
- Runs `install-business.sh`
- Reuses shared modules instead of replacing them
## Shared Config
All install profiles read the same shared file:
- `config/system-stack.env`
If the file does not exist, the installer copies:
- `config/system-stack.env.example`
This keeps host identity, ports, Wi-Fi, ShellCrash path, and module toggles centralized.
## Default Access URLs
With the default hostname and ports, a fresh hub exposes:
- `http://live.local:8001` main control plane
- `http://live.local:8001/shellcrash` ShellCrash editor route
- `http://live.local:8001/android` Android device center route
- `http://live.local:7681` WebTTY
- `http://live.local:8082` File Browser, default login `live / 12345678`
- `http://live.local:19999` Netdata
The ShellCrash and Android pages are separate routes on purpose, so they can be bookmarked directly instead of sharing the same tabbed root URL.
## Separation Rules
- The hub profile must keep working without SRS.
- The business profile must keep working without Cockpit, Homepage, File Browser, or Netdata.
- The control plane should show degraded modules as unavailable instead of crashing.
- Every service must be removable by systemd or compose boundaries.

View File

@@ -0,0 +1,103 @@
# Super Hub Architecture
## Goal
Build the ARM or X86 node as a reusable Linux super hub instead of a single-purpose
streaming box. The Douyin or YouTube workflow is one optional business module that can
be installed alone or layered on top of the hub later.
## Install Profiles
- `install-hub.sh`
- Base Linux super hub
- Web control plane
- mDNS `live.local`
- Wi-Fi onboarding profile
- ShellCrash
- WebTTY
- Cockpit
- File Browser
- Homepage
- Netdata
- Hardware probe
- `install-business.sh`
- Shared control plane runtime
- ffmpeg, mpv, ADB, Chromium
- SRS
- Hardware probe and adaptive decode defaults
- Douyin or YouTube business orchestration
- `install-all.sh`
- Runs the hub profile first
- Runs the business profile second
- Safe to rerun because the shared pieces are idempotent
## Layers
1. Base Node
- Debian / Ubuntu / Armbian
- `live` user
- mDNS with `live.local`
- Wi-Fi bootstrap
- hardware probe and graceful degradation
2. Control Plane
- FastAPI backend
- Next.js panel
- managed config roots
- service registry and status polling
3. Hub Modules
- ShellCrash
- WebTTY
- Cockpit
- File Browser
- Homepage
- Netdata
- Android Web Panel
4. Business Modules
- Douyin / YouTube relay app
- SRS
- HDMI playback bridge
- Android / ADB integration
- Chromium persistent profile
- Browser-based device control via web-scrcpy
5. Future Modules
- Caddy
- WireGuard
- FRP
- Matrix
- Uptime Kuma
- n8n
- AI and media workloads
## Design Rules
- `install-hub.sh` must never hard-depend on business services such as SRS.
- `install-business.sh` must work on a clean Linux system without requiring the hub profile first.
- If the hub profile already exists, the business profile must reuse it instead of replacing it.
- Each service stays in its own systemd unit or compose file so it can be removed without breaking unrelated modules.
- The control plane edits only managed roots and never exposes arbitrary filesystem write access.
- ARM and X86 share one control plane, then diverge only through the generated hardware profile.
## Current Implementation
- `scripts/linux/lib/common.sh` contains the shared install functions used by every profile.
- `scripts/linux/install_hub.sh` installs the reusable Linux super hub.
- `scripts/linux/install_business.sh` installs the Douyin or YouTube business module.
- `scripts/linux/install_stack.sh` remains as a compatibility entrypoint for `hub`, `business`, and `all`.
- `src/control_plane.py` exposes managed roots, hardware profile loading, and service actions.
- `scripts/hardware_probe.py` generates safe runtime defaults for ARM and X86.
- `services/` keeps dockerized modules isolated instead of merging them into one monolith.
## Recommended Next Modules
- Caddy for unified reverse proxy and TLS
- wg-easy for WireGuard mesh management
- FRP panel for tunnel management
- Uptime Kuma for alerting
- n8n for workflow automation
- Matrix plus Element for messaging and operations collaboration

4
doctor.sh Normal file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec bash "$ROOT_DIR/live-platform/scripts/doctor.sh" "$@"

View File

@@ -1,4 +1,5 @@
# douyin_youtube_ffplay.py
# Linux仓库内推流实现编码由 config/hardware.envLIVE_VIDEO_ENCODER与可选 NVENC 探测决定。
import queue
import unicodedata
import platform
@@ -17,6 +18,41 @@ class StreamEnded(Exception):
pass
_FFMPEG_STATS_PERIOD_OK: bool | None = None
def _require_ffmpeg_stats_period() -> None:
"""推流依赖 -stats_periodffmpeg 约 4.4+Debian 11 默认 4.3 需升级。"""
global _FFMPEG_STATS_PERIOD_OK
if _FFMPEG_STATS_PERIOD_OK is True:
return
if _FFMPEG_STATS_PERIOD_OK is False:
raise RuntimeError(
"ffmpeg 不支持 -stats_period。板端执行: sudo bash scripts/linux/upgrade_ffmpeg_newer.sh"
)
try:
r = subprocess.run(
["ffmpeg", "-hide_banner", "-h", "full"],
capture_output=True,
text=True,
timeout=30,
)
blob = (r.stdout or "") + (r.stderr or "")
if "-stats_period" in blob:
_FFMPEG_STATS_PERIOD_OK = True
return
except FileNotFoundError:
_FFMPEG_STATS_PERIOD_OK = False
raise RuntimeError("未找到 ffmpeg请先安装。") from None
except Exception as e:
print(f"[WARN] 无法检测 ffmpeg 是否支持 -stats_period: {e}")
return
_FFMPEG_STATS_PERIOD_OK = False
raise RuntimeError(
"ffmpeg 不支持 -stats_period需约 4.4+)。板端执行: sudo bash scripts/linux/upgrade_ffmpeg_newer.sh"
)
def sanitize_title(text: str, max_len: int = 120) -> str:
if not text:
return ""
@@ -70,7 +106,6 @@ def start_douyin_youtube_ffplay(
recording: set,
recording_time_list: dict,
running_list: list,
monitoring: list,
clear_record_info,
color_obj,
):
@@ -125,18 +160,22 @@ def start_douyin_youtube_ffplay(
STALE_OUTPUT_SECONDS = 40
RECONNECT_DELAY_MAX = 10
END_KEYWORDS = [
# 明确结束 / 平台拒绝:停止整条转推(与瞬时网络错误区分)
TERMINAL_KEYWORDS = [
"live has ended",
"server returned 404",
"ingestion error",
"access denied",
"server returned 403",
"client disconnected",
]
# 瞬时错误:结束当前 ffmpeg 进程后由外层循环退避重连
TRANSIENT_KEYWORDS = [
"connection reset by peer",
"failed to write trailer",
"invalid data found",
"server returned 403",
"server returned 404",
"server returned 500",
"ingestion error",
"access denied",
"packet too short",
"client disconnected",
"live has ended",
"no route to host",
"connection timed out",
"connection refused",
@@ -157,24 +196,74 @@ def start_douyin_youtube_ffplay(
stream_title = sanitize_title(f"{anchor_name}{('_' + clean_title) if clean_title else ''}_{timestamp_str}")
print(f"[INFO] {rec_info} 开始推流到 YouTube: {stream_title}")
# ====================== NVENC 检测(符合 YouTube 推荐比特率)======================
# ====================== 视频编码hardware.envARM→ NVENC → libx264 ======================
def _load_simple_env(path: str) -> dict[str, str]:
out: dict[str, str] = {}
if not os.path.isfile(path):
return out
with open(path, "r", encoding="utf-8", errors="ignore") as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, _, v = line.partition("=")
key = k.strip()
if key.startswith("#"):
continue
out[key] = v.strip().strip('"').strip("'")
return out
codec_v = "libx264"
# ultrafast+zerolatency在软编下尽量让 speed≥1.0;画质略降、直播可接受
video_args = ["-preset", "ultrafast", "-tune", "zerolatency", "-profile:v", "high", "-level", "4.0",
"-b:v", b_v, "-maxrate", maxrate, "-bufsize", bufsize]
try:
if platform.system().lower() in ["windows", "linux"]:
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=5)
if check.returncode == 0:
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=5)
if "h264_nvenc" in (enc.stdout or ""):
codec_v = "h264_nvenc"
video_args = ["-profile:v", "high", "-level", "4.0",
"-tune", "ll", "-rc", "cbr", "-b:v", b_v,
"-maxrate", maxrate, "-bufsize", bufsize]
print("[INFO] 检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
except Exception as e:
print(f"[WARN] NVENC 检测失败,回退软件编码: {e}")
video_args = [
"-preset", "ultrafast", "-tune", "zerolatency", "-profile:v", "high", "-level", "4.0",
"-b:v", b_v, "-maxrate", maxrate, "-bufsize", bufsize,
]
_config_dir = os.path.dirname(os.path.abspath(config_file))
_hw_env = _load_simple_env(os.path.join(_config_dir, "hardware.env"))
_want_enc = (_hw_env.get("LIVE_VIDEO_ENCODER") or "").strip()
_hw_enc_active = False
if _want_enc and platform.system().lower() == "linux":
try:
enc = subprocess.run(
["ffmpeg", "-hide_banner", "-encoders"],
capture_output=True,
text=True,
timeout=6,
)
blob = (enc.stdout or "") + (enc.stderr or "")
if _want_enc in blob and _want_enc.startswith("h264_"):
codec_v = _want_enc
video_args = ["-b:v", b_v, "-maxrate", maxrate, "-bufsize", bufsize]
_hw_enc_active = True
print(f"[INFO] 使用 config/hardware.env LIVE_VIDEO_ENCODER={codec_v}(板载硬件编码)")
except Exception as e:
print(f"[WARN] LIVE_VIDEO_ENCODER 校验失败: {e}")
if not _hw_enc_active:
try:
if platform.system().lower() == "linux":
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=5)
if check.returncode == 0:
enc = subprocess.run(
["ffmpeg", "-hide_banner", "-encoders"],
capture_output=True,
text=True,
timeout=5,
)
if "h264_nvenc" in (enc.stdout or ""):
codec_v = "h264_nvenc"
video_args = [
"-profile:v", "high", "-level", "4.0",
"-tune", "ll", "-rc", "cbr", "-b:v", b_v,
"-maxrate", maxrate, "-bufsize", bufsize,
]
print("[INFO] 检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
except Exception as e:
print(f"[WARN] NVENC 检测失败,回退软件编码: {e}")
_cpu_n = os.cpu_count() or 4
_filter_threads = str(min(4, max(1, _cpu_n // 2)))
# ====================== 开源音频处理:背景音乐弱化 + 防版权 ======================
_script_dir = os.path.dirname(os.path.abspath(__file__))
@@ -236,7 +325,7 @@ def start_douyin_youtube_ffplay(
"-err_detect", "ignore_err",
"-max_delay", "500000",
"-thread_queue_size", "2048",
"-filter_threads", "4",
"-filter_threads", _filter_threads,
"-headers", douyin_headers,
"-re",
"-i", real_url,
@@ -253,6 +342,8 @@ def start_douyin_youtube_ffplay(
"-f", "flv", youtube_rtmp
]
_require_ffmpeg_stats_period()
proc = None
stderr_q = None
reader_t = None
@@ -314,13 +405,20 @@ def start_douyin_youtube_ffplay(
print(f"[FFmpeg] {line}")
line_lower = line.lower()
if any(kw in line_lower for kw in END_KEYWORDS):
print("[ERROR] 检测到主播真下播或被明确拒绝,停止推流")
if any(kw in line_lower for kw in TERMINAL_KEYWORDS):
print("[ERROR] 判定直播结束或平台明确拒绝,停止推流")
cleanup_proc(proc)
raise StreamEnded()
if any(kw in line_lower for kw in TRANSIENT_KEYWORDS):
print(f"[WARN] 瞬时错误,将退避重试: {line[:160]}")
cleanup_proc(proc)
retry_delay = min(max(retry_delay, INITIAL_RETRY_DELAY) * 2, MAX_RETRY_DELAY)
break
if re.search(r'frame=\s*\d+', line_lower) or "fps=" in line_lower:
last_frame_time = time.time()
retry_delay = INITIAL_RETRY_DELAY
if time.time() - start_time > START_CHECK_AFTER:
if time.time() - last_frame_time > NO_FRAME_TIMEOUT:
@@ -356,13 +454,19 @@ def start_douyin_youtube_ffplay(
cleanup_proc(proc)
try:
recording.discard(record_name)
recording_time_list.pop(record_name, None)
if real_url in running_list:
running_list.remove(real_url)
monitoring[0] = max(0, monitoring[0] - 1)
# 统一由 clear_record_info 维护 running_list / monitoring / url_comments
clear_record_info(record_name, real_url)
except Exception:
pass
color_obj.print_colored(f"[{record_name}] YouTube 推流已彻底停止并清理完毕\n", color_obj.GREEN)
color_obj.print_colored(f"[{record_name}] YouTube 推流已彻底停止并清理完毕\n", color_obj.GREEN)
if __name__ == "__main__":
# 兼容旧 PM2/脚本名:实际业务入口为 youtube.py监控 URL_config + 调用本模块推流)
import runpy
from pathlib import Path
_root = Path(__file__).resolve().parent
runpy.run_path(str(_root / "youtube.py"), run_name="__main__")

View File

@@ -1,6 +1,11 @@
/**
* PM2在项目根执行 `pm2 start ecosystem.config.cjs` 后 `pm2 save`
* web 使用 scripts/launch.py若缺少 web-console/out 会自动 npm build 再起 uvicorn
*
* 单频道:通常只启一个 youtube.py或 tiktok.py+ web。
* 多频道 Pro每路需要独立 config/youtube.ini + URL_config.ini或独立工作目录
* 请为每路复制一份目录或使用符号链接,再为每个 PM2 app 设置不同的 cwd进程名与控制台 Pro 线路一致(如 youtube2__xxx
* 音频处理链与 GitLab 分支 ffmpegAudioRMFoode 中 douyin_youtube_ffplay 对齐Linux ARM/x86 编码由 config/hardware.env 与硬件探测辅助。
*/
const path = require("path");
const root = __dirname;
@@ -18,9 +23,29 @@ module.exports = {
PORT: process.env.PORT || "8001",
},
},
// { name: "youtube", cwd: root, script: py, args: "youtube.py" },
// { name: "tiktok", cwd: root, script: py, args: "tiktok.py" },
// Linux: { name: "obs", cwd: root, script: "bash", args: `-lc ${JSON.stringify(path.join(root, "obs.sh"))}` },
// Win: { name: "obs", cwd: root, script: "cmd.exe", args: "/c obs.bat" },
// —— 单频道直播Linux ARM/x86 通用,二选一或按需启用)——
// {
// name: "youtube",
// cwd: root,
// script: "youtube.py",
// interpreter: py,
// env: { PYTHONUNBUFFERED: "1" },
// },
// {
// name: "tiktok",
// cwd: root,
// script: "tiktok.py",
// interpreter: py,
// env: { PYTHONUNBUFFERED: "1" },
// },
// —— Pro 第二路示例:把 /opt/live/lane2 换成你独立配置目录(内含 config/youtube.ini、URL_config.ini——
// {
// name: "youtube2__lane2",
// cwd: "/opt/live/lane2",
// script: "youtube.py",
// interpreter: py,
// env: { PYTHONUNBUFFERED: "1" },
// },
// { name: "obs", cwd: root, script: "bash", args: `-lc ${JSON.stringify(path.join(root, "obs.sh"))}` },
],
};

View File

@@ -6,112 +6,18 @@ GitHub: https://github.com/ihmily
Copyright (c) 2024 by Hmily, All Rights Reserved.
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
import platform
import zipfile
from pathlib import Path
import requests
from tqdm import tqdm
from src.logger import logger
current_platform = platform.system()
execute_dir = os.path.split(os.path.realpath(sys.argv[0]))[0]
current_env_path = os.environ.get('PATH')
ffmpeg_path = os.path.join(execute_dir, 'ffmpeg')
def unzip_file(zip_path: str | Path, extract_to: str | Path, delete: bool = True) -> None:
if not os.path.exists(extract_to):
os.makedirs(extract_to)
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
zip_ref.extractall(extract_to)
if delete and os.path.exists(zip_path):
os.remove(zip_path)
def get_lanzou_download_link(url: str, password: str | None = None) -> str | None:
try:
headers = {
'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
'Origin': 'https://wweb.lanzouv.com',
'Referer': 'https://wweb.lanzouv.com/iXncv0dly6mh',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0',
}
response = requests.get(url, headers=headers)
sign = re.search("var skdklds = '(.*?)';", response.text).group(1)
data = {
'action': 'downprocess',
'sign': sign,
'p': password,
'kd': '1',
}
response = requests.post('https://wweb.lanzouv.com/ajaxm.php', headers=headers, data=data)
json_data = response.json()
download_url = json_data['dom'] + "/file/" + json_data['url']
response = requests.get(download_url, headers=headers)
return response.url
except Exception as e:
logger.error(f"Failed to obtain ffmpeg download address. {e}")
def install_ffmpeg_windows():
try:
logger.warning("ffmpeg is not installed.")
logger.debug("Installing the latest version of ffmpeg for Windows...")
ffmpeg_url = get_lanzou_download_link('https://wweb.lanzouv.com/iHAc22ly3r3g', 'eots')
if ffmpeg_url:
full_file_name = 'ffmpeg_latest_build_20250124.zip'
version = 'v20250124'
zip_file_path = Path(execute_dir) / full_file_name
if Path(zip_file_path).exists():
logger.debug("ffmpeg installation file already exists, start install...")
else:
response = requests.get(ffmpeg_url, stream=True)
total_size = int(response.headers.get('Content-Length', 0))
block_size = 1024
with tqdm(total=total_size, unit="B", unit_scale=True,
ncols=100, desc=f'Downloading ffmpeg ({version})') as t:
with open(zip_file_path, 'wb') as f:
for data in response.iter_content(block_size):
t.update(len(data))
f.write(data)
unzip_file(zip_file_path, execute_dir)
os.environ['PATH'] = ffmpeg_path + os.pathsep + current_env_path
result = subprocess.run(["ffmpeg", "-version"], capture_output=True)
if result.returncode == 0:
logger.debug('ffmpeg installation was successful')
else:
logger.error('ffmpeg installation failed. Please manually install ffmpeg by yourself')
return True
else:
logger.error("Please manually install ffmpeg by yourself")
except Exception as e:
logger.error(f"type: {type(e).__name__}, ffmpeg installation failed {e}")
def install_ffmpeg_mac():
logger.warning("ffmpeg is not installed.")
logger.debug("Installing the stable version of ffmpeg for macOS...")
try:
result = subprocess.run(["brew", "install", "ffmpeg"], capture_output=True)
if result.returncode == 0:
logger.debug('ffmpeg installation was successful. Restart for changes to take effect.')
return True
else:
logger.error("ffmpeg installation failed")
except subprocess.CalledProcessError as e:
logger.error(f"Failed to install ffmpeg using Homebrew. {e}")
logger.error("Please install ffmpeg manually or check your Homebrew installation.")
except Exception as e:
logger.error(f"An unexpected error occurred: {e}")
current_env_path = os.environ.get("PATH") or ""
ffmpeg_path = os.path.join(execute_dir, "ffmpeg")
def install_ffmpeg_linux():
@@ -159,15 +65,9 @@ def install_ffmpeg_linux():
def install_ffmpeg() -> bool:
if current_platform == "Windows":
return install_ffmpeg_windows()
elif current_platform == "Linux":
if current_platform == "Linux":
return install_ffmpeg_linux()
elif current_platform == "Darwin":
return install_ffmpeg_mac()
else:
logger.debug(f"ffmpeg auto installation is not supported on this platform: {current_platform}. "
f"Please install ffmpeg manually.")
logger.debug("ffmpeg 自动安装仅支持 Linux请使用系统包管理器安装 ffmpeg。")
return False

4
install-all.sh Normal file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec bash "$ROOT_DIR/scripts/linux/install_stack.sh" all "$@"

4
install-business.sh Normal file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec bash "$ROOT_DIR/scripts/linux/install_business.sh" "$@"

4
install-core.sh Normal file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec bash "$ROOT_DIR/live-platform/scripts/install-core.sh" "$@"

4
install-hub.sh Normal file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec bash "$ROOT_DIR/scripts/linux/install_hub.sh" "$@"

4
install-live.sh Normal file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec bash "$ROOT_DIR/live-platform/scripts/install-live.sh" "$@"

View File

@@ -0,0 +1,19 @@
{
"id": "douyinyoutube",
"name": "无人直播(抖音/TikTok → YouTube / SRS / HDMI",
"enabled": true,
"layer": "business",
"install_marker": "/opt/live/business/douyinyoutube/.installed",
"ffmpeg": {
"template_id": "default",
"auto_restart": true,
"retry_seconds": 15
},
"streams": {
"douyin_input_url": "",
"youtube_rtmp_key": "",
"srs_publish_url": "",
"hdmi_device": ""
},
"note": "具体推流参数仍可由 PM2 脚本与 config/*.ini 管理;此处为声明式元数据,供控制台与恢复策略使用"
}

View File

@@ -0,0 +1,16 @@
{
"version": 1,
"wifi": {
"enabled": true,
"connection_name": "live-wifi",
"ssid": "live",
"psk": "12345678"
},
"ipv6": {
"disable": true
},
"android_proxy": {
"mode": "gateway_optional",
"description": "可选透明网关或 ShellCrash 规则;具体由 ShellCrash 与 network 脚本实现"
}
}

View File

@@ -0,0 +1,12 @@
{
"id": "android_panel",
"layer": "infra",
"enabled": true,
"manager": "systemd",
"ports": { "http": 5000 },
"env": {
"ENABLE_ANDROID_PANEL": "1",
"ANDROID_PANEL_PORT": "5000",
"ANDROID_PANEL_DIR": "/opt/live-modules/web-scrcpy"
}
}

View File

@@ -0,0 +1,9 @@
{
"id": "caddy",
"layer": "infra",
"enabled": false,
"manager": "systemd",
"description": "可选:统一 live.local:80 入口,反代控制台静态 + /api → FastAPI",
"ports": { "http": 80, "https": 443 },
"template": "/opt/live/templates/Caddyfile"
}

View File

@@ -0,0 +1,13 @@
{
"id": "filebrowser",
"layer": "infra",
"enabled": true,
"manager": "docker",
"ports": { "http": 8082 },
"env": {
"ENABLE_FILEBROWSER": "1",
"FILEBROWSER_PORT": "8082",
"FILEBROWSER_USER": "live",
"FILEBROWSER_PASS": "12345678"
}
}

View File

@@ -0,0 +1,8 @@
{
"id": "frp",
"layer": "infra",
"enabled": false,
"manager": "systemd",
"description": "占位:内网穿透,由运维填入 frpc 配置后启用",
"ports": {}
}

View File

@@ -0,0 +1,8 @@
{
"id": "homepage",
"layer": "infra",
"enabled": true,
"manager": "docker",
"ports": { "http": 3080 },
"env": { "ENABLE_HOMEPAGE": "1", "HOMEPAGE_PORT": "3080" }
}

View File

@@ -0,0 +1,27 @@
{
"id": "neko",
"layer": "infra",
"enabled": true,
"manager": "docker",
"ports": { "http": 9200 },
"env": {
"ENABLE_NEKO": "1",
"NEKO_HTTP_PORT": "9200",
"NEKO_USER_PASS": "live",
"NEKO_ADMIN_PASS": "admin",
"ENABLE_NEKO_FF": "1",
"NEKO_FF_HTTP_PORT": "9201",
"NEKO_FF_WEBRTC_EPR": "52200-52300",
"NEKO_FF_WEBRTC_UDP_RANGE": "52200-52300",
"NEKO_FF_DESKTOP_SCREEN": "1920x1080@30",
"NEKO_FF_PROFILE_HOST_DIR": "",
"NEKO_PROFILE_HOST_DIR": "",
"NEKO_SHM_SIZE": "2gb",
"NEKO_DESKTOP_SCREEN": "1920x1080@30",
"NEKO_WEBRTC_EPR": "52000-52100",
"NEKO_WEBRTC_UDP_RANGE": "52000-52100",
"NEKO_WEBRTC_ICELITE": "1",
"NEKO_WEBRTC_NAT1TO1": "",
"NEKO_PLUGINS_ENABLED": "true"
}
}

View File

@@ -0,0 +1,8 @@
{
"id": "netdata",
"layer": "infra",
"enabled": true,
"manager": "native",
"ports": { "http": 19999 },
"env": { "NETDATA_PORT": "19999", "ENABLE_NETDATA": "1" }
}

View File

@@ -0,0 +1,11 @@
{
"id": "shellcrash",
"layer": "infra",
"enabled": true,
"manager": "systemd",
"ports": {},
"env": {
"SHELLCRASH_DIR": "/etc/ShellCrash",
"ENABLE_SHELLCRASH": "1"
}
}

View File

@@ -0,0 +1,16 @@
{
"id": "srs",
"layer": "infra",
"enabled": true,
"manager": "docker",
"ports": {
"http": 8080,
"rtmp": 1935,
"api": 1985
},
"env": {
"SRS_HTTP_PORT": "8080",
"SRS_RTMP_PORT": "1935",
"SRS_API_PORT": "1985"
}
}

View File

@@ -0,0 +1,13 @@
{
"id": "webtty",
"layer": "core",
"enabled": true,
"manager": "systemd",
"ports": { "http": 7681 },
"env": {
"ENABLE_WEBTTY": "1",
"WEBTTY_PORT": "7681",
"WEBTTY_USER": "live",
"WEBTTY_PASS": "12345678"
}
}

View File

@@ -0,0 +1,8 @@
{
"id": "wireguard",
"layer": "infra",
"enabled": false,
"manager": "systemd",
"description": "占位VPN 接口,与 ShellCrash 并存时需规划路由",
"ports": {}
}

View File

@@ -0,0 +1,21 @@
{
"version": 1,
"platform": {
"arch": "auto",
"description": "auto = detect via uname -m (aarch64/arm64/x86_64)"
},
"identity": {
"hostname_alias": "live",
"mdns_enabled": true
},
"paths": {
"state_dir": "/opt/live/state",
"logs_dir": "/opt/live/logs",
"generated_dir": "/opt/live/generated"
},
"web_console": {
"api_port": 8001,
"public_host": "live.local",
"note": "生产环境由 Caddy :80 反代到静态资源与 API"
}
}

View File

@@ -0,0 +1,13 @@
# 将本文件复制到 /opt/live/templates/Caddyfile 并按实际路径修改
# 目标http://live.local → 静态控制台 + /api/* → FastAPI
{$LIVE_DOMAIN:live.local} {
encode gzip
# 静态Next export 由 web.py 同端口托管时可改为 reverse_proxy
handle_path /api/* {
reverse_proxy 127.0.0.1:8001
}
handle {
reverse_proxy 127.0.0.1:8001
}
}

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# 一键体检:网络 / 端口 / adb / ffmpeg / docker / 服务状态(模块化输出)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
REPO="$(cd "$ROOT/.." && pwd)"
# shellcheck source=/dev/null
source "$ROOT/scripts/lib/common.sh"
echo "=== Live Platform Doctor ==="
echo "arch: $(detect_arch)"
echo "hostname: $(hostname -f 2>/dev/null || hostname)"
section() { echo ""; echo "--- $1 ---"; }
section "loopback / DNS"
ping -c1 -W2 127.0.0.1 >/dev/null 2>&1 && echo "ping 127.0.0.1: ok" || echo "ping 127.0.0.1: fail"
getent hosts live.local >/dev/null 2>&1 && echo "live.local resolves: yes" || echo "live.local resolves: (optional mdns)"
section "config"
if [[ -f /opt/live/config/system.json ]]; then
echo "system.json: present"
else
echo "system.json: MISSING (run install-core)"
fi
if [[ -f /opt/live/generated/system-stack.env ]]; then
echo "generated env: present"
else
echo "generated env: missing"
fi
section "ports (LISTEN)"
command -v ss >/dev/null 2>&1 && ss -tlnp 2>/dev/null | head -n 40 || netstat -tlnp 2>/dev/null | head -n 40 || true
section "adb"
if command -v adb >/dev/null 2>&1; then
adb devices -l || true
else
echo "adb not installed"
fi
section "ffmpeg"
command -v ffmpeg >/dev/null 2>&1 && ffmpeg -version | head -n1 || echo "ffmpeg missing"
section "docker"
if command -v docker >/dev/null 2>&1; then
docker info >/dev/null 2>&1 && echo "docker: daemon ok" || echo "docker: daemon unreachable"
else
echo "docker not installed"
fi
section "proxy hint (ShellCrash)"
if [[ -d /etc/ShellCrash ]]; then
echo "ShellCrash dir: /etc/ShellCrash"
else
echo "ShellCrash dir: not found"
fi
if [[ -f "$REPO/web.py" ]] && command -v curl >/dev/null 2>&1; then
section "API"
PORT="${PORT:-8001}"
curl -sS -m 3 "http://127.0.0.1:${PORT}/health" && echo "" || echo "health check failed on :$PORT"
section "Port 80 (live-edge / Caddy)"
systemctl is-active live-edge.service 2>/dev/null || echo "live-edge.service: inactive"
curl -sS -m 5 -o /dev/null -w "GET http://127.0.0.1/ → http_code=%{http_code}\n" "http://127.0.0.1/" || echo "curl :80 failed"
fi
echo ""
echo "=== Doctor finished ==="

View File

@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# 幂等:同步配置中心默认 JSON → /opt/live/config生成 system-stack.env安装 Hub无业务模块
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
REPO="$(cd "$ROOT/.." && pwd)"
# shellcheck source=/dev/null
source "$ROOT/scripts/lib/common.sh"
ARCH="$(detect_arch)"
echo "[live-platform] install-core arch=$ARCH"
ensure_live_dirs
if [[ ! -f /opt/live/config/system.json ]]; then
cp -a "$ROOT/config/defaults/system.json" /opt/live/config/system.json
fi
if [[ ! -f /opt/live/config/network.json ]]; then
cp -a "$ROOT/config/defaults/network.json" /opt/live/config/network.json
fi
if command -v rsync >/dev/null 2>&1; then
rsync -a --ignore-existing "$ROOT/config/defaults/services/" /opt/live/config/services/
rsync -a --ignore-existing "$ROOT/config/defaults/business/" /opt/live/config/business/
else
cp -n "$ROOT/config/defaults/services/"*.json /opt/live/config/services/ 2>/dev/null || true
cp -n "$ROOT/config/defaults/business/"*.json /opt/live/config/business/ 2>/dev/null || true
fi
export LIVE_CONFIG_ROOT="${LIVE_CONFIG_ROOT:-/opt/live/config}"
python3 "$ROOT/tools/render_stack_env.py" /opt/live/generated/system-stack.env
if [[ -n "${LINK_REPO_CONFIG:-}" ]]; then
mkdir -p "$REPO/config"
ln -sf /opt/live/generated/system-stack.env "$REPO/config/system-stack.env"
fi
if [[ -x "$REPO/scripts/linux/install_hub.sh" ]]; then
bash "$REPO/scripts/linux/install_hub.sh" "$@"
else
echo "[live-platform] WARN: scripts/linux/install_hub.sh missing, skip hub packages"
fi
echo "[live-platform] install-core done. Env: /opt/live/generated/system-stack.env"

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# 幂等:仅无人直播业务模块(与 install-core 解耦)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
REPO="$(cd "$ROOT/.." && pwd)"
if [[ -x "$REPO/scripts/linux/install_business.sh" ]]; then
bash "$REPO/scripts/linux/install_business.sh" "$@"
else
echo "[live-platform] ERROR: scripts/linux/install_business.sh not found"
exit 1
fi
mkdir -p /opt/live/business/douyinyoutube
touch /opt/live/business/douyinyoutube/.installed
echo "[live-platform] install-live done"

View File

@@ -0,0 +1,29 @@
# shellcheck shell=bash
# 被 install-core / install-live / doctor 引用;禁止写业务逻辑,仅路径与权限。
live_platform_root() {
local here
here="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
printf '%s\n' "$here"
}
repo_root() {
local lp
lp="$(live_platform_root)"
cd "$lp/.." && pwd
}
detect_arch() {
case "$(uname -m)" in
aarch64|arm64) echo "arm64" ;;
x86_64|amd64) echo "amd64" ;;
*) echo "generic" ;;
esac
}
ensure_live_dirs() {
sudo mkdir -p /opt/live/config/services /opt/live/config/business \
/opt/live/generated /opt/live/state /opt/live/logs /opt/live/templates 2>/dev/null || \
mkdir -p /opt/live/config/services /opt/live/config/business \
/opt/live/generated /opt/live/state /opt/live/logs /opt/live/templates
}

View File

@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""生成 Caddy 配置HTTP :80 → 本机 FastAPI+静态PORT默认 8001"""
from __future__ import annotations
import json
import sys
from pathlib import Path
def main() -> int:
alias = "live"
port = "8001"
out = Path("/opt/live/generated/Caddyfile")
cfg = Path("/opt/live/config/system.json")
if cfg.is_file():
data = json.loads(cfg.read_text(encoding="utf-8"))
ident = data.get("identity") or {}
web = data.get("web_console") or {}
alias = str(ident.get("hostname_alias") or alias)
if web.get("api_port") is not None:
port = str(web["api_port"])
if len(sys.argv) >= 2:
alias = sys.argv[1]
if len(sys.argv) >= 3:
port = sys.argv[2]
if len(sys.argv) >= 4:
out = Path(sys.argv[3])
host = f"{alias}.local"
body = f"""# Generated by live-platform/tools/render_caddyfile.py - do not hand-edit
# http://{host}/ and http://<LAN-IP>/ -> 127.0.0.1:{port}
{host}:80, :80 {{
\treverse_proxy 127.0.0.1:{port}
}}
"""
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(body, encoding="utf-8")
print(str(out))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""从 /opt/live/config或 LIVE_CONFIG_ROOTJSON 配置生成 system-stack.env供现有 service_ctl / install 脚本读取。"""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
def config_root() -> Path:
env = os.environ.get("LIVE_CONFIG_ROOT", "").strip()
if env:
return Path(env)
if Path("/opt/live/config/system.json").is_file():
return Path("/opt/live/config")
here = Path(__file__).resolve()
return here.parent.parent / "config" / "defaults"
def load_json(path: Path) -> dict:
if not path.is_file():
return {}
return json.loads(path.read_text(encoding="utf-8"))
def collect_env(root: Path) -> dict[str, str]:
lines: dict[str, str] = {}
sys_j = load_json(root / "system.json")
web = sys_j.get("web_console") or {}
ident = sys_j.get("identity") or {}
if ident.get("hostname_alias"):
lines["HOSTNAME_ALIAS"] = str(ident["hostname_alias"])
if web.get("api_port") is not None:
lines["PORT"] = str(web["api_port"])
net = load_json(root / "network.json")
wifi = net.get("wifi") or {}
if wifi.get("connection_name"):
lines["WIFI_CONNECTION_NAME"] = str(wifi["connection_name"])
if wifi.get("ssid"):
lines["WIFI_SSID"] = str(wifi["ssid"])
if wifi.get("psk"):
lines["WIFI_PSK"] = str(wifi["psk"])
if net.get("ipv6", {}).get("disable"):
lines["DISABLE_IPV6"] = "1"
svc_dir = root / "services"
if svc_dir.is_dir():
for f in sorted(svc_dir.glob("*.json")):
data = load_json(f)
env = data.get("env") or {}
if not isinstance(env, dict):
continue
for k, v in env.items():
if v is not None and str(v) != "":
lines[str(k)] = str(v)
ports = {}
if svc_dir.is_dir():
for f in sorted(svc_dir.glob("*.json")):
data = load_json(f)
pid = data.get("id") or f.stem
pr = data.get("ports") or {}
if isinstance(pr, dict) and "http" in pr:
ports[pid] = pr["http"]
# 固定映射homepage 使用 HOMEPAGE_PORT
if "homepage" in ports:
lines.setdefault("HOMEPAGE_PORT", str(ports["homepage"]))
lines.setdefault("ENABLE_MDNS", "1")
lines.setdefault("ENABLE_WIFI_PROFILE", "1")
lines.setdefault("ENABLE_BROWSER", "1")
lines.setdefault("ENABLE_COCKPIT", "1")
return lines
def main() -> int:
root = config_root()
out = Path(os.environ.get("LIVE_ENV_OUT", "/opt/live/generated/system-stack.env"))
if len(sys.argv) > 1:
out = Path(sys.argv[1])
lines = collect_env(root)
out.parent.mkdir(parents=True, exist_ok=True)
body = "\n".join(f"{k}={v}" for k, v in sorted(lines.items())) + "\n"
out.write_text(
"# Generated by live-platform/tools/render_stack_env.py — do not hand-edit\n" + body,
encoding="utf-8",
)
# 开发树:可选同步到仓库 config由 install 脚本 ln -sf
print(str(out))
return 0
if __name__ == "__main__":
raise SystemExit(main())

246
main.py
View File

@@ -8,8 +8,13 @@ Update: 2025-10-23 19:48:05
Copyright (c) 2023-2025 by Hmily, All Rights Reserved.
Function: Record live stream video.
"""
from __future__ import annotations
import asyncio
import os
from src.async_thread_loop import run_async_coro
import sys
import builtins
import subprocess
@@ -78,8 +83,7 @@ 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"
clear_command = "clear"
color_obj = utils.Color()
os.environ['PATH'] = ffmpeg_path + os.pathsep + current_env_path
@@ -91,10 +95,6 @@ def signal_handler(_signal, _frame):
signal.signal(signal.SIGTERM, signal_handler)
import shutil
# 全局变量(只初始化一次)
_last_lines_count = 0
@@ -183,8 +183,7 @@ def display_info2() -> None:
try:
sys.stdout.flush()
time.sleep(5)
if Path(sys.executable).name != 'pythonw.exe':
os.system(clear_command)
os.system(clear_command)
print(f"\r共监测{monitoring}个直播中", end=" | ")
print(f"同一时间访问网络的线程数: {max_request}", end=" | ")
print(f"是否开启代理录制: {'' if use_proxy else ''}", end=" | ")
@@ -266,15 +265,6 @@ def delete_line(file_path: str, del_line: str, delete_all: bool = False) -> None
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:
@@ -293,7 +283,7 @@ def segment_video(converts_file_path: str, segment_save_file_path: str, segment_
segment_save_file_path,
]
_output = subprocess.check_output(
ffmpeg_command, stderr=subprocess.STDOUT, startupinfo=get_startup_info(os_type)
ffmpeg_command, stderr=subprocess.STDOUT
)
if is_original_delete:
time.sleep(1)
@@ -328,7 +318,7 @@ def converts_mp4(converts_file_path: str, is_original_delete: bool = True) -> No
"-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)
ffmpeg_command, stderr=subprocess.STDOUT
)
if is_original_delete:
time.sleep(1)
@@ -348,7 +338,7 @@ def converts_m4a(converts_file_path: str, is_original_delete: bool = True) -> No
"-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))
], stderr=subprocess.STDOUT)
if is_original_delete:
time.sleep(1)
if os.path.exists(converts_file_path):
@@ -445,7 +435,7 @@ def push_message(record_name: str, live_url: str, content: str) -> None:
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)
command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
stdout, stderr = process.communicate()
stdout_decoded = stdout.decode('utf-8')
@@ -510,7 +500,7 @@ def check_subprocess(record_name: str, record_url: str, ffmpeg_command: list, sa
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)
ffmpeg_command, stdin=subprocess.PIPE, stderr=subprocess.STDOUT
)
subs_file_path = save_file_path.rsplit('.', maxsplit=1)[0]
@@ -527,12 +517,7 @@ def check_subprocess(record_name: str, record_url: str, ffmpeg_command: list, sa
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.send_signal(signal.SIGINT)
process.wait()
return True
time.sleep(1)
@@ -670,27 +655,27 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
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(
json_data = run_async_coro(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(
json_data = run_async_coro(spider.get_douyin_app_stream_data(
url=record_url,
proxy_addr=proxy_address,
cookies=dy_cookie))
port_info = asyncio.run(
port_info = run_async_coro(
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(
json_data = run_async_coro(spider.get_tiktok_stream_data(
url=record_url,
proxy_addr=proxy_address,
cookies=tiktok_cookie))
port_info = asyncio.run(
port_info = run_async_coro(
stream.get_tiktok_stream_url(json_data, record_quality, proxy_address))
else:
logger.error("错误信息: 网络异常请检查网络是否能正常访问TikTok平台")
@@ -698,23 +683,23 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
elif record_url.find("https://live.kuaishou.com/") > -1:
platform = '快手直播'
with semaphore:
json_data = asyncio.run(spider.get_kuaishou_stream_data(
json_data = run_async_coro(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))
port_info = run_async_coro(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(
json_data = run_async_coro(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))
port_info = run_async_coro(stream.get_huya_stream_url(json_data, record_quality))
else:
port_info = asyncio.run(spider.get_huya_app_stream_url(
port_info = run_async_coro(spider.get_huya_app_stream_url(
url=record_url,
proxy_addr=proxy_address,
cookies=hy_cookie
@@ -723,52 +708,52 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
elif record_url.find("https://www.douyu.com/") > -1:
platform = '斗鱼直播'
with semaphore:
json_data = asyncio.run(spider.get_douyu_info_data(
json_data = run_async_coro(spider.get_douyu_info_data(
url=record_url, proxy_addr=proxy_address, cookies=douyu_cookie))
port_info = asyncio.run(stream.get_douyu_stream_url(
port_info = run_async_coro(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(
json_data = run_async_coro(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))
port_info = run_async_coro(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(
json_data = run_async_coro(spider.get_bilibili_room_info(
url=record_url, proxy_addr=proxy_address, cookies=bili_cookie))
port_info = asyncio.run(stream.get_bilibili_stream_url(
port_info = run_async_coro(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(
port_info = run_async_coro(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(
port_info = run_async_coro(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(
port_info = run_async_coro(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(
json_data = run_async_coro(spider.get_sooplive_stream_data(
url=record_url, proxy_addr=proxy_address,
cookies=sooplive_cookie,
username=sooplive_username,
@@ -778,51 +763,51 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
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))
port_info = run_async_coro(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(
json_data = run_async_coro(spider.get_netease_stream_data(
url=record_url, cookies=netease_cookie))
port_info = asyncio.run(stream.get_netease_stream_url(json_data, record_quality))
port_info = run_async_coro(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(
port_info = run_async_coro(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(
json_data = run_async_coro(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))
port_info = run_async_coro(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(
port_info = run_async_coro(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(
json_data = run_async_coro(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))
port_info = run_async_coro(stream.get_stream_url(json_data, record_quality, spec=True))
else:
logger.error("错误信息: 网络异常请检查本网络是否能正常访问WinkTV直播平台")
@@ -830,7 +815,7 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
platform = 'FlexTV'
with semaphore:
if global_proxy or proxy_address:
json_data = asyncio.run(spider.get_flextv_stream_data(
json_data = run_async_coro(spider.get_flextv_stream_data(
url=record_url,
proxy_addr=proxy_address,
cookies=flextv_cookie,
@@ -842,7 +827,7 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
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))
port_info = run_async_coro(stream.get_stream_url(json_data, record_quality, spec=True))
else:
port_info = json_data
else:
@@ -851,7 +836,7 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
elif record_url.find("look.163.com/") > -1:
platform = 'Look直播'
with semaphore:
port_info = asyncio.run(spider.get_looklive_stream_url(
port_info = run_async_coro(spider.get_looklive_stream_url(
url=record_url, proxy_addr=proxy_address, cookies=look_cookie
))
@@ -859,7 +844,7 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
platform = 'PopkonTV'
with semaphore:
if global_proxy or proxy_address:
port_info = asyncio.run(spider.get_popkontv_stream_url(
port_info = run_async_coro(spider.get_popkontv_stream_url(
url=record_url,
proxy_addr=proxy_address,
access_token=popkontv_access_token,
@@ -879,7 +864,7 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
elif record_url.find("twitcasting.tv/") > -1:
platform = 'TwitCasting'
with semaphore:
json_data = asyncio.run(spider.get_twitcasting_stream_url(
json_data = run_async_coro(spider.get_twitcasting_stream_url(
url=record_url,
proxy_addr=proxy_address,
cookies=twitcasting_cookie,
@@ -887,7 +872,7 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
username=twitcasting_username,
password=twitcasting_password
))
port_info = asyncio.run(stream.get_stream_url(json_data, record_quality, spec=False))
port_info = run_async_coro(stream.get_stream_url(json_data, record_quality, spec=False))
if port_info and port_info.get('new_cookies'):
utils.update_config(
@@ -898,36 +883,36 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
elif record_url.find("live.baidu.com/") > -1:
platform = '百度直播'
with semaphore:
json_data = asyncio.run(spider.get_baidu_stream_data(
json_data = run_async_coro(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))
port_info = run_async_coro(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(
json_data = run_async_coro(spider.get_weibo_stream_data(
url=record_url, proxy_addr=proxy_address, cookies=weibo_cookie))
port_info = asyncio.run(stream.get_stream_url(
port_info = run_async_coro(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(
port_info = run_async_coro(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(
json_data = run_async_coro(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))
port_info = run_async_coro(stream.get_stream_url(json_data, record_quality, spec=True))
else:
logger.error("错误信息: 网络异常请检查本网络是否能正常访问TwitchTV直播平台")
@@ -935,7 +920,7 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
if global_proxy or proxy_address:
platform = 'LiveMe'
with semaphore:
port_info = asyncio.run(spider.get_liveme_stream_url(
port_info = run_async_coro(spider.get_liveme_stream_url(
url=record_url, proxy_addr=proxy_address, cookies=liveme_cookie))
else:
logger.error("错误信息: 网络异常请检查本网络是否能正常访问LiveMe直播平台")
@@ -943,113 +928,113 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
elif record_url.find("www.huajiao.com/") > -1:
platform = '花椒直播'
with semaphore:
port_info = asyncio.run(spider.get_huajiao_stream_url(
port_info = run_async_coro(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(
port_info = run_async_coro(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(
json_data = run_async_coro(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))
port_info = run_async_coro(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(
json_data = run_async_coro(spider.get_acfun_stream_data(
url=record_url, proxy_addr=proxy_address, cookies=acfun_cookie))
port_info = asyncio.run(stream.get_stream_url(
port_info = run_async_coro(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(
port_info = run_async_coro(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(
port_info = run_async_coro(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(
port_info = run_async_coro(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(
port_info = run_async_coro(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(
json_data = run_async_coro(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))
port_info = run_async_coro(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(
port_info = run_async_coro(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(
port_info = run_async_coro(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(
port_info = run_async_coro(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(
port_info = run_async_coro(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(
port_info = run_async_coro(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(
port_info = run_async_coro(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(
port_info = run_async_coro(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(
port_info = run_async_coro(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(
port_info = run_async_coro(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'])
@@ -1057,16 +1042,16 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
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(
json_data = run_async_coro(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))
port_info = run_async_coro(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(
json_data = run_async_coro(spider.get_taobao_stream_url(
url=record_url, proxy_addr=proxy_address, cookies=taobao_cookie))
port_info = asyncio.run(stream.get_stream_url(
port_info = run_async_coro(stream.get_stream_url(
json_data, record_quality,
url_type='all', hls_extra_key='hlsUrl', flv_extra_key='flvUrl'
))
@@ -1074,7 +1059,7 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
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(
port_info = run_async_coro(spider.get_jd_stream_url(
url=record_url, proxy_addr=proxy_address, cookies=jd_cookie))
elif record_url.find("faceit.com/") > -1:
@@ -1082,34 +1067,34 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
with semaphore:
if global_proxy or proxy_address:
with semaphore:
json_data = asyncio.run(spider.get_faceit_stream_data(
json_data = run_async_coro(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))
port_info = run_async_coro(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(
port_info = run_async_coro(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(
port_info = run_async_coro(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(
port_info = run_async_coro(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(
port_info = run_async_coro(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:
@@ -1620,23 +1605,42 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
error_window.append(1)
else:
import sys
sys.path.insert(0, './dist')
from douyin_srs_ffplay import start_douyin_srs_ffplay
# 调用
start_douyin_srs_ffplay(
title=title_in_name,
anchor_name=anchor_name,
real_url=real_url,
rec_info=rec_info,
record_name=record_name,
recording=recording,
recording_time_list=recording_time_list,
running_list=running_list,
monitoring=monitoring,
clear_record_info=clear_record_info,
color_obj=color_obj
)
# 可选:项目 dist/ 下提供 douyin_srs_ffplay否则使用仓库内 YouTube 推流
_repo_root = Path(__file__).resolve().parent
_dist = _repo_root / "dist"
if _dist.is_dir():
sys.path.insert(0, str(_dist))
try:
from douyin_srs_ffplay import start_douyin_srs_ffplay
start_douyin_srs_ffplay(
title=title_in_name,
anchor_name=anchor_name,
real_url=real_url,
rec_info=rec_info,
record_name=record_name,
recording=recording,
recording_time_list=recording_time_list,
running_list=running_list,
monitoring=monitoring,
clear_record_info=clear_record_info,
color_obj=color_obj,
)
except ImportError:
from douyin_youtube_ffplay import start_douyin_youtube_ffplay
start_douyin_youtube_ffplay(
title=title_in_name,
anchor_name=anchor_name,
real_url=real_url,
rec_info=rec_info,
record_name=record_name,
recording=recording,
recording_time_list=recording_time_list,
running_list=running_list,
clear_record_info=clear_record_info,
color_obj=color_obj,
)
count_time = time.time()
@@ -2201,4 +2205,4 @@ while True:
t2.start()
first_run = False
time.sleep(3)
time.sleep(3)

View File

@@ -1,16 +0,0 @@
# OSX
#
.DS_Store
# Android/IntelliJ
#
build/
.idea
.gradle
local.properties
*.iml
*.hprof
.cxx/
# Bundle artifacts
*.jsbundle

View File

@@ -1,182 +0,0 @@
apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react"
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
/**
* This is the configuration block to customize your React Native Android app.
* By default you don't need to apply any configuration, just uncomment the lines you need.
*/
react {
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
hermesCommand = new File(["node", "--print", "require.resolve('hermes-compiler/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/hermesc/%OS-BIN%/hermesc"
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean()
// Use Expo CLI to bundle the app, this ensures the Metro config
// works correctly with Expo projects.
cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
bundleCommand = "export:embed"
/* Folders */
// The root of your project, i.e. where "package.json" lives. Default is '../..'
// root = file("../../")
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native
// reactNativeDir = file("../../node_modules/react-native")
// The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
// codegenDir = file("../../node_modules/@react-native/codegen")
/* Variants */
// The list of variants to that are debuggable. For those we're going to
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
// debuggableVariants = ["liteDebug", "prodDebug"]
/* Bundling */
// A list containing the node command and its flags. Default is just 'node'.
// nodeExecutableAndArgs = ["node"]
//
// The path to the CLI configuration file. Default is empty.
// bundleConfig = file(../rn-cli.config.js)
//
// The name of the generated asset file containing your JS bundle
// bundleAssetName = "MyApplication.android.bundle"
//
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
// entryFile = file("../js/MyApplication.android.js")
//
// A list of extra flags to pass to the 'bundle' commands.
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
// extraPackagerArgs = []
/* Hermes Commands */
// The hermes compiler command to run. By default it is 'hermesc'
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
//
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
// hermesFlags = ["-O", "-output-source-map"]
/* Autolinking */
autolinkLibrariesWithApp()
}
/**
* Set this to true in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization).
*/
def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
/**
* The preferred build flavor of JavaScriptCore (JSC)
*
* For example, to use the international variant, you can use:
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
*
* The international variant includes ICU i18n library and necessary data
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
* give correct results when using with locales other than en-US. Note that
* this variant is about 6MiB larger per architecture than default.
*/
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
android {
ndkVersion rootProject.ext.ndkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk rootProject.ext.compileSdkVersion
namespace 'com.douyin.recorder.remote'
defaultConfig {
applicationId 'com.douyin.recorder.remote'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0.0"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
}
signingConfigs {
debug {
storeFile file('debug.keystore')
storePassword 'android'
keyAlias 'androiddebugkey'
keyPassword 'android'
}
}
buildTypes {
debug {
signingConfig signingConfigs.debug
}
release {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
shrinkResources enableShrinkResources.toBoolean()
minifyEnabled enableMinifyInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
crunchPngs enablePngCrunchInRelease.toBoolean()
}
}
packagingOptions {
jniLibs {
def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false'
useLegacyPackaging enableLegacyPackaging.toBoolean()
}
}
androidResources {
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
// Apply static values from `gradle.properties` to the `android.packagingOptions`
// Accepts values in comma delimited lists, example:
// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop ->
// Split option: 'foo,bar' -> ['foo', 'bar']
def options = (findProperty("android.packagingOptions.$prop") ?: "").split(",");
// Trim all elements in place.
for (i in 0..<options.size()) options[i] = options[i].trim();
// `[] - ""` is essentially `[""].filter(Boolean)` removing all empty strings.
options -= ""
if (options.length > 0) {
println "android.packagingOptions.$prop += $options ($options.length)"
// Ex: android.packagingOptions.pickFirsts += '**/SCCS/**'
options.each {
android.packagingOptions[prop] += it
}
}
}
dependencies {
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
if (isGifEnabled) {
// For animated gif support
implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}")
}
if (isWebpEnabled) {
// For webp support
implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}")
if (isWebpAnimatedEnabled) {
// Animated webp support
implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}")
}
}
if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android")
} else {
implementation jscFlavor
}
}

Binary file not shown.

View File

@@ -1,14 +0,0 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# react-native-reanimated
-keep class com.swmansion.reanimated.** { *; }
-keep class com.facebook.react.turbomodule.** { *; }
# Add any project specific keep options here:

View File

@@ -1,7 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
</manifest>

View File

@@ -1,7 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
</manifest>

View File

@@ -1,33 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:replace="android:maxSdkVersion"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:replace="android:maxSdkVersion"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https"/>
</intent>
</queries>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false">
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode|smallestScreenSize" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="mobile"/>
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -1,65 +0,0 @@
package com.douyin.recorder.remote
import expo.modules.splashscreen.SplashScreenManager
import android.os.Build
import android.os.Bundle
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate
import expo.modules.ReactActivityDelegateWrapper
class MainActivity : ReactActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// Set the theme to AppTheme BEFORE onCreate to support
// coloring the background, status bar, and navigation bar.
// This is required for expo-splash-screen.
// setTheme(R.style.AppTheme);
// @generated begin expo-splashscreen - expo prebuild (DO NOT MODIFY) sync-f3ff59a738c56c9a6119210cb55f0b613eb8b6af
SplashScreenManager.registerOnActivity(this)
// @generated end expo-splashscreen
super.onCreate(null)
}
/**
* Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component.
*/
override fun getMainComponentName(): String = "main"
/**
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
*/
override fun createReactActivityDelegate(): ReactActivityDelegate {
return ReactActivityDelegateWrapper(
this,
BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
object : DefaultReactActivityDelegate(
this,
mainComponentName,
fabricEnabled
){})
}
/**
* Align the back button behavior with Android S
* where moving root activities to background instead of finishing activities.
* @see <a href="https://developer.android.com/reference/android/app/Activity#onBackPressed()">onBackPressed</a>
*/
override fun invokeDefaultOnBackPressed() {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
if (!moveTaskToBack(false)) {
// For non-root activities, use the default implementation to finish them.
super.invokeDefaultOnBackPressed()
}
return
}
// Use the default back button implementation on Android S
// because it's doing more than [Activity.moveTaskToBack] in fact.
super.invokeDefaultOnBackPressed()
}
}

View File

@@ -1,45 +0,0 @@
package com.douyin.recorder.remote
import android.app.Application
import android.content.res.Configuration
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.ReactPackage
import com.facebook.react.ReactHost
import com.facebook.react.common.ReleaseLevel
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
import expo.modules.ApplicationLifecycleDispatcher
import expo.modules.ExpoReactHostFactory
class MainApplication : Application(), ReactApplication {
override val reactHost: ReactHost by lazy {
ExpoReactHostFactory.getDefaultReactHost(
context = applicationContext,
packageList =
PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
}
)
}
override fun onCreate() {
super.onCreate()
DefaultNewArchitectureEntryPoint.releaseLevel = try {
ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
} catch (e: IllegalArgumentException) {
ReleaseLevel.STABLE
}
loadReactNative(this)
ApplicationLifecycleDispatcher.onApplicationCreate(this)
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

View File

@@ -1,6 +0,0 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splashscreen_background"/>
<item>
<bitmap android:gravity="center" android:src="@drawable/splashscreen_logo"/>
</item>
</layer-list>

View File

@@ -1,37 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2014 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
android:insetTop="@dimen/abc_edit_text_inset_top_material"
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material"
>
<selector>
<!--
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'
<item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
-->
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
</selector>
</inset>

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon>

Some files were not shown because too many files have changed in this diff Show More