feat: harden recording lifecycle and refresh fnOS UI

This commit is contained in:
2026-08-03 23:45:26 +08:00
parent e5b50ea85c
commit ecc737f0bd
90 changed files with 6995 additions and 1826 deletions
+2
View File
@@ -6,6 +6,8 @@
frontend/node_modules/
frontend/dist/
frontend/dist-postgres/
frontend/test-results/
frontend/playwright-report/
**/.dotnet-cli-home/
.codex-temp/
.tools/
+10 -12
View File
@@ -163,7 +163,7 @@ ILiveDanmakuConnection
|------|------|
| FFmpeg 路径 | ffmpeg 可执行文件路径 |
| 输出根目录 | 录制文件输出根目录 |
| 输出模板 | 路径模板,支持 `{platform}` `{roomId}` `{anchor}` `{title}` `{yyyy}` `{MM}` `{dd}` `{HHmmss}` 等变量 |
| 输出模板 | 路径模板,支持 `{platform}` `{roomId}` `{anchor}` `{title}` `{sessionId}` `{yyyy}` `{MM}` `{dd}` `{HHmmss}` 等变量;已有主播/日期目录直接复用,同名文件自动追加会话短 ID,绝不静默覆盖 |
| 默认画质 | 优先选择的流画质 |
| 保存格式 | MP4 / TS |
| 保存模式 | 单文件 / 分段 |
@@ -257,37 +257,35 @@ docker compose up -d
```bash
./scripts/build-postgresql-fnos-package.sh
./scripts/build-fnos-package.sh
./scripts/smoke-postgresql-fnos-package.sh artifacts/fnos/nxsir-postgresql-15.1.0-x86_64.fpk
./scripts/smoke-postgresql-fnos-package.sh artifacts/fnos/nxsir-postgresql-15.1.1-x86_64.fpk
./scripts/smoke-fnos-package.sh \
artifacts/fnos/liverecorder-1.1.0-x86_64.fpk \
artifacts/fnos/nxsir-postgresql-15.1.0-x86_64.fpk
./scripts/smoke-fnos-migration.sh \
artifacts/fnos/liverecorder-1.1.0-x86_64.fpk \
artifacts/fnos/nxsir-postgresql-15.1.0-x86_64.fpk
artifacts/fnos/liverecorder-1.2.13-x86_64.fpk \
artifacts/fnos/nxsir-postgresql-15.1.1-x86_64.fpk
```
- 使用 fnOS 开发者平台提供的官方 `fnpack` 构建;可通过 `FNPACK=/path/to/fnpack` 指定工具路径
- 两个 FPK 都是 x86_64 原生应用,不依赖 Docker
- 先安装 `nxsir.postgresql`,再安装 Live RecorderfnOS 会通过 `install_dep_apps` 检查依赖
- Live Recorder 直接依赖 `nxsir.postgresql` 和商店版 `nodejs_v22`fnOS 会通过 `install_dep_apps` 检查并启用依赖
- PostgreSQL 共享服务只监听 `127.0.0.1:15432`,独立管理界面默认端口为 `15433`
- 每个应用经回环接入 API 获得独立数据库、独立 SCRAM 角色和随机密码;支持 pgvector
- PostgreSQL 管理界面有独立管理员登录、客户端/数据库/角色/会话管理、只读 SQL 和手动备份恢复
- Live Recorder 安装向导会要求设置自身 `admin` 密码,并填写 PostgreSQL 服务的应用接入令牌
- 从旧 fnOS 包升级时会自动迁移原内置 PostgreSQL;只有自定义转储、SHA-256 和十张业务表行数校验全部成功后才切换
- 迁移失败会继续使用旧数据库并在下次启动重试;旧数据库和迁移转储不会自动删除
- Live Recorder 1.1.0 暂时仍携带旧 PG15 运行时,仅用于升级迁移和安全回退,不会在新安装上启动第二个数据库进程
- Live Recorder 1.2.5 仅连接 PostgreSQL 共享服务,不再打包、启动或回退到原内置 PostgreSQL
- 已删除旧内置 PostgreSQL 自动迁移能力;升级前必须确认应用已经取得共享数据库凭据,遗留数据目录不会自动删除
- Node.js 由 fnOS 商店的 `nodejs_v22` 提供,使用 `/var/apps/nodejs_v22/target/bin/node`Live Recorder FPK 不再内置 Node.js
- 依赖 fnOS 系统环境同时提供 `ffmpeg``ffprobe`,安装前请先确认二者可执行
- Web 管理界面默认使用端口 `18080`
- PostgreSQL 数据位于其应用持久化目录,手动备份位于共享目录 `postgresql/backups`
- 录制文件默认保存在 fnOS 共享目录 `liverecorder/records`;可在“设置 → 录制 → 输出根目录”修改
更完整的安装、端口凭据与迁移说明见 [docs/postgresql-migration.md](docs/postgresql-migration.md)。
更完整的安装、端口凭据说明见 [docs/postgresql-migration.md](docs/postgresql-migration.md)。
其他 fnOS 应用接入共享数据库时,请直接参考 [docs/fnos-postgresql-client-integration.md](docs/fnos-postgresql-client-integration.md)。
## 验证
- `dotnet build LiveRecorder.sln --no-restore -m:1`
- `npm run build`frontend 目录)
- `npm run test:e2e`frontend 目录,覆盖 1920 / 1366 / 1024 / 768 / 390
- Docker Compose 完整部署验证通过
## 后续规划
+9 -17
View File
@@ -1,13 +1,15 @@
# PostgreSQL 共享服务与历史数据迁移
# PostgreSQL 共享服务部署说明
## fnOS 原生部署
fnOS 方案由两个独立 FPK 组成:
fnOS 方案由两个独立 FPK 和一个商店运行时依赖组成:
| 应用 | 默认端口 | 持久化内容 |
|---|---:|---|
| `nxsir.postgresql` | 管理界面 `15433`、数据库 `127.0.0.1:15432` | PostgreSQL 数据、凭据散列、审计日志 |
| `liverecorder` | Web 管理界面 `18080` | 应用日志、签发后的数据库客户端凭据、迁移回退数据 |
| `liverecorder` | Web 管理界面 `18080` | 应用日志、签发后的数据库客户端凭据 |
Live Recorder 还依赖商店应用 `nodejs_v22`,并从 `/var/apps/nodejs_v22/target/bin/node` 调用 Node.js 22。FPK 的 `install_dep_apps=nxsir.postgresql:nodejs_v22` 会让 fnOS 检查并启用两个依赖。
安装顺序:
@@ -16,18 +18,9 @@ fnOS 方案由两个独立 FPK 组成:
3. Live Recorder 只通过 `127.0.0.1` 注册。共享服务为它创建独立数据库和 SCRAM 角色,随机密码只在注册响应中返回一次。
4. 注册成功后,接入令牌会从 Live Recorder 持久化目录删除;签发凭据保存在权限为 `0600``postgres-client.conf`
新安装不会启动 Live Recorder 包内的旧 PostgreSQL。录制路径默认是 fnOS 共享目录 `liverecorder/records`,也可以在“设置 → 录制 → 输出根目录”修改;路径模板会继续在该根目录下生成平台、主播、日期等层级,已有目录会直接复用,不会重复嵌套
Live Recorder 1.2.5 起只使用共享 PostgreSQL,不再打包、启动或回退到旧内置 PostgreSQL,也不再提供旧内置数据库的自动迁移能力。升级前必须确认 `postgres-client.conf` 有效,或在升级向导填写共享服务应用接入令牌。历史版本留下的 `postgres/``postgres-migration/` 目录不会被应用读取,也不会自动删除
### 从旧 fnOS 版本自动迁移
升级包检测到旧 `PG_VERSION` 且尚无迁移标记时会:
1. 启动旧的私有 PG15,只读导出 custom-format 转储并生成 SHA-256。
2. 清空新签发的目标 schema,以 `--no-owner --no-acl` 恢复。
3. 精确比较十张业务表在源库和目标库中的行数。
4. 全部成功后写入 `shared-database.active` 标记并停止旧 PostgreSQL。
任一步失败都会继续使用旧数据库,下次启动再重试。系统不会自动删除旧数据、转储、校验文件;确认新版本稳定并另行备份后再手工清理。迁移标记一旦存在,凭据损坏时应用会拒绝回退到已经过期的旧库,防止录制数据分叉。
录制路径默认是 fnOS 共享目录 `liverecorder/records`,也可以在“设置 → 录制 → 输出根目录”修改;路径模板会继续在该根目录下生成平台、主播、日期等层级,已有目录会直接复用,不会重复嵌套。
### 管理与备份
@@ -39,9 +32,8 @@ fnOS 方案由两个独立 FPK 组成:
### fnOS 验证
```bash
./scripts/smoke-postgresql-fnos-package.sh artifacts/fnos/nxsir-postgresql-15.1.0-x86_64.fpk
./scripts/smoke-fnos-package.sh artifacts/fnos/liverecorder-1.1.0-x86_64.fpk artifacts/fnos/nxsir-postgresql-15.1.0-x86_64.fpk
./scripts/smoke-fnos-migration.sh artifacts/fnos/liverecorder-1.1.0-x86_64.fpk artifacts/fnos/nxsir-postgresql-15.1.0-x86_64.fpk
./scripts/smoke-postgresql-fnos-package.sh artifacts/fnos/nxsir-postgresql-15.1.1-x86_64.fpk
./scripts/smoke-fnos-package.sh artifacts/fnos/liverecorder-1.2.9-x86_64.fpk artifacts/fnos/nxsir-postgresql-15.1.1-x86_64.fpk
```
下面保留 Docker/宿主机从旧 SQLite 导入 PostgreSQL 的流程。
+1 -1
View File
@@ -1,5 +1,5 @@
appname=nxsir.postgresql
version=15.1.0
version=15.1.1
display_name=PostgreSQL 共享服务
desc=面向 fnOS 应用的原生 PostgreSQL 15 共享数据库服务,包含 pgvector、安全凭据签发、管理面板和手动备份恢复
platform=x86
+9 -176
View File
@@ -8,29 +8,22 @@ VOLUME_ROOT="${TRIM_APPDEST_VOL:-$DATA_ROOT/volume}"
RECORD_ROOT="${LIVE_RECORDER_RECORD_ROOT:-$VOLUME_ROOT/@appshare/liverecorder/records}"
RUNTIME_ROOT="$APP_ROOT/runtime"
SERVER="$APP_ROOT/server/LiveRecorder.WebApi"
PG_BIN="$RUNTIME_ROOT/usr/lib/postgresql/15/bin"
PG_SHARE="$RUNTIME_ROOT/usr/share/postgresql/15"
PG_LIB="$RUNTIME_ROOT/usr/lib/postgresql/15/lib"
NODE_BIN="$RUNTIME_ROOT/bin/node"
NODEJS_ROOT="${NODEJS_ROOT:-/var/apps/nodejs_v22/target}"
NODE_BIN="$NODEJS_ROOT/bin/node"
CURL_BIN="$RUNTIME_ROOT/bin/curl"
CA_BUNDLE="$RUNTIME_ROOT/etc/ssl/certs/ca-certificates.crt"
PG_DATA="$DATA_ROOT/postgres"
RUN_ROOT="$DATA_ROOT/run"
LOG_ROOT="$DATA_ROOT/log"
APP_PID_FILE="$RUN_ROOT/liverecorder.pid"
APP_LOG="$LOG_ROOT/liverecorder.log"
PG_LOG="$LOG_ROOT/postgresql.log"
ADMIN_PASSWORD_FILE="$DATA_ROOT/admin-password.seed"
POSTGRES_ENROLLMENT_TOKEN_FILE="$DATA_ROOT/postgres-enrollment-token.seed"
POSTGRES_CREDENTIALS_FILE="$DATA_ROOT/postgres-client.conf"
POSTGRES_MIGRATION_ROOT="$DATA_ROOT/postgres-migration"
POSTGRES_MIGRATION_MARKER="$POSTGRES_MIGRATION_ROOT/shared-database.active"
PG_PORT="${LIVE_RECORDER_POSTGRES_PORT:-54329}"
SERVICE_PORT="${TRIM_SERVICE_PORT:-18080}"
POSTGRES_SERVICE_API="${POSTGRES_SERVICE_API:-http://127.0.0.1:15433}"
SYSTEM_PATH="${PATH:-/usr/local/bin:/usr/bin:/bin}"
RUNTIME_PATH="$RUNTIME_ROOT/bin:$SYSTEM_PATH"
RUNTIME_LIBRARY_PATH="$RUNTIME_ROOT/lib:$PG_LIB"
RUNTIME_PATH="$RUNTIME_ROOT/bin:$NODEJS_ROOT/bin:$SYSTEM_PATH"
RUNTIME_LIBRARY_PATH="$RUNTIME_ROOT/lib"
DATABASE_CONNECTION_STRING=""
SHARED_DB_HOST=""
SHARED_DB_PORT=""
@@ -54,10 +47,6 @@ app_pid() {
return 1
}
run_pg() {
env LD_LIBRARY_PATH="$RUNTIME_LIBRARY_PATH" PATH="$PG_BIN:$RUNTIME_PATH" "$@"
}
run_native() {
env LD_LIBRARY_PATH="$RUNTIME_LIBRARY_PATH" PATH="$RUNTIME_PATH" \
SSL_CERT_FILE="$CA_BUNDLE" CURL_CA_BUNDLE="$CA_BUNDLE" "$@"
@@ -144,94 +133,11 @@ ensure_shared_credentials() {
enroll_shared_database
}
run_shared_pg() {
env LD_LIBRARY_PATH="$RUNTIME_LIBRARY_PATH" PATH="$PG_BIN:$RUNTIME_PATH" \
PGPASSWORD="$SHARED_DB_PASSWORD" "$@"
}
collect_database_counts() {
mode=$1
destination=$2
: >"$destination"
for table_name in AppSettings CleanupOperations LiveRooms RecordResults RecordSessions RecordTasks RecordUploadJobs SystemLogEntries UserAccounts UserSessions; do
if [ "$mode" = "private" ]; then
count=$(run_pg "$PG_BIN/psql" -h "$RUN_ROOT" -p "$PG_PORT" -U liverecorder -d live_recorder -Atqc \
"SELECT count(*) FROM \"$table_name\"" 2>/dev/null) || return 1
else
count=$(run_shared_pg "$PG_BIN/psql" -h "$SHARED_DB_HOST" -p "$SHARED_DB_PORT" -U "$SHARED_DB_USER" -d "$SHARED_DB_NAME" -Atqc \
"SELECT count(*) FROM \"$table_name\"" 2>/dev/null) || return 1
fi
printf '%s=%s\n' "$table_name" "$count" >>"$destination"
done
}
migrate_private_postgres() {
mkdir -p "$POSTGRES_MIGRATION_ROOT"
chmod 0700 "$POSTGRES_MIGRATION_ROOT"
dump_file="$POSTGRES_MIGRATION_ROOT/private-postgres-15.dump"
source_counts="$POSTGRES_MIGRATION_ROOT/source-counts.txt"
target_counts="$POSTGRES_MIGRATION_ROOT/target-counts.txt"
start_postgres || return 1
log_message "开始导出原内置 PostgreSQL 数据库。"
run_pg "$PG_BIN/pg_dump" \
-h "$RUN_ROOT" -p "$PG_PORT" -U liverecorder -d live_recorder \
--format=custom --no-owner --no-acl --file "$dump_file.tmp" >>"$PG_LOG" 2>&1 || return 1
mv "$dump_file.tmp" "$dump_file"
sha256sum "$dump_file" >"$dump_file.sha256"
collect_database_counts private "$source_counts" || return 1
log_message "开始恢复数据到 PostgreSQL 共享服务。"
run_shared_pg "$PG_BIN/psql" \
-h "$SHARED_DB_HOST" -p "$SHARED_DB_PORT" -U "$SHARED_DB_USER" -d "$SHARED_DB_NAME" \
-v ON_ERROR_STOP=1 -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public AUTHORIZATION \"$SHARED_DB_USER\"" >>"$PG_LOG" 2>&1 || return 1
run_shared_pg "$PG_BIN/pg_restore" \
-h "$SHARED_DB_HOST" -p "$SHARED_DB_PORT" -U "$SHARED_DB_USER" -d "$SHARED_DB_NAME" \
--no-owner --no-acl --exit-on-error "$dump_file" >>"$PG_LOG" 2>&1 || return 1
collect_database_counts shared "$target_counts" || return 1
if ! cmp -s "$source_counts" "$target_counts"; then
log_message "共享数据库行数校验失败,继续使用原数据库。"
return 1
fi
umask 077
{
printf 'migrated_at=%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
printf 'database=%s\n' "$SHARED_DB_NAME"
printf 'dump=%s\n' "$dump_file"
} >"$POSTGRES_MIGRATION_MARKER"
stop_postgres || return 1
log_message "内置 PostgreSQL 已迁移到共享服务,旧数据和迁移转储已保留。"
}
select_database_connection() {
if ! ensure_shared_credentials; then
if [ -f "$POSTGRES_MIGRATION_MARKER" ]; then
log_message "已完成共享数据库迁移,但当前凭据不可用;为避免使用过期旧库,应用不会启动。"
return 1
fi
if [ -f "$PG_DATA/PG_VERSION" ]; then
log_message "共享服务暂不可用,本次启动继续使用原内置 PostgreSQL。"
start_postgres || return 1
DATABASE_CONNECTION_STRING="Host=$RUN_ROOT;Port=$PG_PORT;Database=live_recorder;Username=liverecorder;Timeout=15;Command Timeout=120;Keepalive=30"
return 0
fi
ensure_shared_credentials || {
log_message "无法连接 PostgreSQL 共享服务或取得数据库凭据,应用不会启动。"
return 1
fi
if [ -f "$PG_DATA/PG_VERSION" ] && [ ! -f "$POSTGRES_MIGRATION_MARKER" ]; then
if ! migrate_private_postgres; then
log_message "共享数据库迁移失败,本次启动继续使用原内置 PostgreSQL;下次启动会重试迁移。"
start_postgres || return 1
DATABASE_CONNECTION_STRING="Host=$RUN_ROOT;Port=$PG_PORT;Database=live_recorder;Username=liverecorder;Timeout=15;Command Timeout=120;Keepalive=30"
return 0
fi
elif [ ! -f "$PG_DATA/PG_VERSION" ] && [ ! -f "$POSTGRES_MIGRATION_MARKER" ]; then
mkdir -p "$POSTGRES_MIGRATION_ROOT"
umask 077
printf 'fresh_install_at=%s\ndatabase=%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$SHARED_DB_NAME" >"$POSTGRES_MIGRATION_MARKER"
fi
return 0
}
}
system_media_tools_available() {
@@ -248,78 +154,6 @@ system_media_tools_available() {
return 0
}
postgres_running() {
[ -x "$PG_BIN/pg_ctl" ] || return 1
[ -f "$PG_DATA/PG_VERSION" ] || return 1
run_pg "$PG_BIN/pg_ctl" -D "$PG_DATA" status >/dev/null 2>&1
}
initialize_postgres() {
if [ -f "$PG_DATA/PG_VERSION" ]; then
return 0
fi
mkdir -p "$PG_DATA" "$RUN_ROOT"
chmod 0700 "$PG_DATA" "$RUN_ROOT"
if ! run_pg "$PG_BIN/initdb" \
-D "$PG_DATA" \
-L "$PG_SHARE" \
--username=liverecorder \
--auth-local=trust \
--auth-host=reject \
--encoding=UTF8 \
--no-locale >>"$PG_LOG" 2>&1; then
log_message "PostgreSQL 初始化失败。"
return 1
fi
{
printf "listen_addresses = ''\n"
printf "port = %s\n" "$PG_PORT"
printf "unix_socket_directories = '%s'\n" "$RUN_ROOT"
printf "max_connections = 40\n"
printf "shared_buffers = '64MB'\n"
printf "timezone = 'UTC'\n"
printf "log_timezone = 'UTC'\n"
} >>"$PG_DATA/postgresql.conf"
return 0
}
start_postgres() {
if postgres_running; then
return 0
fi
initialize_postgres || return 1
if ! run_pg "$PG_BIN/pg_ctl" -D "$PG_DATA" -l "$PG_LOG" -w start; then
log_message "PostgreSQL 启动失败。"
return 1
fi
database_exists=$(run_pg "$PG_BIN/psql" \
-h "$RUN_ROOT" -p "$PG_PORT" -U liverecorder -d postgres -Atqc \
"SELECT 1 FROM pg_database WHERE datname = 'live_recorder'" 2>/dev/null || true)
if [ "$database_exists" != "1" ]; then
if ! run_pg "$PG_BIN/createdb" \
-h "$RUN_ROOT" -p "$PG_PORT" -U liverecorder live_recorder >>"$PG_LOG" 2>&1; then
log_message "Live Recorder 数据库创建失败。"
return 1
fi
fi
return 0
}
stop_postgres() {
if postgres_running; then
# A first-run migration can dirty enough pages that a NAS needs more
# than pg_ctl's 60-second default to finish the shutdown checkpoint.
# Do not let restart race a database that is still shutting down.
if ! run_pg "$PG_BIN/pg_ctl" -D "$PG_DATA" -m fast -t 180 -w stop >>"$PG_LOG" 2>&1; then
log_message "PostgreSQL 未能在 180 秒内安全停止,已取消后续重启。"
return 1
fi
fi
}
launch_app_process() {
(
export ASPNETCORE_ENVIRONMENT=Production
@@ -354,7 +188,7 @@ start_app() {
log_message "应用程序不存在或不可执行:$SERVER"
return 1
fi
if [ ! -x "$PG_BIN/postgres" ] || [ ! -x "$NODE_BIN" ] || [ ! -x "$CURL_BIN" ] || [ ! -s "$CA_BUNDLE" ]; then
if [ ! -x "$NODE_BIN" ] || [ ! -x "$CURL_BIN" ] || [ ! -s "$CA_BUNDLE" ]; then
log_message "FPK 原生运行环境不完整。"
return 1
fi
@@ -400,7 +234,7 @@ stop_app() {
if pid=$(app_pid); then
kill "$pid" 2>/dev/null || true
attempt=0
while kill -0 "$pid" 2>/dev/null && [ "$attempt" -lt 30 ]; do
while kill -0 "$pid" 2>/dev/null && [ "$attempt" -lt 50 ]; do
sleep 1
attempt=$((attempt + 1))
done
@@ -409,7 +243,6 @@ stop_app() {
fi
fi
rm -f "$APP_PID_FILE"
stop_postgres
}
case "${1:-status}" in
+1 -1
View File
@@ -1,3 +1,3 @@
#!/bin/bash
# Persistent PostgreSQL data and recordings are preserved by default.
# Persistent application data and recordings are preserved by default.
exit 0
+2 -2
View File
@@ -1,5 +1,5 @@
appname=liverecorder
version=1.1.0
version=1.2.13
display_name=Live Recorder
desc=原生直播录制系统,使用独立 PostgreSQL 共享服务,支持分片录制、弹幕采集与 OpenList 自动上传
platform=x86
@@ -10,4 +10,4 @@ desktop_uidir=ui
desktop_applaunchname=liverecorder.Application
checkport=true
ctl_stop=true
install_dep_apps=nxsir.postgresql
install_dep_apps=nxsir.postgresql:nodejs_v22
+2 -2
View File
@@ -4,13 +4,13 @@
"items": [
{
"type": "tips",
"helpText": "本次升级会自动把原内置 PostgreSQL 数据迁移到共享服务。迁移成功前仍可回退到原数据库,旧数据不会自动删除。"
"helpText": "本版本仅使用独立 PostgreSQL 共享服务,不再包含或启动内置数据库。已有共享数据库凭据可继续使用。"
},
{
"type": "password",
"field": "wizard_postgres_enrollment_token",
"label": "PostgreSQL 应用接入令牌",
"helpText": "首次迁移需要填写共享服务的应用接入令牌;已经完成迁移的后续升级可以留空。",
"helpText": "尚未取得共享数据库凭据时需要填写应用接入令牌;已有有效凭据的后续升级可以留空。",
"rules": [
{
"max": 256,
+64
View File
@@ -16,6 +16,7 @@
"vue-router": "^4.5.0"
},
"devDependencies": {
"@playwright/test": "^1.62.1",
"@vitejs/plugin-vue": "^5.2.3",
"typescript": "^5.7.3",
"vite": "^6.2.0",
@@ -1012,6 +1013,22 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@playwright/test": {
"version": "1.62.1",
"resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmmirror.com/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -2681,6 +2698,53 @@
}
}
},
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.9",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.9.tgz",
+3 -1
View File
@@ -7,7 +7,8 @@
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"build:postgres-admin": "vite build --config vite.postgres.config.ts",
"preview": "vite preview"
"preview": "vite preview",
"test:e2e": "playwright test"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
@@ -18,6 +19,7 @@
"vue-router": "^4.5.0"
},
"devDependencies": {
"@playwright/test": "^1.62.1",
"@vitejs/plugin-vue": "^5.2.3",
"typescript": "^5.7.3",
"vite": "^6.2.0",
+33
View File
@@ -0,0 +1,33 @@
import { defineConfig } from "@playwright/test";
const viewports = [
{ name: "desktop-1920", width: 1920, height: 1080 },
{ name: "desktop-1366", width: 1366, height: 768 },
{ name: "tablet-1024", width: 1024, height: 768 },
{ name: "tablet-768", width: 768, height: 1024 },
{ name: "mobile-390", width: 390, height: 844 }
];
export default defineConfig({
testDir: "./tests/e2e",
outputDir: "./test-results",
fullyParallel: false,
retries: 0,
reporter: [["list"]],
use: {
baseURL: "http://127.0.0.1:47173",
colorScheme: "light",
reducedMotion: "reduce",
trace: "retain-on-failure"
},
webServer: {
command: "VITE_DISABLE_DEVTOOLS=1 npm run dev -- --host 127.0.0.1 --port 47173 --strictPort",
url: "http://127.0.0.1:47173",
reuseExistingServer: false,
timeout: 120_000
},
projects: viewports.map(({ name, width, height }) => ({
name,
use: { viewport: { width, height } }
}))
});
-1
View File
@@ -154,7 +154,6 @@ function notifyBackendUnavailable(message: string) {
}
apiClient.interceptors.request.use((config) => {
console.log("[API DEBUG]", config.method?.toUpperCase(), config.baseURL || "", config.url || "", "→", (config.baseURL || "") + (config.url || ""));
const token = localStorage.getItem("live-recorder-token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
+12 -5
View File
@@ -229,7 +229,7 @@ watch(() => route.fullPath, () => { mobileNavVisible.value = false; });
<style scoped>
/* ==================== Shell ==================== */
.app-shell { display: flex; min-height: 100vh; }
.app-shell { display: flex; width: 100%; height: 100dvh; min-height: 0; overflow: hidden; }
.scrim { position: fixed; inset: 0; background: rgba(15,23,42,.5); z-index: 39; opacity: 0; pointer-events: none; transition: opacity .2s; }
.scrim.open { opacity: 1; pointer-events: auto; }
@@ -287,12 +287,16 @@ html[data-theme="dark"] .app-health__txt { color: #6ee7b7; }
.collapsed .app-health { justify-content: center; padding: 10px 0; }
/* ==================== Main column ==================== */
.app-main-col { flex: 1; margin-left: var(--sidebar-w); min-width: 0; transition: margin .2s ease; }
.app-main-col {
display: flex; flex: 1; flex-direction: column; height: 100dvh;
min-width: 0; min-height: 0; margin-left: var(--sidebar-w); overflow: hidden;
transition: margin .2s ease;
}
.app-sidebar.collapsed ~ .app-main-col { margin-left: var(--sidebar-w-collapsed); }
/* ==================== Topbar ==================== */
.app-topbar {
position: sticky; top: 0; z-index: 20;
position: relative; z-index: 20; flex: 0 0 var(--topbar-h);
height: var(--topbar-h); display: flex; align-items: center; gap: 12px;
padding: 0 24px;
background: color-mix(in srgb, var(--surface) 70%, transparent);
@@ -312,7 +316,10 @@ html[data-theme="dark"] .app-topbar { border-bottom-color: rgba(255,255,255,.06)
.app-user-btn__name { font-size: 13px; font-weight: 700; }
/* ==================== Main ==================== */
.app-main { padding: 28px 32px; overflow-x: hidden; }
.app-main {
flex: 1; min-width: 0; min-height: 0; padding: 24px 28px 0;
overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable;
}
.backend-alert { width: min(100%, var(--page-max)); margin: 0 auto 18px; border-radius: var(--radius-md); }
/* ==================== Responsive ==================== */
@@ -322,7 +329,7 @@ html[data-theme="dark"] .app-topbar { border-bottom-color: rgba(255,255,255,.06)
.app-sidebar:not(.mobile-open) .app-brand { display: none; }
.app-sidebar:not(.mobile-open) .app-sidebar__foot { display: none; }
.app-main-col { margin-left: 0 !important; }
.app-main { padding: 12px 12px; }
.app-main { padding: 12px 12px 0; }
.app-topbar { padding: 0 10px; gap: 6px; }
.app-topbar__menu-btn { display: grid; }
.app-breadcrumb { font-size: 11.5px; max-width: 130px; }
+12 -2
View File
@@ -52,12 +52,16 @@ const visible = computed({
<style scoped>
:deep(.right-drawer .el-drawer__body) {
height: 100%;
padding: 0;
overflow: hidden;
}
.right-drawer__shell {
display: flex;
min-height: 100%;
width: 100%;
height: 100%;
min-height: 0;
flex-direction: column;
background: var(--surface);
}
@@ -69,6 +73,7 @@ const visible = computed({
gap: 16px;
padding: 24px 24px 18px;
border-bottom: 1px solid var(--border-subtle);
flex: 0 0 auto;
}
.right-drawer__eyebrow {
@@ -100,9 +105,12 @@ const visible = computed({
}
.right-drawer__body {
flex: 1;
flex: 1 1 auto;
min-height: 0;
padding: 20px 24px 24px;
overflow: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.right-drawer__footer {
@@ -111,5 +119,7 @@ const visible = computed({
gap: 12px;
padding: 16px 24px 24px;
border-top: 1px solid var(--border-subtle);
flex: 0 0 auto;
background: var(--surface);
}
</style>
+69
View File
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
const props = withDefaults(defineProps<{
src?: string | null;
alt?: string;
size?: number;
}>(), {
src: "",
alt: "",
size: 48
});
const emit = defineEmits<{
error: [event: Event];
}>();
const failed = ref(false);
const style = computed(() => ({
width: `${props.size}px`,
height: `${props.size}px`
}));
watch(() => props.src, () => {
failed.value = false;
});
function handleError(event: Event) {
failed.value = true;
emit("error", event);
}
</script>
<template>
<span class="safe-avatar" :style="style">
<img
v-if="src && !failed"
:src="src"
:alt="alt"
loading="lazy"
decoding="async"
referrerpolicy="no-referrer"
@error="handleError"
/>
<span v-else class="safe-avatar__fallback"><slot /></span>
</span>
</template>
<style scoped>
.safe-avatar {
display: inline-grid;
flex: 0 0 auto;
place-items: center;
overflow: hidden;
border-radius: 50%;
color: var(--accent-strong);
background: var(--accent-soft);
font-weight: 700;
}
.safe-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.safe-avatar__fallback {
line-height: 1;
}
</style>
@@ -0,0 +1,116 @@
<script setup lang="ts">
import { computed } from "vue";
interface StorageCapacityStatus {
isEnabled: boolean;
isAvailable: boolean;
checkedPath: string;
totalBytes: number;
usedBytes: number;
availableBytes: number;
usagePercent: number;
freePercent: number;
tier: string;
message: string;
}
const props = defineProps<{ status: StorageCapacityStatus }>();
const percentage = computed(() => {
const value = Number(props.status.usagePercent);
return Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : 0;
});
const tier = computed(() => props.status.tier?.toLowerCase() || "red");
const statusLabel = computed(() => {
if (!props.status.isAvailable) return "无法读取";
if (!props.status.isEnabled) return "保护未启用";
if (tier.value === "green") return "空间充足";
if (tier.value === "yellow") return "空间偏低";
return "空间紧张";
});
const tagType = computed<"success" | "warning" | "danger" | "info">(() => {
if (!props.status.isAvailable) return "danger";
if (!props.status.isEnabled) return "info";
if (tier.value === "green") return "success";
if (tier.value === "yellow") return "warning";
return "danger";
});
const barColor = computed(() => {
if (!props.status.isAvailable) return "var(--danger)";
if (!props.status.isEnabled) return "var(--text-muted)";
if (tier.value === "green") return "var(--success)";
if (tier.value === "yellow") return "var(--warning)";
return "var(--danger)";
});
function formatBytes(bytes: number) {
if (!Number.isFinite(bytes) || bytes < 0) return "--";
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
let value = bytes;
let index = 0;
while (value >= 1024 && index < units.length - 1) {
value /= 1024;
index += 1;
}
return `${value.toFixed(value >= 100 || index === 0 ? 0 : value >= 10 ? 1 : 2)} ${units[index]}`;
}
</script>
<template>
<section class="storage-capacity" data-testid="storage-capacity">
<div class="storage-capacity__header">
<div>
<div class="storage-capacity__eyebrow">录制存储</div>
<div class="storage-capacity__headline">
<strong>{{ status.isAvailable ? `${percentage.toFixed(1)}%` : "--" }}</strong>
<span>已使用</span>
</div>
</div>
<el-tag :type="tagType" effect="light">{{ statusLabel }}</el-tag>
</div>
<el-progress
:percentage="percentage"
:stroke-width="12"
:show-text="false"
:color="barColor"
:aria-label="`存储已使用 ${percentage.toFixed(1)}%`"
/>
<dl class="storage-capacity__metrics">
<div><dt>已使用</dt><dd>{{ status.isAvailable ? formatBytes(status.usedBytes) : "--" }}</dd></div>
<div><dt>可用</dt><dd>{{ status.isAvailable ? formatBytes(status.availableBytes) : "--" }}</dd></div>
<div><dt>总容量</dt><dd>{{ status.isAvailable ? formatBytes(status.totalBytes) : "--" }}</dd></div>
<div><dt>剩余比例</dt><dd>{{ status.isAvailable ? `${status.freePercent.toFixed(1)}%` : "--" }}</dd></div>
</dl>
<div class="storage-capacity__foot">
<span class="storage-capacity__path" :title="status.checkedPath">{{ status.checkedPath || "未配置输出路径" }}</span>
<el-tooltip v-if="status.message" :content="status.message" placement="top">
<span class="storage-capacity__help" tabindex="0">状态说明</span>
</el-tooltip>
</div>
</section>
</template>
<style scoped>
.storage-capacity { display: grid; gap: 18px; }
.storage-capacity__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.storage-capacity__eyebrow { color: var(--text-muted); font-size: 12px; font-weight: 700; }
.storage-capacity__headline { display: flex; align-items: baseline; gap: 8px; margin-top: 5px; }
.storage-capacity__headline strong { color: var(--text-primary); font-size: 30px; line-height: 1; font-variant-numeric: tabular-nums; }
.storage-capacity__headline span { color: var(--text-muted); font-size: 13px; }
.storage-capacity__metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin: 0; }
.storage-capacity__metrics > div { min-width: 0; padding: 10px 12px; border-radius: var(--radius-sm); background: var(--surface-muted); }
.storage-capacity__metrics dt { color: var(--text-muted); font-size: 11px; }
.storage-capacity__metrics dd { margin: 4px 0 0; color: var(--text-primary); font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; }
.storage-capacity__foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-width: 0; color: var(--text-muted); font-size: 12px; }
.storage-capacity__path { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-mono); }
.storage-capacity__help { flex: 0 0 auto; color: var(--accent); cursor: help; }
@media (max-width: 640px) {
.storage-capacity__metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.storage-capacity__foot { align-items: flex-start; flex-direction: column; }
.storage-capacity__path { width: 100%; white-space: normal; overflow-wrap: anywhere; }
}
</style>
+28 -10
View File
@@ -172,12 +172,12 @@ html[data-theme="dark"] #app {
/* ============================ Base ============================ */
* { box-sizing: border-box; }
html, body, #app { margin: 0; min-height: 100%; }
html, body, #app { margin: 0; width: 100%; height: 100%; min-height: 100%; }
body {
color: var(--text-primary);
background: var(--bg-base);
text-rendering: optimizeLegibility;
overflow-x: hidden;
overflow: hidden;
}
a { color: inherit; text-decoration: none; }
button { font-family: inherit; cursor: pointer; }
@@ -190,7 +190,7 @@ button { font-family: inherit; cursor: pointer; }
::selection { background: var(--accent-soft); }
/* ============================ Page layout ============================ */
.page-stack { display: grid; gap: var(--page-gap); width: min(100%, var(--page-max)); margin: 0 auto; }
.page-stack { display: grid; gap: var(--page-gap); width: min(100%, var(--page-max)); margin: 0 auto; padding-bottom: 24px; }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.page-header > div:first-child { flex: 1; min-width: 0; }
.page-title { margin: 0; font-size: 24px; font-weight: 750; letter-spacing: -.02em; line-height: 1.25; color: var(--text-primary); }
@@ -204,7 +204,7 @@ button { font-family: inherit; cursor: pointer; }
border-radius: var(--radius-md); border: 1px solid var(--border-subtle);
background: var(--surface); box-shadow: var(--shadow-xs);
}
.surface-card:hover { box-shadow: var(--shadow-sm); }
.surface-card:hover { border-color: var(--border-base); }
.surface-card .el-card__body { padding: 18px; }
/* stat grid / KPI cards */
@@ -224,9 +224,17 @@ button { font-family: inherit; cursor: pointer; }
.section-subtitle { margin: 0; color: var(--text-muted); font-size: 12.5px; line-height: 1.7; }
.section-header, .toolbar-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 18px; border-bottom: 1px solid var(--border-subtle); }
.toolbar-row { margin-bottom: 18px; }
.list-filterbar {
display: grid; grid-template-columns: minmax(240px, 1fr) 180px auto; align-items: center;
gap: 10px; margin: 0 0 18px;
}
.list-filterbar__count { color: var(--text-muted); font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; }
/* table */
.table-scroll-shell { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
.table-scroll-shell {
width: 100%; min-width: 0; overflow-x: auto; overflow-y: visible;
padding-bottom: 2px; scrollbar-gutter: stable; -webkit-overflow-scrolling: touch;
}
.cell-title { color: var(--text-primary); font-size: 15px; font-weight: 700; line-height: 1.45; }
.cell-subtitle { margin-top: 2px; color: var(--text-muted); font-size: 12px; line-height: 1.6; font-weight: 500; }
.cell-mono, .table-date-text, .monospace { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
@@ -326,21 +334,29 @@ html[data-theme="dark"] .el-table th.el-table__cell { background: #16223a; }
.el-table tr { background: transparent; }
.el-table tbody tr:nth-child(even) { background: rgba(15, 23, 42, 0.018); }
html[data-theme="dark"] .el-table tbody tr:nth-child(even) { background: rgba(255, 255, 255, 0.02); }
.premium-table { min-width: 920px; }
.premium-table { width: 100%; }
/* descriptions */
.el-descriptions { --el-descriptions-table-border: 1px solid var(--border-subtle); }
.el-descriptions__body .el-descriptions__table { border-radius: var(--radius-sm); overflow: hidden; }
.el-descriptions__body .el-descriptions__label.el-descriptions__cell.is-bordered-label { background: var(--surface-muted); color: var(--text-muted); font-weight: 600; }
.el-descriptions__body .el-descriptions__content.el-descriptions__cell.is-bordered-content { background: var(--surface); color: var(--text-primary); }
.el-descriptions__content { min-width: 0; overflow-wrap: anywhere; word-break: break-word; }
/* dialogs */
.el-dialog { border-radius: var(--radius-md); border: 1px solid var(--border-base); background: var(--surface); box-shadow: var(--shadow-float); }
.el-overlay-dialog { display: grid; place-items: center; padding: 16px; overflow: hidden; }
.el-dialog {
display: flex; flex-direction: column; max-width: calc(100vw - 32px);
max-height: calc(100dvh - 32px); margin: 0 !important;
border-radius: var(--radius-md); border: 1px solid var(--border-base);
background: var(--surface); box-shadow: var(--shadow-float);
}
.el-message-box, .el-popover.el-popper, .el-select__popper.el-popper, .el-picker__popper.el-popper { border-color: var(--border-subtle); background: var(--surface); color: var(--text-primary); box-shadow: var(--shadow-float); }
.el-dropdown__popper.el-popper .el-dropdown-menu { border-color: var(--border-subtle); background: var(--surface); box-shadow: var(--shadow-md); border-radius: var(--radius-sm); }
.el-dropdown-menu__item.danger-menu-item { color: var(--danger); }
.el-dialog__header { margin: 0; padding: 20px 24px 10px; }
.el-dialog__title { color: var(--text-primary); font-size: 18px; font-weight: 700; letter-spacing: -.03em; }
.el-dialog__body { padding: 12px 24px 6px; }
.el-dialog__body { min-height: 0; padding: 12px 24px 6px; overflow: auto; overscroll-behavior: contain; }
.el-dialog__footer { padding: 12px 24px 20px; }
.el-empty__description, .el-empty__description p, .el-result__subtitle { color: var(--text-secondary); }
@@ -379,11 +395,13 @@ html[data-theme="dark"] .el-table tbody tr:nth-child(even) { background: rgba(25
.stat-card__value { font-size: 24px; }
.desktop-only { display: none !important; }
.mobile-only { display: block !important; }
.el-dialog { width: min(100vw - 16px, 560px) !important; margin: 3vh auto 0 !important; }
.el-overlay-dialog { padding: 8px; }
.el-dialog { width: min(100vw - 16px, 560px) !important; max-height: calc(100dvh - 16px); margin: 0 !important; }
.el-dialog__header { padding: 16px 16px 8px; }
.el-dialog__body { padding: 10px 16px 4px; }
.el-dialog__footer { padding: 10px 16px 16px; }
.el-table th.el-table__cell { padding: 10px 12px; font-size: 11px; }
.el-table td.el-table__cell { padding: 10px 12px; font-size: 12.5px; line-height: 1.45; }
.list-filterbar { grid-template-columns: 1fr; }
.list-filterbar__count { justify-self: start; }
}
@media (max-width: 400px) { .stats-grid { grid-template-columns: 1fr; } }
+52 -1
View File
@@ -191,6 +191,17 @@ export interface RecordSession {
tasks: RecordTask[];
}
export interface RecordSessionListResponse {
items: RecordSession[];
totalCount: number;
skip: number;
take: number;
totalSessionCount: number;
activeSessionCount: number;
totalTaskCount: number;
totalDanmakuCount: number;
}
export interface SystemLog {
id: string;
level: number;
@@ -301,6 +312,7 @@ export interface UploadTaskListResponse {
items: UploadTaskItem[];
totalCount: number;
notUploadedCount: number;
failedArtifactCount: number;
succeededCount: number;
failedCount: number;
queuedCount: number;
@@ -472,6 +484,7 @@ export interface SystemSettings {
enableRetentionCleanup: boolean;
retentionDays: number;
retentionDeleteFiles: boolean;
retentionRequireUploadSuccess: boolean;
retentionVideoFileCondition: CleanupVideoFileCondition;
retentionTaskStatuses: number[];
enableAutoReconnect: boolean;
@@ -659,10 +672,18 @@ export interface RecoveryOverview {
export interface StorageGuardStatus {
isEnabled: boolean;
isAvailable: boolean;
hasEnoughSpace: boolean;
checkedPath: string;
totalBytes: number;
usedBytes: number;
availableBytes: number;
requiredBytes: number;
usagePercent: number;
freePercent: number;
greenThresholdPercent: number;
redThresholdPercent: number;
tier: string;
message: string;
}
@@ -745,7 +766,7 @@ export const availabilityLabelMap: Record<number, string> = {
export const currentRecordingStateLabelMap: Record<number, string> = {
0: "未开播",
1: "开播",
1: "开播(未录制)",
2: "录制中"
};
@@ -826,6 +847,22 @@ export const autoStartDecisionLabelMap: Record<string, string> = {
poll_failed: "轮询失败"
};
export const autoStartDecisionSummaryMap: Record<string, string> = {
started: "自动开录已成功启动。",
skipped_disabled: "直播间已禁用,本次自动开录已跳过。",
skipped_storage: "存储空间不满足录制要求,本次自动开录已跳过。",
skipped_active_session: "已有活动录制会话,本次自动开录已跳过。",
skipped_offline: "直播间当前未开播,本次自动开录已跳过。",
skipped_debounce: "仍处于防抖等待时间,本次自动开录已跳过。",
failed_startup: "录制进程启动失败。",
poll_failed_transient: "直播状态巡检暂时失败,系统将自动重试。",
poll_failed: "直播状态巡检失败。"
};
export function formatAutoStartDecisionSummary(code?: string, fallback?: string) {
return (code && autoStartDecisionSummaryMap[code]) || fallback || "暂无自动开录摘要";
}
export const platformLabelMap: Record<number, string> = {
0: "未知",
1: "Douyin",
@@ -899,20 +936,34 @@ export interface DashboardData {
todayDanmakuCount: number;
activeSessionCount: number;
recentErrorCount: number;
currentErrorCount: number;
storageStatus: DashboardStorageStatus;
recentSessions: DashboardRecentSession[];
topRooms: DashboardTopRoom[];
pendingTranscodeCount: number;
pendingUploadCount: number;
queuedDataBytes: number;
oldestTranscodeUpdatedAt?: string | null;
oldestUploadProgressAt?: string | null;
stalledUploadCount: number;
uploadCleanupFailureCount: number;
}
export interface DashboardStorageStatus {
isEnabled: boolean;
isAvailable: boolean;
hasEnoughSpace: boolean;
message: string;
checkedPath: string;
totalBytes: number;
usedBytes: number;
availableBytes: number;
requiredBytes: number;
tier: string;
usagePercent: number;
freePercent: number;
greenThresholdPercent: number;
redThresholdPercent: number;
}
export interface DashboardRecentSession {
+140 -188
View File
@@ -1,10 +1,13 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { Bell, Clock, DataAnalysis, House, VideoCamera, Warning } from "@element-plus/icons-vue";
import { Clock, House, Refresh, Upload, VideoCamera, Warning } from "@element-plus/icons-vue";
import apiClient, { getApiErrorMessage } from "@/api/client";
import EmptyState from "@/components/ui/EmptyState.vue";
import MetricCard from "@/components/ui/MetricCard.vue";
import type { DashboardData } from "@/types";
import StatusBadge from "@/components/ui/StatusBadge.vue";
import StorageCapacity from "@/components/ui/StorageCapacity.vue";
import type { DashboardData, DashboardRecentSession } from "@/types";
import { sessionStatusLabelMap } from "@/types";
const router = useRouter();
@@ -12,45 +15,36 @@ const loading = ref(false);
const loadError = ref("");
const data = ref<DashboardData | null>(null);
const queueTotal = computed(() => (data.value?.pendingTranscodeCount ?? 0) + (data.value?.pendingUploadCount ?? 0));
const hasAttention = computed(() => Boolean(
data.value && (
data.value.currentErrorCount > 0 ||
data.value.storageStatus.tier !== "Green" ||
queueTotal.value > 0
)
));
function formatDuration(seconds?: number) {
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) return "-";
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) return "--";
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return hours > 0 ? `${hours} 小时 ${minutes}` : `${minutes} 分钟`;
}
function formatDataSize(bytes?: number) {
if (typeof bytes !== "number" || bytes <= 0) return "-";
if (typeof bytes !== "number" || bytes < 0) return "--";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
}
function formatDate(value?: string) {
return value ? new Date(value).toLocaleString() : "-";
return value ? new Date(value).toLocaleString() : "--";
}
function sessionStatusTagType(status: number): "" | "success" | "warning" | "danger" | "info" {
if (status === 2) return "success";
if (status === 5) return "danger";
if (status === 4 || status === 6) return "info";
return "warning";
}
function storageTierTagType(): "success" | "warning" | "danger" {
const tier = data.value?.storageStatus.tier;
if (tier === "Green") return "success";
if (tier === "Yellow") return "warning";
return "danger";
}
function storageTierLabel(): string {
const tier = data.value?.storageStatus.tier;
if (tier === "Green") return "正常";
if (tier === "Yellow") return "警告";
return "紧急";
function sessionStatus(session: DashboardRecentSession) {
return sessionStatusLabelMap[session.status] ?? "未知";
}
async function loadData() {
@@ -70,179 +64,137 @@ onMounted(loadData);
</script>
<template>
<div class="page-stack">
<div class="page-stack dashboard-page">
<div class="page-header">
<div>
<div class="page-kicker">系统概览</div>
<div class="page-kicker">运行中心</div>
<h1 class="page-title">仪表盘</h1>
<p class="page-subtitle">系统运行状态一览包含直播间录制会话弹幕和存储概况</p>
<p class="page-subtitle">先处理异常与积压再查看录制产出和最近活动</p>
</div>
<div class="header-actions">
<el-button @click="loadData" :loading="loading">刷新</el-button>
<el-button :icon="Refresh" :loading="loading" @click="loadData">刷新</el-button>
</div>
</div>
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<el-skeleton v-if="loading && !data" animated :rows="6" />
<el-skeleton v-if="loading && !data" animated :rows="8" />
<template v-else-if="data">
<!-- KPI -->
<div class="stats-grid">
<MetricCard label="正在录制" :value="data.activeRecordingCount">
<template #icon><el-icon :size="18"><VideoCamera /></el-icon></template>
</MetricCard>
<MetricCard label="直播间" :value="`${data.liveRoomCount} / ${data.offlineRoomCount}`" description="共接入">
<template #icon><el-icon :size="18"><House /></el-icon></template>
</MetricCard>
<MetricCard label="今日录制时长" :value="formatDuration(data.todayRecordingSeconds)">
<template #icon><el-icon :size="18"><Clock /></el-icon></template>
</MetricCard>
<MetricCard label="今日数据量" :value="formatDataSize(data.todayDataBytes)">
<template #icon><el-icon :size="18"><DataAnalysis /></el-icon></template>
</MetricCard>
<MetricCard label="今日弹幕" :value="data.todayDanmakuCount.toLocaleString()">
<template #icon><el-icon :size="18"><Bell /></el-icon></template>
</MetricCard>
<MetricCard
label="24h 异常"
:value="data.recentErrorCount"
:description="data.recentErrorCount > 0 ? '请前往系统日志页面排查' : '系统运行正常'"
>
<template #icon><el-icon :size="18"><Warning /></el-icon></template>
</MetricCard>
<section v-if="hasAttention" class="attention-bar" aria-label="需要关注">
<div class="attention-bar__icon"><el-icon><Warning /></el-icon></div>
<div class="attention-bar__copy">
<strong>有需要关注的运行状态</strong>
<span>
最近 30 分钟 {{ data.currentErrorCount }} 个异常
{{ queueTotal }} 个处理任务等待完成存储状态为 {{ data.storageStatus.tier === "Green" ? "正常" : "受限" }}
</span>
</div>
<el-button size="small" @click="router.push({ name: 'logs' })">查看日志</el-button>
</section>
<div v-if="data.recentErrorCount > data.currentErrorCount" class="history-note">
24 小时共记录 {{ data.recentErrorCount }} 个历史异常最近 30 分钟为 {{ data.currentErrorCount }} 历史记录不代表系统当前仍有故障
<el-button link size="small" @click="router.push({ name: 'logs' })">查看历史日志</el-button>
</div>
<!-- storage + queue -->
<el-row :gutter="18">
<el-col :lg="12" :sm="24">
<el-card class="surface-card" shadow="never">
<h3 class="section-title">存储状态</h3>
<p class="section-subtitle">录制输出路径的磁盘剩余空间和当前录制保护阈值</p>
<div style="display:flex;align-items:center;gap:28px;margin-top:18px">
<div class="ring-wrap" :style="{ background: `conic-gradient(var(--success) ${Number(data.storageStatus.usagePercent.toFixed(1))}%, var(--surface-hover) 0)` }">
<div class="ring-inner">
<div class="ring-num">{{ data.storageStatus.usagePercent.toFixed(1) }}%</div>
<div class="ring-cap">已使用</div>
</div>
</div>
<div style="flex:1;display:grid;gap:14px">
<div>
<div style="font-size:12px;color:var(--text-muted)">可用空间</div>
<div style="font-size:18px;font-weight:700;font-variant-numeric:tabular-nums">
{{ formatDataSize(data.storageStatus.availableBytes) }}
<span style="font-size:13px;font-weight:500;color:var(--text-muted)">/ 总计</span>
</div>
</div>
<div style="display:flex;gap:24px">
<div><div style="font-size:12px;color:var(--text-muted)">水位线</div><el-tag :type="storageTierTagType()" size="small">{{ storageTierLabel() }}</el-tag></div>
<div><div style="font-size:12px;color:var(--text-muted)">说明</div><div style="font-size:13px;font-weight:500;color:var(--text-secondary)">{{ data.storageStatus.message || "-" }}</div></div>
</div>
</div>
</div>
</el-card>
</el-col>
<el-col :lg="12" :sm="24">
<el-card class="surface-card" shadow="never">
<h3 class="section-title">处理队列</h3>
<p class="section-subtitle">待转码和待上传的文件积压情况</p>
<div style="display:grid;gap:14px;margin-top:18px">
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border:1px solid var(--border-subtle);border-radius:var(--radius-sm);background:var(--surface-muted)">
<div><div style="font-weight:600;font-size:13.5px">待转码</div><div style="font-size:12px;color:var(--text-muted)">FFmpeg 后处理队列</div></div>
<div :style="{ fontSize: '26px', fontWeight: 800, fontVariantNumeric: 'tabular-nums', color: data.pendingTranscodeCount > 0 ? 'var(--warning)' : 'var(--text-primary)' }">{{ data.pendingTranscodeCount }}</div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border:1px solid var(--border-subtle);border-radius:var(--radius-sm);background:var(--surface-muted)">
<div><div style="font-weight:600;font-size:13.5px">待上传</div><div style="font-size:12px;color:var(--text-muted)">WebDAV / S3 / OpenList</div></div>
<div :style="{ fontSize: '26px', fontWeight: 800, fontVariantNumeric: 'tabular-nums', color: data.pendingUploadCount > 0 ? 'var(--warning)' : 'var(--text-primary)' }">{{ data.pendingUploadCount }}</div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border:1px solid var(--border-subtle);border-radius:var(--radius-sm);background:var(--surface-muted)">
<div><div style="font-weight:600;font-size:13.5px">积压数据量</div><div style="font-size:12px;color:var(--text-muted)">等待归档的本地文件总量</div></div>
<div style="font-size:20px;font-weight:700;font-variant-numeric:tabular-nums">{{ formatDataSize(data.queuedDataBytes) }}</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<div class="dashboard-metrics">
<MetricCard label="正在录制" :value="data.activeRecordingCount" description="当前活动录制会话" :icon="VideoCamera" />
<MetricCard label="在线直播间" :value="`${data.liveRoomCount} / ${data.totalRoomCount}`" description="在线 / 已接入" :icon="House" />
<MetricCard label="今日录制" :value="formatDuration(data.todayRecordingSeconds)" :description="`${formatDataSize(data.todayDataBytes)} · ${data.todayDanmakuCount.toLocaleString()} 条弹幕`" :icon="Clock" />
<MetricCard label="待处理" :value="queueTotal" :description="`${data.pendingTranscodeCount} 转码 · ${data.pendingUploadCount} 上传`" :icon="Upload" />
</div>
<!-- recent sessions + top rooms -->
<el-row :gutter="18">
<el-col :lg="12" :sm="24">
<el-card class="surface-card" shadow="never">
<h3 class="section-title">最近会话</h3>
<p class="section-subtitle">最近创建的录制会话点击可跳转至详情</p>
<div class="table-scroll-shell" style="margin-top:14px">
<el-table :data="data.recentSessions" class="premium-table" size="small">
<el-table-column label="直播间" min-width="140" prop="liveRoomTitle" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="sessionStatusTagType(row.status)" size="small">
{{ sessionStatusLabelMap[row.status] }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="分片" width="60" prop="segmentCount" />
<el-table-column label="开始时间" width="160">
<template #default="{ row }">{{ formatDate(row.startedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button size="small" @click="router.push({ name: 'record-session-detail', params: { id: row.id } })">查看</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</el-col>
<el-col :lg="12" :sm="24">
<el-card class="surface-card" shadow="never">
<h3 class="section-title">今日热门直播间</h3>
<p class="section-subtitle">今日录制时长最长的直播间Top 5</p>
<div class="table-scroll-shell" style="margin-top:14px">
<el-table :data="data.topRooms" class="premium-table" size="small">
<el-table-column label="直播间" min-width="130">
<template #default="{ row }">{{ row.title || row.anchorName || "-" }}</template>
</el-table-column>
<el-table-column label="平台" width="90" prop="platformName" />
<el-table-column label="会话数" width="70" prop="sessionCount" />
<el-table-column label="录制时长" width="100">
<template #default="{ row }">{{ formatDuration(row.totalDurationSeconds) }}</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button size="small" @click="router.push({ name: 'live-rooms' })">查看</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</el-col>
</el-row>
<div class="operations-grid">
<el-card class="surface-card storage-card" shadow="never">
<div class="panel-heading">
<div><h2>存储空间</h2><p>输出目录实际容量与录制保护状态</p></div>
</div>
<StorageCapacity :status="data.storageStatus" />
</el-card>
<el-card class="surface-card queue-card" shadow="never">
<div class="panel-heading">
<div><h2>处理队列</h2><p>需要系统继续处理的本地文件</p></div>
</div>
<button class="queue-row" type="button" @click="router.push({ name: 'transcode-tasks' })">
<span><strong>待转码</strong><small>FFmpeg 后处理</small></span><b>{{ data.pendingTranscodeCount }}</b>
</button>
<button class="queue-row" type="button" @click="router.push({ name: 'upload-tasks' })">
<span><strong>待上传</strong><small>远端归档</small></span><b>{{ data.pendingUploadCount }}</b>
</button>
<div v-if="data.stalledUploadCount > 0" class="queue-volume">
<span>上传停滞超过 60 分钟</span><strong>{{ data.stalledUploadCount }}</strong>
</div>
<div v-if="data.uploadCleanupFailureCount > 0" class="queue-volume">
<span>本地清理等待重试</span><strong>{{ data.uploadCleanupFailureCount }}</strong>
</div>
<div class="queue-volume"><span>积压数据量</span><strong>{{ formatDataSize(data.queuedDataBytes) }}</strong></div>
</el-card>
</div>
<div class="activity-grid">
<el-card class="surface-card activity-card" shadow="never">
<div class="panel-heading">
<div><h2>最近录制</h2><p>最新创建的录制会话</p></div>
<el-button link @click="router.push({ name: 'record-tasks' })">查看全部</el-button>
</div>
<EmptyState v-if="data.recentSessions.length === 0" title="暂无录制会话" description="直播开始录制后会出现在这里" />
<button
v-for="session in data.recentSessions"
v-else
:key="session.id"
class="activity-row"
type="button"
@click="router.push({ name: 'record-session-detail', params: { id: session.id } })"
>
<span class="activity-row__main"><strong>{{ session.liveRoomTitle }}</strong><small>{{ formatDate(session.startedAt) }} · {{ session.segmentCount }} 个分片</small></span>
<StatusBadge :label="sessionStatus(session)" :status="session.status" />
</button>
</el-card>
<el-card class="surface-card activity-card" shadow="never">
<div class="panel-heading">
<div><h2>今日录制排行</h2><p>按录制时长排序的直播间</p></div>
<el-button link @click="router.push({ name: 'live-rooms' })">直播间</el-button>
</div>
<EmptyState v-if="data.topRooms.length === 0" title="今日暂无录制" description="完成录制后会生成今日排行" />
<button v-for="(room, index) in data.topRooms" v-else :key="room.liveRoomId" class="activity-row" type="button" @click="router.push({ name: 'live-rooms' })">
<span class="rank">{{ index + 1 }}</span>
<span class="activity-row__main"><strong>{{ room.title || room.anchorName || room.roomId }}</strong><small>{{ room.platformName }} · {{ room.sessionCount }} 个会话</small></span>
<span class="activity-row__value">{{ formatDuration(room.totalDurationSeconds) }}</span>
</button>
</el-card>
</div>
</template>
</div>
</template>
<style scoped>
.page-stack { display: grid; gap: var(--page-gap); }
.header-actions { align-self: center; }
.page-error-alert { border-radius: var(--radius-md); }
.ring-wrap {
--p: 32;
width: 92px; height: 92px; flex-shrink: 0;
border-radius: 99px; display: grid; place-items: center;
}
.ring-inner {
width: 70px; height: 70px; border-radius: 99px;
background: var(--surface); display: grid; place-items: center; text-align: center;
}
.ring-num { font-size: 20px; font-weight: 800; line-height: 1; }
.ring-cap { font-size: 10.5px; color: var(--text-muted); margin-top: 2px; }
@media (max-width: 768px) {
.header-actions { width: 100%; }
.header-actions :deep(.el-button) { flex: 1; }
.ring-wrap { width: 68px; height: 68px; }
.ring-inner { width: 50px; height: 50px; }
.ring-num { font-size: 16px; }
}
.dashboard-page { gap: 18px; }
.attention-bar { display: flex; align-items: center; gap: 14px; padding: 14px 16px; border: 1px solid color-mix(in srgb, var(--warning) 28%, var(--border-subtle)); border-radius: var(--radius-md); background: var(--warning-soft); }
.attention-bar__icon { display: grid; place-items: center; width: 34px; height: 34px; flex: 0 0 auto; border-radius: 50%; color: var(--warning); background: var(--surface); }
.attention-bar__copy { display: grid; flex: 1; min-width: 0; gap: 3px; }
.attention-bar__copy strong { font-size: 14px; }
.attention-bar__copy span { color: var(--text-secondary); font-size: 12.5px; line-height: 1.5; }
.history-note { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 12px; border-radius: var(--radius-sm); background: var(--surface-subtle); color: var(--text-muted); font-size: 12px; line-height: 1.5; }
.dashboard-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.operations-grid { display: grid; grid-template-columns: minmax(0, 1.8fr) minmax(280px, .8fr); gap: 14px; }
.activity-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 18px; }
.panel-heading h2 { margin: 0; font-size: 16px; }
.panel-heading p { margin: 5px 0 0; color: var(--text-muted); font-size: 12.5px; }
.queue-card :deep(.el-card__body), .activity-card :deep(.el-card__body) { display: grid; }
.queue-row, .activity-row { display: flex; align-items: center; width: 100%; gap: 12px; padding: 12px; border: 0; border-top: 1px solid var(--border-subtle); background: transparent; color: var(--text-primary); text-align: left; }
.queue-row:hover, .activity-row:hover { background: var(--surface-hover); }
.queue-row span, .activity-row__main { display: grid; flex: 1; min-width: 0; gap: 4px; }
.queue-row small, .activity-row small { color: var(--text-muted); font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.queue-row b { font-size: 24px; font-variant-numeric: tabular-nums; }
.queue-volume { display: flex; justify-content: space-between; gap: 12px; padding: 14px 12px 0; color: var(--text-muted); font-size: 12px; }
.queue-volume strong { color: var(--text-primary); font-size: 14px; }
.activity-row__main strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; }
.activity-row__value { flex: 0 0 auto; color: var(--text-secondary); font-size: 12.5px; font-weight: 700; }
.rank { display: grid; place-items: center; width: 26px; height: 26px; flex: 0 0 auto; border-radius: 7px; background: var(--surface-muted); color: var(--text-muted); font-weight: 800; }
@media (max-width: 1100px) { .dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .operations-grid { grid-template-columns: 1fr; } }
@media (max-width: 768px) { .activity-grid { grid-template-columns: 1fr; } .attention-bar { align-items: flex-start; flex-wrap: wrap; } .attention-bar .el-button { width: 100%; } }
@media (max-width: 480px) { .dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } }
</style>
+139 -281
View File
@@ -1,15 +1,17 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import apiClient, { getApiErrorMessage } from "@/api/client";
import EmptyState from "@/components/ui/EmptyState.vue";
import MetricCard from "@/components/ui/MetricCard.vue";
import RightDrawer from "@/components/ui/RightDrawer.vue";
import SafeAvatar from "@/components/ui/SafeAvatar.vue";
import StatusBadge from "@/components/ui/StatusBadge.vue";
import { useViewport } from "@/composables/useViewport";
import type { BatchLiveRoomsResult, ImportLiveRoomsRequest, ImportLiveRoomsResult, LiveRoom, RecordTask } from "@/types";
import {
autoStartDecisionLabelMap,
formatAutoStartDecisionSummary,
formatQualityLabel,
availabilityLabelMap,
currentRecordingStateLabelMap,
@@ -47,10 +49,11 @@ const loadError = ref("");
const roomDetailVisible = ref(false);
const activeRoom = ref<LiveRoom | null>(null);
const { isMobile } = useViewport();
const roomsTableShellRef = ref<HTMLElement | null>(null);
const roomsTableProxyRef = ref<HTMLElement | null>(null);
const showRoomsTableProxyScroll = ref(false);
const roomsTableProxyInnerWidth = ref(0);
const roomSearch = ref("");
const roomState = ref("all");
const mobileRoomPage = ref(1);
const mobileRoomPageSize = 12;
const failedAvatarUrls = ref<Set<string>>(new Set());
const createForm = reactive({
url: "",
@@ -113,12 +116,36 @@ const selectedRoomIds = computed(() => selectedRooms.value.map((item) => item.id
const selectedRoomCount = computed(() => selectedRooms.value.length);
const hasSelectedRooms = computed(() => selectedRoomCount.value > 0);
const pendingDeleteCount = computed(() => pendingDeleteRoom.value ? 1 : pendingDeleteRoomIds.value.length);
const totalRooms = computed(() => rooms.value.length);
const enabledRooms = computed(() => rooms.value.filter((item) => item.isEnabled).length);
const disabledRooms = computed(() => rooms.value.filter((item) => !item.isEnabled).length);
const liveRooms = computed(() => rooms.value.filter((item) => item.availabilityStatus === 2).length);
const recordingRooms = computed(() => rooms.value.filter((item) => item.currentRecordingState === 2).length);
const priorityRooms = computed(() => rooms.value.filter((item) => item.isPriority).length);
const filteredRooms = computed(() => {
const keyword = roomSearch.value.trim().toLowerCase();
return rooms.value.filter((room) => {
const matchesKeyword = !keyword || [room.title, room.anchorName, room.alias, room.roomId, room.platformName]
.some((value) => String(value || "").toLowerCase().includes(keyword));
const matchesState = roomState.value === "all" ||
(roomState.value === "live" && room.availabilityStatus === 2) ||
(roomState.value === "recording" && room.currentRecordingState === 2) ||
(roomState.value === "enabled" && room.isEnabled) ||
(roomState.value === "disabled" && !room.isEnabled);
return matchesKeyword && matchesState;
});
});
const mobileRoomPageCount = computed(() =>
Math.max(1, Math.ceil(filteredRooms.value.length / mobileRoomPageSize))
);
const paginatedMobileRooms = computed(() => {
const start = (mobileRoomPage.value - 1) * mobileRoomPageSize;
return filteredRooms.value.slice(start, start + mobileRoomPageSize);
});
watch([roomSearch, roomState], () => {
mobileRoomPage.value = 1;
});
watch(mobileRoomPageCount, (pageCount) => {
mobileRoomPage.value = Math.min(mobileRoomPage.value, pageCount);
});
const activeRoomSubtitle = computed(() => {
if (!activeRoom.value) {
return "";
@@ -126,182 +153,16 @@ const activeRoomSubtitle = computed(() => {
return `${activeRoom.value.platformName || "--"} · ${activeRoom.value.roomId || "--"}`;
});
const tableHeight = computed(() => (isMobile.value ? undefined : 700));
function clearRoomFilters() {
roomSearch.value = "";
roomState.value = "all";
}
const createDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 560px)" : "560px"));
const importDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 720px)" : "720px"));
const recordDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 440px)" : "440px"));
const settingsDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 760px)" : "840px"));
const deleteDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 560px)" : "clamp(480px, 46vw, 560px)"));
let autoRefreshTimer: number | null = null;
let roomsTableScrollWrap: HTMLElement | null = null;
let roomsTableResizeObserver: ResizeObserver | null = null;
let observedRoomsTableShell: HTMLElement | null = null;
let observedRoomsTableWrap: HTMLElement | null = null;
let syncingRoomsTableProxy = false;
let syncingRoomsTableBody = false;
function cleanupRoomsTableScrollSync() {
if (roomsTableScrollWrap) {
roomsTableScrollWrap.removeEventListener("scroll", handleRoomsTableBodyScroll);
roomsTableScrollWrap = null;
}
roomsTableResizeObserver?.disconnect();
roomsTableResizeObserver = null;
observedRoomsTableShell = null;
observedRoomsTableWrap = null;
}
function getRoomsTableScrollWrap() {
const shell = roomsTableShellRef.value;
if (!shell) {
return null;
}
return shell.querySelector<HTMLElement>(".el-table__body-wrapper .el-scrollbar__wrap") ??
shell.querySelector<HTMLElement>(".el-scrollbar__wrap") ??
shell.querySelector<HTMLElement>(".el-table__body-wrapper");
}
function getRoomsTableContentWidth(wrap: HTMLElement) {
const shell = roomsTableShellRef.value;
if (!shell) {
return wrap.scrollWidth;
}
const widths = [
wrap.scrollWidth,
wrap.firstElementChild?.scrollWidth ?? 0,
shell.querySelector<HTMLElement>(".el-table__body table")?.scrollWidth ?? 0,
shell.querySelector<HTMLElement>(".el-table__header table")?.scrollWidth ?? 0
];
const wrapRect = wrap.getBoundingClientRect();
let maxRight = 0;
shell.querySelectorAll<HTMLElement>(
".el-table__header th, .el-table__body td, .room-actions-cell, .config-summary, .auto-start-cell"
).forEach((element) => {
const rect = element.getBoundingClientRect();
maxRight = Math.max(maxRight, rect.right - wrapRect.left + wrap.scrollLeft);
});
widths.push(Math.ceil(maxRight) + 24);
return Math.max(...widths);
}
function handleRoomsTableBodyScroll() {
if (syncingRoomsTableProxy) {
return;
}
const proxy = roomsTableProxyRef.value;
if (!proxy || !roomsTableScrollWrap) {
return;
}
syncingRoomsTableBody = true;
proxy.scrollLeft = roomsTableScrollWrap.scrollLeft;
window.requestAnimationFrame(() => {
syncingRoomsTableBody = false;
});
}
function handleRoomsTableProxyScroll() {
if (syncingRoomsTableBody || !roomsTableScrollWrap) {
return;
}
const proxy = roomsTableProxyRef.value;
if (!proxy) {
return;
}
syncingRoomsTableProxy = true;
roomsTableScrollWrap.scrollLeft = proxy.scrollLeft;
window.requestAnimationFrame(() => {
syncingRoomsTableProxy = false;
});
}
function ensureRoomsTableResizeObserver() {
if (typeof ResizeObserver === "undefined") {
return;
}
const shell = roomsTableShellRef.value;
const wrap = roomsTableScrollWrap;
if (roomsTableResizeObserver && observedRoomsTableShell === shell && observedRoomsTableWrap === wrap) {
return;
}
roomsTableResizeObserver?.disconnect();
roomsTableResizeObserver = new ResizeObserver(() => {
void syncRoomsTableProxyScroll();
});
if (shell) {
roomsTableResizeObserver.observe(shell);
}
if (wrap) {
roomsTableResizeObserver.observe(wrap);
}
observedRoomsTableShell = shell;
observedRoomsTableWrap = wrap;
}
async function syncRoomsTableProxyScroll() {
await nextTick();
if (isMobile.value) {
showRoomsTableProxyScroll.value = false;
roomsTableProxyInnerWidth.value = 0;
cleanupRoomsTableScrollSync();
return;
}
const wrap = getRoomsTableScrollWrap();
if (roomsTableScrollWrap !== wrap) {
roomsTableScrollWrap?.removeEventListener("scroll", handleRoomsTableBodyScroll);
roomsTableScrollWrap = wrap;
roomsTableScrollWrap?.addEventListener("scroll", handleRoomsTableBodyScroll, { passive: true });
}
const proxy = roomsTableProxyRef.value;
if (!wrap) {
showRoomsTableProxyScroll.value = false;
roomsTableProxyInnerWidth.value = 0;
ensureRoomsTableResizeObserver();
return;
}
const scrollWidth = getRoomsTableContentWidth(wrap);
const clientWidth = wrap.clientWidth;
const shellClientWidth = roomsTableShellRef.value?.clientWidth ?? clientWidth;
const proxyViewportWidth = proxy?.clientWidth ?? shellClientWidth;
const canScrollHorizontally = scrollWidth > clientWidth + 1;
const proxyContentWidth = scrollWidth + Math.max(0, proxyViewportWidth - clientWidth);
roomsTableProxyInnerWidth.value = canScrollHorizontally ? Math.ceil(proxyContentWidth) : 0;
showRoomsTableProxyScroll.value = canScrollHorizontally;
if (canScrollHorizontally && proxy && Math.abs(proxy.scrollLeft - wrap.scrollLeft) > 1) {
proxy.scrollLeft = wrap.scrollLeft;
}
ensureRoomsTableResizeObserver();
}
async function loadRooms() {
loading.value = true;
loadError.value = "";
@@ -309,7 +170,6 @@ async function loadRooms() {
try {
const { data } = await apiClient.get<LiveRoom[]>("/live-rooms");
rooms.value = data;
void syncRoomsTableProxyScroll();
} catch (error) {
loadError.value = getApiErrorMessage(error, "直播间列表加载失败,请稍后重试。");
} finally {
@@ -344,7 +204,6 @@ async function autoRefreshRooms() {
const { data } = await apiClient.get<LiveRoom[]>("/live-rooms");
rooms.value = data;
loadError.value = "";
void syncRoomsTableProxyScroll();
} catch {
// Keep the current view stable; the background poller will try again.
}
@@ -711,12 +570,26 @@ function getRoomAvatarText(room: LiveRoom) {
return source.slice(0, 1).toUpperCase();
}
function getRoomAvatarUrl(room: LiveRoom) {
const url = room.avatarUrl || room.coverUrl || "";
return url && !failedAvatarUrls.value.has(url) ? url : "";
}
function markRoomAvatarFailed(room: LiveRoom) {
const url = room.avatarUrl || room.coverUrl || "";
if (!url || failedAvatarUrls.value.has(url)) {
return;
}
failedAvatarUrls.value = new Set([...failedAvatarUrls.value, url]);
}
function roomAvailabilityLabel(room: LiveRoom) {
return availabilityLabelMap[room.availabilityStatus] ?? "--";
}
function latestEventLabel(room: LiveRoom) {
return room.lastAutoStartDecisionSummary || "暂无事件";
return formatAutoStartDecisionSummary(room.lastAutoStartDecisionCode, room.lastAutoStartDecisionSummary || "暂无事件");
}
function getQualityLabel(quality?: string | null) {
@@ -814,17 +687,13 @@ function nullableStringFromSelect(value: string | number) {
onMounted(async () => {
await loadRooms();
void syncRoomsTableProxyScroll();
startAutoRefresh();
document.addEventListener("visibilitychange", handleVisibilityChange);
window.addEventListener("resize", syncRoomsTableProxyScroll);
});
onBeforeUnmount(() => {
stopAutoRefresh();
document.removeEventListener("visibilitychange", handleVisibilityChange);
window.removeEventListener("resize", syncRoomsTableProxyScroll);
cleanupRoomsTableScrollSync();
});
</script>
@@ -857,36 +726,13 @@ onBeforeUnmount(() => {
<MetricCard label="重点监控" :value="priorityRooms" description="标记为重点巡检与优先关注的房间" :icon="RefreshRight" />
</div>
<el-card class="surface-card focus-card" shadow="never">
<div class="focus-card__header">
<div>
<div class="focus-card__eyebrow">监控概览</div>
<h3 class="section-title">直播采集态势</h3>
</div>
<StatusBadge
:label="recordingRooms > 0 ? '录制通道活跃' : '等待开播'"
:status="recordingRooms > 0"
context="boolean"
/>
</div>
<p class="focus-card__description">
当前共接入 {{ totalRooms }} 个直播间其中 {{ enabledRooms }} 个处于自动巡检范围{{ disabledRooms }} 个处于保留但停用状态
</p>
<div class="focus-card__chips">
<span class="info-pill">自动开录复用现有后端策略</span>
<span class="info-pill">单房间配置优先于全局设置</span>
<span class="info-pill">无实时吞吐字段时统一显示占位</span>
</div>
</el-card>
</div>
<el-card class="surface-card table-card" shadow="never" v-loading="loading">
<div class="toolbar-row">
<div>
<h3 class="section-title">直播间列表</h3>
<p class="section-subtitle">继续使用真实接口数据统一强化为监控控制台视图支持查看详情手动录制和单房间配置覆盖</p>
<p class="section-subtitle">搜索主播或房间并按运行状态快速筛选</p>
</div>
<el-space v-if="!isMobile" wrap class="batch-actions">
<span class="batch-actions__count">已选 {{ selectedRoomCount }} </span>
@@ -898,20 +744,32 @@ onBeforeUnmount(() => {
</el-space>
</div>
<div class="list-filterbar">
<el-input v-model="roomSearch" clearable placeholder="搜索主播、标题、平台或 Room ID" />
<el-select v-model="roomState" aria-label="直播间状态筛选">
<el-option label="全部状态" value="all" />
<el-option label="正在直播" value="live" />
<el-option label="正在录制" value="recording" />
<el-option label="已启用" value="enabled" />
<el-option label="已停用" value="disabled" />
</el-select>
<span class="list-filterbar__count">{{ filteredRooms.length }} / {{ rooms.length }}</span>
</div>
<EmptyState
v-if="!loading && rooms.length === 0"
title="暂无数据"
description="当前筛选条件下没有可展示内容"
action-text="刷新列表"
@action="loadRooms"
v-if="!loading && filteredRooms.length === 0"
:title="rooms.length === 0 ? '尚未添加直播间' : '没有匹配的直播间'"
:description="rooms.length === 0 ? '添加直播间后即可开始自动巡检和录制' : '请调整搜索词或状态筛选'"
:action-text="rooms.length === 0 ? '刷新列表' : '清除筛选'"
@action="rooms.length === 0 ? loadRooms() : clearRoomFilters()"
/>
<div v-else-if="isMobile" class="data-card-list room-card-list">
<article v-for="row in rooms" :key="row.id" class="data-card room-card">
<article v-for="row in paginatedMobileRooms" :key="row.id" class="data-card room-card">
<div class="data-card__header">
<el-avatar :src="row.avatarUrl || row.coverUrl" :size="52" class="room-avatar">
<SafeAvatar :src="getRoomAvatarUrl(row)" :alt="row.anchorName || row.title" :size="52" class="room-avatar" @error="markRoomAvatarFailed(row)">
{{ getRoomAvatarText(row) }}
</el-avatar>
</SafeAvatar>
<div>
<div class="data-card__title">{{ row.title || row.anchorName || row.roomId }}</div>
@@ -987,32 +845,36 @@ onBeforeUnmount(() => {
</div>
<div class="data-card__actions">
<el-button size="small" @click="openRoomDetails(row)">查看详情</el-button>
<el-button size="small" @click="refreshRoom(row)">刷新</el-button>
<el-button size="small" @click="openSettingsDialog(row)">配置</el-button>
<el-button size="small" @click="copyRoomLink(row)">复制链接</el-button>
<el-button size="small" @click="openOriginalRoom(row)">打开原房间</el-button>
<el-button size="small" type="primary" :disabled="!row.isEnabled" @click="openRecordDialog(row)">
录制
</el-button>
<el-button size="small" type="danger" plain @click="openDeleteDialog(row)">删除</el-button>
<el-button size="small" type="primary" @click="openRoomDetails(row)">查看详情</el-button>
<el-dropdown trigger="click">
<el-button size="small">更多</el-button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item :disabled="!row.isEnabled" @click="openRecordDialog(row)">开始录制</el-dropdown-item>
<el-dropdown-item @click="refreshRoom(row)">刷新状态</el-dropdown-item>
<el-dropdown-item @click="openSettingsDialog(row)">房间配置</el-dropdown-item>
<el-dropdown-item @click="copyRoomLink(row)">复制链接</el-dropdown-item>
<el-dropdown-item @click="openOriginalRoom(row)">打开原房间</el-dropdown-item>
<el-dropdown-item divided class="danger-menu-item" @click="openDeleteDialog(row)">删除</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
</div>
</article>
<el-pagination
v-model:current-page="mobileRoomPage"
class="mobile-room-pagination"
background
layout="prev, pager, next"
:page-size="mobileRoomPageSize"
:pager-count="5"
:total="filteredRooms.length"
hide-on-single-page
/>
</div>
<div v-else ref="roomsTableShellRef" class="table-scroll-shell rooms-table-shell">
<div
v-if="showRoomsTableProxyScroll"
ref="roomsTableProxyRef"
class="rooms-table-proxy-scroll"
@scroll.passive="handleRoomsTableProxyScroll"
>
<div class="rooms-table-proxy-scroll__inner" :style="{ width: `${roomsTableProxyInnerWidth}px` }"></div>
</div>
<div v-else class="table-scroll-shell rooms-table-shell" data-testid="rooms-table-scroll">
<el-table
:data="rooms"
:height="tableHeight"
:data="filteredRooms"
class="premium-table rooms-table"
table-layout="fixed"
row-key="id"
@@ -1023,9 +885,9 @@ onBeforeUnmount(() => {
<el-table-column label="直播间" min-width="340">
<template #default="{ row }">
<div class="room-summary-cell">
<el-avatar :src="row.avatarUrl || row.coverUrl" :size="52" class="room-avatar">
<SafeAvatar :src="getRoomAvatarUrl(row)" :alt="row.anchorName || row.title" :size="52" class="room-avatar" @error="markRoomAvatarFailed(row)">
{{ getRoomAvatarText(row) }}
</el-avatar>
</SafeAvatar>
<div class="room-summary-cell__copy">
<div class="cell-title">{{ row.title || row.anchorName || row.roomId }}</div>
<div class="cell-subtitle">{{ row.anchorName || "未知主播" }}</div>
@@ -1112,34 +974,27 @@ onBeforeUnmount(() => {
</template>
</el-table-column>
<el-table-column label="监控字段" width="168">
<template #default="{ row }">
<div class="table-placeholder-grid">
<span>在线人数 --</span>
<span>码率 --</span>
<span>采集账号 --</span>
</div>
</template>
</el-table-column>
<el-table-column label="最近检测" width="180">
<template #default="{ row }">
<div class="table-date-text">{{ formatDate(row.lastCheckedAt) }}</div>
</template>
</el-table-column>
<el-table-column label="操作" width="380">
<el-table-column label="操作" width="170" fixed="right">
<template #default="{ row }">
<div class="room-actions-cell">
<el-button size="small" @click="openRoomDetails(row)">查看</el-button>
<el-button size="small" @click="refreshRoom(row)">刷新</el-button>
<el-button size="small" @click="openSettingsDialog(row)">配置</el-button>
<el-button size="small" @click="copyRoomLink(row)">复制链接</el-button>
<el-button size="small" @click="openOriginalRoom(row)">打开原房间</el-button>
<el-button size="small" type="primary" :disabled="!row.isEnabled" @click="openRecordDialog(row)">
开始录制
</el-button>
<el-button size="small" type="danger" plain @click="openDeleteDialog(row)">删除</el-button>
<el-button size="small" type="primary" @click="openRoomDetails(row)">查看</el-button>
<el-dropdown trigger="click">
<el-button size="small">更多</el-button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item :disabled="!row.isEnabled" @click="openRecordDialog(row)">开始录制</el-dropdown-item>
<el-dropdown-item @click="refreshRoom(row)">刷新状态</el-dropdown-item>
<el-dropdown-item @click="openSettingsDialog(row)">房间配置</el-dropdown-item>
<el-dropdown-item @click="copyRoomLink(row)">复制链接</el-dropdown-item>
<el-dropdown-item @click="openOriginalRoom(row)">打开原房间</el-dropdown-item>
<el-dropdown-item divided class="danger-menu-item" @click="openDeleteDialog(row)">删除</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
</div>
</template>
</el-table-column>
@@ -1154,9 +1009,9 @@ onBeforeUnmount(() => {
>
<div v-if="activeRoom" class="detail-panel">
<div class="detail-panel__hero">
<el-avatar :src="activeRoom.avatarUrl || activeRoom.coverUrl" :size="64" class="room-avatar">
<SafeAvatar :src="getRoomAvatarUrl(activeRoom)" :alt="activeRoom.anchorName || activeRoom.title" :size="64" class="room-avatar" @error="markRoomAvatarFailed(activeRoom)">
{{ getRoomAvatarText(activeRoom) }}
</el-avatar>
</SafeAvatar>
<div>
<div class="detail-panel__title">{{ activeRoom.anchorName || "未知主播" }}</div>
<div class="detail-panel__meta">{{ activeRoom.originalLiveRoomUrl || activeRoom.sourceUrl || "--" }}</div>
@@ -1674,28 +1529,18 @@ onBeforeUnmount(() => {
font-size: 13px;
}
.list-filterbar {
display: grid;
grid-template-columns: minmax(220px, 1fr) 180px auto;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.list-filterbar__count { color: var(--text-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.rooms-table-shell {
overflow: hidden;
}
.rooms-table-proxy-scroll {
position: sticky;
top: 0;
z-index: 5;
overflow-x: auto;
overflow-y: hidden;
margin-bottom: 10px;
padding-bottom: 6px;
background: var(--surface);
scrollbar-width: thin;
}
.rooms-table-proxy-scroll__inner {
height: 1px;
}
.rooms-table {
min-width: 1920px;
}
.rooms-table :deep(.el-table__cell) {
@@ -1724,6 +1569,11 @@ onBeforeUnmount(() => {
gap: 16px;
}
.mobile-room-pagination {
justify-content: center;
padding-top: 4px;
}
.room-card__toggle {
display: flex;
align-items: center;
@@ -2040,6 +1890,9 @@ onBeforeUnmount(() => {
flex-direction: column;
}
.list-filterbar { grid-template-columns: 1fr 160px; }
.list-filterbar__count { grid-column: 1 / -1; }
.batch-actions {
justify-content: flex-start;
}
@@ -2063,6 +1916,11 @@ onBeforeUnmount(() => {
}
}
@media (max-width: 600px) {
.list-filterbar { grid-template-columns: 1fr; }
.list-filterbar__count { grid-column: auto; }
}
@media (max-width: 640px) {
.header-actions :deep(.el-space__item) {
width: 100%;
+27 -12
View File
@@ -7,7 +7,9 @@ import { logLevelLabelMap } from "@/types";
const AUTO_REFRESH_INTERVAL_MS = 15000;
const loading = ref(false);
const initialLoading = ref(false);
const refreshing = ref(false);
const requestInFlight = ref(false);
const logs = ref<SystemLog[]>([]);
const loadError = ref("");
const { isMobile } = useViewport();
@@ -31,13 +33,25 @@ const newestLogTime = computed(() => (logs.value[0] ? formatDate(logs.value[0].c
let autoRefreshTimer: number | null = null;
async function loadLogs() {
if (loading.value) {
function mergeLogs(nextLogs: SystemLog[]) {
const previousById = new Map(logs.value.map((item) => [item.id, item]));
return nextLogs.map((item) => {
const previous = previousById.get(item.id);
return previous && JSON.stringify(previous) === JSON.stringify(item) ? previous : item;
});
}
async function loadLogs(background = false) {
if (requestInFlight.value) {
return;
}
loading.value = true;
loadError.value = "";
requestInFlight.value = true;
initialLoading.value = !background && logs.value.length === 0;
refreshing.value = !initialLoading.value;
if (!background) {
loadError.value = "";
}
try {
const { data } = await apiClient.get<SystemLog[]>("/logs", {
@@ -50,11 +64,13 @@ async function loadLogs() {
}
});
logs.value = data;
logs.value = mergeLogs(data);
} catch (error) {
loadError.value = getApiErrorMessage(error, "系统日志加载失败,请稍后重试。");
} finally {
loading.value = false;
requestInFlight.value = false;
initialLoading.value = false;
refreshing.value = false;
}
}
@@ -63,7 +79,7 @@ async function autoRefreshLogs() {
return;
}
await loadLogs();
await loadLogs(true);
}
function startAutoRefresh() {
@@ -126,7 +142,7 @@ onBeforeUnmount(() => {
</div>
<div class="page-toolbar">
<el-button @click="loadLogs">刷新日志</el-button>
<el-button :loading="refreshing" @click="loadLogs(false)">刷新日志</el-button>
</div>
</div>
@@ -187,7 +203,7 @@ onBeforeUnmount(() => {
<el-input-number v-model="filters.take" :min="1" :max="500" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadLogs">应用筛选</el-button>
<el-button type="primary" @click="loadLogs(false)">应用筛选</el-button>
</el-form-item>
</el-form>
</el-card>
@@ -230,8 +246,7 @@ onBeforeUnmount(() => {
<div v-else class="table-scroll-shell logs-table-shell">
<el-table
:data="logs"
v-loading="loading"
:height="720"
v-loading="initialLoading"
class="premium-table logs-table"
table-layout="auto"
>
+56 -29
View File
@@ -14,15 +14,36 @@ const browser = ref<MediaBrowserResponse | null>(null);
const previewVisible = ref(false);
const previewTitle = ref("");
const previewUrl = ref("");
const mediaSearch = ref("");
const mediaType = ref("all");
const currentPathLabel = computed(() => browser.value?.currentPath || "平台目录");
const directoryCount = computed(() => browser.value?.items.filter((item) => item.type === "directory").length ?? 0);
const mediaFileCount = computed(() => browser.value?.items.filter((item) => item.type !== "directory").length ?? 0);
const transcodeReadyCount = computed(() => browser.value?.items.filter((item) => item.canTranscode).length ?? 0);
const filteredItems = computed(() => {
const keyword = mediaSearch.value.trim().toLowerCase();
return (browser.value?.items ?? []).filter((item) => {
const matchesKeyword = !keyword || [item.name, item.relativePath].some((value) => value.toLowerCase().includes(keyword));
const matchesType = mediaType.value === "all" ||
(mediaType.value === "directory" && item.type === "directory") ||
(mediaType.value === "video" && (item.type === "mp4" || item.type === "ts")) ||
(mediaType.value === "xml" && item.type === "xml") ||
(mediaType.value === "other" && !["directory", "mp4", "ts", "xml"].includes(item.type));
return matchesKeyword && matchesType;
});
});
function clearFilters() {
mediaSearch.value = "";
mediaType.value = "all";
}
async function loadDirectory(path = "") {
loading.value = true;
loadError.value = "";
mediaSearch.value = "";
mediaType.value = "all";
try {
const { data } = await apiClient.get<MediaBrowserResponse>("/media/browser", {
@@ -181,18 +202,30 @@ onMounted(() => {
</el-button>
</div>
<div class="list-filterbar media-filterbar">
<el-input v-model="mediaSearch" clearable placeholder="搜索文件名或相对路径" />
<el-select v-model="mediaType" aria-label="文件类型筛选">
<el-option label="全部类型" value="all" />
<el-option label="目录" value="directory" />
<el-option label="视频" value="video" />
<el-option label="XML" value="xml" />
<el-option label="其他" value="other" />
</el-select>
<span class="list-filterbar__count">{{ filteredItems.length }} / {{ browser?.items.length ?? 0 }}</span>
</div>
<el-skeleton v-if="loading && !browser" animated :rows="8" />
<EmptyState
v-else-if="browser && browser.items.length === 0"
title="暂无数据"
description="当前筛选条件下没有可展示内容"
action-text="刷新目录"
@action="refreshCurrentDirectory"
v-else-if="browser && filteredItems.length === 0"
:title="browser.items.length === 0 ? '目录为空' : '没有匹配的文件'"
:description="browser.items.length === 0 ? '当前目录下没有可展示内容' : '请调整搜索词或文件类型'"
:action-text="browser.items.length === 0 ? '刷新目录' : '清除筛选'"
@action="browser.items.length === 0 ? refreshCurrentDirectory() : clearFilters()"
/>
<div v-else-if="browser" class="table-scroll-shell">
<el-table :data="browser.items" class="premium-table" table-layout="auto">
<div v-else-if="browser" class="table-scroll-shell" data-testid="media-table-scroll">
<el-table :data="filteredItems" class="premium-table media-table" table-layout="auto">
<el-table-column label="名称" min-width="260">
<template #default="{ row }">
<div class="file-cell">
@@ -219,30 +252,24 @@ onMounted(() => {
{{ formatDate(row.modifiedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" min-width="320" fixed="right">
<el-table-column label="操作" width="170" fixed="right">
<template #default="{ row }">
<div class="action-row">
<el-button v-if="row.type === 'directory'" size="small" @click="openDirectory(row.relativePath)">进入目录</el-button>
<el-button v-if="row.type === 'mp4'" size="small" @click="previewVideo(row)">预览视频</el-button>
<el-button v-if="row.type === 'xml'" size="small" @click="openFile(row)">查看 XML</el-button>
<el-button
v-if="row.type !== 'directory'"
size="small"
plain
@click="openFile(row, true)"
>
下载
</el-button>
<el-button
v-if="row.canTranscode"
size="small"
type="primary"
plain
:loading="transcodePath === row.relativePath"
@click="transcodeFile(row)"
>
转码为 MP4
</el-button>
<el-button v-if="row.type === 'directory'" size="small" type="primary" @click="openDirectory(row.relativePath)">进入</el-button>
<el-button v-else-if="row.type === 'mp4'" size="small" type="primary" @click="previewVideo(row)">预览</el-button>
<el-button v-else-if="row.type === 'xml'" size="small" type="primary" @click="openFile(row)">查看</el-button>
<el-button v-else size="small" type="primary" @click="openFile(row, true)">下载</el-button>
<el-dropdown v-if="row.type !== 'directory'" trigger="click">
<el-button size="small">更多</el-button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item @click="openFile(row, true)">下载文件</el-dropdown-item>
<el-dropdown-item
v-if="row.canTranscode"
:disabled="transcodePath === row.relativePath"
@click="transcodeFile(row)"
>转码为 MP4</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
</div>
</template>
</el-table-column>
@@ -2,7 +2,6 @@
import { computed, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useViewport } from "@/composables/useViewport";
import apiClient, { getApiErrorMessage, buildApiUrl } from "@/api/client";
import { useDanmakuPlayer } from "@/composables/useDanmakuPlayer";
import DanmakuPlayer from "@/components/player/DanmakuPlayer.vue";
@@ -29,7 +28,6 @@ const props = defineProps<{
}>();
const router = useRouter();
const { isMobile } = useViewport();
// Danmaku replay dialog
const { danmakuEvents: replayDanmakuEvents, loading: danmakuLoading, error: danmakuError, loadTaskDanmaku, clear: clearDanmaku } = useDanmakuPlayer();
@@ -77,7 +75,6 @@ const uploadLoading = ref(false);
const loadError = ref("");
const detail = ref<RecordSessionDetail | null>(null);
const visibleLayers = ref(["session", "segments", "processing", "danmaku", "automation"]);
const logTableHeight = computed(() => (isMobile.value ? undefined : 360));
const timelineDurationSeconds = computed(() => {
const total = detail.value?.timeline.totalDurationSeconds ?? 0;
@@ -283,7 +280,7 @@ onMounted(loadDetail);
</p>
</div>
<el-space class="header-actions">
<el-space wrap class="header-actions">
<el-button @click="router.push({ name: 'record-tasks' })">返回列表</el-button>
<el-button @click="loadDetail">刷新</el-button>
<el-button :loading="uploadLoading" @click="uploadSessionArtifacts">上传会话</el-button>
@@ -547,7 +544,7 @@ onMounted(loadDetail);
{{ formatDuration(row.durationSeconds) }}
</template>
</el-table-column>
<el-table-column label="操作" min-width="200">
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openTaskDetail(row.recordTaskId)">查看分片</el-button>
<el-button
@@ -574,7 +571,7 @@ onMounted(loadDetail);
</div>
<div class="table-scroll-shell">
<el-table :data="detail.logs" :height="logTableHeight" class="premium-table" table-layout="auto">
<el-table :data="detail.logs" class="premium-table detail-logs-table" table-layout="auto">
<el-table-column label="级别" width="100">
<template #default="{ row }">
<el-tag :type="logTagType(row.level)">
@@ -792,6 +789,12 @@ onMounted(loadDetail);
color: var(--text-secondary);
}
.segment-label,
.log-detail {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.preview-empty {
display: grid;
place-items: center;
+2 -7
View File
@@ -61,7 +61,6 @@ async function toggleDanmaku() {
showDanmaku.value = true;
}
const rowGutter = computed(() => (isMobile.value ? 14 : 18));
const logTableHeight = computed(() => (isMobile.value ? undefined : 420));
const activeTaskStatuses = new Set([1, 2, 3, 7]);
const canManualTranscode = computed(() => {
const task = detail.value?.task;
@@ -282,7 +281,7 @@ onMounted(loadDetailAndPreview);
</p>
</div>
<el-space class="header-actions">
<el-space wrap class="header-actions">
<el-button @click="router.push({ name: 'record-tasks' })">返回列表</el-button>
<el-button @click="loadDetailAndPreview">刷新</el-button>
<el-button
@@ -466,7 +465,7 @@ onMounted(loadDetailAndPreview);
<p class="section-subtitle">日志已带上会话与任务关联可看到 ffmpeg巡检和弹幕采集的上下文</p>
<div class="table-scroll-shell detail-logs-shell">
<el-table :data="detail.logs" :height="logTableHeight" class="premium-table detail-logs-table">
<el-table :data="detail.logs" class="premium-table detail-logs-table">
<el-table-column label="级别" width="100">
<template #default="{ row }">
<el-tag :type="logTagType(row.level)">
@@ -513,10 +512,6 @@ onMounted(loadDetailAndPreview);
padding-inline: 4px;
}
.detail-logs-table {
min-width: 940px;
}
.detail-card :deep(.el-card__body),
.preview-card :deep(.el-card__body),
.logs-card :deep(.el-card__body) {
+233 -150
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElNotification } from "element-plus";
import { Bell, Connection, VideoCamera } from "@element-plus/icons-vue";
@@ -22,6 +22,7 @@ import type {
RecordArtifactUploadBatchResult,
RecordArtifactUploadItemResult,
RecordSession,
RecordSessionListResponse,
RecordTask
} from "@/types";
import {
@@ -68,14 +69,26 @@ const selectedTaskMap = ref<Record<string, RecordTask>>({});
const realtimeConnected = ref(false);
const realtimeError = ref("");
const { isMobile } = useViewport();
const sessionSearch = ref("");
const sessionState = ref("all");
const currentPage = ref(1);
const pageSize = computed(() => isMobile.value ? 12 : 20);
const totalCount = ref(0);
const totalSessionCount = ref(0);
const activeSessionCount = ref(0);
const totalTaskCount = ref(0);
const totalDanmakuCount = ref(0);
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)));
let sessionsEventSource: EventSource | null = null;
let cleanupPollTimer: number | null = null;
let searchTimer: number | null = null;
let activeRequest: AbortController | null = null;
let requestSerial = 0;
let appMain: HTMLElement | null = null;
const selectedTasks = computed(() => Object.values(selectedTaskMap.value));
const activeSessionCount = computed(() => sessions.value.filter((item) => isActiveStatus(item.status)).length);
const totalTaskCount = computed(() => sessions.value.reduce((sum, item) => sum + item.tasks.length, 0));
const totalDanmakuCount = computed(() => sessions.value.reduce((sum, item) => sum + item.totalDanmakuMessageCount, 0));
const filteredSessions = computed(() => sessions.value);
const selectedSessionCount = computed(() => selectedSessionIds.value.length);
const mixedSelectionLabel = computed(() => {
const parts = [];
@@ -285,23 +298,28 @@ function applySessionsSnapshot(
resetSelection?: boolean;
}
) {
const nextSessionIds = new Set(data.map((item) => item.id));
const previousById = new Map(sessions.value.map((item) => [item.id, item]));
const mergedData = data.map((item) => {
const previous = previousById.get(item.id);
return previous && JSON.stringify(previous) === JSON.stringify(item) ? previous : item;
});
const nextSessionIds = new Set(mergedData.map((item) => item.id));
const nextTaskMap = new Map<string, RecordTask>();
data.forEach((session) => {
mergedData.forEach((session) => {
session.tasks.forEach((task) => {
nextTaskMap.set(task.id, task);
});
});
sessions.value = data;
sessions.value = mergedData;
if (options?.resetPanels || activeSessionPanels.value.length === 0) {
activeSessionPanels.value = data.slice(0, 4).map((item) => item.id);
activeSessionPanels.value = mergedData.slice(0, 4).map((item) => item.id);
} else {
activeSessionPanels.value = activeSessionPanels.value.filter((sessionId) => nextSessionIds.has(sessionId));
if (activeSessionPanels.value.length === 0 && data.length > 0) {
activeSessionPanels.value = data.slice(0, 4).map((item) => item.id);
if (activeSessionPanels.value.length === 0 && mergedData.length > 0) {
activeSessionPanels.value = mergedData.slice(0, 4).map((item) => item.id);
}
}
@@ -323,30 +341,109 @@ function applySessionsSnapshot(
);
}
async function loadSessions(options?: { resetPanels?: boolean; resetSelection?: boolean }) {
loading.value = true;
loadError.value = "";
async function loadSessions(options: {
resetPanels?: boolean;
resetSelection?: boolean;
background?: boolean;
preserveScroll?: boolean;
cancelPrevious?: boolean;
} = {}) {
if (activeRequest && !options.cancelPrevious) {
return;
}
activeRequest?.abort();
const controller = new AbortController();
activeRequest = controller;
const serial = ++requestSerial;
const background = options.background === true;
const scrollTop = appMain?.scrollTop ?? 0;
if (!background) {
loading.value = true;
loadError.value = "";
}
try {
const { data } = await apiClient.get<RecordSession[]>("/record-sessions");
applySessionsSnapshot(data, {
const params: Record<string, string | number> = {
skip: (currentPage.value - 1) * pageSize.value,
take: pageSize.value,
state: sessionState.value
};
const search = sessionSearch.value.trim();
if (search) {
params.search = search;
}
const { data } = await apiClient.get<RecordSessionListResponse>("/record-sessions/page", {
params,
signal: controller.signal
});
if (serial !== requestSerial) {
return;
}
const maxPage = Math.max(1, Math.ceil(data.totalCount / pageSize.value));
if (currentPage.value > maxPage) {
currentPage.value = maxPage;
queueMicrotask(() => void loadSessions({ ...options, cancelPrevious: true }));
return;
}
applySessionsSnapshot(data.items, {
resetPanels: options?.resetPanels ?? true,
resetSelection: options?.resetSelection ?? true
});
totalCount.value = data.totalCount;
totalSessionCount.value = data.totalSessionCount;
activeSessionCount.value = data.activeSessionCount;
totalTaskCount.value = data.totalTaskCount;
totalDanmakuCount.value = data.totalDanmakuCount;
markBackendAvailable();
await nextTick();
if (options.preserveScroll && appMain) {
appMain.scrollTop = scrollTop;
}
} catch (error) {
if (controller.signal.aborted) {
return;
}
loadError.value = getApiErrorMessage(error, "录制会话加载失败,请稍后重试。");
} finally {
loading.value = false;
if (serial === requestSerial) {
activeRequest = null;
loading.value = false;
}
}
}
function handlePageChange(page: number) {
currentPage.value = page;
void loadSessions({ cancelPrevious: true, resetPanels: true, resetSelection: true }).then(() => {
document.querySelector(".sessions-card")?.scrollIntoView({ block: "start" });
});
}
function scheduleSearchReload() {
if (searchTimer !== null) {
window.clearTimeout(searchTimer);
}
searchTimer = window.setTimeout(() => {
currentPage.value = 1;
void loadSessions({ cancelPrevious: true, resetPanels: true, resetSelection: true });
}, 300);
}
function connectRealtimeUpdates() {
if (typeof window === "undefined" || typeof EventSource === "undefined") {
realtimeError.value = "当前浏览器不支持实时更新,仍可手动刷新列表。";
return;
}
if (document.visibilityState !== "visible") {
return;
}
const token = localStorage.getItem("live-recorder-token");
if (!token) {
realtimeError.value = "未检测到登录凭证,实时更新未启用。";
@@ -368,18 +465,13 @@ function connectRealtimeUpdates() {
markBackendAvailable();
};
sessionsEventSource.addEventListener("sessions", (event) => {
try {
const nextSessions = JSON.parse((event as MessageEvent<string>).data) as RecordSession[];
applySessionsSnapshot(nextSessions, { resetPanels: false, resetSelection: false });
realtimeConnected.value = true;
realtimeError.value = "";
loadError.value = "";
markBackendAvailable();
} catch {
realtimeConnected.value = false;
realtimeError.value = "实时更新数据解析失败,已保留手动刷新入口。";
}
sessionsEventSource.addEventListener("refresh", () => {
void loadSessions({
background: true,
preserveScroll: true,
resetPanels: false,
resetSelection: false
});
});
sessionsEventSource.onerror = () => {
@@ -398,6 +490,20 @@ function closeRealtimeUpdates() {
sessionsEventSource = null;
}
function handleVisibilityChange() {
if (document.visibilityState === "visible") {
void loadSessions({
background: true,
preserveScroll: true,
resetPanels: false,
resetSelection: false
}).finally(connectRealtimeUpdates);
return;
}
closeRealtimeUpdates();
}
function persistCleanupOperationId(id: string | null) {
if (typeof window === "undefined") {
return;
@@ -840,8 +946,20 @@ function formatFileSize(bytes?: number) {
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
watch(sessionSearch, scheduleSearchReload);
watch(sessionState, () => {
currentPage.value = 1;
void loadSessions({ cancelPrevious: true, resetPanels: true, resetSelection: true });
});
watch(pageSize, () => {
currentPage.value = 1;
void loadSessions({ cancelPrevious: true, resetPanels: true, resetSelection: true });
});
onMounted(async () => {
await loadSessions();
appMain = document.querySelector<HTMLElement>(".app-main");
document.addEventListener("visibilitychange", handleVisibilityChange);
await loadSessions({ cancelPrevious: true });
await restoreCleanupTracking();
connectRealtimeUpdates();
});
@@ -849,6 +967,11 @@ onMounted(async () => {
onBeforeUnmount(() => {
closeRealtimeUpdates();
stopCleanupPolling();
activeRequest?.abort();
document.removeEventListener("visibilitychange", handleVisibilityChange);
if (searchTimer !== null) {
window.clearTimeout(searchTimer);
}
});
</script>
@@ -918,7 +1041,7 @@ onBeforeUnmount(() => {
<div class="record-ops-grid">
<div class="stats-grid">
<MetricCard label="录制会话" :value="sessions.length" description="按整场直播聚合的录制会话数" :icon="VideoCamera" />
<MetricCard label="录制会话" :value="totalSessionCount" description="按整场直播聚合的录制会话数" :icon="VideoCamera" />
<MetricCard label="活跃会话" :value="activeSessionCount" description="启动中、录制中、停止中、处理中" :icon="Connection" />
<MetricCard label="分片任务" :value="totalTaskCount" description="包含单文件模式下的唯一任务" :icon="Bell" />
<MetricCard label="弹幕事件" :value="totalDanmakuCount" description="累计写入 XML 的事件总数" :icon="Bell" />
@@ -945,28 +1068,39 @@ onBeforeUnmount(() => {
>
删除已选会话{{ selectedSessionCount > 0 ? `${selectedSessionCount}` : "" }}
</el-button>
<el-button plain :loading="deleting" @click="openConditionalDeleteDialog">
按条件清理
</el-button>
<el-button plain :loading="deleting" @click="openDeleteEmptySessionsDialog">
清理无分片会话
</el-button>
<el-button plain :loading="deleting" @click="openDeleteMissingFileTasksDialog">
清理无文件分片
</el-button>
<el-dropdown trigger="click">
<el-button plain :loading="deleting">清理工具</el-button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item @click="openConditionalDeleteDialog">按条件清理</el-dropdown-item>
<el-dropdown-item @click="openDeleteEmptySessionsDialog">清理无分片会话</el-dropdown-item>
<el-dropdown-item @click="openDeleteMissingFileTasksDialog">清理无文件分片</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
</div>
</div>
<div class="list-filterbar">
<el-input v-model="sessionSearch" clearable placeholder="搜索直播间、Room ID、会话、任务或文件路径" />
<el-select v-model="sessionState" aria-label="录制会话状态筛选">
<el-option label="全部状态" value="all" />
<el-option label="进行中" value="active" />
<el-option label="已完成" value="completed" />
<el-option label="失败" value="failed" />
<el-option label="已停止" value="stopped" />
</el-select>
<span class="list-filterbar__count">当前页 {{ filteredSessions.length }} / {{ totalCount }}</span>
</div>
<EmptyState
v-if="!loading && sessions.length === 0"
title="暂无录制任务"
description="当前筛选条件下没有可展示内容"
v-if="!loading && filteredSessions.length === 0"
:title="totalSessionCount === 0 ? '暂无录制任务' : '没有匹配的录制会话'"
:description="totalSessionCount === 0 ? '录制开始后,会话会显示在这里。' : '请调整搜索词或状态筛选。'"
action-text="刷新列表"
@action="loadSessions()"
/>
<div v-else-if="isMobile" class="data-card-list session-card-list">
<article v-for="session in sessions" :key="session.id" class="data-card session-card">
<article v-for="session in filteredSessions" :key="session.id" class="data-card session-card">
<div class="data-card__header">
<div>
<div class="data-card__title">{{ session.liveRoomTitle }}</div>
@@ -1002,23 +1136,15 @@ onBeforeUnmount(() => {
</div>
<div class="data-card__actions">
<el-button size="small" @click.stop="openSessionDetail(session)">查看会话</el-button>
<el-button size="small" :loading="uploadingSessionId === session.id" @click.stop="uploadSession(session)">
上传会话
</el-button>
<el-button
v-if="isActiveStatus(session.status)"
size="small"
type="danger"
plain
:loading="stoppingSessionId === session.id"
@click.stop="stopSession(session)"
>
停止会话
</el-button>
<el-button size="small" type="danger" plain :loading="deleting" @click.stop="openDeleteSessionDialog([session.id])">
删除会话
</el-button>
<el-button size="small" type="primary" @click.stop="openSessionDetail(session)">查看</el-button>
<el-dropdown trigger="click" @click.stop>
<el-button size="small">更多</el-button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item :disabled="uploadingSessionId === session.id" @click="uploadSession(session)">上传会话</el-dropdown-item>
<el-dropdown-item v-if="isActiveStatus(session.status)" :disabled="stoppingSessionId === session.id" @click="stopSession(session)">停止会话</el-dropdown-item>
<el-dropdown-item divided class="danger-menu-item" :disabled="deleting" @click="openDeleteSessionDialog([session.id])">删除会话</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
</div>
<div class="session-card__tasks">
@@ -1067,33 +1193,15 @@ onBeforeUnmount(() => {
</div>
<div class="data-card__actions">
<el-button size="small" @click="openDetail(task)">详情</el-button>
<el-button
v-if="canTriggerSegmentCompleted(task)"
size="small"
:loading="triggeringSegmentCompletedTaskId === task.id"
@click="triggerSegmentCompleted(task)"
>
触发完成事件
</el-button>
<el-button
v-if="isDeletableTask(task)"
size="small"
:loading="uploadingTaskId === task.id"
@click="uploadTask(task)"
>
上传
</el-button>
<el-button
v-if="isDeletableTask(task)"
size="small"
type="danger"
plain
:loading="deleting"
@click="openDeleteTaskDialog([task.id])"
>
删除
</el-button>
<el-button size="small" type="primary" @click="openDetail(task)">查看</el-button>
<el-dropdown trigger="click">
<el-button size="small">更多</el-button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item v-if="canTriggerSegmentCompleted(task)" :disabled="triggeringSegmentCompletedTaskId === task.id" @click="triggerSegmentCompleted(task)">触发完成事件</el-dropdown-item>
<el-dropdown-item v-if="isDeletableTask(task)" :disabled="uploadingTaskId === task.id" @click="uploadTask(task)">上传</el-dropdown-item>
<el-dropdown-item v-if="isDeletableTask(task)" divided class="danger-menu-item" :disabled="deleting" @click="openDeleteTaskDialog([task.id])">删除</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
</div>
</article>
</div>
@@ -1102,7 +1210,7 @@ onBeforeUnmount(() => {
<el-collapse v-else v-model="activeSessionPanels" class="session-collapse">
<el-collapse-item
v-for="session in sessions"
v-for="session in filteredSessions"
:key="session.id"
:name="session.id"
class="session-panel"
@@ -1157,29 +1265,15 @@ onBeforeUnmount(() => {
</div>
<div class="session-actions">
<el-button @click.stop="openSessionDetail(session)">
查看会话
</el-button>
<el-button :loading="uploadingSessionId === session.id" @click.stop="uploadSession(session)">
上传会话
</el-button>
<el-button
v-if="isActiveStatus(session.status)"
type="danger"
plain
:loading="stoppingSessionId === session.id"
@click.stop="stopSession(session)"
>
停止会话
</el-button>
<el-button
type="danger"
plain
:loading="deleting"
@click.stop="openDeleteSessionDialog([session.id])"
>
删除会话
</el-button>
<el-button type="primary" @click.stop="openSessionDetail(session)">查看会话</el-button>
<el-dropdown trigger="click" @click.stop>
<el-button>更多操作</el-button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item :disabled="uploadingSessionId === session.id" @click="uploadSession(session)">上传会话</el-dropdown-item>
<el-dropdown-item v-if="isActiveStatus(session.status)" :disabled="stoppingSessionId === session.id" @click="stopSession(session)">停止会话</el-dropdown-item>
<el-dropdown-item divided class="danger-menu-item" :disabled="deleting" @click="openDeleteSessionDialog([session.id])">删除会话</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
</div>
<div class="tasks-table-shell">
@@ -1253,46 +1347,19 @@ onBeforeUnmount(() => {
</template>
</el-table-column>
<el-table-column label="操作" width="320">
<el-table-column label="操作" width="170" fixed="right">
<template #default="{ row }">
<div class="task-actions-cell">
<el-button size="small" @click="openDetail(row)">详情</el-button>
<el-button
v-if="canTriggerSegmentCompleted(row)"
size="small"
:loading="triggeringSegmentCompletedTaskId === row.id"
@click="triggerSegmentCompleted(row)"
>
触发事件
</el-button>
<el-button
v-if="isDeletableTask(row)"
size="small"
:loading="uploadingTaskId === row.id"
@click="uploadTask(row)"
>
上传
</el-button>
<el-button
v-if="isActiveStatus(row.status) && session.activeSegmentIndex === row.segmentIndex"
size="small"
type="danger"
plain
:loading="stoppingSessionId === session.id"
@click="stopSession(session)"
>
停止
</el-button>
<el-button
v-if="isDeletableTask(row)"
size="small"
type="danger"
plain
:loading="deleting"
@click="openDeleteTaskDialog([row.id])"
>
删除
</el-button>
<el-button size="small" type="primary" @click="openDetail(row)">查看</el-button>
<el-dropdown trigger="click">
<el-button size="small">更多</el-button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item v-if="canTriggerSegmentCompleted(row)" :disabled="triggeringSegmentCompletedTaskId === row.id" @click="triggerSegmentCompleted(row)">触发完成事件</el-dropdown-item>
<el-dropdown-item v-if="isDeletableTask(row)" :disabled="uploadingTaskId === row.id" @click="uploadTask(row)">上传</el-dropdown-item>
<el-dropdown-item v-if="isActiveStatus(row.status) && session.activeSegmentIndex === row.segmentIndex" :disabled="stoppingSessionId === session.id" @click="stopSession(session)">停止会话</el-dropdown-item>
<el-dropdown-item v-if="isDeletableTask(row)" divided class="danger-menu-item" :disabled="deleting" @click="openDeleteTaskDialog([row.id])">删除</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
</div>
</template>
</el-table-column>
@@ -1301,6 +1368,17 @@ onBeforeUnmount(() => {
</div>
</el-collapse-item>
</el-collapse>
<div v-if="totalCount > pageSize" class="session-pagination">
<el-pagination
background
layout="prev, pager, next"
:current-page="currentPage"
:page-size="pageSize"
:page-count="totalPages"
@current-change="handlePageChange"
/>
</div>
</el-card>
<el-dialog
@@ -1531,6 +1609,12 @@ onBeforeUnmount(() => {
padding-top: 18px;
}
.session-pagination {
display: flex;
justify-content: center;
margin-top: 20px;
}
.toolbar-row {
display: flex;
align-items: flex-start;
@@ -1693,7 +1777,6 @@ onBeforeUnmount(() => {
.nested-table {
border-radius: 12px;
min-width: 1220px;
}
.nested-table :deep(.el-table__cell) {
+13 -15
View File
@@ -6,6 +6,7 @@ import apiClient, { getApiErrorMessage } from "@/api/client";
import EmptyState from "@/components/ui/EmptyState.vue";
import MetricCard from "@/components/ui/MetricCard.vue";
import StatusBadge from "@/components/ui/StatusBadge.vue";
import StorageCapacity from "@/components/ui/StorageCapacity.vue";
import { useViewport } from "@/composables/useViewport";
import type {
RecoverableFinalization,
@@ -13,8 +14,8 @@ import type {
RecoveryActionResult,
RecoveryOverview
} from "@/types";
import { autoStartDecisionLabelMap, taskStatusLabelMap } from "@/types";
import { RefreshRight, VideoCamera, WarningFilled } from "@element-plus/icons-vue";
import { autoStartDecisionLabelMap, formatAutoStartDecisionSummary, taskStatusLabelMap } from "@/types";
import { RefreshRight, VideoCamera } from "@element-plus/icons-vue";
const loading = ref(false);
const retryAllLoading = ref(false);
@@ -137,6 +138,10 @@ function autoStartDecisionLabel(code?: string) {
return autoStartDecisionLabelMap[code] ?? code;
}
function autoStartDecisionSummary(item: RecoverableLiveRoom) {
return formatAutoStartDecisionSummary(item.lastAutoStartDecisionCode, item.lastAutoStartDecisionSummary);
}
function autoStartDecisionTagType(code?: string) {
if (code === "started") {
return "success";
@@ -188,21 +193,14 @@ onMounted(loadOverview);
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<div class="stats-grid recovery-metrics" v-loading="loading">
<MetricCard
label="存储保护"
:value="storage?.hasEnoughSpace ? '可恢复' : '受限'"
:description="storage?.message || '暂无存储数据'"
:icon="WarningFilled"
/>
<MetricCard label="可重试开录" :value="liveRooms.length" description="在线、启用中且没有活动会话的直播间" :icon="RefreshRight" />
<MetricCard label="可恢复转码" :value="finalizations.length" description="等待继续或可手动补转码的 MP4 任务" :icon="VideoCamera" />
<MetricCard
label="剩余空间"
:value="storage ? formatBytes(storage.availableBytes) : '--'"
:description="`恢复阈值 ${storage ? formatBytes(storage.requiredBytes) : '--'}`"
/>
</div>
<el-card v-if="storage" class="surface-card" shadow="never">
<StorageCapacity :status="storage" />
</el-card>
<el-card class="surface-card table-card" shadow="never">
<div class="toolbar-row">
<div>
@@ -254,7 +252,7 @@ onMounted(loadOverview);
</div>
<div>
<dt>最近决策</dt>
<dd>{{ row.lastAutoStartDecisionSummary || "暂无摘要" }}</dd>
<dd>{{ autoStartDecisionSummary(row) }}</dd>
</div>
<div style="grid-column: 1 / -1;" v-if="row.lastAutoStartDecisionDetail">
<dt>原因详情</dt>
@@ -308,7 +306,7 @@ onMounted(loadOverview);
/>
<span class="table-date-text">{{ formatDate(row.lastAutoStartDecisionAt) }}</span>
</div>
<div class="cell-subtitle">{{ row.lastAutoStartDecisionSummary || "暂无自动开录摘要" }}</div>
<div class="cell-subtitle">{{ autoStartDecisionSummary(row) }}</div>
<div v-if="row.lastAutoStartDecisionDetail" class="decision-cell__detail">
{{ row.lastAutoStartDecisionDetail }}
</div>
+27 -11
View File
@@ -43,6 +43,7 @@ const authStore = useAuthStore();
const route = useRoute();
const router = useRouter();
const retentionCleanupStorageKey = "live-recorder-settings-retention-cleanup-operation-id";
const showAdvancedSettings = ref(false);
const { isMobile } = useViewport();
const { themeMode, density, sidebarCollapsed } = useUiPreferences();
@@ -129,6 +130,7 @@ const form = reactive<SettingsFormModel>({
enableRetentionCleanup: false,
retentionDays: 30,
retentionDeleteFiles: false,
retentionRequireUploadSuccess: false,
retentionVideoFileCondition: "any",
retentionTaskStatuses: [],
enableAutoReconnect: true,
@@ -1128,6 +1130,10 @@ onBeforeRouteLeave(async () => {
<span>折叠侧栏</span>
<el-switch v-model="sidebarCollapsed" />
</label>
<label class="settings-quickbar__switch">
<span>高级设置</span>
<el-switch v-model="showAdvancedSettings" />
</label>
</section>
<div class="settings-grid" v-loading="loading">
@@ -1277,7 +1283,7 @@ onBeforeRouteLeave(async () => {
<el-form-item label="绿色水位线:剩余空间高于 (%)">
<el-input-number
v-model="form.storageGreenThresholdPercent"
:min="5"
:min="10"
:max="90"
:step="5"
:disabled="!form.enableStorageGuard"
@@ -1289,44 +1295,49 @@ onBeforeRouteLeave(async () => {
<el-form-item label="红色水位线:剩余空间低于 (%)">
<el-input-number
v-model="form.storageRedThresholdPercent"
:min="1"
:max="85"
:min="5"
:max="Math.max(5, form.storageGreenThresholdPercent - 5)"
:step="5"
:disabled="!form.enableStorageGuard"
/>
<div class="field-hint">低于此比例时暂停所有录制和转码仅保留上传</div>
<div class="field-hint">低于此比例时暂停录制空间满足安全余量时仍允许 MP4 收尾上传继续</div>
</el-form-item>
</el-col>
</el-row>
</el-form>
<div class="helper-panel">
恢复阈值建议高于暂停阈值避免磁盘空间在临界值附近反复抖动MP4 转码会额外占用中间 TS 文件空间<br/>
绿色/红色水位线控制三级存储保护<b>绿色</b>(正常录制) <b>黄色</b>(拒绝新录制现有继续转码上传) <b>红色</b>(暂停所有录制转码上传清盘)
建议绿色水位线至少 30%红色水位线至少 10%系统最低允许 10% / 5%,并强制保留 5% 的黄色缓冲区。恢复阈值建议高于暂停阈值避免磁盘空间在临界值附近反复抖动。MP4 转码会额外占用中间 TS 文件空间。<br/>
绿色/红色水位线控制三级存储保护<b>绿色</b>(正常录制) <b>黄色</b>(拒绝新录制现有继续转码上传) <b>红色</b>(暂停录制有安全余量时继续 MP4 收尾上传继续清盘)
</div>
</el-card>
<el-card class="surface-card settings-card" shadow="never">
<el-card v-show="showAdvancedSettings" class="surface-card settings-card settings-card--advanced" shadow="never">
<h3 class="section-title">保留清理</h3>
<p class="section-subtitle">按保留天数清理不活跃的会话任务结果和日志可选删除磁盘文件</p>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="8">
<el-col :span="6">
<el-form-item label="启用自动清理">
<el-switch v-model="form.enableRetentionCleanup" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-col :span="6">
<el-form-item label="保留天数">
<el-input-number v-model="form.retentionDays" :min="1" :max="3650" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-col :span="6">
<el-form-item label="删除磁盘文件">
<el-switch v-model="form.retentionDeleteFiles" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="仅清理已上传任务">
<el-switch v-model="form.retentionRequireUploadSuccess" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="视频文件条件">
<el-select v-model="form.retentionVideoFileCondition" style="width: 100%">
@@ -1424,7 +1435,7 @@ onBeforeRouteLeave(async () => {
</el-form>
</el-card>
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<el-card v-show="showAdvancedSettings" class="surface-card settings-card settings-card--advanced settings-grid__full" shadow="never">
<h3 class="section-title">路径模板</h3>
<p class="section-subtitle">目录和文件名模板均支持变量分片目录结构完全由模板控制</p>
@@ -2493,6 +2504,7 @@ onBeforeRouteLeave(async () => {
.settings-quickbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 18px;
padding: 12px 16px;
}
@@ -2628,6 +2640,10 @@ onBeforeRouteLeave(async () => {
padding-top: 20px;
}
.settings-card--advanced {
border-style: dashed;
}
.settings-card :deep(.el-form) {
max-width: 1120px;
}
+82 -28
View File
@@ -5,34 +5,75 @@ import { ElMessage } from "element-plus";
import apiClient, { getApiErrorMessage } from "@/api/client";
import type { TranscodeTaskItem } from "@/types";
import { formatQualityLabel, outputFormatLabelMap, taskStatusLabelMap } from "@/types";
import { useViewport } from "@/composables/useViewport";
const router = useRouter();
const { isMobile } = useViewport();
const loading = ref(false);
const initialLoading = ref(false);
const refreshing = ref(false);
const requestInFlight = ref(false);
const transcodeStartingTaskId = ref<string | null>(null);
const loadError = ref("");
const items = ref<TranscodeTaskItem[]>([]);
const tableHeight = computed(() => (isMobile.value ? undefined : 560));
const taskSearch = ref("");
const taskState = ref("all");
let refreshTimer: number | null = null;
const activeCount = computed(() => items.value.filter((item) => Boolean(item.task.postProcessStage)).length);
const manualCount = computed(() => items.value.filter((item) => item.canManualTranscode).length);
const mp4Count = computed(() => items.value.filter((item) => item.task.outputFormat === 0).length);
const filteredItems = computed(() => {
const keyword = taskSearch.value.trim().toLowerCase();
return items.value.filter((item) => {
const matchesKeyword = !keyword || [
item.task.liveRoomTitle,
item.task.recordSessionId,
item.task.id,
item.sourceFilePath,
item.result?.filePath,
item.task.outputFilePath
].some((value) => String(value || "").toLowerCase().includes(keyword));
const matchesState = taskState.value === "all" ||
(taskState.value === "processing" && Boolean(item.task.postProcessStage)) ||
(taskState.value === "manual" && item.canManualTranscode) ||
(taskState.value === "completed" && !item.task.postProcessStage && !item.canManualTranscode);
return matchesKeyword && matchesState;
});
});
async function loadItems() {
loading.value = true;
loadError.value = "";
function clearFilters() {
taskSearch.value = "";
taskState.value = "all";
}
function mergeItems(nextItems: TranscodeTaskItem[]) {
const previousById = new Map(items.value.map((item) => [item.task.id, item]));
return nextItems.map((item) => {
const previous = previousById.get(item.task.id);
return previous && JSON.stringify(previous) === JSON.stringify(item) ? previous : item;
});
}
async function loadItems(background = false) {
if (requestInFlight.value) {
return;
}
requestInFlight.value = true;
initialLoading.value = !background && items.value.length === 0;
refreshing.value = !initialLoading.value;
if (!background) {
loadError.value = "";
}
try {
const { data } = await apiClient.get<TranscodeTaskItem[]>("/transcode-tasks");
items.value = data;
items.value = mergeItems(data);
} catch (error) {
loadError.value = getApiErrorMessage(error, "转码任务加载失败,请稍后重试。");
} finally {
loading.value = false;
requestInFlight.value = false;
initialLoading.value = false;
refreshing.value = false;
}
}
@@ -42,7 +83,7 @@ async function startManualTranscode(taskId: string) {
try {
await apiClient.post(`/record-tasks/${taskId}/transcode`);
ElMessage.success("已开始手动转码,请稍后刷新查看进度。");
await loadItems();
await loadItems(true);
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "手动转码启动失败。"));
} finally {
@@ -110,7 +151,7 @@ function setupAutoRefresh() {
refreshTimer = window.setInterval(() => {
if (document.visibilityState === "visible") {
loadItems();
void loadItems(true);
}
}, 15000);
}
@@ -141,7 +182,7 @@ onBeforeUnmount(() => {
<el-space wrap class="header-actions">
<el-button @click="router.push({ name: 'record-tasks' })">返回录制任务</el-button>
<el-button @click="router.push({ name: 'media-browser' })">录制目录</el-button>
<el-button @click="loadItems">刷新列表</el-button>
<el-button :loading="refreshing" @click="loadItems(false)">刷新列表</el-button>
</el-space>
</div>
@@ -178,10 +219,23 @@ onBeforeUnmount(() => {
</div>
</div>
<el-empty v-if="!loading && items.length === 0" description="当前没有需要关注的转码任务" />
<div class="list-filterbar">
<el-input v-model="taskSearch" clearable placeholder="搜索直播间、会话、任务或文件路径" />
<el-select v-model="taskState" aria-label="转码状态筛选">
<el-option label="全部状态" value="all" />
<el-option label="处理中" value="processing" />
<el-option label="可手动转码" value="manual" />
<el-option label="已结束" value="completed" />
</el-select>
<span class="list-filterbar__count">{{ filteredItems.length }} / {{ items.length }}</span>
</div>
<el-empty v-if="!initialLoading && filteredItems.length === 0" :description="items.length === 0 ? '当前没有需要关注的转码任务' : '没有匹配的转码任务'">
<el-button v-if="items.length > 0" type="primary" @click="clearFilters">清除筛选</el-button>
</el-empty>
<div v-else class="table-scroll-shell">
<el-table :data="items" :height="tableHeight" class="premium-table" table-layout="auto">
<el-table :data="filteredItems" class="premium-table transcode-table" table-layout="auto">
<el-table-column label="直播间 / 分片" min-width="260">
<template #default="{ row }">
<div class="cell-title">{{ row.task.liveRoomTitle }}</div>
@@ -222,21 +276,21 @@ onBeforeUnmount(() => {
{{ formatDate(row.task.endedAt || row.task.startedAt || row.task.createdAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right">
<el-table-column label="操作" width="170" fixed="right">
<template #default="{ row }">
<div class="action-row">
<el-button size="small" @click="openTask(row.task.id)">查看分片</el-button>
<el-button size="small" @click="openSession(row.task.recordSessionId)">查看会话</el-button>
<el-button
v-if="row.canManualTranscode"
size="small"
type="primary"
plain
:loading="transcodeStartingTaskId === row.task.id"
@click="startManualTranscode(row.task.id)"
>
手动转码
</el-button>
<el-button size="small" type="primary" @click="openTask(row.task.id)">查看</el-button>
<el-dropdown trigger="click">
<el-button size="small">更多</el-button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item @click="openSession(row.task.recordSessionId)">查看会话</el-dropdown-item>
<el-dropdown-item
v-if="row.canManualTranscode"
:disabled="transcodeStartingTaskId === row.task.id"
@click="startManualTranscode(row.task.id)"
>手动转码</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
</div>
</template>
</el-table-column>
+334 -123
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { Bell, CircleCheck, CircleClose, UploadFilled } from "@element-plus/icons-vue";
@@ -7,6 +7,7 @@ import apiClient, { getApiErrorMessage } from "@/api/client";
import EmptyState from "@/components/ui/EmptyState.vue";
import MetricCard from "@/components/ui/MetricCard.vue";
import StatusBadge from "@/components/ui/StatusBadge.vue";
import { useViewport } from "@/composables/useViewport";
import type { RecordArtifactUploadItemResult, UploadTaskItem, UploadTaskListResponse } from "@/types";
import {
platformLabelMap,
@@ -14,23 +15,30 @@ import {
} from "@/types";
const router = useRouter();
const loading = ref(false);
const { isWideDesktop } = useViewport();
const initialLoading = ref(false);
const refreshing = ref(false);
const loadError = ref("");
const items = ref<UploadTaskItem[]>([]);
const totalCount = ref(0);
const notUploadedCount = ref(0);
const failedArtifactCount = ref(0);
const succeededCount = ref(0);
const failedCount = ref(0);
const queuedCount = ref(0);
const uploadingCount = ref(0);
const waitingRetryCount = ref(0);
const uploadStatusFilter = ref<number | null>(null);
const taskSearch = ref("");
const currentPage = ref(1);
const pageSize = 50;
const pageSize = computed(() => isWideDesktop.value ? 50 : 12);
const uploadingTaskId = ref<string | null>(null);
const retryingFailed = ref(false);
const uploadingAllPending = ref(false);
let refreshTimer: number | null = null;
let activeRequest: AbortController | null = null;
let requestSerial = 0;
let appMain: HTMLElement | null = null;
const filterOptions = [
{ label: "全部", value: null as number | null },
@@ -42,45 +50,129 @@ const filterOptions = [
{ label: "等待重试", value: 5 }
];
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize)));
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)));
const filteredItems = computed(() => {
const keyword = taskSearch.value.trim().toLowerCase();
if (!keyword) {
return items.value;
}
async function loadUploadStatus() {
loading.value = true;
loadError.value = "";
return items.value.filter((item) => [
item.liveRoomTitle,
item.roomId,
item.filePath,
item.remoteVideoPath,
item.uploadErrorMessage,
item.recordTaskId
].some((value) => String(value || "").toLowerCase().includes(keyword)));
});
function mergeItems(nextItems: UploadTaskItem[]) {
const previousById = new Map(items.value.map((item) => [item.recordTaskId, item]));
return nextItems.map((item) => {
const previous = previousById.get(item.recordTaskId);
return previous && JSON.stringify(previous) === JSON.stringify(item) ? previous : item;
});
}
function stopRefreshTimer() {
if (refreshTimer !== null) {
window.clearTimeout(refreshTimer);
refreshTimer = null;
}
}
function scheduleRefresh() {
stopRefreshTimer();
if (document.visibilityState !== "visible") {
return;
}
const hasActiveJobs = queuedCount.value + uploadingCount.value + waitingRetryCount.value > 0;
refreshTimer = window.setTimeout(async () => {
await loadUploadStatus({ background: true, preserveScroll: true });
scheduleRefresh();
}, hasActiveJobs ? 5000 : 15000);
}
async function loadUploadStatus(options: { background?: boolean; cancelPrevious?: boolean; preserveScroll?: boolean } = {}) {
if (activeRequest && !options.cancelPrevious) {
return;
}
activeRequest?.abort();
const controller = new AbortController();
activeRequest = controller;
const serial = ++requestSerial;
const isInitial = items.value.length === 0 && !options.background;
initialLoading.value = isInitial;
refreshing.value = !isInitial;
if (!options.background) {
loadError.value = "";
}
const scrollTop = appMain?.scrollTop ?? 0;
try {
const params: Record<string, string | number> = {
skip: (currentPage.value - 1) * pageSize,
take: pageSize
skip: (currentPage.value - 1) * pageSize.value,
take: pageSize.value
};
if (uploadStatusFilter.value !== null) {
params.uploadStatus = uploadStatusFilter.value;
}
const { data } = await apiClient.get<UploadTaskListResponse>("/record-tasks/upload-status", { params });
items.value = data.items;
const { data } = await apiClient.get<UploadTaskListResponse>("/record-tasks/upload-status", {
params,
signal: controller.signal
});
if (serial !== requestSerial) {
return;
}
const maxPage = Math.max(1, Math.ceil(data.totalCount / pageSize.value));
if (currentPage.value > maxPage) {
currentPage.value = maxPage;
queueMicrotask(() => void loadUploadStatus({ cancelPrevious: true, preserveScroll: true }));
return;
}
items.value = mergeItems(data.items);
totalCount.value = data.totalCount;
notUploadedCount.value = data.notUploadedCount;
failedArtifactCount.value = data.failedArtifactCount;
succeededCount.value = data.succeededCount;
failedCount.value = data.failedCount;
queuedCount.value = data.queuedCount;
uploadingCount.value = data.uploadingCount;
waitingRetryCount.value = data.waitingRetryCount;
await nextTick();
if (options.preserveScroll !== false && appMain) {
appMain.scrollTop = scrollTop;
}
} catch (error) {
if (controller.signal.aborted) {
return;
}
loadError.value = getApiErrorMessage(error, "上传任务列表加载失败,请稍后重试。");
} finally {
loading.value = false;
if (serial === requestSerial) {
activeRequest = null;
initialLoading.value = false;
refreshing.value = false;
}
}
}
function handleFilterChange() {
currentPage.value = 1;
loadUploadStatus();
void loadUploadStatus({ cancelPrevious: true, preserveScroll: true });
}
function handlePageChange(page: number) {
currentPage.value = page;
loadUploadStatus();
void loadUploadStatus({ cancelPrevious: true, preserveScroll: false }).then(() => {
document.querySelector(".upload-card")?.scrollIntoView({ block: "start" });
});
}
async function uploadTask(task: UploadTaskItem) {
@@ -89,7 +181,7 @@ async function uploadTask(task: UploadTaskItem) {
try {
const { data } = await apiClient.post<RecordArtifactUploadItemResult>(`/record-tasks/${task.recordTaskId}/upload`);
ElMessage[data.success ? "success" : "warning"](data.message);
await loadUploadStatus();
await loadUploadStatus({ cancelPrevious: true, preserveScroll: true });
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "上传失败,请稍后重试。"));
} finally {
@@ -125,7 +217,7 @@ async function retryAllFailed() {
ElMessage[successCount > 0 ? "success" : "warning"](
`重试请求已处理:已受理 ${successCount},失败 ${failCount}`
);
await loadUploadStatus();
await loadUploadStatus({ cancelPrevious: true, preserveScroll: true });
} finally {
retryingFailed.value = false;
}
@@ -159,7 +251,7 @@ async function uploadAllPending() {
ElMessage[successCount > 0 ? "success" : "warning"](
`批量上传请求已处理:已受理 ${successCount},失败 ${failCount}`
);
await loadUploadStatus();
await loadUploadStatus({ cancelPrevious: true, preserveScroll: true });
} finally {
uploadingAllPending.value = false;
}
@@ -169,6 +261,14 @@ function openDetail(task: UploadTaskItem) {
router.push({ name: "record-task-detail", params: { id: task.recordTaskId } });
}
function canUpload(task: UploadTaskItem) {
return ![1, 3, 4, 5].includes(task.uploadStatus);
}
function isUploadInProgress(task: UploadTaskItem) {
return [3, 4, 5].includes(task.uploadStatus);
}
function formatDate(value?: string) {
return value ? new Date(value).toLocaleString() : "-";
}
@@ -199,19 +299,30 @@ function formatProgress(value?: number) {
: "-";
}
onMounted(() => {
void loadUploadStatus();
refreshTimer = window.setInterval(() => {
if (!loading.value && !uploadingTaskId.value && !retryingFailed.value && !uploadingAllPending.value) {
void loadUploadStatus();
}
}, 5000);
function handleVisibilityChange() {
if (document.visibilityState === "visible") {
void loadUploadStatus({ background: true, preserveScroll: true }).finally(scheduleRefresh);
} else {
stopRefreshTimer();
}
}
watch(isWideDesktop, () => {
currentPage.value = 1;
void loadUploadStatus({ cancelPrevious: true, preserveScroll: true }).finally(scheduleRefresh);
});
onMounted(async () => {
appMain = document.querySelector<HTMLElement>(".app-main");
document.addEventListener("visibilitychange", handleVisibilityChange);
await loadUploadStatus();
scheduleRefresh();
});
onBeforeUnmount(() => {
if (refreshTimer !== null) {
window.clearInterval(refreshTimer);
}
stopRefreshTimer();
activeRequest?.abort();
document.removeEventListener("visibilitychange", handleVisibilityChange);
});
</script>
@@ -228,38 +339,29 @@ onBeforeUnmount(() => {
<div class="page-toolbar">
<el-button @click="router.push({ name: 'record-tasks' })">录制任务</el-button>
<el-button @click="loadUploadStatus">刷新列表</el-button>
<el-button :loading="refreshing" @click="loadUploadStatus({ cancelPrevious: true, preserveScroll: true })">刷新列表</el-button>
</div>
</div>
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<div class="stats-grid">
<MetricCard label="总计" :value="totalCount" description="所有有录制结果的分片" :icon="Bell" />
<MetricCard label="待上传" :value="notUploadedCount" description="尚未上传的分片" :icon="UploadFilled" />
<MetricCard label="已上传" :value="succeededCount" description="上传成功的分片" :icon="CircleCheck" />
<MetricCard label="上传失败" :value="failedCount" description="上传失败的分片" :icon="CircleClose" />
<MetricCard label="队列处理" :value="queuedCount + uploadingCount" description="排队或正在由 OpenList 复制" :icon="UploadFilled" />
<MetricCard label="等待重试" :value="waitingRetryCount" description="按退避策略等待下一次尝试" :icon="Bell" />
<MetricCard label="全部任务" :value="totalCount" :description="`已完成 ${succeededCount} 个`" :icon="Bell" />
<MetricCard label="待上传" :value="notUploadedCount" description="尚未进入上传队列" :icon="UploadFilled" />
<MetricCard label="队列处理中" :value="queuedCount + uploadingCount" :description="`排队 ${queuedCount} · 上传中 ${uploadingCount}`" :icon="CircleCheck" />
<MetricCard
label="需要处理"
:value="failedCount + failedArtifactCount + waitingRetryCount"
:description="`上传失败 ${failedCount} · 失败残片 ${failedArtifactCount} · 等待重试 ${waitingRetryCount}`"
:icon="CircleClose"
/>
</div>
<el-card class="surface-card upload-card" shadow="never">
<div class="toolbar-row">
<div class="toolbar-row__filter">
<span class="filter-label">上传状态</span>
<el-radio-group
v-model="uploadStatusFilter"
size="small"
@change="handleFilterChange"
>
<el-radio-button
v-for="opt in filterOptions"
:key="String(opt.value)"
:value="opt.value"
>
{{ opt.label }}
</el-radio-button>
</el-radio-group>
<div>
<h3 class="section-title">上传队列</h3>
<p class="section-subtitle">按状态和文件信息定位任务批量操作仅作用于当前页</p>
</div>
<div class="toolbar-row__actions">
<el-button
@@ -281,65 +383,104 @@ onBeforeUnmount(() => {
</div>
</div>
<el-skeleton v-if="loading" :rows="6" animated />
<div class="list-filterbar">
<el-input v-model="taskSearch" clearable placeholder="搜索直播间、任务、文件或远端路径" />
<el-select v-model="uploadStatusFilter" placeholder="全部状态" aria-label="上传状态筛选" @change="handleFilterChange">
<el-option v-for="opt in filterOptions" :key="String(opt.value)" :label="opt.label" :value="opt.value" />
</el-select>
<span class="list-filterbar__count">本页 {{ filteredItems.length }} / {{ items.length }}</span>
</div>
<el-skeleton v-if="initialLoading && items.length === 0" :rows="6" animated />
<EmptyState
v-else-if="items.length === 0"
title="暂无上传任务"
description="当前筛选条件下没有可展示的上传记录。"
v-else-if="filteredItems.length === 0"
:title="items.length === 0 ? '暂无上传任务' : '没有匹配的上传任务'"
:description="items.length === 0 ? '当前状态下没有可展示的上传记录。' : '请调整搜索关键词。'"
action-text="刷新列表"
@action="loadUploadStatus"
/>
<template v-else>
<div class="table-scroll-shell">
<div v-if="!isWideDesktop" class="upload-card-list">
<article v-for="row in filteredItems" :key="row.recordTaskId" class="upload-item-card">
<div class="upload-item-card__header">
<div class="upload-item-card__identity">
<strong>{{ row.liveRoomTitle }}</strong>
<span>{{ platformLabelMap[row.platform] ?? "-" }} · {{ row.roomId }} · 分片 #{{ row.segmentIndex }}</span>
</div>
<StatusBadge
:label="uploadStatusLabelMap[row.uploadStatus]"
:status="row.uploadStatus"
context="upload"
/>
</div>
<div class="upload-item-card__section">
<span class="upload-item-card__label">本地文件</span>
<span class="monospace path-text">{{ row.filePath || "-" }}</span>
<span class="cell-subtitle">{{ formatFileSize(row.fileSizeBytes) }}</span>
</div>
<div v-if="isUploadInProgress(row)" class="upload-item-card__progress">
<el-progress :percentage="Math.round(row.uploadProgressPercent || 0)" :stroke-width="7" :show-text="false" />
<span>{{ formatProgress(row.uploadProgressPercent) }} · {{ row.uploadAttemptCount || 0 }} </span>
</div>
<dl class="upload-item-card__facts">
<div><dt>上传方式</dt><dd>{{ row.lastUploadProvider || "-" }}</dd></div>
<div><dt>上传时间</dt><dd>{{ formatDate(row.lastUploadedAt) }}</dd></div>
</dl>
<div v-if="row.remoteVideoPath" class="upload-item-card__section">
<span class="upload-item-card__label">远端路径</span>
<span class="monospace path-text">{{ row.remoteVideoPath }}</span>
</div>
<div v-if="row.uploadErrorMessage" class="upload-item-card__error">{{ row.uploadErrorMessage }}</div>
<div class="upload-item-card__actions">
<el-button type="primary" plain @click="openDetail(row)">查看详情</el-button>
<el-button
v-if="canUpload(row)"
type="primary"
:loading="uploadingTaskId === row.recordTaskId"
@click="uploadTask(row)"
>{{ row.uploadStatus === 2 ? "重试上传" : "立即上传" }}</el-button>
</div>
</article>
</div>
<div v-else class="upload-table-shell">
<el-table
:data="items"
:data="filteredItems"
class="premium-table upload-table"
table-layout="auto"
table-layout="fixed"
row-key="recordTaskId"
>
<el-table-column label="直播间" min-width="160">
<el-table-column label="直播间" min-width="170">
<template #default="{ row }">
<div>
<div class="cell-primary">{{ row.liveRoomTitle }}</div>
<div class="cell-subtitle monospace">{{ row.roomId }}</div>
<div class="cell-subtitle">{{ platformLabelMap[row.platform] ?? "-" }} · <span class="monospace">{{ row.roomId }}</span> · #{{ row.segmentIndex }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="平台" width="100">
<template #default="{ row }">
{{ platformLabelMap[row.platform] ?? "-" }}
</template>
</el-table-column>
<el-table-column label="分片" width="80">
<template #default="{ row }">
<span class="monospace">#{{ row.segmentIndex }}</span>
</template>
</el-table-column>
<el-table-column label="文件" min-width="260">
<el-table-column label="文件" min-width="220">
<template #default="{ row }">
<div class="monospace path-text">{{ row.filePath || "-" }}</div>
<div class="cell-subtitle">{{ formatFileSize(row.fileSizeBytes) }}</div>
</template>
</el-table-column>
<el-table-column label="上传状态" width="120">
<el-table-column label="状态与进度" min-width="170">
<template #default="{ row }">
<StatusBadge
:label="uploadStatusLabelMap[row.uploadStatus]"
:status="row.uploadStatus"
context="upload"
/>
</template>
</el-table-column>
<el-table-column label="进度 / 重试" width="150">
<template #default="{ row }">
<div v-if="row.uploadStatus === 3 || row.uploadStatus === 4 || row.uploadStatus === 5">
<div v-if="isUploadInProgress(row)" class="table-progress">
<el-progress
:percentage="Math.round(row.uploadProgressPercent || 0)"
:stroke-width="6"
@@ -352,47 +493,27 @@ onBeforeUnmount(() => {
下次{{ formatDate(row.nextUploadAttemptAt) }}
</div>
</div>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="上传方式" width="100">
<template #default="{ row }">
{{ row.lastUploadProvider || "-" }}
</template>
</el-table-column>
<el-table-column label="远端路径" min-width="220">
<el-table-column label="远端结果" min-width="220">
<template #default="{ row }">
<div class="monospace path-text">{{ row.remoteVideoPath || "-" }}</div>
<div class="cell-subtitle">{{ row.lastUploadProvider || "-" }} · {{ formatDate(row.lastUploadedAt) }}</div>
<div v-if="row.uploadErrorMessage" class="error-text">{{ row.uploadErrorMessage }}</div>
</template>
</el-table-column>
<el-table-column label="上传时间" width="170">
<template #default="{ row }">
{{ formatDate(row.lastUploadedAt) }}
</template>
</el-table-column>
<el-table-column label="上传错误" min-width="160">
<template #default="{ row }">
<span class="error-text">{{ row.uploadErrorMessage || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<el-table-column label="操作" width="160" align="right">
<template #default="{ row }">
<div class="task-actions-cell">
<el-button size="small" @click="openDetail(row)">详情</el-button>
<el-button size="small" type="primary" @click="openDetail(row)">查看</el-button>
<el-button
v-if="row.uploadStatus !== 1 && row.uploadStatus !== 3 && row.uploadStatus !== 4 && row.uploadStatus !== 5"
v-if="canUpload(row)"
size="small"
type="primary"
:loading="uploadingTaskId === row.recordTaskId"
@click="uploadTask(row)"
>
{{ row.uploadStatus === 2 ? "重试" : "上传" }}
</el-button>
>{{ row.uploadStatus === 2 ? "重试" : "上传" }}</el-button>
</div>
</template>
</el-table-column>
@@ -445,32 +566,20 @@ onBeforeUnmount(() => {
flex-wrap: wrap;
}
.toolbar-row__filter {
display: flex;
align-items: center;
gap: 12px;
}
.filter-label {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
white-space: nowrap;
}
.toolbar-row__actions {
display: flex;
align-items: center;
gap: 10px;
}
.table-scroll-shell {
overflow-x: auto;
.upload-table-shell {
min-width: 0;
}
.upload-table {
min-width: 1200px;
border-radius: 12px;
.upload-table { border-radius: 12px; }
.table-progress {
margin-top: 9px;
}
.pagination-row {
@@ -497,6 +606,108 @@ onBeforeUnmount(() => {
gap: 8px;
flex-wrap: nowrap;
white-space: nowrap;
justify-content: flex-end;
}
.upload-card-list {
display: grid;
gap: 12px;
}
.upload-item-card {
min-width: 0;
padding: 16px;
border: 1px solid var(--border-subtle);
border-radius: 14px;
background: var(--surface-raised);
}
.upload-item-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.upload-item-card__identity {
display: grid;
min-width: 0;
gap: 4px;
}
.upload-item-card__identity span,
.upload-item-card__label,
.upload-item-card__progress span,
.upload-item-card__facts dt {
color: var(--text-secondary);
font-size: 12px;
}
.upload-item-card__section {
display: grid;
min-width: 0;
gap: 3px;
margin-top: 14px;
}
.upload-item-card__progress {
display: grid;
gap: 5px;
margin-top: 14px;
}
.upload-item-card__facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin: 14px 0 0;
}
.upload-item-card__facts div {
min-width: 0;
padding: 10px;
border-radius: 10px;
background: var(--surface-muted);
}
.upload-item-card__facts dt,
.upload-item-card__facts dd {
margin: 0;
}
.upload-item-card__facts dd {
margin-top: 4px;
overflow-wrap: anywhere;
}
.upload-item-card__error {
margin-top: 12px;
padding: 10px 12px;
border-radius: 10px;
color: var(--danger);
background: color-mix(in srgb, var(--danger) 9%, transparent);
overflow-wrap: anywhere;
}
.upload-item-card__actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 16px;
}
.upload-item-card__actions :deep(.el-button) {
width: 100%;
margin: 0;
}
.upload-item-card__actions :deep(.el-button:only-child) {
grid-column: 1 / -1;
}
.path-text {
overflow-wrap: anywhere;
word-break: break-word;
}
.task-actions-cell :deep(.el-button) {
+502
View File
@@ -0,0 +1,502 @@
import { expect, test, type Page, type TestInfo } from "@playwright/test";
const now = "2026-08-02T12:00:00Z";
const room = {
id: "room-1",
platform: 0,
platformName: "哔哩哔哩",
sourceUrl: "https://live.example/123",
originalLiveRoomUrl: "https://live.example/123",
normalizedUrl: "https://live.example/123",
roomId: "123456",
title: "夏日音乐直播间",
anchorName: "示例主播",
isPinned: true,
isPriority: false,
overrides: {},
effectiveSettings: {
preferredQuality: "origin",
outputFormat: 0,
saveMode: 1,
recordingTemplate: 0,
segmentDurationMinutes: 30,
enableAutoReconnect: true,
reconnectDelayMaxSeconds: 60,
readWriteTimeoutMilliseconds: 30000,
enableDanmakuRecording: true,
danmakuIncludeNonChatEvents: false,
danmakuMinPollIntervalMilliseconds: 1000,
danmakuRetryDelayMaxSeconds: 30
},
isEnabled: true,
availabilityStatus: 2,
currentRecordingState: 2,
lastAutoStartDecisionCode: "started",
lastAutoStartDecisionSummary: "已自动开始录制",
lastAutoStartDecisionDetail: "直播状态确认后创建录制会话。",
lastAutoStartDecisionAt: now,
lastCheckedAt: now,
createdAt: now,
updatedAt: now
};
const task = {
id: "task-1",
liveRoomId: room.id,
recordSessionId: "session-12345678",
segmentIndex: 1,
liveRoomTitle: room.title,
platform: 0,
roomId: room.roomId,
status: 4,
preferredQuality: "origin",
outputFormat: 0,
outputFilePath: "/volume1/录制/示例主播/2026-08-02/分片-001.mp4",
createdAt: now,
startedAt: now,
endedAt: now,
durationSeconds: 1800,
uploadStatus: 0
};
const session = {
id: "session-12345678",
liveRoomId: room.id,
liveRoomTitle: room.title,
platform: 0,
roomId: room.roomId,
status: 4,
preferredQuality: "origin",
outputFormat: 0,
saveMode: 1,
activeSegmentIndex: 0,
segmentCount: 1,
createdAt: now,
startedAt: now,
endedAt: now,
totalFileSizeBytes: 8_589_934_592,
totalDanmakuMessageCount: 2680,
uploadedSegmentCount: 0,
failedUploadSegmentCount: 0,
uploadingSegmentCount: 0,
tasks: [task]
};
const uploadTask = {
recordTaskId: task.id,
recordSessionId: session.id,
liveRoomId: room.id,
liveRoomTitle: room.title,
platform: room.platform,
roomId: room.roomId,
segmentIndex: 1,
outputFormat: 0,
filePath: task.outputFilePath,
fileSizeBytes: 8_589_934_592,
uploadStatus: 0,
uploadProgressPercent: 0,
uploadAttemptCount: 0,
createdAt: now
};
const dashboard = {
activeRecordingCount: 1,
liveRoomCount: 3,
offlineRoomCount: 5,
totalRoomCount: 8,
todayRecordingSeconds: 12600,
todayDataBytes: 32_212_254_720,
todayDanmakuCount: 12860,
activeSessionCount: 1,
recentErrorCount: 2,
currentErrorCount: 0,
storageStatus: {
isEnabled: true,
isAvailable: true,
hasEnoughSpace: true,
message: "空间充足",
checkedPath: "/volume1/录制/示例主播/2026-08-02",
totalBytes: 1_000_000_000_000,
usedBytes: 750_000_000_000,
availableBytes: 250_000_000_000,
requiredBytes: 100_000_000_000,
tier: "Green",
usagePercent: 75,
freePercent: 25,
greenThresholdPercent: 30,
redThresholdPercent: 10
},
recentSessions: [session],
topRooms: [{ liveRoomId: room.id, title: room.title, anchorName: room.anchorName, platformName: room.platformName, roomId: room.roomId, sessionCount: 1, totalDurationSeconds: 12600 }],
pendingTranscodeCount: 2,
pendingUploadCount: 3,
queuedDataBytes: 4_294_967_296
};
async function mockApi(page: Page) {
await page.addInitScript(() => {
localStorage.setItem("live-recorder-token", "e2e-token");
localStorage.setItem("live-recorder-user", JSON.stringify({
userId: "e2e-user",
username: "tester",
displayName: "界面测试"
}));
});
await page.route(/^http:\/\/127\.0\.0\.1:47173\/api\//, async (route) => {
const url = new URL(route.request().url());
const path = url.pathname;
let body: unknown = {};
if (path === "/api/dashboard") body = dashboard;
else if (path === "/api/live-rooms") body = [room];
else if (path === "/api/record-sessions/page") body = {
items: [session],
totalCount: 1,
skip: 0,
take: 20,
totalSessionCount: 1,
activeSessionCount: 0,
totalTaskCount: 1,
totalDanmakuCount: session.totalDanmakuMessageCount
};
else if (path === "/api/record-sessions") body = [session];
else if (path === "/api/record-tasks/upload-status") body = {
items: [uploadTask],
totalCount: 1,
notUploadedCount: 1,
failedArtifactCount: 0,
succeededCount: 0,
failedCount: 0,
queuedCount: 0,
uploadingCount: 0,
waitingRetryCount: 0
};
else if (path === "/api/settings") body = {};
else if (path.includes("/record-sessions/stream")) {
await route.fulfill({ status: 200, contentType: "text/event-stream", body: "" });
return;
}
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body) });
});
}
async function expectNoDocumentOverflow(page: Page) {
await expect.poll(() => page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth
}))).toEqual(await page.evaluate(() => ({
scrollWidth: document.documentElement.clientWidth,
clientWidth: document.documentElement.clientWidth
})));
}
async function capture(page: Page, testInfo: TestInfo, name: string) {
await page.screenshot({ path: testInfo.outputPath(`${name}.png`), fullPage: true });
}
test.beforeEach(async ({ page }) => {
page.on("pageerror", (error) => console.error("[browser pageerror]", error.message));
page.on("console", (message) => {
if (message.type() === "error") console.error("[browser console]", message.text());
});
await mockApi(page);
});
test("dashboard keeps storage semantics and the shell within the viewport", async ({ page }, testInfo) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "仪表盘" })).toBeVisible();
const storage = page.getByTestId("storage-capacity");
await expect(storage).toContainText("75.0%");
await expect(storage).toContainText("已使用");
await expect(storage).toContainText("25.0%");
await expectNoDocumentOverflow(page);
await capture(page, testInfo, "dashboard");
});
test("live room table, drawer and dialog retain their final actions", async ({ page }, testInfo) => {
await page.goto("/live-rooms");
await expect(page.getByRole("heading", { name: "直播间列表" })).toBeVisible();
await expectNoDocumentOverflow(page);
if ((page.viewportSize()?.width ?? 0) >= 768) {
const actionHeader = page.getByRole("columnheader", { name: "操作" }).last();
await expect(actionHeader).toBeVisible();
const box = await actionHeader.boundingBox();
expect(box && box.x + box.width).toBeLessThanOrEqual((page.viewportSize()?.width ?? 0) + 1);
}
await page.getByRole("button", { name: /^查看/ }).first().click();
const drawerFooter = page.locator(".right-drawer__footer");
await expect(drawerFooter).toBeVisible();
const footerBox = await drawerFooter.boundingBox();
expect(footerBox && footerBox.y + footerBox.height).toBeLessThanOrEqual((page.viewportSize()?.height ?? 0) + 1);
await page.getByRole("button", { name: "关闭", exact: true }).click();
await expect(drawerFooter).toBeHidden();
await page.getByRole("button", { name: "新增直播间" }).click();
const dialogFooter = page.locator(".el-dialog__footer");
await expect(dialogFooter).toBeVisible();
const dialogFooterBox = await dialogFooter.boundingBox();
expect(dialogFooterBox && dialogFooterBox.y + dialogFooterBox.height).toBeLessThanOrEqual((page.viewportSize()?.height ?? 0) + 1);
await page.getByRole("button", { name: "取消", exact: true }).click();
await expect(dialogFooter).toBeHidden();
await capture(page, testInfo, "live-rooms");
});
test("mobile live room list paginates large collections", async ({ page }) => {
test.skip((page.viewportSize()?.width ?? 0) >= 768, "mobile-only pagination contract");
const manyRooms = Array.from({ length: 25 }, (_, index) => ({
...room,
id: `room-${index + 1}`,
roomId: String(100000 + index + 1),
title: `分页直播间 ${index + 1}`,
anchorName: `分页主播 ${index + 1}`
}));
await page.route("**/api/live-rooms", async (route) => {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(manyRooms) });
});
await page.goto("/live-rooms");
await expect(page.locator(".room-card")).toHaveCount(12);
await expect(page.getByText("分页直播间 1", { exact: true })).toBeVisible();
await page.locator(".mobile-room-pagination .btn-next").click();
await expect(page.getByText("分页直播间 13", { exact: true })).toBeVisible();
await expect(page.locator(".room-card")).toHaveCount(12);
});
test("record task cards and tables expose one primary action", async ({ page }, testInfo) => {
await page.goto("/record-tasks");
await expect(page.getByRole("heading", { name: "录制任务" })).toBeVisible();
await expect(page.getByPlaceholder(/搜索直播间/)).toBeVisible();
await expect(page.getByRole("button", { name: /查看/ }).first()).toBeVisible();
await expect(page.getByRole("button", { name: /更多/ }).first()).toBeVisible();
await expectNoDocumentOverflow(page);
await capture(page, testInfo, "record-tasks");
});
test("record sessions paginate on the server and bound the rendered page", async ({ page }) => {
const allSessions = Array.from({ length: 200 }, (_, index) => ({
...session,
id: `session-${index + 1}`,
liveRoomTitle: `分页录制会话 ${index + 1}`,
roomId: String(200000 + index + 1),
tasks: [{ ...task, id: `task-${index + 1}`, recordSessionId: `session-${index + 1}` }]
}));
const requests: Array<{ skip: number; take: number }> = [];
await page.route("**/api/record-sessions/page**", async (route) => {
const url = new URL(route.request().url());
const skip = Number(url.searchParams.get("skip") || 0);
const take = Number(url.searchParams.get("take") || 20);
requests.push({ skip, take });
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: allSessions.slice(skip, skip + take),
totalCount: allSessions.length,
skip,
take,
totalSessionCount: allSessions.length,
activeSessionCount: 0,
totalTaskCount: allSessions.length,
totalDanmakuCount: allSessions.length * session.totalDanmakuMessageCount
})
});
});
await page.goto("/record-tasks");
const expectedPageSize = (page.viewportSize()?.width ?? 0) < 768 ? 12 : 20;
await expect.poll(() => requests.length).toBeGreaterThan(0);
expect(requests[0]).toEqual({ skip: 0, take: expectedPageSize });
await expect(page.getByText("当前页 " + expectedPageSize + " / 共 200")).toBeVisible();
await expect(page.locator((page.viewportSize()?.width ?? 0) < 768 ? ".session-card" : ".session-panel"))
.toHaveCount(expectedPageSize);
const renderedNodeCount = await page.evaluate(() => document.getElementsByTagName("*").length);
expect(renderedNodeCount).toBeLessThan(5_000);
await page.locator(".session-pagination .btn-next").click();
await expect.poll(() => requests.some((item) => item.skip === expectedPageSize)).toBeTruthy();
await expect(page.getByText(`分页录制会话 ${expectedPageSize + 1}`, { exact: true })).toBeVisible();
});
test("mobile record session refresh keeps the current page and scroll position", async ({ page }) => {
test.skip((page.viewportSize()?.width ?? 0) >= 768, "mobile refresh contract");
await page.addInitScript(() => {
class MockEventSource {
onopen: ((event: Event) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
private listeners = new Map<string, Array<(event: Event) => void>>();
constructor() {
(window as any).__recordSessionEventSource = this;
window.setTimeout(() => this.onopen?.(new Event("open")), 0);
}
addEventListener(type: string, listener: EventListenerOrEventListenerObject) {
const callback = typeof listener === "function"
? listener
: (event: Event) => listener.handleEvent(event);
this.listeners.set(type, [...(this.listeners.get(type) ?? []), callback]);
}
emit(type: string) {
this.listeners.get(type)?.forEach((listener) => listener(new MessageEvent(type, { data: "{}" })));
}
close() {}
}
Object.defineProperty(window, "EventSource", { configurable: true, value: MockEventSource });
(window as any).__emitRecordSessionRefresh = () =>
(window as any).__recordSessionEventSource?.emit("refresh");
});
const allSessions = Array.from({ length: 60 }, (_, index) => ({
...session,
id: `refresh-session-${index + 1}`,
liveRoomTitle: `刷新录制会话 ${index + 1}`,
tasks: [{ ...task, id: `refresh-task-${index + 1}`, recordSessionId: `refresh-session-${index + 1}` }]
}));
let requestCount = 0;
await page.route("**/api/record-sessions/page**", async (route) => {
requestCount++;
const url = new URL(route.request().url());
const skip = Number(url.searchParams.get("skip") || 0);
const take = Number(url.searchParams.get("take") || 12);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: allSessions.slice(skip, skip + take),
totalCount: allSessions.length,
skip,
take,
totalSessionCount: allSessions.length,
activeSessionCount: 0,
totalTaskCount: allSessions.length,
totalDanmakuCount: 0
})
});
});
await page.goto("/record-tasks");
await expect(page.locator(".session-card")).toHaveCount(12);
const main = page.locator(".app-main");
await main.evaluate((element) => { element.scrollTop = element.scrollHeight; });
const scrollTopBefore = await main.evaluate((element) => element.scrollTop);
expect(scrollTopBefore).toBeGreaterThan(500);
await page.evaluate(() => (window as any).__emitRecordSessionRefresh());
await expect.poll(() => requestCount).toBeGreaterThan(1);
await expect(page.locator(".session-card")).toHaveCount(12);
const scrollTopAfter = await main.evaluate((element) => element.scrollTop);
expect(Math.abs(scrollTopAfter - scrollTopBefore)).toBeLessThanOrEqual(2);
});
test("upload tasks use cards on mobile and never require table horizontal scrolling", async ({ page }, testInfo) => {
await page.goto("/upload-tasks");
await expect(page.getByRole("heading", { name: "上传任务" })).toBeVisible();
await expect(page.getByPlaceholder(/搜索直播间/)).toBeVisible();
await expect(page.getByText(task.outputFilePath, { exact: true })).toBeVisible();
await expectNoDocumentOverflow(page);
if ((page.viewportSize()?.width ?? 0) < 1280) {
await expect(page.locator(".upload-item-card")).toBeVisible();
await expect(page.locator(".upload-table")).toHaveCount(0);
await expect(page.getByRole("button", { name: "查看详情" })).toBeVisible();
await expect(page.getByRole("button", { name: "立即上传" })).toBeVisible();
} else {
await expect(page.getByRole("columnheader", { name: "操作" })).toBeVisible();
const tableScroll = page.locator(".upload-table .el-scrollbar__wrap");
await expect(tableScroll).toBeVisible();
const dimensions = await tableScroll.evaluate((element) => ({
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth
}));
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth + 1);
const uploadButton = page.getByRole("button", { name: "上传", exact: true });
await expect(uploadButton).toBeVisible();
const uploadButtonBox = await uploadButton.boundingBox();
expect(uploadButtonBox && uploadButtonBox.x + uploadButtonBox.width)
.toBeLessThanOrEqual((page.viewportSize()?.width ?? 0) + 1);
}
await capture(page, testInfo, "upload-tasks");
});
test("mobile upload polling keeps cards and scroll position while progress updates", async ({ page }) => {
test.skip((page.viewportSize()?.width ?? 0) >= 768, "mobile polling contract");
const allItems = Array.from({ length: 60 }, (_, index) => ({
...uploadTask,
recordTaskId: `upload-task-${index + 1}`,
segmentIndex: index + 1,
liveRoomTitle: `轮询直播间 ${index + 1}`,
uploadStatus: 3,
uploadProgressPercent: 10
}));
let requestCount = 0;
let inFlight = 0;
let maxInFlight = 0;
await page.route("**/api/record-tasks/upload-status**", async (route) => {
requestCount++;
inFlight++;
maxInFlight = Math.max(maxInFlight, inFlight);
const url = new URL(route.request().url());
const skip = Number(url.searchParams.get("skip") || 0);
const take = Number(url.searchParams.get("take") || 50);
if (requestCount > 1) {
await new Promise((resolve) => setTimeout(resolve, 500));
}
const progress = requestCount > 1 ? 42 : 10;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: allItems.slice(skip, skip + take).map((item) => ({ ...item, uploadProgressPercent: progress })),
totalCount: allItems.length,
notUploadedCount: 0,
failedArtifactCount: 0,
succeededCount: 0,
failedCount: 0,
queuedCount: 0,
uploadingCount: allItems.length,
waitingRetryCount: 0
})
});
inFlight--;
});
await page.goto("/upload-tasks");
await expect(page.locator(".upload-item-card")).toHaveCount(12);
await expect(page.getByText("10.0% · 第 0 次").first()).toBeVisible();
const main = page.locator(".app-main");
await main.evaluate((element) => { element.scrollTop = element.scrollHeight; });
const scrollTopBefore = await main.evaluate((element) => element.scrollTop);
expect(scrollTopBefore).toBeGreaterThan(500);
await expect.poll(() => requestCount, { timeout: 10_000 }).toBeGreaterThan(1);
await expect(page.locator(".upload-item-card")).toHaveCount(12);
await expect(page.locator(".el-skeleton")).toHaveCount(0);
await expect(page.getByText("42.0% · 第 0 次").first()).toBeVisible();
const scrollTopAfter = await main.evaluate((element) => element.scrollTop);
expect(Math.abs(scrollTopAfter - scrollTopBefore)).toBeLessThanOrEqual(2);
expect(maxInFlight).toBe(1);
});
test("settings layer advanced controls without clipping the quick bar", async ({ page }, testInfo) => {
await page.goto("/settings/recording");
await expect(page.getByRole("heading", { name: "系统设置" })).toBeVisible();
await expect(page.getByRole("heading", { name: "保留清理" })).toBeHidden();
await page.getByText("高级设置", { exact: true }).click();
await expect(page.getByRole("heading", { name: "保留清理" })).toBeVisible();
await expectNoDocumentOverflow(page);
await capture(page, testInfo, "settings");
});
+1 -1
View File
@@ -5,7 +5,7 @@ import path from "node:path";
export default defineConfig(({ command }) => ({
plugins: [
...(command === "serve" ? [VueDevTools()] : []),
...(command === "serve" && process.env.VITE_DISABLE_DEVTOOLS !== "1" ? [VueDevTools()] : []),
vue()
],
resolve: {
+6 -51
View File
@@ -2,9 +2,7 @@
set -euo pipefail
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
VERSION=1.1.0
NODE_VERSION=22.18.0
NODE_ARCHIVE_SHA256=c1bfeecf1d7404fa74728f9db72e697decbd8119ccc6f5a294d795756dfcfca7
VERSION=1.2.13
OUTPUT="${1:-$ROOT_DIR/artifacts/fnos/liverecorder-${VERSION}-x86_64.fpk}"
WORKSPACE_CACHE=$(CDPATH= cd -- "$ROOT_DIR/.." && pwd)
DOTNET_BIN="${DOTNET:-$WORKSPACE_CACHE/.dotnet8/dotnet}"
@@ -26,7 +24,7 @@ FNPACK_BIN=$(command -v "$FNPACK_BIN") || {
exit 1
}
for command_name in npm apt-get curl dpkg-deb readelf realpath find install sha256sum tar xz node; do
for command_name in npm apt-get curl dpkg-deb readelf realpath find install sha256sum tar node; do
command -v "$command_name" >/dev/null 2>&1 || {
printf 'required build command is missing: %s\n' "$command_name" >&2
exit 1
@@ -45,8 +43,7 @@ EXTRACT_ROOT="$WORK_DIR/debian-root"
RUNTIME_ROOT="$STAGE/app/runtime"
mkdir -p "$STAGE/app/server" "$RUNTIME_ROOT/bin" "$RUNTIME_ROOT/lib" \
"$RUNTIME_ROOT/usr/lib/postgresql/15/bin" "$RUNTIME_ROOT/usr/lib/postgresql/15/lib" \
"$RUNTIME_ROOT/usr/share/postgresql/15" "$RUNTIME_ROOT/etc/ssl/certs" \
"$RUNTIME_ROOT/etc/ssl/certs" \
"$PACKED_ROOT" "$FNPACK_TMP_ROOT" "$EXTRACT_ROOT"
cp -a "$ROOT_DIR/fnos/." "$STAGE/"
@@ -102,8 +99,6 @@ apt-get "${APT_OPTIONS[@]}" \
--no-install-recommends \
--yes \
install \
postgresql-15 \
postgresql-client-15 \
curl \
ca-certificates
@@ -143,44 +138,7 @@ copy_extracted_file() {
install -m "$mode" "$resolved" "$destination"
}
copy_dereferenced_tree() {
local source_root=$1 destination_root=$2 relative source mode
[ -d "$source_root" ] || { printf 'missing extracted runtime directory: %s\n' "$source_root" >&2; return 1; }
while IFS= read -r -d '' relative; do
mkdir -p "$destination_root/${relative#./}"
done < <(cd "$source_root" && find . -type d -print0)
while IFS= read -r -d '' relative; do
source="$source_root/${relative#./}"
mode=0644
[ -x "$source" ] && mode=0755
copy_extracted_file "$source" "$destination_root/${relative#./}" "$mode"
done < <(cd "$source_root" && find . \( -type f -o -type l \) -print0)
}
printf 'Assembling minimal relocatable runtime...\n'
for source in "$EXTRACT_ROOT/usr/lib/postgresql/15/bin/"*; do
[ -f "$source" ] || [ -L "$source" ] || continue
copy_extracted_file "$source" "$RUNTIME_ROOT/usr/lib/postgresql/15/bin/$(basename -- "$source")" 0755
done
copy_dereferenced_tree \
"$EXTRACT_ROOT/usr/share/postgresql/15" \
"$RUNTIME_ROOT/usr/share/postgresql/15"
for source in "$EXTRACT_ROOT/usr/lib/postgresql/15/lib/"*.so*; do
[ -f "$source" ] || [ -L "$source" ] || continue
copy_extracted_file "$source" "$RUNTIME_ROOT/usr/lib/postgresql/15/lib/$(basename -- "$source")" 0755
done
NODE_ARCHIVE="$WORK_DIR/node-v${NODE_VERSION}-linux-x64.tar.xz"
NODE_DIST_ROOT="$WORK_DIR/node-dist"
printf 'Downloading pinned official Node.js %s runtime...\n' "$NODE_VERSION"
curl --fail --location --retry 3 \
"https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" \
--output "$NODE_ARCHIVE"
printf '%s %s\n' "$NODE_ARCHIVE_SHA256" "$NODE_ARCHIVE" | sha256sum --check --status
mkdir -p "$NODE_DIST_ROOT"
tar -xJf "$NODE_ARCHIVE" -C "$NODE_DIST_ROOT" --strip-components=1
install -m 0755 "$NODE_DIST_ROOT/bin/node" "$RUNTIME_ROOT/bin/node"
copy_extracted_file "$EXTRACT_ROOT/usr/bin/curl" "$RUNTIME_ROOT/bin/curl" 0755
CA_CONFIG="$EXTRACT_ROOT/etc/ca-certificates.conf"
@@ -209,7 +167,7 @@ while IFS= read -r -d '' elf_file; do
if readelf -h "$elf_file" >/dev/null 2>&1; then
ELF_QUEUE+=("$elf_file")
fi
done < <(find "$RUNTIME_ROOT/bin" "$RUNTIME_ROOT/usr/lib/postgresql/15/bin" "$RUNTIME_ROOT/usr/lib/postgresql/15/lib" -type f -print0)
done < <(find "$RUNTIME_ROOT/bin" -type f -print0)
is_system_glibc_library() {
case "$1" in
@@ -241,10 +199,6 @@ while [ "$queue_index" -lt "${#ELF_QUEUE[@]}" ]; do
done
for required_file in \
"$RUNTIME_ROOT/usr/lib/postgresql/15/bin/postgres" \
"$RUNTIME_ROOT/usr/lib/postgresql/15/bin/initdb" \
"$RUNTIME_ROOT/usr/lib/postgresql/15/bin/pg_ctl" \
"$RUNTIME_ROOT/bin/node" \
"$RUNTIME_ROOT/bin/curl"; do
test -x "$required_file" || { printf 'native runtime file is missing: %s\n' "$required_file" >&2; exit 1; }
done
@@ -263,5 +217,6 @@ mv "$PACKED_ROOT/liverecorder.fpk" "$OUTPUT"
sha256sum "$(basename -- "$OUTPUT")" >"$(basename -- "$OUTPUT").sha256"
)
"$ROOT_DIR/scripts/verify-fnos-package.sh" "$OUTPUT"
LIVERECORDER_VERIFY_TMPDIR="${LIVERECORDER_VERIFY_TMPDIR:-$BUILD_TMP_ROOT}" \
"$ROOT_DIR/scripts/verify-fnos-package.sh" "$OUTPUT"
printf 'Built %s\n' "$OUTPUT"
+1 -1
View File
@@ -2,7 +2,7 @@
set -euo pipefail
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
VERSION=15.1.0
VERSION=15.1.1
PGVECTOR_VERSION=0.8.6
PGVECTOR_PACKAGE_VERSION=0.8.6-1.pgdg12%2B1
PGVECTOR_SHA256=b27ff894d1e2d23ebd7528fcb986923391977cbd5c5379ed74527875246854ca
-157
View File
@@ -1,157 +0,0 @@
#!/bin/bash
set -euo pipefail
LIVE_PACKAGE=${1:?usage: smoke-fnos-migration.sh liverecorder.fpk postgresql-service.fpk [temporary-directory]}
POSTGRES_PACKAGE=${2:?usage: smoke-fnos-migration.sh liverecorder.fpk postgresql-service.fpk [temporary-directory]}
SMOKE_TMP_ROOT="${3:-${LIVERECORDER_MIGRATION_SMOKE_TMPDIR:-${TMPDIR:-/tmp}}}"
mkdir -p "$SMOKE_TMP_ROOT"
SMOKE_TMP_ROOT=$(CDPATH= cd -- "$SMOKE_TMP_ROOT" && pwd)
WORK_DIR=$(mktemp -d "${SMOKE_TMP_ROOT%/}/liverecorder-migration-smoke.XXXXXX")
STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/liverecorder-migration-state.XXXXXX")
LIVE_PACKAGE_ROOT="$WORK_DIR/live-package"
LIVE_APP_ROOT="$WORK_DIR/live-app"
LIVE_DATA_ROOT="$STATE_DIR/live-var"
LIVE_VOLUME_ROOT="$WORK_DIR/live-volume"
PG_PACKAGE_ROOT="$WORK_DIR/postgres-package"
PG_APP_ROOT="$WORK_DIR/postgres-app"
PG_DATA_ROOT="$STATE_DIR/postgres-var"
PG_VOLUME_ROOT="$WORK_DIR/postgres-volume"
LIVE_PORT=${LIVERECORDER_MIGRATION_SMOKE_PORT:-19680}
PRIVATE_PG_PORT=${LIVERECORDER_MIGRATION_PRIVATE_PG_PORT:-19629}
PG_API_PORT=${POSTGRES_SERVICE_MIGRATION_API_PORT:-19633}
PG_PORT=${POSTGRES_SERVICE_MIGRATION_PG_PORT:-19632}
LIVE_CONTROL="$LIVE_PACKAGE_ROOT/cmd/main"
PG_CONTROL="$PG_PACKAGE_ROOT/cmd/main"
ADMIN_PASSWORD='LiveRecorder-Migration-2026!'
PG_ADMIN_PASSWORD='Postgres-Migration-Admin-2026!'
ENROLLMENT_TOKEN='Postgres-Migration-Enrollment-2026!'
MEDIA_PATH="${PATH:-/usr/local/bin:/usr/bin:/bin}"
if ! PATH="$MEDIA_PATH" command -v ffmpeg >/dev/null 2>&1 || \
! PATH="$MEDIA_PATH" command -v ffprobe >/dev/null 2>&1; then
mkdir -p "$WORK_DIR/system-media-stubs"
ln -s "$(type -P true)" "$WORK_DIR/system-media-stubs/ffmpeg"
ln -s "$(type -P true)" "$WORK_DIR/system-media-stubs/ffprobe"
MEDIA_PATH="$WORK_DIR/system-media-stubs:$MEDIA_PATH"
fi
run_live_control() {
TRIM_APPDEST="$LIVE_APP_ROOT" TRIM_PKGVAR="$LIVE_DATA_ROOT" \
TRIM_APPDEST_VOL="$LIVE_VOLUME_ROOT" TRIM_SERVICE_PORT="$LIVE_PORT" \
LIVE_RECORDER_POSTGRES_PORT="$PRIVATE_PG_PORT" \
POSTGRES_SERVICE_API="http://127.0.0.1:$PG_API_PORT" PATH="$MEDIA_PATH" "$LIVE_CONTROL" "$@"
}
run_postgres_control() {
TRIM_APPDEST="$PG_APP_ROOT" TRIM_PKGVAR="$PG_DATA_ROOT" \
TRIM_APPDEST_VOL="$PG_VOLUME_ROOT" TRIM_SERVICE_PORT="$PG_API_PORT" \
POSTGRES_SERVICE_PORT="$PG_PORT" "$PG_CONTROL" "$@"
}
cleanup() {
status=$?
if [ -x "$LIVE_CONTROL" ]; then run_live_control stop >/dev/null 2>&1 || true; fi
if [ -x "$PG_CONTROL" ]; then run_postgres_control stop >/dev/null 2>&1 || true; fi
if [ "$status" -ne 0 ]; then
printf '%s\n' 'fnOS PostgreSQL migration smoke test failed; service logs follow:' >&2
for log_file in \
"$PG_DATA_ROOT/log/postgresql.log" "$PG_DATA_ROOT/log/postgres-service.log" \
"$LIVE_DATA_ROOT/log/postgresql.log" "$LIVE_DATA_ROOT/log/liverecorder.log"; do
if [ -f "$log_file" ]; then
printf '%s\n' "--- $log_file ---" >&2
tail -n 200 "$log_file" >&2 || true
fi
done
fi
rm -rf -- "$WORK_DIR"
rm -rf -- "$STATE_DIR"
return "$status"
}
trap cleanup EXIT HUP INT TERM
mkdir -p "$LIVE_PACKAGE_ROOT" "$LIVE_APP_ROOT" "$LIVE_VOLUME_ROOT" \
"$PG_PACKAGE_ROOT" "$PG_APP_ROOT" "$PG_VOLUME_ROOT"
tar -xzf "$LIVE_PACKAGE" -C "$LIVE_PACKAGE_ROOT"
tar -xzf "$LIVE_PACKAGE_ROOT/app.tgz" -C "$LIVE_APP_ROOT"
rm -f "$LIVE_PACKAGE_ROOT/app.tgz"
tar -xzf "$POSTGRES_PACKAGE" -C "$PG_PACKAGE_ROOT"
tar -xzf "$PG_PACKAGE_ROOT/app.tgz" -C "$PG_APP_ROOT"
rm -f "$PG_PACKAGE_ROOT/app.tgz"
TRIM_PKGVAR="$LIVE_DATA_ROOT" \
wizard_admin_password="$ADMIN_PASSWORD" wizard_admin_password_confirm="$ADMIN_PASSWORD" \
wizard_postgres_enrollment_token="$ENROLLMENT_TOKEN" \
"$LIVE_PACKAGE_ROOT/cmd/install_callback"
mv "$LIVE_DATA_ROOT/postgres-enrollment-token.seed" "$WORK_DIR/enrollment-token.seed"
PRIVATE_PG_BIN="$LIVE_APP_ROOT/runtime/usr/lib/postgresql/15/bin"
PRIVATE_PG_SHARE="$LIVE_APP_ROOT/runtime/usr/share/postgresql/15"
PRIVATE_PG_LIB="$LIVE_APP_ROOT/runtime/usr/lib/postgresql/15/lib"
PRIVATE_RUNTIME_LIBS="$LIVE_APP_ROOT/runtime/lib:$PRIVATE_PG_LIB"
PRIVATE_PG_DATA="$LIVE_DATA_ROOT/postgres"
PRIVATE_RUN_ROOT="$LIVE_DATA_ROOT/run"
mkdir -p "$PRIVATE_PG_DATA" "$PRIVATE_RUN_ROOT" "$LIVE_DATA_ROOT/log"
chmod 0700 "$PRIVATE_PG_DATA" "$PRIVATE_RUN_ROOT"
env LD_LIBRARY_PATH="$PRIVATE_RUNTIME_LIBS" "$PRIVATE_PG_BIN/initdb" \
-D "$PRIVATE_PG_DATA" -L "$PRIVATE_PG_SHARE" --username=liverecorder \
--auth-local=trust --auth-host=reject --encoding=UTF8 --no-locale \
>"$LIVE_DATA_ROOT/log/postgresql.log" 2>&1
{
printf "listen_addresses = ''\n"
printf "port = %s\n" "$PRIVATE_PG_PORT"
printf "unix_socket_directories = '%s'\n" "$PRIVATE_RUN_ROOT"
printf "max_connections = 40\nshared_buffers = '64MB'\ntimezone = 'UTC'\nlog_timezone = 'UTC'\n"
} >>"$PRIVATE_PG_DATA/postgresql.conf"
# With a legacy PG15 cluster and no enrollment seed, the package must boot the
# old database. The Web API then creates the exact EF schema and initial admin.
run_live_control start
curl -fsS "http://127.0.0.1:$LIVE_PORT/health/ready" | grep -q '"status":"ready"'
source_user_count=$(env LD_LIBRARY_PATH="$PRIVATE_RUNTIME_LIBS" \
"$PRIVATE_PG_BIN/psql" -h "$PRIVATE_RUN_ROOT" -p "$PRIVATE_PG_PORT" \
-U liverecorder -d live_recorder -Atqc 'SELECT count(*) FROM "UserAccounts"')
test "$source_user_count" -gt 0
run_live_control stop
TRIM_PKGVAR="$PG_DATA_ROOT" \
wizard_postgres_admin_password="$PG_ADMIN_PASSWORD" \
wizard_postgres_admin_password_confirm="$PG_ADMIN_PASSWORD" \
wizard_postgres_enrollment_token="$ENROLLMENT_TOKEN" \
wizard_postgres_enrollment_token_confirm="$ENROLLMENT_TOKEN" \
"$PG_PACKAGE_ROOT/cmd/install_callback"
mv "$WORK_DIR/enrollment-token.seed" "$LIVE_DATA_ROOT/postgres-enrollment-token.seed"
chmod 0600 "$LIVE_DATA_ROOT/postgres-enrollment-token.seed"
run_postgres_control start
run_live_control start
curl -fsS "http://127.0.0.1:$LIVE_PORT/health/ready" | grep -q '"status":"ready"'
MARKER="$LIVE_DATA_ROOT/postgres-migration/shared-database.active"
DUMP="$LIVE_DATA_ROOT/postgres-migration/private-postgres-15.dump"
test -s "$MARKER"
grep -q '^migrated_at=' "$MARKER"
test -s "$DUMP"
sha256sum -c "$DUMP.sha256" >/dev/null
test -f "$LIVE_DATA_ROOT/postgres/PG_VERSION"
if env LD_LIBRARY_PATH="$PRIVATE_RUNTIME_LIBS" \
"$PRIVATE_PG_BIN/pg_ctl" -D "$PRIVATE_PG_DATA" status >/dev/null 2>&1; then
printf '%s\n' 'legacy private PostgreSQL was still running after migration' >&2
exit 1
fi
CREDENTIALS="$LIVE_DATA_ROOT/postgres-client.conf"
shared_database=$(sed -n 's/^database=//p' "$CREDENTIALS")
shared_user=$(sed -n 's/^username=//p' "$CREDENTIALS")
shared_password=$(sed -n 's/^password=//p' "$CREDENTIALS")
SHARED_PG_BIN="$PG_APP_ROOT/runtime/usr/lib/postgresql/15/bin"
SHARED_PG_LIBS="$PG_APP_ROOT/runtime/usr/lib/x86_64-linux-gnu:$PG_APP_ROOT/runtime/lib/x86_64-linux-gnu:$PG_APP_ROOT/runtime/usr/lib/postgresql/15/lib"
target_user_count=$(env LD_LIBRARY_PATH="$SHARED_PG_LIBS" PGPASSWORD="$shared_password" \
"$SHARED_PG_BIN/psql" -h 127.0.0.1 -p "$PG_PORT" -U "$shared_user" \
-d "$shared_database" -Atqc 'SELECT count(*) FROM "UserAccounts"')
test "$source_user_count" = "$target_user_count"
run_live_control stop
run_postgres_control status
curl -fsS "http://127.0.0.1:$PG_API_PORT/health/ready" | grep -q '"status":"ready"'
printf '%s\n' 'fnOS migration smoke test passed: populated legacy PG15 migrated with row-count parity, checksum dump and rollback data preserved'
+38 -9
View File
@@ -26,6 +26,11 @@ ADMIN_PASSWORD='LiveRecorder-Smoke-2026!'
PG_ADMIN_PASSWORD='Postgres-Admin-Smoke-2026!'
ENROLLMENT_TOKEN='Postgres-Enrollment-Smoke-2026!'
MEDIA_PATH="${PATH:-/usr/local/bin:/usr/bin:/bin}"
NODEJS_ROOT="$WORK_DIR/nodejs-v22-target"
NODEJS_BIN="${NODEJS_SMOKE_BIN:-$(type -P node)}"
mkdir -p "$NODEJS_ROOT/bin"
ln -s "$NODEJS_BIN" "$NODEJS_ROOT/bin/node"
if ! PATH="$MEDIA_PATH" command -v ffmpeg >/dev/null 2>&1 || \
! PATH="$MEDIA_PATH" command -v ffprobe >/dev/null 2>&1; then
@@ -38,7 +43,8 @@ fi
run_live_control() {
TRIM_APPDEST="$LIVE_APP_ROOT" TRIM_PKGVAR="$LIVE_DATA_ROOT" \
TRIM_APPDEST_VOL="$LIVE_VOLUME_ROOT" TRIM_SERVICE_PORT="$LIVE_PORT" \
POSTGRES_SERVICE_API="http://127.0.0.1:$PG_API_PORT" PATH="$MEDIA_PATH" "$LIVE_CONTROL" "$@"
POSTGRES_SERVICE_API="http://127.0.0.1:$PG_API_PORT" NODEJS_ROOT="$NODEJS_ROOT" \
PATH="$MEDIA_PATH" "$LIVE_CONTROL" "$@"
}
run_postgres_control() {
@@ -55,7 +61,7 @@ cleanup() {
printf '%s\n' 'fnOS shared-stack smoke test failed; service logs follow:' >&2
for log_file in \
"$PG_DATA_ROOT/log/postgresql.log" "$PG_DATA_ROOT/log/postgres-service.log" \
"$LIVE_DATA_ROOT/log/postgresql.log" "$LIVE_DATA_ROOT/log/liverecorder.log"; do
"$LIVE_DATA_ROOT/log/liverecorder.log"; do
if [ -f "$log_file" ]; then
printf '%s\n' "--- $log_file ---" >&2
tail -n 160 "$log_file" >&2 || true
@@ -77,6 +83,12 @@ tar -xzf "$LIVE_PACKAGE" -C "$LIVE_PACKAGE_ROOT"
tar -xzf "$LIVE_PACKAGE_ROOT/app.tgz" -C "$LIVE_APP_ROOT"
rm -f "$LIVE_PACKAGE_ROOT/app.tgz"
test ! -e "$LIVE_APP_ROOT/runtime/usr/lib/postgresql"
test ! -e "$LIVE_APP_ROOT/runtime/usr/share/postgresql"
test ! -e "$LIVE_APP_ROOT/runtime/lib/libLLVM-14.so.1"
test ! -e "$LIVE_APP_ROOT/runtime/lib/libz3.so.4"
test ! -e "$LIVE_APP_ROOT/runtime/bin/node"
TRIM_PKGVAR="$PG_DATA_ROOT" \
wizard_postgres_admin_password="$PG_ADMIN_PASSWORD" \
wizard_postgres_admin_password_confirm="$PG_ADMIN_PASSWORD" \
@@ -100,16 +112,13 @@ curl -fsS "$BASE_URL/health/ready" | grep -q '"status":"ready"'
curl -fsS "$BASE_URL/" | grep -q '<div id="app"></div>'
CREDENTIALS="$LIVE_DATA_ROOT/postgres-client.conf"
MARKER="$LIVE_DATA_ROOT/postgres-migration/shared-database.active"
test -s "$CREDENTIALS"
test "$(stat -c '%a' "$CREDENTIALS")" = "600"
grep -q '^host=127\.0\.0\.1$' "$CREDENTIALS"
grep -q "^port=$PG_PORT$" "$CREDENTIALS"
grep -q '^database=appdb_liverecorder_' "$CREDENTIALS"
grep -q '^username=app_liverecorder_' "$CREDENTIALS"
test -s "$MARKER"
grep -q '^fresh_install_at=' "$MARKER"
test ! -f "$LIVE_DATA_ROOT/postgres/PG_VERSION"
test ! -e "$LIVE_DATA_ROOT/postgres"
test ! -e "$LIVE_DATA_ROOT/postgres-enrollment-token.seed"
login_response=$(curl -fsS -H 'Content-Type: application/json' \
@@ -120,13 +129,21 @@ test -n "$token"
settings_response=$(curl -fsS -H "Authorization: Bearer $token" "$BASE_URL/api/settings")
expected_record_root="$LIVE_VOLUME_ROOT/@appshare/liverecorder/records"
printf '%s' "$settings_response" | grep -Fq "\"outputRoot\":\"$expected_record_root\""
session_page_response=$(curl -fsS -H "Authorization: Bearer $token" \
"$BASE_URL/api/record-sessions/page?skip=0&take=12&state=all")
printf '%s' "$session_page_response" | grep -q '"items":\[\]'
printf '%s' "$session_page_response" | grep -q '"totalCount":0'
printf '%s' "$session_page_response" | grep -q '"take":12'
filtered_session_page_response=$(curl -fsS -H "Authorization: Bearer $token" \
"$BASE_URL/api/record-sessions/page?skip=0&take=12&state=active&search=needle")
printf '%s' "$filtered_session_page_response" | grep -q '"totalCount":0'
runtime_libs="$LIVE_APP_ROOT/runtime/lib:$LIVE_APP_ROOT/runtime/usr/lib/postgresql/15/lib"
runtime_libs="$LIVE_APP_ROOT/runtime/lib"
ca_bundle="$LIVE_APP_ROOT/runtime/etc/ssl/certs/ca-certificates.crt"
node_bin="$LIVE_APP_ROOT/runtime/bin/node"
node_bin="$NODEJS_ROOT/bin/node"
curl_bin="$LIVE_APP_ROOT/runtime/bin/curl"
signer="$LIVE_APP_ROOT/server/Platforms/Douyin/Signing/sign-xbogus.js"
LD_LIBRARY_PATH="$runtime_libs" "$node_bin" --version | grep -q '^v22\.18\.0$'
LD_LIBRARY_PATH="$runtime_libs" "$node_bin" --version | grep -Eq '^v[0-9]+\.'
signature=$(LD_LIBRARY_PATH="$runtime_libs" "$node_bin" "$signer" \
'aid=6383&device_platform=web&room_id=1' 'Mozilla/5.0 LiveRecorder fnOS shared-stack smoke test')
test -n "$signature"
@@ -145,4 +162,16 @@ run_live_control start
run_live_control status
curl -fsS "$BASE_URL/health/ready" | grep -q '"status":"ready"'
run_live_control stop
run_postgres_control stop
rm -f "$CREDENTIALS" "$LIVE_DATA_ROOT/postgres-enrollment-token.seed"
mkdir -p "$LIVE_DATA_ROOT/postgres"
printf '15\n' >"$LIVE_DATA_ROOT/postgres/PG_VERSION"
if run_live_control start; then
printf '%s\n' 'Live Recorder unexpectedly fell back to a legacy private PostgreSQL database' >&2
exit 1
fi
grep -q '无法连接 PostgreSQL 共享服务或取得数据库凭据,应用不会启动' \
"$LIVE_DATA_ROOT/log/liverecorder.log"
printf '%s\n' 'fnOS shared-stack smoke test passed: independent PostgreSQL stayed running, Live Recorder enrolled, persisted credentials and restarted without Docker'
+1
View File
@@ -71,6 +71,7 @@ curl -fsS -c "$COOKIE_JAR" -H 'Content-Type: application/json' \
--data "{\"username\":\"admin\",\"password\":\"$ADMIN_PASSWORD\"}" \
"$BASE_URL/api/v1/auth/login" >/dev/null
curl -fsS -b "$COOKIE_JAR" "$BASE_URL/api/v1/overview" | grep -q '"version"'
curl -fsS -b "$COOKIE_JAR" "$BASE_URL/api/v1/sessions" | grep -q '^\['
enroll() {
app_id=$1
+9 -5
View File
@@ -73,21 +73,25 @@ if awk '
fi
grep -q '^server/wwwroot/index.html$' "$WORK_DIR/app-files.txt"
grep -q '^runtime/usr/lib/postgresql/15/bin/postgres$' "$WORK_DIR/app-files.txt"
grep -q '^runtime/usr/lib/postgresql/15/bin/initdb$' "$WORK_DIR/app-files.txt"
grep -q '^runtime/usr/lib/postgresql/15/bin/pg_ctl$' "$WORK_DIR/app-files.txt"
grep -q '^runtime/usr/share/postgresql/15/postgresql.conf.sample$' "$WORK_DIR/app-files.txt"
grep -q '^ui/config$' "$WORK_DIR/app-files.txt"
grep -q '^ui/images/icon_64.png$' "$WORK_DIR/app-files.txt"
if [ "$appname" = "liverecorder" ]; then
grep -q '^server/LiveRecorder.WebApi$' "$WORK_DIR/app-files.txt"
grep -q '^server/Platforms/Douyin/Signing/sign-xbogus.js$' "$WORK_DIR/app-files.txt"
grep -q '^runtime/bin/node$' "$WORK_DIR/app-files.txt"
grep -q '^runtime/bin/curl$' "$WORK_DIR/app-files.txt"
grep -q '^runtime/etc/ssl/certs/ca-certificates.crt$' "$WORK_DIR/app-files.txt"
test "$(manifest_value install_dep_apps)" = "nxsir.postgresql:nodejs_v22"
if grep -Eq '^runtime/(bin/node|usr/(lib|share)/postgresql/|lib/(libLLVM|libz3))' "$WORK_DIR/app-files.txt"; then
printf 'Live Recorder must use shared PostgreSQL and the fnOS nodejs_v22 dependency\n' >&2
exit 1
fi
else
grep -q '^server/PostgresService.WebApi$' "$WORK_DIR/app-files.txt"
grep -q '^runtime/usr/lib/postgresql/15/bin/postgres$' "$WORK_DIR/app-files.txt"
grep -q '^runtime/usr/lib/postgresql/15/bin/initdb$' "$WORK_DIR/app-files.txt"
grep -q '^runtime/usr/lib/postgresql/15/bin/pg_ctl$' "$WORK_DIR/app-files.txt"
grep -q '^runtime/usr/share/postgresql/15/postgresql.conf.sample$' "$WORK_DIR/app-files.txt"
grep -q '^runtime/usr/lib/postgresql/15/lib/vector.so$' "$WORK_DIR/app-files.txt"
grep -q '^runtime/usr/share/postgresql/15/extension/vector.control$' "$WORK_DIR/app-files.txt"
fi
@@ -61,8 +61,28 @@ public interface IRecordSessionRepository
{
Task<RecordSession?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<RecordSession>> GetByIdsAsync(
IReadOnlyCollection<Guid> ids,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<RecordSession>> ListAsync(Guid? liveRoomId = null, CancellationToken cancellationToken = default);
Task<IReadOnlyList<RecordSession>> ListOverviewAsync(Guid? liveRoomId, int take, CancellationToken cancellationToken = default);
Task<(IReadOnlyList<RecordSession> Items, int TotalCount)> ListPageAsync(
Guid? liveRoomId,
IReadOnlyCollection<RecordSessionStatus>? statuses,
string? search,
int skip,
int take,
CancellationToken cancellationToken = default);
Task<RecordSessionOverviewTotals> GetOverviewTotalsAsync(
Guid? liveRoomId = null,
CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<Guid>> ListActiveIdsAsync(Guid? liveRoomId = null, CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<Guid>> ListActiveLiveRoomIdsAsync(CancellationToken cancellationToken = default);
Task<RecordSession?> GetActiveByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default);
@@ -80,6 +100,12 @@ public interface IRecordSessionRepository
void Remove(RecordSession recordSession);
}
public sealed record RecordSessionOverviewTotals(
int TotalSessionCount,
int ActiveSessionCount,
int TotalTaskCount,
int TotalDanmakuCount);
public interface IRecordResultRepository
{
Task<RecordResult?> GetByTaskIdAsync(Guid recordTaskId, CancellationToken cancellationToken = default);
@@ -90,6 +116,8 @@ public interface IRecordResultRepository
Task<long> SumPendingUploadBytesAsync(CancellationToken cancellationToken = default);
Task<int> CountFailedArtifactAsync(CancellationToken cancellationToken = default);
Task<List<(RecordResult Result, RecordTask Task)>> ListUploadStatusAsync(
RecordArtifactUploadStatus? uploadStatusFilter,
int skip,
@@ -139,6 +167,17 @@ public interface ISystemLogRepository
Task<IReadOnlyList<Guid>> ListSessionIdsWithoutTasksAsync(CancellationToken cancellationToken = default);
}
public interface IOperationsMetricsRepository
{
Task<OperationsMetricsSnapshot> GetAsync(CancellationToken cancellationToken = default);
}
public sealed record OperationsMetricsSnapshot(
DateTimeOffset? OldestTranscodeUpdatedAt,
DateTimeOffset? OldestUploadProgressAt,
int StalledUploadCount,
int CleanupFailureCount);
public interface IUserAccountRepository
{
Task<UserAccount?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
@@ -28,8 +28,17 @@ public interface IFfmpegService
TimeSpan timeout,
CancellationToken cancellationToken = default);
Task<int> StopAllAndWaitAsync(
TimeSpan gracefulTimeout,
TimeSpan forceKillTimeout,
CancellationToken cancellationToken = default);
Task<bool> TryReconcileInactiveSessionAsync(Guid recordSessionId, CancellationToken cancellationToken = default);
Task<int> RecoverOrphanedTerminalSessionTasksAsync(CancellationToken cancellationToken = default);
Task<bool> TryRecoverOrphanedTerminalTaskAsync(Guid recordTaskId, CancellationToken cancellationToken = default);
Task<bool> StartManualFinalizeTaskAsync(Guid recordTaskId, CancellationToken cancellationToken = default);
Task<LiveRecorder.Application.Models.Media.TranscodeMediaFileResultDto> StartManualFinalizeFileAsync(
@@ -0,0 +1,6 @@
namespace LiveRecorder.Application.Abstractions.Recording;
public interface IRecordingStartLock
{
Task<IAsyncDisposable> AcquireAsync(Guid liveRoomId, CancellationToken cancellationToken = default);
}
@@ -23,6 +23,7 @@ public interface IEventScriptService
string segmentFilePath,
DateTimeOffset occurredAt,
bool forceRun = false,
Guid? eventId = null,
CancellationToken cancellationToken = default);
Task<EventScriptTestResultDto> TestAsync(
@@ -19,6 +19,8 @@ public interface IStorageGuardService
StorageGuardResult CheckCanStartOrResume(SystemSettingsDto settings, long additionalRequiredBytes = 0);
StorageGuardResult CheckShouldPause(SystemSettingsDto settings);
StorageGuardResult CheckCanFinalize(SystemSettingsDto settings, long estimatedTemporaryBytes);
}
public sealed record StorageGuardResult(
@@ -29,25 +31,37 @@ public sealed record StorageGuardResult(
long RequiredBytes,
string Message)
{
public bool IsAvailable { get; init; }
public long TotalBytes { get; init; }
public long UsedBytes { get; init; }
public double FreePercent { get; init; }
public double GreenThresholdPercent { get; init; }
public double RedThresholdPercent { get; init; }
/// <summary>
/// Current storage tier (Green/Yellow/Red).
/// </summary>
public StorageTier Tier { get; init; }
/// <summary>
/// Disk usage percentage (0-100). Only populated when IsEnabled is true.
/// Disk usage percentage (0-100).
/// </summary>
public double UsagePercent { get; init; }
/// <summary>
/// True if new recordings can be started. Only true in Green tier.
/// This replaces the old binary HasEnoughSpace check — the tier system is the single source of truth.
/// True if new recordings can be started. Both the percentage and absolute
/// resume thresholds must be satisfied.
/// </summary>
public bool CanStartNewRecording => Tier == StorageTier.Green;
public bool CanStartNewRecording => !IsEnabled || Tier == StorageTier.Green;
/// <summary>
/// True if active recordings should be paused. Only true in Red tier.
/// This replaces the old MB-based CheckShouldPause — consolidated into the tier system.
/// True if active recordings should be paused. Either the percentage or
/// absolute pause threshold can put storage into the Red tier.
/// </summary>
public bool ShouldPauseActive => Tier == StorageTier.Red;
public bool ShouldPauseActive => IsEnabled && Tier == StorageTier.Red;
}
@@ -51,6 +51,25 @@ public sealed class RecordSessionDto
public required IReadOnlyList<RecordTaskDto> Tasks { get; init; }
}
public sealed class RecordSessionListResponse
{
public required IReadOnlyList<RecordSessionDto> Items { get; init; }
public int TotalCount { get; init; }
public int Skip { get; init; }
public int Take { get; init; }
public int TotalSessionCount { get; init; }
public int ActiveSessionCount { get; init; }
public int TotalTaskCount { get; init; }
public int TotalDanmakuCount { get; init; }
}
public sealed class RecordSessionDetailDto
{
public required RecordSessionDto Session { get; init; }
@@ -239,6 +239,8 @@ public sealed class UploadTaskListResponse
public int NotUploadedCount { get; init; }
public int FailedArtifactCount { get; init; }
public int SucceededCount { get; init; }
public int FailedCount { get; init; }
@@ -15,12 +15,18 @@ public sealed class StorageGuardStatusDto
{
public bool IsEnabled { get; init; }
public bool IsAvailable { get; init; }
public bool HasEnoughSpace { get; init; }
public required string CheckedPath { get; init; }
public long AvailableBytes { get; init; }
public long TotalBytes { get; init; }
public long UsedBytes { get; init; }
public long RequiredBytes { get; init; }
public required string Message { get; init; }
@@ -30,6 +36,12 @@ public sealed class StorageGuardStatusDto
/// <summary>Disk usage percentage (0-100).</summary>
public double UsagePercent { get; init; }
public double FreePercent { get; init; }
public double GreenThresholdPercent { get; init; }
public double RedThresholdPercent { get; init; }
}
public sealed class RecoverableLiveRoomDto
@@ -50,6 +50,11 @@ public sealed class DashboardDto
/// </summary>
public int RecentErrorCount { get; init; }
/// <summary>
/// Number of Error-level system logs in the last 30 minutes. This drives the current-health warning.
/// </summary>
public int CurrentErrorCount { get; init; }
/// <summary>
/// Current storage guard status.
/// </summary>
@@ -79,15 +84,32 @@ public sealed class DashboardDto
/// Total file size in bytes of files awaiting upload.
/// </summary>
public long QueuedDataBytes { get; init; }
public DateTimeOffset? OldestTranscodeUpdatedAt { get; init; }
public DateTimeOffset? OldestUploadProgressAt { get; init; }
public int StalledUploadCount { get; init; }
public int UploadCleanupFailureCount { get; init; }
}
public sealed class StorageStatusDto
{
public bool IsEnabled { get; init; }
public bool IsAvailable { get; init; }
public bool HasEnoughSpace { get; init; }
public string Message { get; init; } = string.Empty;
public string CheckedPath { get; init; } = string.Empty;
public long TotalBytes { get; init; }
public long UsedBytes { get; init; }
public long AvailableBytes { get; init; }
public long RequiredBytes { get; init; }
public string Tier { get; init; } = "Green";
public double UsagePercent { get; init; }
public double FreePercent { get; init; }
public double GreenThresholdPercent { get; init; }
public double RedThresholdPercent { get; init; }
}
public sealed class RecentSessionItemDto
@@ -249,6 +249,8 @@ public sealed class SystemSettingsDto
public bool RetentionDeleteFiles { get; set; } = false;
public bool RetentionRequireUploadSuccess { get; set; } = false;
public string RetentionVideoFileCondition { get; set; } = CleanupVideoFileConditions.Any;
public IReadOnlyList<int> RetentionTaskStatuses { get; set; } = [];
@@ -530,6 +532,8 @@ public sealed class UpdateSystemSettingsRequest
public bool RetentionDeleteFiles { get; set; } = false;
public bool RetentionRequireUploadSuccess { get; set; } = false;
public string RetentionVideoFileCondition { get; set; } = CleanupVideoFileConditions.Any;
public IReadOnlyList<int> RetentionTaskStatuses { get; set; } = [];
@@ -16,6 +16,7 @@ public sealed class DashboardService
private readonly ISystemLogRepository _systemLogRepository;
private readonly ISystemSettingsService _systemSettingsService;
private readonly IStorageGuardService _storageGuardService;
private readonly IOperationsMetricsRepository _operationsMetricsRepository;
public DashboardService(
ILiveRoomRepository liveRoomRepository,
@@ -24,7 +25,8 @@ public sealed class DashboardService
IRecordResultRepository recordResultRepository,
ISystemLogRepository systemLogRepository,
ISystemSettingsService systemSettingsService,
IStorageGuardService storageGuardService)
IStorageGuardService storageGuardService,
IOperationsMetricsRepository operationsMetricsRepository)
{
_liveRoomRepository = liveRoomRepository;
_recordSessionRepository = recordSessionRepository;
@@ -33,6 +35,7 @@ public sealed class DashboardService
_systemLogRepository = systemLogRepository;
_systemSettingsService = systemSettingsService;
_storageGuardService = storageGuardService;
_operationsMetricsRepository = operationsMetricsRepository;
}
public async Task<DashboardDto> GetDashboardAsync(CancellationToken cancellationToken = default)
@@ -45,6 +48,7 @@ public sealed class DashboardService
var todayUtcStart = new DateTimeOffset(todayBeijingDate.ToDateTime(TimeOnly.MinValue), ChinaTime.Zone.GetUtcOffset(beijingNow.DateTime)).ToUniversalTime();
var todayUtcEnd = new DateTimeOffset(todayBeijingDate.ToDateTime(TimeOnly.MaxValue), ChinaTime.Zone.GetUtcOffset(beijingNow.DateTime)).ToUniversalTime();
var recentErrorSince = now.AddHours(-24);
var currentErrorSince = now.AddMinutes(-30);
// Run queries sequentially — DbContext is not thread-safe
var activeRecordingCount = await _recordSessionRepository.CountByStatusAsync(RecordSessionStatus.Running, cancellationToken);
@@ -53,6 +57,7 @@ public sealed class DashboardService
var totalRoomCount = await _liveRoomRepository.CountAsync(cancellationToken);
var activeSessionCount = await _recordSessionRepository.CountActiveAsync(cancellationToken);
var recentErrorCount = await _systemLogRepository.CountRecentErrorsAsync(recentErrorSince, cancellationToken);
var currentErrorCount = await _systemLogRepository.CountRecentErrorsAsync(currentErrorSince, cancellationToken);
var todayRecordingSeconds = await _recordTaskRepository.SumDurationSecondsAsync(todayUtcStart, todayUtcEnd, cancellationToken);
var (todayTotalBytes, todayTotalDanmaku) = await _recordResultRepository.GetTodayAggregateAsync(todayUtcStart, todayUtcEnd, cancellationToken);
var recentSessions = await _recordSessionRepository.ListRecentAsync(5, cancellationToken);
@@ -62,6 +67,7 @@ public sealed class DashboardService
var pendingTranscodeCount = await _recordTaskRepository.CountByStatusAsync(RecordTaskStatus.Processing, cancellationToken);
var pendingUploadCount = await _recordResultRepository.CountPendingUploadAsync(cancellationToken);
var queuedDataBytes = await _recordResultRepository.SumPendingUploadBytesAsync(cancellationToken);
var operationsMetrics = await _operationsMetricsRepository.GetAsync(cancellationToken);
return new DashboardDto
{
@@ -74,17 +80,31 @@ public sealed class DashboardService
TodayDanmakuCount = todayTotalDanmaku,
ActiveSessionCount = activeSessionCount,
RecentErrorCount = recentErrorCount,
CurrentErrorCount = currentErrorCount,
StorageStatus = new StorageStatusDto
{
IsEnabled = storageCheck.IsEnabled,
IsAvailable = storageCheck.IsAvailable,
HasEnoughSpace = storageCheck.HasEnoughSpace,
Message = storageCheck.Message ?? string.Empty,
CheckedPath = storageCheck.CheckedPath,
TotalBytes = storageCheck.TotalBytes,
UsedBytes = storageCheck.UsedBytes,
AvailableBytes = storageCheck.AvailableBytes,
RequiredBytes = storageCheck.RequiredBytes,
Tier = storageCheck.Tier.ToString(),
UsagePercent = storageCheck.UsagePercent
UsagePercent = storageCheck.UsagePercent,
FreePercent = storageCheck.FreePercent,
GreenThresholdPercent = storageCheck.GreenThresholdPercent,
RedThresholdPercent = storageCheck.RedThresholdPercent
},
PendingTranscodeCount = pendingTranscodeCount,
PendingUploadCount = pendingUploadCount,
QueuedDataBytes = queuedDataBytes,
OldestTranscodeUpdatedAt = operationsMetrics.OldestTranscodeUpdatedAt,
OldestUploadProgressAt = operationsMetrics.OldestUploadProgressAt,
StalledUploadCount = operationsMetrics.StalledUploadCount,
UploadCleanupFailureCount = operationsMetrics.CleanupFailureCount,
RecentSessions = recentSessions
.Select(MapRecentSession)
.ToList(),
@@ -34,6 +34,7 @@ public sealed class RecordService
private readonly LiveRoomStatusService _liveRoomStatusService;
private readonly LiveRoomRecordingSettingsResolver _liveRoomRecordingSettingsResolver;
private readonly IStorageGuardService _storageGuardService;
private readonly IRecordingStartLock _recordingStartLock;
private readonly IUnitOfWork _unitOfWork;
public RecordService(
@@ -53,6 +54,7 @@ public sealed class RecordService
LiveRoomStatusService liveRoomStatusService,
LiveRoomRecordingSettingsResolver liveRoomRecordingSettingsResolver,
IStorageGuardService storageGuardService,
IRecordingStartLock recordingStartLock,
IUnitOfWork unitOfWork)
{
_liveRoomRepository = liveRoomRepository;
@@ -71,6 +73,7 @@ public sealed class RecordService
_liveRoomStatusService = liveRoomStatusService;
_liveRoomRecordingSettingsResolver = liveRoomRecordingSettingsResolver;
_storageGuardService = storageGuardService;
_recordingStartLock = recordingStartLock;
_unitOfWork = unitOfWork;
}
@@ -249,13 +252,24 @@ public sealed class RecordService
var now = DateTimeOffset.UtcNow;
var storageAnchorName = GetStorageAnchorName(liveRoom, settings);
var recordSession = new RecordSession(liveRoom.Id, preferredQuality, outputFormat, saveMode, now);
await _recordSessionRepository.AddAsync(recordSession, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
RecordSession recordSession;
RecordTask initialTask;
await using (await _recordingStartLock.AcquireAsync(liveRoom.Id, cancellationToken))
{
activeSession = await _recordSessionRepository.GetActiveByLiveRoomIdAsync(liveRoom.Id, cancellationToken);
if (activeSession is not null)
{
throw new InvalidOperationException("An active recording session already exists for the live room.");
}
var initialTask = new RecordTask(liveRoom.Id, recordSession.Id, 1, preferredQuality, outputFormat, now);
await _recordTaskRepository.AddAsync(initialTask, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
recordSession = new RecordSession(liveRoom.Id, preferredQuality, outputFormat, saveMode, now);
await _recordSessionRepository.AddAsync(recordSession, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
initialTask = new RecordTask(liveRoom.Id, recordSession.Id, 1, preferredQuality, outputFormat, now);
await _recordTaskRepository.AddAsync(initialTask, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
}
try
{
@@ -300,7 +314,9 @@ public sealed class RecordService
streamResult.SelectedQuality,
outputFormat,
saveMode,
recordSession.Id,
now);
outputPattern = EnsureNonConflictingOutputPattern(outputPattern, outputFormat, saveMode, recordSession.Id);
var initialOutputPath = ResolveSegmentOutputPath(outputPattern, outputFormat, saveMode, 1);
recordSession.MarkStarting(streamResult.SelectedUrl, outputPattern, now);
@@ -422,6 +438,12 @@ public sealed class RecordService
continue;
}
if (IsUploadProtected(recordTask.UploadJob?.Status ?? recordTask.Result?.UploadStatus))
{
warnings.Add($"Task {recordTask.Id} is waiting for OpenList upload or cleanup and cannot be deleted.");
continue;
}
if (request.DeleteFiles)
{
TryDeleteRecordOutput(recordTask, warnings, deletedFilePaths, deletedDanmakuPaths);
@@ -452,11 +474,10 @@ public sealed class RecordService
if (affectedSessionIds.Count > 0)
{
var sessions = await _recordSessionRepository.ListAsync(cancellationToken: cancellationToken);
foreach (var session in sessions.Where(item => affectedSessionIds.Contains(item.Id)))
var sessions = await _recordSessionRepository.GetByIdsAsync(affectedSessionIds, cancellationToken);
foreach (var session in sessions)
{
var remainingTasks = await _recordTaskRepository.ListBySessionIdAsync(session.Id, cancellationToken);
if (remainingTasks.Count != 0 || IsActiveSessionStatus(session.Status))
if (session.RecordTasks.Count != 0 || IsActiveSessionStatus(session.Status))
{
continue;
}
@@ -605,7 +626,7 @@ public sealed class RecordService
segmentFilePath,
occurredAt,
forceRun: true,
cancellationToken);
cancellationToken: cancellationToken);
await _systemLogService.WriteAsync(
SystemLogLevel.Info,
@@ -844,7 +865,8 @@ public sealed class RecordService
};
private static bool IsActiveTaskStatus(RecordTaskStatus status) =>
status is RecordTaskStatus.Starting
status is RecordTaskStatus.Pending
or RecordTaskStatus.Starting
or RecordTaskStatus.Running
or RecordTaskStatus.Stopping
or RecordTaskStatus.Processing;
@@ -868,7 +890,12 @@ public sealed class RecordService
}
private static bool IsActiveSessionStatus(RecordSessionStatus status) =>
status is RecordSessionStatus.Starting or RecordSessionStatus.Running or RecordSessionStatus.Stopping;
status is RecordSessionStatus.Pending or RecordSessionStatus.Starting or RecordSessionStatus.Running or RecordSessionStatus.Stopping;
internal static bool IsUploadProtected(RecordArtifactUploadStatus? status) =>
status is RecordArtifactUploadStatus.Queued
or RecordArtifactUploadStatus.Uploading
or RecordArtifactUploadStatus.WaitingRetry;
private static string BuildOutputPathPattern(
string outputRoot,
@@ -881,6 +908,7 @@ public sealed class RecordService
string selectedQuality,
RecordOutputFormat outputFormat,
RecordSaveMode saveMode,
Guid recordSessionId,
DateTimeOffset now)
{
var localNow = ChinaTime.ToBeijingTime(now);
@@ -894,6 +922,7 @@ public sealed class RecordService
title,
selectedQuality,
localNow,
recordSessionId,
segmentSuffix: string.Empty);
var directoryPath = BuildDirectoryPath(
outputDirectoryTemplate,
@@ -903,6 +932,7 @@ public sealed class RecordService
title,
selectedQuality,
localNow,
recordSessionId,
baseFileStem);
var fileNameStem = BuildFileNameStem(
effectiveFileNameTemplate,
@@ -912,12 +942,71 @@ public sealed class RecordService
title,
selectedQuality,
localNow,
recordSessionId,
saveMode == RecordSaveMode.Segmented ? "_%05d" : string.Empty);
var folder = Path.Combine(outputRoot, directoryPath);
var extension = outputFormat == RecordOutputFormat.Ts ? "ts" : "mp4";
return Path.Combine(folder, $"{fileNameStem}.{extension}");
}
private static string EnsureNonConflictingOutputPattern(
string outputPattern,
RecordOutputFormat outputFormat,
RecordSaveMode saveMode,
Guid recordSessionId)
{
var absolutePattern = Path.IsPathRooted(outputPattern)
? Path.GetFullPath(outputPattern)
: Path.GetFullPath(outputPattern, AppContext.BaseDirectory);
if (!HasOutputPatternConflict(absolutePattern, outputFormat, saveMode))
{
return outputPattern;
}
var extension = Path.GetExtension(outputPattern);
var stem = outputPattern[..^extension.Length];
var shortSessionId = recordSessionId.ToString("N")[..8];
return $"{stem}_{shortSessionId}{extension}";
}
private static bool HasOutputPatternConflict(
string outputPattern,
RecordOutputFormat outputFormat,
RecordSaveMode saveMode)
{
if (saveMode == RecordSaveMode.SingleFile)
{
if (File.Exists(outputPattern))
{
return true;
}
return outputFormat == RecordOutputFormat.Mp4 &&
File.Exists(Path.Combine(
Path.GetDirectoryName(outputPattern)!,
$"{Path.GetFileNameWithoutExtension(outputPattern)}.recording.ts"));
}
var directory = Path.GetDirectoryName(outputPattern);
var filePattern = Path.GetFileName(outputPattern);
var tokenIndex = filePattern.IndexOf("%05d", StringComparison.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(directory) || tokenIndex < 0 || !Directory.Exists(directory))
{
return false;
}
var prefix = filePattern[..tokenIndex];
var finalSuffix = filePattern[(tokenIndex + 4)..];
var recorderSuffix = outputFormat == RecordOutputFormat.Mp4
? Path.ChangeExtension(finalSuffix, ".ts")
: finalSuffix;
return Directory.EnumerateFiles(directory, $"{prefix}*", SearchOption.TopDirectoryOnly)
.Select(Path.GetFileName)
.Any(name => name is not null &&
(name.EndsWith(finalSuffix, StringComparison.OrdinalIgnoreCase) ||
name.EndsWith(recorderSuffix, StringComparison.OrdinalIgnoreCase)));
}
private static string? GetStorageAnchorName(LiveRoom liveRoom, Application.Models.Settings.SystemSettingsDto settings)
{
if (!settings.UseAliasForStorage)
@@ -952,6 +1041,7 @@ public sealed class RecordService
string? title,
string quality,
DateTimeOffset now,
Guid recordSessionId,
string fileStem)
{
var raw = ApplyOutputTemplate(
@@ -962,6 +1052,7 @@ public sealed class RecordService
title,
quality,
now,
recordSessionId,
forPathSegment: true,
fileStem,
segmentSuffix: string.Empty);
@@ -982,6 +1073,7 @@ public sealed class RecordService
string? title,
string quality,
DateTimeOffset now,
Guid recordSessionId,
string segmentSuffix)
{
var raw = ApplyOutputTemplate(
@@ -992,6 +1084,7 @@ public sealed class RecordService
title,
quality,
now,
recordSessionId,
forPathSegment: false,
fileStem: string.Empty,
segmentSuffix);
@@ -1026,6 +1119,7 @@ public sealed class RecordService
string? title,
string quality,
DateTimeOffset now,
Guid recordSessionId,
bool forPathSegment,
string fileStem,
string segmentSuffix)
@@ -1042,6 +1136,7 @@ public sealed class RecordService
["anchor"] = NormalizeTokenValue(anchorName, "unknown-anchor"),
["title"] = NormalizeTokenValue(title, "untitled"),
["quality"] = NormalizeTokenValue(quality, "origin"),
["sessionId"] = recordSessionId.ToString("N")[..8],
["fileStem"] = fileStem,
["segmentSuffix"] = segmentSuffix,
["yyyy"] = now.ToString("yyyy"),
@@ -10,6 +10,9 @@ namespace LiveRecorder.Application.Services;
public sealed class RecordSessionService
{
private const int OverviewSessionLimit = 200;
private const int DefaultPageSize = 20;
private const int MaximumPageSize = 100;
private static readonly TimeSpan GracefulDeleteTimeout = TimeSpan.FromSeconds(15);
private static readonly TimeSpan ForcedKillTimeout = TimeSpan.FromSeconds(8);
@@ -50,10 +53,7 @@ public sealed class RecordSessionService
public async Task<IReadOnlyList<RecordSessionDto>> ListAsync(Guid? liveRoomId = null, CancellationToken cancellationToken = default)
{
await _stoppedOrphanRecordSessionCleanupService.CleanupAsync(cancellationToken: cancellationToken);
await ReconcileActiveSessionsAsync(liveRoomId, cancellationToken);
var sessions = await _recordSessionRepository.ListAsync(liveRoomId, cancellationToken);
var sessions = await _recordSessionRepository.ListOverviewAsync(liveRoomId, OverviewSessionLimit, cancellationToken);
var runtimeStates = _ffmpegService.GetTaskRuntimeStates(
sessions.SelectMany(static item => item.RecordTasks).Select(static item => item.Id).ToArray());
return sessions
@@ -62,6 +62,40 @@ public sealed class RecordSessionService
.ToList();
}
public async Task<RecordSessionListResponse> ListPageAsync(
Guid? liveRoomId = null,
string? state = null,
string? search = null,
int skip = 0,
int take = DefaultPageSize,
CancellationToken cancellationToken = default)
{
var normalizedSkip = Math.Max(0, skip);
var normalizedTake = Math.Clamp(take, 1, MaximumPageSize);
var (sessions, totalCount) = await _recordSessionRepository.ListPageAsync(
liveRoomId,
ResolveStatusFilter(state),
search,
normalizedSkip,
normalizedTake,
cancellationToken);
var totals = await _recordSessionRepository.GetOverviewTotalsAsync(liveRoomId, cancellationToken);
var runtimeStates = _ffmpegService.GetTaskRuntimeStates(
sessions.SelectMany(static item => item.RecordTasks).Select(static item => item.Id).ToArray());
return new RecordSessionListResponse
{
Items = sessions.Select(item => RecordModelMapper.MapSession(item, runtimeStates)).ToList(),
TotalCount = totalCount,
Skip = normalizedSkip,
Take = normalizedTake,
TotalSessionCount = totals.TotalSessionCount,
ActiveSessionCount = totals.ActiveSessionCount,
TotalTaskCount = totals.TotalTaskCount,
TotalDanmakuCount = totals.TotalDanmakuCount
};
}
public async Task<RecordSessionDetailDto?> GetDetailAsync(Guid id, CancellationToken cancellationToken = default)
{
var session = await _recordSessionRepository.GetByIdAsync(id, cancellationToken);
@@ -276,6 +310,12 @@ public sealed class RecordSessionService
session = sessionSnapshot;
}
if (session.RecordTasks.Any(task => RecordService.IsUploadProtected(task.UploadJob?.Status ?? task.Result?.UploadStatus)))
{
warnings.Add($"Session {session.Id} contains OpenList uploads or cleanup retries and was not deleted.");
continue;
}
if (_ffmpegService.IsRunning(session.Id) || IsActiveStatus(session.Status))
{
warnings.Add($"Session {session.Id} is still active and could not be deleted.");
@@ -344,12 +384,7 @@ public sealed class RecordSessionService
private async Task ReconcileActiveSessionsAsync(Guid? liveRoomId, CancellationToken cancellationToken)
{
var sessions = await _recordSessionRepository.ListAsync(liveRoomId, cancellationToken);
var activeIds = sessions
.Where(item => IsActiveStatus(item.Status))
.Select(item => item.Id)
.Distinct()
.ToArray();
var activeIds = await _recordSessionRepository.ListActiveIdsAsync(liveRoomId, cancellationToken);
foreach (var activeId in activeIds)
{
@@ -360,6 +395,22 @@ public sealed class RecordSessionService
private static bool IsActiveStatus(RecordSessionStatus status) =>
status is RecordSessionStatus.Starting or RecordSessionStatus.Running or RecordSessionStatus.Stopping;
private static IReadOnlyCollection<RecordSessionStatus>? ResolveStatusFilter(string? state) =>
state?.Trim().ToLowerInvariant() switch
{
null or "" or "all" => null,
"active" =>
[
RecordSessionStatus.Starting,
RecordSessionStatus.Running,
RecordSessionStatus.Stopping
],
"completed" => [RecordSessionStatus.Completed],
"failed" => [RecordSessionStatus.Failed],
"stopped" => [RecordSessionStatus.Stopped],
_ => throw new ArgumentException("Unsupported recording session state filter.", nameof(state))
};
private static bool CanDeleteMissingFileSession(RecordSession session)
{
if (IsActiveStatus(session.Status))
@@ -12,6 +12,9 @@ namespace LiveRecorder.Application.Services;
public sealed class SystemSettingsService : ISystemSettingsService
{
private const double MinimumStorageGreenThresholdPercent = 10;
private const double MinimumStorageRedThresholdPercent = 5;
private const double MinimumStorageThresholdGapPercent = 5;
private const string FfmpegPathKey = "ffmpeg.path";
private const string OutputRootKey = "recording.output_root";
private const string OutputDirectoryTemplateKey = "recording.output_directory_template";
@@ -85,6 +88,7 @@ public sealed class SystemSettingsService : ISystemSettingsService
private const string EnableRetentionCleanupKey = "retention.cleanup.enabled";
private const string RetentionDaysKey = "retention.cleanup.days";
private const string RetentionDeleteFilesKey = "retention.cleanup.delete_files";
private const string RetentionRequireUploadSuccessKey = "retention.cleanup.require_upload_success";
private const string RetentionVideoFileConditionKey = "retention.cleanup.video_file_condition";
private const string RetentionTaskStatusesKey = "retention.cleanup.task_statuses";
private const string EnableEmailNotificationKey = "notification.email.enabled";
@@ -126,6 +130,15 @@ public sealed class SystemSettingsService : ISystemSettingsService
{
var settings = await _appSettingRepository.ListAsync(cancellationToken);
var lookup = settings.ToDictionary(static item => item.Key, static item => item.Value, StringComparer.OrdinalIgnoreCase);
var storageGreenThresholdPercent = GetDoubleValue(
lookup,
StorageGreenThresholdPercentKey,
30,
MinimumStorageGreenThresholdPercent,
90);
var storageRedThresholdPercent = Math.Min(
GetDoubleValue(lookup, StorageRedThresholdPercentKey, 10, MinimumStorageRedThresholdPercent, 85),
storageGreenThresholdPercent - MinimumStorageThresholdGapPercent);
return new SystemSettingsDto
{
@@ -149,8 +162,8 @@ public sealed class SystemSettingsService : ISystemSettingsService
EnableStorageGuard = bool.TryParse(GetValue(lookup, EnableStorageGuardKey, "true"), out var enableStorageGuard) && enableStorageGuard,
PauseRecordingWhenFreeSpaceBelowMegabytes = GetIntValue(lookup, PauseRecordingWhenFreeSpaceBelowMegabytesKey, 1024, 0, 1048576),
ResumeRecordingWhenFreeSpaceAboveMegabytes = GetIntValue(lookup, ResumeRecordingWhenFreeSpaceAboveMegabytesKey, 4096, 0, 1048576),
StorageGreenThresholdPercent = GetDoubleValue(lookup, StorageGreenThresholdPercentKey, 30, 5, 90),
StorageRedThresholdPercent = GetDoubleValue(lookup, StorageRedThresholdPercentKey, 10, 1, 85),
StorageGreenThresholdPercent = storageGreenThresholdPercent,
StorageRedThresholdPercent = storageRedThresholdPercent,
EnableAutoReconnect = bool.TryParse(GetValue(lookup, EnableReconnectKey, "true"), out var enableReconnect) && enableReconnect,
ReconnectDelayMaxSeconds = GetIntValue(lookup, ReconnectDelayMaxSecondsKey, 5, 1, 300),
ReadWriteTimeoutMilliseconds = GetIntValue(lookup, ReadWriteTimeoutMillisecondsKey, 15000000, 1000, 60000000),
@@ -232,6 +245,7 @@ public sealed class SystemSettingsService : ISystemSettingsService
EnableRetentionCleanup = bool.TryParse(GetValue(lookup, EnableRetentionCleanupKey, "false"), out var enableRetentionCleanup) && enableRetentionCleanup,
RetentionDays = GetIntValue(lookup, RetentionDaysKey, 30, 1, 3650),
RetentionDeleteFiles = bool.TryParse(GetValue(lookup, RetentionDeleteFilesKey, "false"), out var retentionDeleteFiles) && retentionDeleteFiles,
RetentionRequireUploadSuccess = bool.TryParse(GetValue(lookup, RetentionRequireUploadSuccessKey, "false"), out var retentionRequireUploadSuccess) && retentionRequireUploadSuccess,
RetentionVideoFileCondition = NormalizeCleanupVideoFileCondition(GetValue(lookup, RetentionVideoFileConditionKey, CleanupVideoFileConditions.Any)),
RetentionTaskStatuses = GetIntListValue(lookup, RetentionTaskStatusesKey),
EnableEmailNotification = bool.TryParse(GetValue(lookup, EnableEmailNotificationKey, "false"), out var enableEmailNotification) && enableEmailNotification,
@@ -305,6 +319,14 @@ public sealed class SystemSettingsService : ISystemSettingsService
var now = DateTimeOffset.UtcNow;
var webDavUpload = request.WebDavUpload ?? new WebDavUploadSettingsDto();
var s3Upload = request.S3Upload ?? new S3UploadSettingsDto();
var storageGreenThresholdPercent = Math.Clamp(
request.StorageGreenThresholdPercent,
MinimumStorageGreenThresholdPercent,
90);
var storageRedThresholdPercent = Math.Clamp(
request.StorageRedThresholdPercent,
MinimumStorageRedThresholdPercent,
Math.Min(85, storageGreenThresholdPercent - MinimumStorageThresholdGapPercent));
await UpsertAsync(FfmpegPathKey, request.FfmpegPath.Trim(), now, cancellationToken);
await UpsertAsync(OutputRootKey, request.OutputRoot.Trim(), now, cancellationToken);
@@ -338,12 +360,12 @@ public sealed class SystemSettingsService : ISystemSettingsService
cancellationToken);
await UpsertAsync(
StorageGreenThresholdPercentKey,
Math.Clamp(request.StorageGreenThresholdPercent, 5, 90).ToString("F1", CultureInfo.InvariantCulture),
storageGreenThresholdPercent.ToString("F1", CultureInfo.InvariantCulture),
now,
cancellationToken);
await UpsertAsync(
StorageRedThresholdPercentKey,
Math.Clamp(request.StorageRedThresholdPercent, 1, 85).ToString("F1", CultureInfo.InvariantCulture),
storageRedThresholdPercent.ToString("F1", CultureInfo.InvariantCulture),
now,
cancellationToken);
await UpsertAsync(EnableReconnectKey, request.EnableAutoReconnect.ToString(), now, cancellationToken);
@@ -410,6 +432,7 @@ public sealed class SystemSettingsService : ISystemSettingsService
await UpsertAsync(EnableRetentionCleanupKey, request.EnableRetentionCleanup.ToString(), now, cancellationToken);
await UpsertAsync(RetentionDaysKey, Math.Clamp(request.RetentionDays, 1, 3650).ToString(), now, cancellationToken);
await UpsertAsync(RetentionDeleteFilesKey, request.RetentionDeleteFiles.ToString(), now, cancellationToken);
await UpsertAsync(RetentionRequireUploadSuccessKey, request.RetentionRequireUploadSuccess.ToString(), now, cancellationToken);
await UpsertAsync(RetentionVideoFileConditionKey, NormalizeCleanupVideoFileCondition(request.RetentionVideoFileCondition), now, cancellationToken);
await UpsertAsync(RetentionTaskStatusesKey, SerializeIntList(request.RetentionTaskStatuses), now, cancellationToken);
await UpsertAsync(EnableEmailNotificationKey, request.EnableEmailNotification.ToString(), now, cancellationToken);
@@ -0,0 +1,63 @@
namespace LiveRecorder.Domain.Entities;
public sealed class RecordCompletionDispatch
{
private RecordCompletionDispatch()
{
}
public RecordCompletionDispatch(Guid recordTaskId, DateTimeOffset createdAt)
{
Id = Guid.NewGuid();
RecordTaskId = recordTaskId;
CreatedAt = createdAt;
UpdatedAt = createdAt;
NextAttemptAt = createdAt;
}
public Guid Id { get; private set; }
public Guid RecordTaskId { get; private set; }
public RecordTask? RecordTask { get; private set; }
public bool ScriptDispatched { get; private set; }
public bool UploadDispatched { get; private set; }
public int AttemptCount { get; private set; }
public string? LastError { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public DateTimeOffset UpdatedAt { get; private set; }
public DateTimeOffset? NextAttemptAt { get; private set; }
public DateTimeOffset? CompletedAt { get; private set; }
public void MarkScriptDispatched(DateTimeOffset updatedAt)
{
ScriptDispatched = true;
UpdatedAt = updatedAt;
CompleteIfReady(updatedAt);
}
public void MarkUploadDispatched(DateTimeOffset updatedAt)
{
UploadDispatched = true;
UpdatedAt = updatedAt;
CompleteIfReady(updatedAt);
}
public void ScheduleRetry(string error, DateTimeOffset nextAttemptAt, DateTimeOffset updatedAt)
{
AttemptCount++;
LastError = string.IsNullOrWhiteSpace(error) ? null : error.Trim();
NextAttemptAt = nextAttemptAt;
UpdatedAt = updatedAt;
}
private void CompleteIfReady(DateTimeOffset updatedAt)
{
if (!ScriptDispatched || !UploadDispatched)
{
return;
}
LastError = null;
NextAttemptAt = null;
CompletedAt = updatedAt;
}
}
@@ -138,12 +138,18 @@ public class RecordTask
UpdatedAt = endedAt;
}
public void MarkFailed(string errorMessage, DateTimeOffset endedAt)
public void MarkFailed(string errorMessage, DateTimeOffset endedAt) =>
MarkFailed(
errorMessage,
endedAt,
StartedAt.HasValue ? Math.Max(0, (endedAt - StartedAt.Value).TotalSeconds) : null);
public void MarkFailed(string errorMessage, DateTimeOffset endedAt, double? durationSeconds)
{
Status = RecordTaskStatus.Failed;
ErrorMessage = errorMessage;
EndedAt = endedAt;
DurationSeconds = StartedAt.HasValue ? Math.Max(0, (endedAt - StartedAt.Value).TotalSeconds) : null;
DurationSeconds = durationSeconds;
RecorderProcessId = null;
UpdatedAt = endedAt;
}
@@ -72,6 +72,10 @@ public sealed class RecordUploadJob
public DateTimeOffset? ExternalTaskStartedAt { get; private set; }
public DateTimeOffset? LastProgressAt { get; private set; }
public string? TransferTargetPath { get; private set; }
public DateTimeOffset? NextAttemptAt { get; private set; }
public DateTimeOffset? VerificationStartedAt { get; private set; }
@@ -112,6 +116,8 @@ public sealed class RecordUploadJob
ExternalTaskId = null;
ExternalTaskType = null;
ExternalTaskStartedAt = null;
LastProgressAt = null;
TransferTargetPath = null;
NextAttemptAt = null;
VerificationStartedAt = null;
ErrorMessage = null;
@@ -141,12 +147,19 @@ public sealed class RecordUploadJob
ExternalTaskId = NormalizeRequired(taskId);
ExternalTaskType = NormalizeRequired(taskType);
ExternalTaskStartedAt ??= updatedAt;
LastProgressAt ??= updatedAt;
SetProgress(progressPercent, updatedAt);
}
public void SetProgress(double progressPercent, DateTimeOffset updatedAt)
{
ProgressPercent = Math.Clamp(progressPercent, 0, 100);
var normalizedProgress = Math.Clamp(progressPercent, 0, 100);
if (normalizedProgress > ProgressPercent + 0.001)
{
LastProgressAt = updatedAt;
}
ProgressPercent = normalizedProgress;
UpdatedAt = updatedAt;
}
@@ -161,6 +174,8 @@ public sealed class RecordUploadJob
ExternalTaskId = null;
ExternalTaskType = null;
ExternalTaskStartedAt = null;
LastProgressAt = null;
TransferTargetPath = null;
VerificationStartedAt = null;
ErrorMessage = null;
@@ -188,6 +203,7 @@ public sealed class RecordUploadJob
ExternalTaskId = null;
ExternalTaskType = null;
ExternalTaskStartedAt = null;
LastProgressAt = null;
VerificationStartedAt = null;
}
@@ -202,6 +218,8 @@ public sealed class RecordUploadJob
ExternalTaskId = null;
ExternalTaskType = null;
ExternalTaskStartedAt = null;
LastProgressAt = null;
TransferTargetPath = null;
NextAttemptAt = null;
VerificationStartedAt = null;
ErrorMessage = null;
@@ -215,6 +233,8 @@ public sealed class RecordUploadJob
ExternalTaskId = null;
ExternalTaskType = null;
ExternalTaskStartedAt = null;
LastProgressAt = null;
TransferTargetPath = null;
NextAttemptAt = null;
VerificationStartedAt = null;
ErrorMessage = NormalizeNullable(errorMessage);
@@ -238,6 +258,41 @@ public sealed class RecordUploadJob
_ => throw new InvalidOperationException("The upload job has no remaining artifact.")
};
public string GetCurrentTransferTargetPath() => TransferTargetPath ?? GetCurrentTargetPath();
public void ResolveCurrentTargetConflict(string targetPath, string transferTargetPath, DateTimeOffset updatedAt)
{
if (CurrentArtifact == RecordUploadArtifactStage.Video)
{
TargetVideoPath = NormalizeRequired(targetPath);
}
else if (CurrentArtifact == RecordUploadArtifactStage.Danmaku)
{
TargetDanmakuPath = NormalizeRequired(targetPath);
}
else
{
throw new InvalidOperationException("The upload job has no remaining artifact.");
}
TransferTargetPath = NormalizeRequired(transferTargetPath);
VerificationStartedAt = null;
UpdatedAt = updatedAt;
}
public void CompleteTransferPromotion(DateTimeOffset updatedAt)
{
TransferTargetPath = null;
VerificationStartedAt = null;
UpdatedAt = updatedAt;
}
public void UpdateTransferTargetPath(string transferTargetPath, DateTimeOffset updatedAt)
{
TransferTargetPath = NormalizeRequired(transferTargetPath);
UpdatedAt = updatedAt;
}
public long GetCurrentSizeBytes() => CurrentArtifact switch
{
RecordUploadArtifactStage.Video => VideoSizeBytes,
@@ -21,6 +21,8 @@ public sealed class LiveRecorderDbContext : DbContext, IUnitOfWork
public DbSet<RecordUploadJob> RecordUploadJobs => Set<RecordUploadJob>();
public DbSet<RecordCompletionDispatch> RecordCompletionDispatches => Set<RecordCompletionDispatch>();
public DbSet<SystemLogEntry> SystemLogEntries => Set<SystemLogEntry>();
public DbSet<CleanupOperation> CleanupOperations => Set<CleanupOperation>();
@@ -81,7 +83,7 @@ public sealed class LiveRecorderDbContext : DbContext, IUnitOfWork
.WithMany(static x => x.RecordTasks)
.HasForeignKey(static x => x.RecordSessionId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(static x => new { x.RecordSessionId, x.SegmentIndex });
builder.HasIndex(static x => new { x.RecordSessionId, x.SegmentIndex }).IsUnique();
});
modelBuilder.Entity<RecordSession>(builder =>
@@ -99,6 +101,9 @@ public sealed class LiveRecorderDbContext : DbContext, IUnitOfWork
.WithMany()
.HasForeignKey(static x => x.LiveRoomId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(static x => x.LiveRoomId)
.IsUnique()
.HasFilter("\"Status\" IN (0, 1, 2, 3)");
});
modelBuilder.Entity<RecordResult>(builder =>
@@ -134,6 +139,7 @@ public sealed class LiveRecorderDbContext : DbContext, IUnitOfWork
builder.Property(static x => x.TargetDanmakuPath).HasMaxLength(2048);
builder.Property(static x => x.ExternalTaskId).HasMaxLength(128);
builder.Property(static x => x.ExternalTaskType).HasMaxLength(32);
builder.Property(static x => x.TransferTargetPath).HasMaxLength(2048);
builder.Property(static x => x.ErrorMessage).HasMaxLength(4096);
builder.HasIndex(static x => x.RecordTaskId).IsUnique();
builder.HasIndex(static x => new { x.Status, x.NextAttemptAt, x.RequestedAt });
@@ -143,6 +149,19 @@ public sealed class LiveRecorderDbContext : DbContext, IUnitOfWork
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<RecordCompletionDispatch>(builder =>
{
builder.ToTable("RecordCompletionDispatches");
builder.HasKey(static x => x.Id);
builder.Property(static x => x.LastError).HasMaxLength(4096);
builder.HasIndex(static x => x.RecordTaskId).IsUnique();
builder.HasIndex(static x => new { x.CompletedAt, x.NextAttemptAt, x.CreatedAt });
builder.HasOne(static x => x.RecordTask)
.WithOne()
.HasForeignKey<RecordCompletionDispatch>(static x => x.RecordTaskId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<SystemLogEntry>(builder =>
{
builder.ToTable("SystemLogEntries");
@@ -196,4 +215,45 @@ public sealed class LiveRecorderDbContext : DbContext, IUnitOfWork
.OnDelete(DeleteBehavior.Cascade);
});
}
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
await EnsureCompletionDispatchesAsync(cancellationToken);
return await base.SaveChangesAsync(cancellationToken);
}
private async Task EnsureCompletionDispatchesAsync(CancellationToken cancellationToken)
{
var terminalTasks = ChangeTracker.Entries<RecordTask>()
.Where(entry => entry.State is EntityState.Added or EntityState.Modified)
.Select(static entry => entry.Entity)
.Where(static task => task.Status is Domain.Enums.RecordTaskStatus.Completed or Domain.Enums.RecordTaskStatus.Stopped)
.ToArray();
foreach (var task in terminalTasks)
{
var result = task.Result ?? ChangeTracker.Entries<RecordResult>()
.Select(static entry => entry.Entity)
.FirstOrDefault(item => item.RecordTaskId == task.Id);
result ??= await RecordResults
.AsNoTracking()
.FirstOrDefaultAsync(item => item.RecordTaskId == task.Id, cancellationToken);
var path = result?.FilePath;
if (string.IsNullOrWhiteSpace(path) ||
result?.DurationSeconds is null or < 5 ||
(task.OutputFormat == Domain.Enums.RecordOutputFormat.Mp4 && !path.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase)))
{
continue;
}
var absolutePath = Path.IsPathRooted(path) ? path : Path.GetFullPath(path, AppContext.BaseDirectory);
if (!File.Exists(absolutePath) || new FileInfo(absolutePath).Length <= 0 ||
RecordCompletionDispatches.Local.Any(item => item.RecordTaskId == task.Id) ||
await RecordCompletionDispatches.AnyAsync(item => item.RecordTaskId == task.Id, cancellationToken))
{
continue;
}
await RecordCompletionDispatches.AddAsync(new RecordCompletionDispatch(task.Id, DateTimeOffset.UtcNow), cancellationToken);
}
}
}
@@ -0,0 +1,151 @@
using LiveRecorder.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LiveRecorder.Infrastructure.Persistence.Migrations;
[DbContext(typeof(LiveRecorderDbContext))]
[Migration("20260803120000_AddRecordingIdempotency")]
public sealed class AddRecordingIdempotency : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
WITH ranked AS (
SELECT "Id", ROW_NUMBER() OVER (
PARTITION BY "RecordSessionId"
ORDER BY "SegmentIndex", "CreatedAt", "Id") AS "NewSegmentIndex"
FROM "RecordTasks"
)
UPDATE "RecordTasks" AS task
SET "SegmentIndex" = ranked."NewSegmentIndex"
FROM ranked
WHERE task."Id" = ranked."Id";
UPDATE "RecordSessions" AS session
SET "SegmentCount" = counts."SegmentCount",
"ActiveSegmentIndex" = LEAST(session."ActiveSegmentIndex", counts."SegmentCount")
FROM (
SELECT "RecordSessionId", COUNT(*)::integer AS "SegmentCount"
FROM "RecordTasks"
GROUP BY "RecordSessionId"
) AS counts
WHERE session."Id" = counts."RecordSessionId";
WITH duplicate_active AS (
SELECT "Id", ROW_NUMBER() OVER (
PARTITION BY "LiveRoomId"
ORDER BY "CreatedAt" DESC, "Id" DESC) AS "Rank"
FROM "RecordSessions"
WHERE "Status" IN (0, 1, 2, 3)
)
UPDATE "RecordSessions" AS session
SET "Status" = 6,
"EndedAt" = COALESCE(session."EndedAt", NOW()),
"RecorderProcessId" = NULL,
"ErrorMessage" = 'Duplicate active reservation reconciled during the 1.2.5 upgrade.',
"UpdatedAt" = NOW()
FROM duplicate_active
WHERE session."Id" = duplicate_active."Id" AND duplicate_active."Rank" > 1;
""");
migrationBuilder.DropIndex(
name: "IX_RecordSessions_LiveRoomId",
table: "RecordSessions");
migrationBuilder.CreateIndex(
name: "IX_RecordSessions_LiveRoomId",
table: "RecordSessions",
column: "LiveRoomId",
unique: true,
filter: "\"Status\" IN (0, 1, 2, 3)");
migrationBuilder.DropIndex(
name: "IX_RecordTasks_RecordSessionId_SegmentIndex",
table: "RecordTasks");
migrationBuilder.CreateIndex(
name: "IX_RecordTasks_RecordSessionId_SegmentIndex",
table: "RecordTasks",
columns: new[] { "RecordSessionId", "SegmentIndex" },
unique: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "LastProgressAt",
table: "RecordUploadJobs",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "TransferTargetPath",
table: "RecordUploadJobs",
type: "character varying(2048)",
maxLength: 2048,
nullable: true);
migrationBuilder.CreateTable(
name: "RecordCompletionDispatches",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
RecordTaskId = table.Column<Guid>(type: "uuid", nullable: false),
ScriptDispatched = table.Column<bool>(type: "boolean", nullable: false),
UploadDispatched = table.Column<bool>(type: "boolean", nullable: false),
AttemptCount = table.Column<int>(type: "integer", nullable: false),
LastError = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
NextAttemptAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
CompletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_RecordCompletionDispatches", x => x.Id);
table.ForeignKey(
name: "FK_RecordCompletionDispatches_RecordTasks_RecordTaskId",
column: x => x.RecordTaskId,
principalTable: "RecordTasks",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_RecordCompletionDispatches_CompletedAt_NextAttemptAt_CreatedAt",
table: "RecordCompletionDispatches",
columns: new[] { "CompletedAt", "NextAttemptAt", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_RecordCompletionDispatches_RecordTaskId",
table: "RecordCompletionDispatches",
column: "RecordTaskId",
unique: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "LastProgressAt", table: "RecordUploadJobs");
migrationBuilder.DropColumn(name: "TransferTargetPath", table: "RecordUploadJobs");
migrationBuilder.DropTable(name: "RecordCompletionDispatches");
migrationBuilder.DropIndex(
name: "IX_RecordSessions_LiveRoomId",
table: "RecordSessions");
migrationBuilder.CreateIndex(
name: "IX_RecordSessions_LiveRoomId",
table: "RecordSessions",
column: "LiveRoomId");
migrationBuilder.DropIndex(
name: "IX_RecordTasks_RecordSessionId_SegmentIndex",
table: "RecordTasks");
migrationBuilder.CreateIndex(
name: "IX_RecordTasks_RecordSessionId_SegmentIndex",
table: "RecordTasks",
columns: new[] { "RecordSessionId", "SegmentIndex" });
}
}
@@ -264,6 +264,61 @@ namespace LiveRecorder.Infrastructure.Persistence.Migrations
b.ToTable("LiveRooms", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordCompletionDispatch", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AttemptCount")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastError")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.Property<DateTimeOffset?>("NextAttemptAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("RecordTaskId")
.HasColumnType("uuid");
b.Property<bool>("ScriptDispatched")
.HasColumnType("boolean");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("UploadDispatched")
.HasColumnType("boolean");
b.HasKey("Id");
b.HasIndex("CompletedAt", "NextAttemptAt", "CreatedAt");
b.HasIndex("RecordTaskId")
.IsUnique();
b.ToTable("RecordCompletionDispatches", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordCompletionDispatch", b =>
{
b.HasOne("LiveRecorder.Domain.Entities.RecordTask", "RecordTask")
.WithOne()
.HasForeignKey("LiveRecorder.Domain.Entities.RecordCompletionDispatch", "RecordTaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("RecordTask");
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordResult", b =>
{
b.Property<Guid>("Id")
@@ -392,7 +447,9 @@ namespace LiveRecorder.Infrastructure.Persistence.Migrations
b.HasKey("Id");
b.HasIndex("LiveRoomId");
b.HasIndex("LiveRoomId")
.IsUnique()
.HasFilter("\"Status\" IN (0, 1, 2, 3)");
b.ToTable("RecordSessions", (string)null);
});
@@ -457,7 +514,8 @@ namespace LiveRecorder.Infrastructure.Persistence.Migrations
b.HasIndex("LiveRoomId");
b.HasIndex("RecordSessionId", "SegmentIndex");
b.HasIndex("RecordSessionId", "SegmentIndex")
.IsUnique();
b.ToTable("RecordTasks", (string)null);
});
@@ -498,6 +556,9 @@ namespace LiveRecorder.Infrastructure.Persistence.Migrations
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset?>("LastProgressAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("NextAttemptAt")
.HasColumnType("timestamp with time zone");
@@ -539,6 +600,10 @@ namespace LiveRecorder.Infrastructure.Persistence.Migrations
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("TransferTargetPath")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
@@ -0,0 +1,80 @@
using System.Data;
using System.Data.Common;
using LiveRecorder.Application.Abstractions.Recording;
using Microsoft.EntityFrameworkCore;
namespace LiveRecorder.Infrastructure.Persistence;
public sealed class PostgresRecordingStartLock : IRecordingStartLock
{
private readonly LiveRecorderDbContext _dbContext;
public PostgresRecordingStartLock(LiveRecorderDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<IAsyncDisposable> AcquireAsync(Guid liveRoomId, CancellationToken cancellationToken = default)
{
var connection = _dbContext.Database.GetDbConnection();
var openedHere = connection.State != ConnectionState.Open;
if (openedHere)
{
await connection.OpenAsync(cancellationToken);
}
var lockKey = BitConverter.ToInt64(liveRoomId.ToByteArray(), 0);
await ExecuteAsync(connection, "SELECT pg_advisory_lock(@key)", lockKey, cancellationToken);
return new Lease(connection, lockKey, openedHere);
}
private static async Task ExecuteAsync(
DbConnection connection,
string commandText,
long lockKey,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = commandText;
var parameter = command.CreateParameter();
parameter.ParameterName = "key";
parameter.Value = lockKey;
command.Parameters.Add(parameter);
await command.ExecuteNonQueryAsync(cancellationToken);
}
private sealed class Lease : IAsyncDisposable
{
private readonly DbConnection _connection;
private readonly long _lockKey;
private readonly bool _closeConnection;
private int _disposed;
public Lease(DbConnection connection, long lockKey, bool closeConnection)
{
_connection = connection;
_lockKey = lockKey;
_closeConnection = closeConnection;
}
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
try
{
await ExecuteAsync(_connection, "SELECT pg_advisory_unlock(@key)", _lockKey, CancellationToken.None);
}
finally
{
if (_closeConnection)
{
await _connection.CloseAsync();
}
}
}
}
}
@@ -76,6 +76,7 @@ public sealed class RecordTaskRepository : IRecordTaskRepository
_dbContext.RecordTasks
.Include(item => item.LiveRoom)
.Include(item => item.Result)
.Include(item => item.UploadJob)
.FirstOrDefaultAsync(item => item.Id == id, cancellationToken);
public async Task<IReadOnlyList<RecordTask>> GetByIdsAsync(IReadOnlyCollection<Guid> ids, CancellationToken cancellationToken = default)
@@ -88,6 +89,7 @@ public sealed class RecordTaskRepository : IRecordTaskRepository
return await _dbContext.RecordTasks
.Include(item => item.LiveRoom)
.Include(item => item.Result)
.Include(item => item.UploadJob)
.Where(item => ids.Contains(item.Id))
.ToListAsync(cancellationToken);
}
@@ -98,6 +100,7 @@ public sealed class RecordTaskRepository : IRecordTaskRepository
.Include(item => item.LiveRoom)
.Include(item => item.RecordSession)
.Include(item => item.Result)
.Include(item => item.UploadJob)
.AsNoTracking();
if (liveRoomId.HasValue)
@@ -117,6 +120,7 @@ public sealed class RecordTaskRepository : IRecordTaskRepository
.Include(item => item.LiveRoom)
.Include(item => item.RecordSession)
.Include(item => item.Result)
.Include(item => item.UploadJob)
.AsNoTracking()
.Where(item => item.RecordSessionId == recordSessionId)
.ToListAsync(cancellationToken);
@@ -153,6 +157,45 @@ public sealed class RecordTaskRepository : IRecordTaskRepository
public void RemoveRange(IEnumerable<RecordTask> recordTasks) => _dbContext.RecordTasks.RemoveRange(recordTasks);
}
public sealed class OperationsMetricsRepository : IOperationsMetricsRepository
{
private static readonly TimeSpan UploadStallThreshold = TimeSpan.FromMinutes(60);
private readonly LiveRecorderDbContext _dbContext;
public OperationsMetricsRepository(LiveRecorderDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<OperationsMetricsSnapshot> GetAsync(CancellationToken cancellationToken = default)
{
var stalledBefore = DateTimeOffset.UtcNow.Subtract(UploadStallThreshold);
var oldestTranscodeUpdatedAt = await _dbContext.RecordTasks
.AsNoTracking()
.Where(static task => task.Status == RecordTaskStatus.Processing)
.Select(static task => (DateTimeOffset?)task.UpdatedAt)
.MinAsync(cancellationToken);
var oldestUploadProgressAt = await _dbContext.RecordUploadJobs
.AsNoTracking()
.Where(static job => job.Status == RecordArtifactUploadStatus.Uploading)
.Select(job => (DateTimeOffset?)(job.LastProgressAt ?? job.ExternalTaskStartedAt ?? job.UpdatedAt))
.MinAsync(cancellationToken);
var stalledUploadCount = await _dbContext.RecordUploadJobs.CountAsync(
job => job.Status == RecordArtifactUploadStatus.Uploading &&
(job.LastProgressAt ?? job.ExternalTaskStartedAt ?? job.UpdatedAt) < stalledBefore,
cancellationToken);
var cleanupFailureCount = await _dbContext.RecordUploadJobs.CountAsync(
static job => job.Status == RecordArtifactUploadStatus.WaitingRetry &&
job.CurrentArtifact == RecordUploadArtifactStage.Completed,
cancellationToken);
return new OperationsMetricsSnapshot(
oldestTranscodeUpdatedAt,
oldestUploadProgressAt,
stalledUploadCount,
cleanupFailureCount);
}
}
public sealed class RecordSessionRepository : IRecordSessionRepository
{
private readonly LiveRecorderDbContext _dbContext;
@@ -167,14 +210,37 @@ public sealed class RecordSessionRepository : IRecordSessionRepository
.Include(item => item.LiveRoom)
.Include(item => item.RecordTasks.OrderBy(task => task.SegmentIndex))
.ThenInclude(item => item.Result)
.Include(item => item.RecordTasks)
.ThenInclude(item => item.UploadJob)
.FirstOrDefaultAsync(item => item.Id == id, cancellationToken);
public async Task<IReadOnlyList<RecordSession>> GetByIdsAsync(
IReadOnlyCollection<Guid> ids,
CancellationToken cancellationToken = default)
{
if (ids.Count == 0)
{
return [];
}
return await _dbContext.RecordSessions
.Include(item => item.LiveRoom)
.Include(item => item.RecordTasks.OrderBy(task => task.SegmentIndex))
.ThenInclude(item => item.Result)
.Include(item => item.RecordTasks)
.ThenInclude(item => item.UploadJob)
.AsSplitQuery()
.Where(item => ids.Contains(item.Id))
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<RecordSession>> ListAsync(Guid? liveRoomId = null, CancellationToken cancellationToken = default)
{
IQueryable<RecordSession> query = _dbContext.RecordSessions
.Include(item => item.LiveRoom)
.Include(item => item.RecordTasks.OrderBy(task => task.SegmentIndex))
.ThenInclude(item => item.Result)
.AsSplitQuery()
.AsNoTracking();
if (liveRoomId.HasValue)
@@ -182,16 +248,181 @@ public sealed class RecordSessionRepository : IRecordSessionRepository
query = query.Where(item => item.LiveRoomId == liveRoomId.Value);
}
var items = await query.ToListAsync(cancellationToken);
return items
return await query
.OrderByDescending(static item => item.CreatedAt)
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<RecordSession>> ListOverviewAsync(
Guid? liveRoomId,
int take,
CancellationToken cancellationToken = default)
{
IQueryable<RecordSession> query = _dbContext.RecordSessions
.Include(item => item.LiveRoom)
.Include(item => item.RecordTasks.OrderBy(task => task.SegmentIndex))
.ThenInclude(item => item.Result)
.AsSplitQuery()
.AsNoTracking();
if (liveRoomId.HasValue)
{
query = query.Where(item => item.LiveRoomId == liveRoomId.Value);
}
var recent = await query
.OrderByDescending(static item => item.CreatedAt)
.Take(Math.Clamp(take, 1, 500))
.ToListAsync(cancellationToken);
var recentIds = recent.Select(static item => item.Id).ToHashSet();
var missingActiveIds = (await ListActiveIdsAsync(liveRoomId, cancellationToken))
.Where(id => !recentIds.Contains(id))
.ToArray();
if (missingActiveIds.Length == 0)
{
return recent;
}
var missingActive = await _dbContext.RecordSessions
.Include(item => item.LiveRoom)
.Include(item => item.RecordTasks.OrderBy(task => task.SegmentIndex))
.ThenInclude(item => item.Result)
.AsSplitQuery()
.AsNoTracking()
.Where(item => missingActiveIds.Contains(item.Id))
.ToListAsync(cancellationToken);
return recent
.Concat(missingActive)
.OrderByDescending(static item => item.CreatedAt)
.ToList();
}
public async Task<(IReadOnlyList<RecordSession> Items, int TotalCount)> ListPageAsync(
Guid? liveRoomId,
IReadOnlyCollection<RecordSessionStatus>? statuses,
string? search,
int skip,
int take,
CancellationToken cancellationToken = default)
{
var query = BuildPageQuery(liveRoomId, statuses, search);
var totalCount = await query.CountAsync(cancellationToken);
var normalizedSkip = Math.Max(0, skip);
var normalizedTake = Math.Clamp(take, 1, 100);
var items = await query
.Include(item => item.LiveRoom)
.Include(item => item.RecordTasks.OrderBy(task => task.SegmentIndex))
.ThenInclude(item => item.Result)
.AsSplitQuery()
.AsNoTracking()
.OrderBy(static item =>
item.Status == RecordSessionStatus.Pending ||
item.Status == RecordSessionStatus.Starting ||
item.Status == RecordSessionStatus.Running ||
item.Status == RecordSessionStatus.Stopping
? 0
: 1)
.ThenByDescending(static item => item.CreatedAt)
.Skip(normalizedSkip)
.Take(normalizedTake)
.ToListAsync(cancellationToken);
return (items, totalCount);
}
public async Task<RecordSessionOverviewTotals> GetOverviewTotalsAsync(
Guid? liveRoomId = null,
CancellationToken cancellationToken = default)
{
IQueryable<RecordSession> sessions = _dbContext.RecordSessions.AsNoTracking();
if (liveRoomId.HasValue)
{
sessions = sessions.Where(item => item.LiveRoomId == liveRoomId.Value);
}
var totalSessionCount = await sessions.CountAsync(cancellationToken);
var activeSessionCount = await sessions.CountAsync(
item => item.Status == RecordSessionStatus.Starting ||
item.Status == RecordSessionStatus.Running ||
item.Status == RecordSessionStatus.Stopping,
cancellationToken);
var totalTaskCount = await sessions
.SelectMany(static item => item.RecordTasks)
.CountAsync(cancellationToken);
var totalDanmakuCount = await sessions
.SelectMany(static item => item.RecordTasks)
.Where(static item => item.Result != null)
.SumAsync(static item => (int?)item.Result!.DanmakuMessageCount, cancellationToken) ?? 0;
return new RecordSessionOverviewTotals(
totalSessionCount,
activeSessionCount,
totalTaskCount,
totalDanmakuCount);
}
private IQueryable<RecordSession> BuildPageQuery(
Guid? liveRoomId,
IReadOnlyCollection<RecordSessionStatus>? statuses,
string? search)
{
IQueryable<RecordSession> query = _dbContext.RecordSessions.AsNoTracking();
if (liveRoomId.HasValue)
{
query = query.Where(item => item.LiveRoomId == liveRoomId.Value);
}
if (statuses is { Count: > 0 })
{
query = query.Where(item => statuses.Contains(item.Status));
}
var normalizedSearch = search?.Trim().ToLower();
if (!string.IsNullOrEmpty(normalizedSearch))
{
query = query.Where(item =>
item.Id.ToString().Contains(normalizedSearch) ||
(item.LiveRoom != null &&
((item.LiveRoom.Title != null && item.LiveRoom.Title.ToLower().Contains(normalizedSearch)) ||
(item.LiveRoom.AnchorName != null && item.LiveRoom.AnchorName.ToLower().Contains(normalizedSearch)) ||
item.LiveRoom.RoomId.ToLower().Contains(normalizedSearch))) ||
item.RecordTasks.Any(task =>
task.Id.ToString().Contains(normalizedSearch) ||
(task.OutputFilePath != null && task.OutputFilePath.ToLower().Contains(normalizedSearch))));
}
return query;
}
public async Task<IReadOnlyCollection<Guid>> ListActiveIdsAsync(
Guid? liveRoomId = null,
CancellationToken cancellationToken = default)
{
var query = _dbContext.RecordSessions
.AsNoTracking()
.Where(item => item.Status == RecordSessionStatus.Starting ||
item.Status == RecordSessionStatus.Pending ||
item.Status == RecordSessionStatus.Running ||
item.Status == RecordSessionStatus.Stopping);
if (liveRoomId.HasValue)
{
query = query.Where(item => item.LiveRoomId == liveRoomId.Value);
}
return await query
.OrderBy(static item => item.CreatedAt)
.Select(static item => item.Id)
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyCollection<Guid>> ListActiveLiveRoomIdsAsync(CancellationToken cancellationToken = default) =>
await _dbContext.RecordSessions
.AsNoTracking()
.Where(item => item.Status == RecordSessionStatus.Starting ||
item.Status == RecordSessionStatus.Pending ||
item.Status == RecordSessionStatus.Running ||
item.Status == RecordSessionStatus.Stopping)
.Select(item => item.LiveRoomId)
@@ -203,9 +434,12 @@ public sealed class RecordSessionRepository : IRecordSessionRepository
.Include(item => item.LiveRoom)
.Include(item => item.RecordTasks)
.ThenInclude(item => item.Result)
.Include(item => item.RecordTasks)
.ThenInclude(item => item.UploadJob)
.FirstOrDefaultAsync(
item => item.LiveRoomId == liveRoomId &&
(item.Status == RecordSessionStatus.Starting ||
(item.Status == RecordSessionStatus.Pending ||
item.Status == RecordSessionStatus.Starting ||
item.Status == RecordSessionStatus.Running ||
item.Status == RecordSessionStatus.Stopping),
cancellationToken);
@@ -215,7 +449,9 @@ public sealed class RecordSessionRepository : IRecordSessionRepository
public Task<int> CountActiveAsync(CancellationToken cancellationToken = default) =>
_dbContext.RecordSessions.CountAsync(item =>
item.Status == RecordSessionStatus.Starting || item.Status == RecordSessionStatus.Running, cancellationToken);
item.Status == RecordSessionStatus.Pending ||
item.Status == RecordSessionStatus.Starting ||
item.Status == RecordSessionStatus.Running, cancellationToken);
public async Task<IReadOnlyList<RecordSession>> ListRecentAsync(int take, CancellationToken cancellationToken = default) =>
await _dbContext.RecordSessions
@@ -270,13 +506,28 @@ public sealed class RecordResultRepository : IRecordResultRepository
}
public Task<int> CountPendingUploadAsync(CancellationToken cancellationToken = default) =>
_dbContext.RecordResults.CountAsync(item => item.UploadStatus == RecordArtifactUploadStatus.NotUploaded, cancellationToken);
_dbContext.RecordResults.CountAsync(
item => item.UploadStatus == RecordArtifactUploadStatus.NotUploaded &&
item.RecordTask != null &&
(item.RecordTask.Status == RecordTaskStatus.Completed ||
item.RecordTask.Status == RecordTaskStatus.Stopped),
cancellationToken);
public Task<long> SumPendingUploadBytesAsync(CancellationToken cancellationToken = default) =>
_dbContext.RecordResults
.Where(item => item.UploadStatus == RecordArtifactUploadStatus.NotUploaded)
.Where(item => item.UploadStatus == RecordArtifactUploadStatus.NotUploaded &&
item.RecordTask != null &&
(item.RecordTask.Status == RecordTaskStatus.Completed ||
item.RecordTask.Status == RecordTaskStatus.Stopped))
.SumAsync(item => item.FileSizeBytes ?? 0L, cancellationToken);
public Task<int> CountFailedArtifactAsync(CancellationToken cancellationToken = default) =>
_dbContext.RecordResults.CountAsync(
item => item.UploadStatus == RecordArtifactUploadStatus.NotUploaded &&
item.RecordTask != null &&
item.RecordTask.Status == RecordTaskStatus.Failed,
cancellationToken);
public async Task<List<(RecordResult Result, RecordTask Task)>> ListUploadStatusAsync(
RecordArtifactUploadStatus? uploadStatusFilter,
int skip,
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("LiveRecorder.Tests")]
@@ -119,6 +119,7 @@ public sealed class CleanupOperationCoordinator
new RetentionCleanupOperationFilters
{
RetentionDays = settings.RetentionDays,
RequireUploadSuccess = settings.RetentionRequireUploadSuccess,
VideoFileCondition = settings.RetentionVideoFileCondition,
TaskStatuses = settings.RetentionTaskStatuses
},
@@ -85,6 +85,8 @@ internal class ConditionalCleanupOperationFilters
internal sealed class RetentionCleanupOperationFilters : ConditionalCleanupOperationFilters
{
public int RetentionDays { get; init; } = 30;
public bool RequireUploadSuccess { get; init; }
}
internal sealed class EmptyCleanupOperationFilters
@@ -0,0 +1,144 @@
using LiveRecorder.Application.Abstractions.Scripting;
using LiveRecorder.Domain.Enums;
using LiveRecorder.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace LiveRecorder.Infrastructure.Services;
public sealed class CompletionDispatchService
{
private static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1);
private readonly LiveRecorderDbContext _dbContext;
private readonly IEventScriptService _eventScriptService;
private readonly RecordUploadService _recordUploadService;
public CompletionDispatchService(
LiveRecorderDbContext dbContext,
IEventScriptService eventScriptService,
RecordUploadService recordUploadService)
{
_dbContext = dbContext;
_eventScriptService = eventScriptService;
_recordUploadService = recordUploadService;
}
public async Task<bool> TryDispatchNextAsync(CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var dispatch = await _dbContext.RecordCompletionDispatches
.Include(item => item.RecordTask)!.ThenInclude(task => task!.LiveRoom)
.Include(item => item.RecordTask)!.ThenInclude(task => task!.RecordSession)
.Include(item => item.RecordTask)!.ThenInclude(task => task!.Result)
.Where(item => item.CompletedAt == null && (!item.NextAttemptAt.HasValue || item.NextAttemptAt <= now))
.OrderBy(static item => item.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (dispatch is null)
{
return false;
}
await DispatchAsync(dispatch, cancellationToken);
return true;
}
public async Task TryDispatchTaskAsync(Guid recordTaskId, CancellationToken cancellationToken = default)
{
var dispatch = await _dbContext.RecordCompletionDispatches
.Include(item => item.RecordTask)!.ThenInclude(task => task!.LiveRoom)
.Include(item => item.RecordTask)!.ThenInclude(task => task!.RecordSession)
.Include(item => item.RecordTask)!.ThenInclude(task => task!.Result)
.FirstOrDefaultAsync(item => item.RecordTaskId == recordTaskId, cancellationToken);
if (dispatch is not null && dispatch.CompletedAt is null)
{
await DispatchAsync(dispatch, cancellationToken);
}
}
private async Task DispatchAsync(Domain.Entities.RecordCompletionDispatch dispatch, CancellationToken cancellationToken)
{
var task = dispatch.RecordTask;
if (task?.RecordSession is null || task.Result is null)
{
dispatch.ScheduleRetry("录制任务上下文尚未准备完成。", DateTimeOffset.UtcNow.Add(RetryDelay), DateTimeOffset.UtcNow);
await _dbContext.SaveChangesAsync(cancellationToken);
return;
}
try
{
if (!dispatch.ScriptDispatched)
{
var scriptResult = await _eventScriptService.RunSegmentCompletedAsync(
task.LiveRoom,
task.RecordSession,
task,
task.Result,
task.Result.FilePath,
task.EndedAt ?? DateTimeOffset.UtcNow,
eventId: dispatch.Id,
cancellationToken: cancellationToken);
if (scriptResult is null || scriptResult.Success)
{
dispatch.MarkScriptDispatched(DateTimeOffset.UtcNow);
await _dbContext.SaveChangesAsync(cancellationToken);
}
else
{
throw new InvalidOperationException(scriptResult.Message);
}
}
if (!dispatch.UploadDispatched)
{
_ = await _recordUploadService.TryAutoUploadTaskAsync(task.Id, cancellationToken);
dispatch.MarkUploadDispatched(DateTimeOffset.UtcNow);
await _dbContext.SaveChangesAsync(cancellationToken);
}
}
catch (Exception ex)
{
dispatch.ScheduleRetry(ex.Message, DateTimeOffset.UtcNow.Add(RetryDelay), DateTimeOffset.UtcNow);
await _dbContext.SaveChangesAsync(cancellationToken);
}
}
}
public sealed class CompletionDispatchBackgroundService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<CompletionDispatchBackgroundService> _logger;
public CompletionDispatchBackgroundService(
IServiceScopeFactory scopeFactory,
ILogger<CompletionDispatchBackgroundService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _scopeFactory.CreateScope();
var service = scope.ServiceProvider.GetRequiredService<CompletionDispatchService>();
var processed = await service.TryDispatchNextAsync(stoppingToken);
await Task.Delay(processed ? TimeSpan.FromMilliseconds(200) : TimeSpan.FromSeconds(2), stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Completion dispatch worker iteration failed.");
await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
}
}
}
}
@@ -100,11 +100,13 @@ public sealed class EventScriptService : IEventScriptService
string segmentFilePath,
DateTimeOffset occurredAt,
bool forceRun = false,
Guid? eventId = null,
CancellationToken cancellationToken = default)
{
var settings = await _settingsService.GetAsync(cancellationToken);
var environment = BuildLiveRoomEnvironment(liveRoom, occurredAt);
environment["LIVE_RECORDER_EVENT"] = "segment_completed";
environment["LIVE_RECORDER_EVENT_ID"] = (eventId ?? recordTask.Id).ToString();
environment["LIVE_RECORDER_RECORD_SESSION_ID"] = recordSession.Id.ToString();
environment["LIVE_RECORDER_RECORD_TASK_ID"] = recordTask.Id.ToString();
environment["LIVE_RECORDER_SEGMENT_INDEX"] = recordTask.SegmentIndex.ToString();
@@ -0,0 +1,297 @@
using System.Diagnostics;
using System.Text.RegularExpressions;
using LiveRecorder.Domain.Enums;
using Microsoft.Extensions.Logging;
namespace LiveRecorder.Infrastructure.Services;
public sealed partial class FfmpegService
{
internal static readonly FfmpegRecoveryContext InitialRecoveryContext = new(
FfmpegInputOptionProfile.Baseline,
AttemptCount: 0,
HasRetriedWithCompatibilityProfile: false,
HasRetriedWithRefreshedStream: false,
HasRetriedWithAlternateProtocol: false,
ForceSoftwareEncoder: false,
HasRetriedWithSoftwareEncoder: false);
internal static FfmpegRecoveryContext AdvanceRecoveryContext(
FfmpegRecoveryContext current,
FfmpegInputOptionProfile inputOptionProfile,
string currentProtocol,
string nextProtocol,
bool refreshedStream = false) =>
current with
{
InputOptionProfile = inputOptionProfile,
AttemptCount = current.AttemptCount + 1,
HasRetriedWithRefreshedStream = current.HasRetriedWithRefreshedStream || refreshedStream,
HasRetriedWithAlternateProtocol = current.HasRetriedWithAlternateProtocol ||
!string.Equals(currentProtocol, nextProtocol, StringComparison.OrdinalIgnoreCase)
};
internal static bool ShouldImmediatelyFallbackFromHls(
string selectedProtocol,
bool hasHlsOverlongHeadersFailure) =>
hasHlsOverlongHeadersFailure &&
string.Equals(selectedProtocol, "hls", StringComparison.OrdinalIgnoreCase);
internal static bool ShouldApplyRuntimeFailureBackoff(
RecordSessionStatus status,
TimeSpan processRuntime) =>
status == RecordSessionStatus.Failed && processRuntime < StableRuntimeResetThreshold;
private async Task<RecoveryVideoEncoderSelection> ResolveRecoveryVideoEncoderAsync(
string ffmpegPath,
bool forceSoftware,
CancellationToken cancellationToken)
{
if (forceSoftware)
{
return RecoveryVideoEncoderSelection.Software;
}
if (_hasProbedRecoveryVideoEncoder)
{
return _cachedRecoveryVideoEncoder;
}
await _recoveryEncoderProbeGate.WaitAsync(cancellationToken);
try
{
if (_hasProbedRecoveryVideoEncoder)
{
return _cachedRecoveryVideoEncoder;
}
var candidates = new List<RecoveryVideoEncoderSelection>
{
new(RecoveryVideoEncoderKind.Nvenc, null)
};
IReadOnlyList<string> renderDevices = [];
try
{
if (Directory.Exists("/dev/dri"))
{
renderDevices = Directory.EnumerateFileSystemEntries("/dev/dri", "renderD*")
.OrderBy(static path => path)
.ToList();
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_logger.LogDebug(ex, "Unable to enumerate /dev/dri render devices for recovery encoding.");
}
foreach (var devicePath in renderDevices)
{
candidates.Add(new RecoveryVideoEncoderSelection(RecoveryVideoEncoderKind.Qsv, devicePath));
}
foreach (var devicePath in renderDevices)
{
candidates.Add(new RecoveryVideoEncoderSelection(RecoveryVideoEncoderKind.Vaapi, devicePath));
}
foreach (var candidate in candidates)
{
if (await ProbeRecoveryVideoEncoderAsync(ffmpegPath, candidate, cancellationToken))
{
_cachedRecoveryVideoEncoder = candidate;
_hasProbedRecoveryVideoEncoder = true;
_logger.LogInformation(
"Recovery video encoder probe selected {EncoderKind} using device {DevicePath}",
candidate.Kind,
candidate.DevicePath ?? "default");
return candidate;
}
}
_cachedRecoveryVideoEncoder = RecoveryVideoEncoderSelection.Software;
_hasProbedRecoveryVideoEncoder = true;
_logger.LogInformation("No usable hardware recovery encoder was detected; libx264 will be used.");
return _cachedRecoveryVideoEncoder;
}
finally
{
_recoveryEncoderProbeGate.Release();
}
}
private async Task<bool> ProbeRecoveryVideoEncoderAsync(
string ffmpegPath,
RecoveryVideoEncoderSelection encoder,
CancellationToken cancellationToken)
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = ffmpegPath,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
}
};
foreach (var argument in BuildRecoveryEncoderProbeArgumentList(encoder))
{
process.StartInfo.ArgumentList.Add(argument);
}
try
{
if (!process.Start())
{
return false;
}
var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(8));
await process.WaitForExitAsync(timeoutCts.Token);
await Task.WhenAll(outputTask, errorTask);
return process.ExitCode == 0;
}
catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception or OperationCanceledException)
{
try
{
if (!process.HasExited)
{
process.Kill(true);
}
}
catch
{
}
_logger.LogDebug(
ex,
"Recovery video encoder probe failed for {EncoderKind} using device {DevicePath}",
encoder.Kind,
encoder.DevicePath ?? "default");
return false;
}
}
private void DisableRecoveryVideoEncoder(RecoveryVideoEncoderSelection encoder)
{
if (encoder.Kind == RecoveryVideoEncoderKind.Software ||
_cachedRecoveryVideoEncoder != encoder)
{
return;
}
_cachedRecoveryVideoEncoder = RecoveryVideoEncoderSelection.Software;
_hasProbedRecoveryVideoEncoder = true;
_logger.LogWarning(
"Recovery video encoder {EncoderKind} failed with a live input and was disabled until the service restarts.",
encoder.Kind);
}
internal static IReadOnlyList<string> BuildRecoveryEncoderProbeArgumentList(
RecoveryVideoEncoderSelection encoder)
{
var arguments = new List<string> { "-hide_banner", "-loglevel", "error" };
AddRecoveryEncoderDeviceArguments(arguments, encoder);
arguments.AddRange(["-f", "lavfi", "-i", "color=c=black:s=128x128:r=1", "-frames:v", "1"]);
arguments.AddRange(BuildRecoveryVideoCodecArguments(encoder));
arguments.AddRange(["-an", "-f", "null", "-"]);
return arguments;
}
internal static IReadOnlyList<RecoverableRecorderSegment> DiscoverRecoverableSegments(
string? outputPathPattern,
RecordOutputFormat outputFormat,
RecordSaveMode saveMode)
{
if (string.IsNullOrWhiteSpace(outputPathPattern) ||
outputFormat != RecordOutputFormat.Mp4 ||
saveMode != RecordSaveMode.Segmented ||
!outputPathPattern.Contains("%05d", StringComparison.OrdinalIgnoreCase))
{
return [];
}
var absoluteOutputPattern = NormalizeAbsolutePath(outputPathPattern);
var recorderPattern = GetRecorderOutputPath(absoluteOutputPattern, outputFormat, saveMode);
var directory = Path.GetDirectoryName(recorderPattern);
var filePattern = Path.GetFileName(recorderPattern);
if (string.IsNullOrWhiteSpace(directory) ||
string.IsNullOrWhiteSpace(filePattern) ||
!Directory.Exists(directory))
{
return [];
}
var tokenIndex = filePattern.IndexOf("%05d", StringComparison.OrdinalIgnoreCase);
if (tokenIndex < 0)
{
return [];
}
var prefix = filePattern[..tokenIndex];
var suffix = filePattern[(tokenIndex + 4)..];
var matcher = new Regex(
$"^{Regex.Escape(prefix)}(?<segment>[0-9]{{5}}){Regex.Escape(suffix)}$",
RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
try
{
return Directory.EnumerateFiles(directory, $"{prefix}*{suffix}", SearchOption.TopDirectoryOnly)
.Select(path => (Path: path, Match: matcher.Match(Path.GetFileName(path))))
.Where(item => item.Match.Success && new FileInfo(item.Path).Length > 0)
.Select(item => new
{
RecorderPath = NormalizeAbsolutePath(item.Path),
SegmentIndex = int.Parse(item.Match.Groups["segment"].Value)
})
.Where(item => item.SegmentIndex > 0)
.GroupBy(item => item.SegmentIndex)
.Select(group => group.First())
.OrderBy(item => item.SegmentIndex)
.Select(item => new RecoverableRecorderSegment(
item.SegmentIndex,
item.RecorderPath,
NormalizeAbsolutePath(ResolveSegmentOutputPath(absoluteOutputPattern, saveMode, item.SegmentIndex))))
.ToList();
}
catch (IOException)
{
return [];
}
catch (UnauthorizedAccessException)
{
return [];
}
}
}
internal sealed record FfmpegRecoveryContext(
FfmpegService.FfmpegInputOptionProfile InputOptionProfile,
int AttemptCount,
bool HasRetriedWithCompatibilityProfile,
bool HasRetriedWithRefreshedStream,
bool HasRetriedWithAlternateProtocol,
bool ForceSoftwareEncoder,
bool HasRetriedWithSoftwareEncoder);
internal enum RecoveryVideoEncoderKind
{
Software = 0,
Nvenc = 1,
Qsv = 2,
Vaapi = 3
}
internal sealed record RecoveryVideoEncoderSelection(RecoveryVideoEncoderKind Kind, string? DevicePath)
{
public static RecoveryVideoEncoderSelection Software { get; } = new(RecoveryVideoEncoderKind.Software, null);
}
internal sealed record RecoverableRecorderSegment(int SegmentIndex, string RecorderPath, string OutputPath);
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,9 @@
using System.Diagnostics;
using System.Globalization;
using System.Text;
using System.Text.Json;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Application.Abstractions.Recording;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
using LiveRecorder.Infrastructure.Persistence;
@@ -13,7 +15,9 @@ namespace LiveRecorder.Infrastructure.Services;
public sealed partial class FfmpegService
{
private const string LowStoragePauseErrorPrefix = "MP4 finalization paused because storage tier is Red (critically low).";
private const string LowStoragePauseErrorPrefix = "MP4 finalization paused because there is not enough temporary disk space.";
private const string ShutdownFinalizationPauseErrorPrefix = "MP4 finalization paused because the application is shutting down.";
private const string RecorderSegmentsManifestSuffix = ".segments.json";
private static readonly TimeSpan Mp4FinalizeInactivityTimeout = TimeSpan.FromMinutes(10);
private static readonly TimeSpan Mp4FinalizePollInterval = TimeSpan.FromSeconds(1);
@@ -23,14 +27,24 @@ public sealed partial class FfmpegService
int mp4FinalizeTimeoutMinutes,
Guid recordSessionId,
Guid recordTaskId,
string sourcePath,
IReadOnlyList<string> sourcePaths,
string targetPath,
double? expectedDurationSeconds,
string? segmentsManifestPath,
CancellationToken cancellationToken)
{
if (!File.Exists(sourcePath))
using var shutdownLinkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _shutdownCts.Token);
cancellationToken = shutdownLinkedCts.Token;
var normalizedSourcePaths = sourcePaths
.Where(static path => !string.IsNullOrWhiteSpace(path))
.Select(NormalizeAbsolutePath)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (normalizedSourcePaths.Length == 0 || normalizedSourcePaths.Any(path => !File.Exists(path)))
{
return (File.Exists(targetPath) ? targetPath : sourcePath, "The intermediate recording file was not found for MP4 finalization.");
return (
File.Exists(targetPath) ? targetPath : normalizedSourcePaths.FirstOrDefault() ?? targetPath,
"One or more intermediate recording files were not found for MP4 finalization.");
}
var tempPath = Path.Combine(
@@ -47,9 +61,9 @@ public sealed partial class FfmpegService
using var storageScope = _serviceScopeFactory.CreateScope();
var settingsService = storageScope.ServiceProvider.GetRequiredService<LiveRecorder.Application.Abstractions.Settings.ISystemSettingsService>();
var settings = await settingsService.GetAsync(cancellationToken);
var sourceSizeBytes = new FileInfo(sourcePath).Length;
var storageCheck = _storageGuardService.CheckCanStartOrResume(settings, sourceSizeBytes);
return storageCheck.ShouldPauseActive
var sourceSizeBytes = normalizedSourcePaths.Sum(path => new FileInfo(path).Length);
var storageCheck = _storageGuardService.CheckCanFinalize(settings, sourceSizeBytes);
return !storageCheck.HasEnoughSpace
? $"{LowStoragePauseErrorPrefix} {storageCheck.Message}"
: null;
}
@@ -58,7 +72,7 @@ public sealed partial class FfmpegService
if (!string.IsNullOrWhiteSpace(lowStoragePauseMessage))
{
SetPostProcessState(recordSessionId, recordTaskId, "Paused: Low storage", null, lowStoragePauseMessage);
return (sourcePath, lowStoragePauseMessage);
return (normalizedSourcePaths[0], lowStoragePauseMessage);
}
SetPostProcessState(
@@ -74,7 +88,7 @@ public sealed partial class FfmpegService
if (!string.IsNullOrWhiteSpace(lowStoragePauseMessage))
{
SetPostProcessState(recordSessionId, recordTaskId, "Paused: Low storage", null, lowStoragePauseMessage);
return (sourcePath, lowStoragePauseMessage);
return (normalizedSourcePaths[0], lowStoragePauseMessage);
}
var stderrLines = new Queue<string>();
@@ -178,6 +192,10 @@ public sealed partial class FfmpegService
SetPostProcessState(recordSessionId, recordTaskId, stage, 0, detail);
var concatInputPath = normalizedSourcePaths.Length > 1
? await WriteFfconcatInputAsync(targetPath, recordTaskId, normalizedSourcePaths, cancellationToken)
: null;
using var finalizeProcess = new Process
{
StartInfo = new ProcessStartInfo
@@ -190,7 +208,11 @@ public sealed partial class FfmpegService
}
};
foreach (var argument in BuildMp4FinalizeArgumentList(sourcePath, tempPath, strategy))
foreach (var argument in BuildMp4FinalizeArgumentList(
concatInputPath ?? normalizedSourcePaths[0],
tempPath,
strategy,
concatInputPath is not null))
{
finalizeProcess.StartInfo.ArgumentList.Add(argument);
}
@@ -238,6 +260,7 @@ public sealed partial class FfmpegService
try
{
finalizeProcess.Start();
_postProcessProcesses[recordTaskId] = finalizeProcess;
finalizeProcess.BeginOutputReadLine();
finalizeProcess.BeginErrorReadLine();
@@ -278,6 +301,22 @@ public sealed partial class FfmpegService
return ex.Message;
}
catch (OperationCanceledException) when (_shutdownCts.IsCancellationRequested)
{
try
{
if (!finalizeProcess.HasExited)
{
finalizeProcess.Kill(true);
}
}
catch (Exception killEx)
{
_logger.LogWarning(killEx, "Shutdown-interrupted MP4 finalization process could not be killed for task {RecordTaskId}", recordTaskId);
}
return ShutdownFinalizationPauseErrorPrefix;
}
catch (Exception ex)
{
try
@@ -294,6 +333,14 @@ public sealed partial class FfmpegService
return ex.Message;
}
finally
{
_postProcessProcesses.TryRemove(recordTaskId, out _);
if (!string.IsNullOrWhiteSpace(concatInputPath) && File.Exists(concatInputPath))
{
File.Delete(concatInputPath);
}
}
if (finalizeProcess.ExitCode == 0 && File.Exists(tempPath))
{
@@ -344,9 +391,17 @@ public sealed partial class FfmpegService
File.Move(tempPath, targetPath);
}
if (!string.Equals(sourcePath, targetPath, StringComparison.OrdinalIgnoreCase) && File.Exists(sourcePath))
foreach (var sourcePath in normalizedSourcePaths)
{
File.Delete(sourcePath);
if (!string.Equals(sourcePath, targetPath, StringComparison.OrdinalIgnoreCase) && File.Exists(sourcePath))
{
File.Delete(sourcePath);
}
}
if (!string.IsNullOrWhiteSpace(segmentsManifestPath) && File.Exists(segmentsManifestPath))
{
File.Delete(segmentsManifestPath);
}
SetPostProcessState(recordSessionId, recordTaskId, "Completed", 100, "MP4 seek index is ready");
@@ -358,7 +413,7 @@ public sealed partial class FfmpegService
File.Delete(tempPath);
}
var fallbackPath = File.Exists(targetPath) ? targetPath : sourcePath;
var fallbackPath = File.Exists(targetPath) ? targetPath : normalizedSourcePaths[0];
string? errorDetail;
lock (stderrLines)
{
@@ -426,9 +481,10 @@ public sealed partial class FfmpegService
mp4FinalizeTimeoutMinutes,
recordSession.Id,
recordTask.Id,
recorderOutputPath,
[recorderOutputPath],
finalOutputPath,
expectedDurationSeconds,
segmentsManifestPath: null,
cancellationToken);
}
@@ -462,100 +518,104 @@ public sealed partial class FfmpegService
return (File.Exists(finalSegmentOutputPath) ? finalSegmentOutputPath : recordTask.OutputFilePath, null);
}
string? materializedSourcePath = null;
var cleanupMaterializedSource = false;
var preserveMaterializedSource = false;
try
string? segmentsManifestPath = null;
if (normalizedRecorderSegmentPaths.Length > 1)
{
materializedSourcePath = await MaterializeRecorderSegmentSourceAsync(
segmentsManifestPath = GetRecorderSegmentsManifestPath(finalSegmentOutputPath);
await WriteRecorderSegmentsManifestAsync(
segmentsManifestPath,
finalSegmentOutputPath,
normalizedRecorderSegmentPaths,
cancellationToken);
if (string.IsNullOrWhiteSpace(materializedSourcePath))
{
return (File.Exists(finalSegmentOutputPath) ? finalSegmentOutputPath : recordTask.OutputFilePath, null);
}
cleanupMaterializedSource =
!string.Equals(materializedSourcePath, normalizedRecorderSegmentPaths[0], StringComparison.OrdinalIgnoreCase);
var finalizationResult = await TryFinalizeMp4Async(
ffmpegPath,
maxConcurrentTranscodeTasks,
mp4FinalizeTimeoutMinutes,
recordSession.Id,
recordTask.Id,
materializedSourcePath,
finalSegmentOutputPath,
expectedDurationSeconds,
cancellationToken);
preserveMaterializedSource = IsLowStoragePauseError(finalizationResult.ErrorMessage);
return finalizationResult;
}
finally
{
if (cleanupMaterializedSource &&
!preserveMaterializedSource &&
!string.IsNullOrWhiteSpace(materializedSourcePath) &&
File.Exists(materializedSourcePath))
{
File.Delete(materializedSourcePath);
}
}
return await TryFinalizeMp4Async(
ffmpegPath,
maxConcurrentTranscodeTasks,
mp4FinalizeTimeoutMinutes,
recordSession.Id,
recordTask.Id,
normalizedRecorderSegmentPaths,
finalSegmentOutputPath,
expectedDurationSeconds,
segmentsManifestPath,
cancellationToken);
}
private static async Task<string?> MaterializeRecorderSegmentSourceAsync(
private static string GetRecorderSegmentsManifestPath(string finalOutputPath) =>
$"{NormalizeAbsolutePath(finalOutputPath)}{RecorderSegmentsManifestSuffix}";
private static async Task WriteRecorderSegmentsManifestAsync(
string manifestPath,
string finalOutputPath,
IReadOnlyList<string> recorderSegmentPaths,
CancellationToken cancellationToken)
{
if (recorderSegmentPaths.Count == 0)
{
return null;
}
if (recorderSegmentPaths.Count == 1)
{
return recorderSegmentPaths[0];
}
var combinedPath = Path.Combine(
Path.GetDirectoryName(finalOutputPath)!,
$"{Path.GetFileNameWithoutExtension(finalOutputPath)}.concat.ts");
if (File.Exists(combinedPath))
{
File.Delete(combinedPath);
}
await using var outputStream = new FileStream(
combinedPath,
FileMode.CreateNew,
FileAccess.Write,
FileShare.None,
bufferSize: 1024 * 128,
useAsync: true);
foreach (var path in recorderSegmentPaths)
{
await using var inputStream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite,
bufferSize: 1024 * 128,
useAsync: true);
await inputStream.CopyToAsync(outputStream, 1024 * 128, cancellationToken);
}
await outputStream.FlushAsync(cancellationToken);
return combinedPath;
var payload = new RecorderSegmentsManifest(
NormalizeAbsolutePath(finalOutputPath),
recorderSegmentPaths.Select(NormalizeAbsolutePath).ToArray(),
DateTimeOffset.UtcNow);
var temporaryPath = $"{manifestPath}.{Guid.NewGuid():N}.tmp";
await File.WriteAllTextAsync(temporaryPath, JsonSerializer.Serialize(payload), Encoding.UTF8, cancellationToken);
File.Move(temporaryPath, manifestPath, overwrite: true);
}
private static IReadOnlyList<string> BuildMp4FinalizeArgumentList(
internal static IReadOnlyList<string> ReadRecorderSegmentsManifest(string finalOutputPath)
{
var manifestPath = GetRecorderSegmentsManifestPath(finalOutputPath);
if (!File.Exists(manifestPath))
{
return [];
}
try
{
var manifest = JsonSerializer.Deserialize<RecorderSegmentsManifest>(File.ReadAllText(manifestPath));
return manifest?.SourcePaths
.Where(static path => !string.IsNullOrWhiteSpace(path))
.Select(NormalizeAbsolutePath)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Where(File.Exists)
.ToArray() ?? [];
}
catch (JsonException)
{
return [];
}
catch (IOException)
{
return [];
}
}
private static async Task<string> WriteFfconcatInputAsync(
string targetPath,
Guid recordTaskId,
IReadOnlyList<string> sourcePaths,
CancellationToken cancellationToken)
{
var concatPath = Path.Combine(
Path.GetDirectoryName(targetPath)!,
$".{Path.GetFileName(targetPath)}.{recordTaskId:N}.ffconcat");
var content = new StringBuilder("ffconcat version 1.0\n");
foreach (var sourcePath in sourcePaths)
{
content.Append("file '")
.Append(NormalizeAbsolutePath(sourcePath)
.Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace("'", "\\'", StringComparison.Ordinal))
.Append("'\n");
}
await File.WriteAllTextAsync(concatPath, content.ToString(), Encoding.UTF8, cancellationToken);
return concatPath;
}
internal static IReadOnlyList<string> BuildMp4FinalizeArgumentList(
string sourcePath,
string targetPath,
Mp4FinalizeStrategy strategy)
Mp4FinalizeStrategy strategy,
bool useConcatDemuxer = false)
{
var arguments = new List<string>
{
@@ -571,16 +631,24 @@ public sealed partial class FfmpegService
"-fflags",
"+genpts+igndts+discardcorrupt",
"-err_detect",
"ignore_err",
"-i",
sourcePath,
"ignore_err"
};
if (useConcatDemuxer)
{
arguments.AddRange(["-f", "concat", "-safe", "0"]);
}
arguments.AddRange(
[
"-i", sourcePath,
"-map",
"0:v:0",
"-map",
"0:a:0?",
"-dn",
"-sn"
};
]);
if (strategy == Mp4FinalizeStrategy.RepairTranscode)
{
@@ -610,7 +678,7 @@ public sealed partial class FfmpegService
return arguments;
}
private static bool IsRepairableMp4FinalizeError(string errorDetail)
internal static bool IsRepairableMp4FinalizeError(string errorDetail)
{
if (IsLowStoragePauseError(errorDetail))
{
@@ -622,25 +690,37 @@ public sealed partial class FfmpegService
errorDetail.Contains("incorrect codec parameters", StringComparison.OrdinalIgnoreCase) ||
errorDetail.Contains("codec parameters", StringComparison.OrdinalIgnoreCase) ||
errorDetail.Contains("Invalid data found when processing input", StringComparison.OrdinalIgnoreCase) ||
errorDetail.Contains("Error initializing output stream", StringComparison.OrdinalIgnoreCase);
errorDetail.Contains("Error initializing output stream", StringComparison.OrdinalIgnoreCase) ||
errorDetail.Contains("Error writing trailer", StringComparison.OrdinalIgnoreCase) ||
errorDetail.Contains("Error muxing a packet", StringComparison.OrdinalIgnoreCase) ||
errorDetail.Contains("Conversion failed", StringComparison.OrdinalIgnoreCase);
}
private static bool IsLowStoragePauseError(string? errorDetail) =>
!string.IsNullOrWhiteSpace(errorDetail) &&
errorDetail.Contains(LowStoragePauseErrorPrefix, StringComparison.OrdinalIgnoreCase);
private static bool IsShutdownFinalizationPauseError(string? errorDetail) =>
!string.IsNullOrWhiteSpace(errorDetail) &&
errorDetail.Contains(ShutdownFinalizationPauseErrorPrefix, StringComparison.OrdinalIgnoreCase);
private static bool IsNoSpaceLeftError(string? errorDetail) =>
!string.IsNullOrWhiteSpace(errorDetail) &&
(errorDetail.Contains("No space left on device", StringComparison.OrdinalIgnoreCase) ||
errorDetail.Contains("database or disk is full", StringComparison.OrdinalIgnoreCase));
private enum Mp4FinalizeStrategy
internal enum Mp4FinalizeStrategy
{
StreamCopy,
RepairTranscode
}
private static IReadOnlyList<string> BuildArgumentList(
private sealed record RecorderSegmentsManifest(
string TargetPath,
IReadOnlyList<string> SourcePaths,
DateTimeOffset CreatedAt);
internal static IReadOnlyList<string> BuildArgumentList(
string streamUrl,
string outputFilePath,
RecordOutputFormat outputFormat,
@@ -654,13 +734,15 @@ public sealed partial class FfmpegService
StreamInputHeaders? inputHeaders,
string? selectedProtocol,
string? selectedVideoCodec,
FfmpegInputOptionProfile inputOptionProfile)
FfmpegInputOptionProfile inputOptionProfile,
RecoveryVideoEncoderSelection? recoveryVideoEncoder = null)
{
var arguments = new List<string> { "-hide_banner", "-y", "-progress", "pipe:1" };
var arguments = new List<string> { "-hide_banner", "-n", "-progress", "pipe:1" };
recoveryVideoEncoder ??= RecoveryVideoEncoderSelection.Software;
var useIntermediateTransportStream = ShouldUseIntermediateTransportStream(outputFilePath, outputFormat, saveMode);
var effectiveStreamUrl = streamUrl;
var isPipeInput = IsHttpInput(streamUrl);
var isPipeInput = ShouldUseCurlPipe(streamUrl, selectedProtocol);
if (isPipeInput)
{
// When the input is HTTP, use pipe:0 so that curl handles the HTTP connection.
@@ -671,7 +753,7 @@ public sealed partial class FfmpegService
}
if (enableReconnect &&
inputOptionProfile == FfmpegInputOptionProfile.Baseline &&
inputOptionProfile != FfmpegInputOptionProfile.Minimal &&
!isPipeInput &&
ShouldEnableReconnect(streamUrl, selectedProtocol))
{
@@ -684,8 +766,21 @@ public sealed partial class FfmpegService
]);
}
arguments.AddRange(["-rw_timeout", readWriteTimeoutMilliseconds.ToString(), "-fflags", "+discardcorrupt+genpts", "-i", effectiveStreamUrl]);
arguments.AddRange(BuildCodecArguments(recordingTemplate));
if (!isPipeInput)
{
AddNativeHttpInputHeaders(arguments, streamUrl, inputHeaders);
}
if (inputOptionProfile == FfmpegInputOptionProfile.TimestampTranscode)
{
AddRecoveryEncoderDeviceArguments(arguments, recoveryVideoEncoder);
}
var inputFlags = inputOptionProfile is FfmpegInputOptionProfile.TimestampRepair or FfmpegInputOptionProfile.TimestampTranscode
? "+discardcorrupt+genpts+igndts"
: "+discardcorrupt+genpts";
arguments.AddRange(["-rw_timeout", readWriteTimeoutMilliseconds.ToString(), "-fflags", inputFlags, "-i", effectiveStreamUrl]);
arguments.AddRange(BuildCodecArguments(recordingTemplate, inputOptionProfile, recoveryVideoEncoder));
var writesTransportStream = useIntermediateTransportStream || outputFormat == RecordOutputFormat.Ts;
var bitstreamFilter = ResolveTransportStreamBitstreamFilter(recordingTemplate, writesTransportStream, selectedVideoCodec);
@@ -728,8 +823,63 @@ public sealed partial class FfmpegService
return arguments;
}
private static IReadOnlyList<string> BuildCodecArguments(RecordingTemplateType recordingTemplate) =>
recordingTemplate switch
private static IReadOnlyList<string> BuildCodecArguments(
RecordingTemplateType recordingTemplate,
FfmpegInputOptionProfile inputOptionProfile,
RecoveryVideoEncoderSelection recoveryVideoEncoder)
{
if (inputOptionProfile == FfmpegInputOptionProfile.TimestampTranscode)
{
var arguments = new List<string>
{
"-map", "0:v:0",
"-map", "0:a:0?",
};
arguments.AddRange(BuildRecoveryVideoCodecArguments(recoveryVideoEncoder));
arguments.AddRange(
[
"-af", "aresample=async=1:first_pts=0,asetpts=PTS-STARTPTS",
"-c:a", "aac",
"-b:a", "128k",
"-avoid_negative_ts", "make_zero"
]);
return arguments;
}
if (inputOptionProfile == FfmpegInputOptionProfile.TimestampRepair)
{
return recordingTemplate switch
{
RecordingTemplateType.BalancedMp4 =>
[
"-c:v", "libx264",
"-preset", "veryfast",
"-crf", "23",
"-c:a", "aac",
"-af", "aresample=async=1:first_pts=0",
"-b:a", "128k"
],
RecordingTemplateType.ArchiveTs =>
[
"-map", "0",
"-c", "copy",
"-c:a", "aac",
"-af", "aresample=async=1:first_pts=0",
"-b:a", "128k"
],
_ =>
[
"-map", "0:v:0",
"-map", "0:a:0?",
"-c:v", "copy",
"-c:a", "aac",
"-af", "aresample=async=1:first_pts=0",
"-b:a", "128k"
]
};
}
return recordingTemplate switch
{
RecordingTemplateType.BalancedMp4 =>
[
@@ -749,6 +899,66 @@ public sealed partial class FfmpegService
"-c", "copy"
]
};
}
internal static void AddRecoveryEncoderDeviceArguments(
ICollection<string> arguments,
RecoveryVideoEncoderSelection encoder)
{
if (string.IsNullOrWhiteSpace(encoder.DevicePath))
{
return;
}
if (encoder.Kind == RecoveryVideoEncoderKind.Qsv)
{
arguments.Add("-qsv_device");
arguments.Add(encoder.DevicePath);
}
else if (encoder.Kind == RecoveryVideoEncoderKind.Vaapi)
{
arguments.Add("-vaapi_device");
arguments.Add(encoder.DevicePath);
}
}
internal static IReadOnlyList<string> BuildRecoveryVideoCodecArguments(
RecoveryVideoEncoderSelection encoder) =>
encoder.Kind switch
{
RecoveryVideoEncoderKind.Nvenc =>
[
"-vf", "settb=AVTB,setpts=PTS-STARTPTS,format=yuv420p",
"-c:v", "h264_nvenc",
"-preset", "p4",
"-cq", "23",
"-b:v", "0",
"-fps_mode", "vfr"
],
RecoveryVideoEncoderKind.Qsv =>
[
"-vf", "settb=AVTB,setpts=PTS-STARTPTS,format=nv12,hwupload=extra_hw_frames=64",
"-c:v", "h264_qsv",
"-preset", "veryfast",
"-global_quality", "23",
"-fps_mode", "vfr"
],
RecoveryVideoEncoderKind.Vaapi =>
[
"-vf", "settb=AVTB,setpts=PTS-STARTPTS,format=nv12,hwupload",
"-c:v", "h264_vaapi",
"-qp", "23",
"-fps_mode", "vfr"
],
_ =>
[
"-vf", "settb=AVTB,setpts=PTS-STARTPTS",
"-c:v", "libx264",
"-preset", "veryfast",
"-crf", "23",
"-fps_mode", "vfr"
]
};
private static string? ResolveTransportStreamBitstreamFilter(
RecordingTemplateType recordingTemplate,
@@ -821,6 +1031,51 @@ public sealed partial class FfmpegService
return false;
}
private async Task<MediaArtifactValidation> ValidateMediaArtifactAsync(
string? outputPath,
RecordOutputFormat outputFormat,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(outputPath) || !File.Exists(outputPath))
{
return MediaArtifactValidation.Invalid("The recorded media file does not exist.");
}
var fileInfo = new FileInfo(outputPath);
if (fileInfo.Length <= 0)
{
return MediaArtifactValidation.Invalid("The recorded media file is empty.");
}
if (outputFormat == RecordOutputFormat.Mp4 &&
!outputPath.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase))
{
return MediaArtifactValidation.Invalid(
"MP4 finalization did not produce an MP4 file. The intermediate recording was kept locally.");
}
using var scope = _serviceScopeFactory.CreateScope();
var metadataService = scope.ServiceProvider.GetRequiredService<IVideoMetadataService>();
var metadata = await metadataService.ExtractMetadataAsync(outputPath, cancellationToken);
if (metadata is null)
{
return MediaArtifactValidation.Invalid(FfprobeUnreadableArtifactError);
}
if (string.IsNullOrWhiteSpace(metadata.VideoCodec))
{
return MediaArtifactValidation.Invalid("The recorded media file does not contain a video stream.", metadata.DurationSeconds);
}
if (!metadata.DurationSeconds.HasValue ||
metadata.DurationSeconds.Value < MinimumUnexpectedExitArtifactDuration.TotalSeconds)
{
return MediaArtifactValidation.Invalid(ShortUnexpectedExitArtifactError, metadata.DurationSeconds);
}
return new MediaArtifactValidation(true, metadata.DurationSeconds, null);
}
private static async Task UpsertRecordResultAsync(
RecordTask recordTask,
LiveRecorderDbContext dbContext,
@@ -830,6 +1085,7 @@ public sealed partial class FfmpegService
string? danmakuFilePath,
int danmakuMessageCount,
DateTimeOffset endedAt,
bool mediaValidatedForDispatch = false,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(effectiveOutputPath))
@@ -838,8 +1094,13 @@ public sealed partial class FfmpegService
}
var resultId = Guid.NewGuid();
var dispatchId = Guid.NewGuid();
var normalizedDanmakuCount = Math.Max(0, danmakuMessageCount);
var finalStatus = (int)recordTask.Status;
var shouldCreateCompletionDispatch = IsCompletionDispatchEligible(
recordTask,
effectiveOutputPath,
fileSize) && mediaValidatedForDispatch;
// Multiple background paths can reconcile the same segment after ffmpeg exits.
// Use the database's atomic upsert instead of EF Add-or-Update to avoid
@@ -859,9 +1120,32 @@ public sealed partial class FfmpegService
"ErrorMessage" = excluded."ErrorMessage",
"UploadStatus" = COALESCE("RecordResults"."UploadStatus", excluded."UploadStatus"),
"DeletedLocalFilesAfterUpload" = COALESCE("RecordResults"."DeletedLocalFilesAfterUpload", excluded."DeletedLocalFilesAfterUpload");
INSERT INTO "RecordCompletionDispatches"
("Id", "RecordTaskId", "ScriptDispatched", "UploadDispatched", "AttemptCount", "LastError", "CreatedAt", "UpdatedAt", "NextAttemptAt", "CompletedAt")
SELECT
{dispatchId}, {recordTask.Id}, FALSE, FALSE, 0, NULL, {endedAt}, {endedAt}, {endedAt}, NULL
WHERE {shouldCreateCompletionDispatch}
ON CONFLICT("RecordTaskId") DO NOTHING;
""", cancellationToken);
}
internal static bool IsCompletionDispatchEligible(
RecordTask recordTask,
string? effectiveOutputPath,
long? fileSize)
{
if (recordTask.Status is not (RecordTaskStatus.Completed or RecordTaskStatus.Stopped) ||
string.IsNullOrWhiteSpace(effectiveOutputPath) ||
(recordTask.OutputFormat == RecordOutputFormat.Mp4 &&
!effectiveOutputPath.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase)))
{
return false;
}
return HasUsableOutput(effectiveOutputPath, fileSize);
}
private static Task<RecordResult?> LoadRecordResultAsync(
LiveRecorderDbContext dbContext,
Guid recordTaskId,
@@ -931,6 +1215,43 @@ public sealed partial class FfmpegService
streamUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
streamUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase);
internal static bool ShouldUseCurlPipe(string streamUrl, string? selectedProtocol) =>
IsHttpInput(streamUrl) && !IsHlsInput(streamUrl, selectedProtocol);
private static bool IsHlsInput(string streamUrl, string? selectedProtocol) =>
string.Equals(selectedProtocol, "hls", StringComparison.OrdinalIgnoreCase) ||
streamUrl.Contains(".m3u8", StringComparison.OrdinalIgnoreCase);
private static void AddNativeHttpInputHeaders(
ICollection<string> arguments,
string streamUrl,
StreamInputHeaders? inputHeaders)
{
if (!IsHttpInput(streamUrl) || inputHeaders is null)
{
return;
}
if (!string.IsNullOrWhiteSpace(inputHeaders.UserAgent))
{
arguments.Add("-user_agent");
arguments.Add(inputHeaders.UserAgent.Trim());
}
if (!string.IsNullOrWhiteSpace(inputHeaders.Referer))
{
arguments.Add("-referer");
arguments.Add(inputHeaders.Referer.Trim());
}
var customHeaders = BuildCustomHeaderArgument(inputHeaders);
if (!string.IsNullOrWhiteSpace(customHeaders))
{
arguments.Add("-headers");
arguments.Add(customHeaders);
}
}
private static bool ShouldEnableReconnect(string streamUrl, string? selectedProtocol)
{
if (!string.IsNullOrWhiteSpace(selectedProtocol) &&
@@ -1134,6 +1455,9 @@ public sealed partial class FfmpegService
private static bool IsActiveSessionStatus(RecordSessionStatus status) =>
status is RecordSessionStatus.Starting or RecordSessionStatus.Running or RecordSessionStatus.Stopping;
private static bool IsTerminalSessionStatus(RecordSessionStatus status) =>
status is RecordSessionStatus.Completed or RecordSessionStatus.Failed or RecordSessionStatus.Stopped;
private static bool TryParseFfmpegProgressSeconds(string line, out double seconds)
{
if (line.StartsWith("out_time=", StringComparison.OrdinalIgnoreCase))
@@ -1163,16 +1487,25 @@ public sealed partial class FfmpegService
return false;
}
private enum FfmpegInputOptionProfile
internal enum FfmpegInputOptionProfile
{
Baseline = 0,
Minimal = 1
Minimal = 1,
TimestampRepair = 2,
TimestampTranscode = 3
}
private sealed record MediaArtifactValidation(bool IsValid, double? DurationSeconds, string? ErrorMessage)
{
public static MediaArtifactValidation Invalid(string errorMessage, double? durationSeconds = null) =>
new(false, durationSeconds, errorMessage);
}
private enum StartupFailureKind
{
None = 0,
InputOptionCompatibility = 1,
StreamHandshake = 2
StreamHandshake = 2,
HardwareEncoderUnavailable = 3
}
}
@@ -5,6 +5,7 @@ using LiveRecorder.Application.Abstractions.Logging;
using LiveRecorder.Application.Abstractions.Notifications;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Application.Abstractions.Recording;
using LiveRecorder.Application.Abstractions.Scripting;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Abstractions.Storage;
using LiveRecorder.Application.Models.Media;
@@ -27,14 +28,23 @@ public sealed partial class FfmpegService : IFfmpegService
private static readonly TimeSpan StartupFailureNotificationCooldown = TimeSpan.FromMinutes(30);
private static readonly TimeSpan StartupFailureMaxBackoff = TimeSpan.FromMinutes(15);
private static readonly TimeSpan StartupFailureBaseBackoff = TimeSpan.FromSeconds(30);
private static readonly TimeSpan RuntimeFailureMaxBackoff = TimeSpan.FromMinutes(5);
private static readonly TimeSpan RuntimeFailureBaseBackoff = TimeSpan.FromSeconds(15);
private readonly ConcurrentDictionary<Guid, SessionProcessRuntime> _processes = new();
private readonly ConcurrentDictionary<Guid, PostProcessRuntimeEntry> _postProcessStates = new();
private readonly ConcurrentDictionary<Guid, Process> _postProcessProcesses = new();
private readonly ConcurrentDictionary<Guid, RoomStartupFailureState> _roomStartupFailureStates = new();
private readonly ConcurrentDictionary<Guid, RoomStartupFailureState> _roomRuntimeFailureStates = new();
private readonly SemaphoreSlim _recoveryEncoderProbeGate = new(1, 1);
private readonly object _transcodeConcurrencyLock = new();
private readonly SemaphoreSlim _orphanRecoveryGate = new(1, 1);
private readonly Queue<TaskCompletionSource<IDisposable>> _transcodeWaiters = new();
private readonly CancellationTokenSource _shutdownCts = new();
private int _activeTranscodeTasks;
private int _maxConcurrentTranscodeTasks = 1;
private bool _hasProbedRecoveryVideoEncoder;
private RecoveryVideoEncoderSelection _cachedRecoveryVideoEncoder = RecoveryVideoEncoderSelection.Software;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly IStorageGuardService _storageGuardService;
private readonly ILiveRoomPollingSignal _liveRoomPollingSignal;
@@ -87,10 +97,7 @@ public sealed partial class FfmpegService : IFfmpegService
initialTask,
streamUrlResult,
recordingSettings,
inputOptionProfile: FfmpegInputOptionProfile.Baseline,
hasRetriedWithCompatibilityProfile: false,
hasRetriedWithRefreshedStream: false,
retryAttemptCount: 0,
InitialRecoveryContext,
cancellationToken);
private async Task StartInternalAsync(
@@ -98,10 +105,7 @@ public sealed partial class FfmpegService : IFfmpegService
RecordTask initialTask,
StreamUrlResult streamUrlResult,
RecordingExecutionSettings recordingSettings,
FfmpegInputOptionProfile inputOptionProfile,
bool hasRetriedWithCompatibilityProfile,
bool hasRetriedWithRefreshedStream,
int retryAttemptCount,
FfmpegRecoveryContext recoveryContext,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(recordSession);
@@ -117,6 +121,9 @@ public sealed partial class FfmpegService : IFfmpegService
using var settingsScope = _serviceScopeFactory.CreateScope();
var settingsService = settingsScope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
var settings = await settingsService.GetAsync(cancellationToken);
var recoveryVideoEncoder = recoveryContext.InputOptionProfile == FfmpegInputOptionProfile.TimestampTranscode
? await ResolveRecoveryVideoEncoderAsync(settings.FfmpegPath, recoveryContext.ForceSoftwareEncoder, cancellationToken)
: RecoveryVideoEncoderSelection.Software;
var outputPathPattern = Path.IsPathRooted(recordSession.OutputPathPattern)
? recordSession.OutputPathPattern
@@ -154,10 +161,8 @@ public sealed partial class FfmpegService : IFfmpegService
streamUrlResult.SelectedVideoCodec,
streamUrlResult.InputHeaders,
recordingSettings,
inputOptionProfile,
hasRetriedWithCompatibilityProfile,
hasRetriedWithRefreshedStream,
retryAttemptCount);
recoveryContext,
recoveryVideoEncoder);
foreach (var argument in BuildArgumentList(
streamUrlResult.SelectedUrl,
@@ -173,7 +178,8 @@ public sealed partial class FfmpegService : IFfmpegService
streamUrlResult.InputHeaders,
streamUrlResult.SelectedProtocol,
streamUrlResult.SelectedVideoCodec,
inputOptionProfile))
recoveryContext.InputOptionProfile,
recoveryVideoEncoder))
{
process.StartInfo.ArgumentList.Add(argument);
}
@@ -199,7 +205,7 @@ public sealed partial class FfmpegService : IFfmpegService
// pipe its stdout into FFmpeg's stdin. This bypasses FFmpeg's built-in HTTP
// handler which has a hard-coded 4096-byte response header buffer that triggers
// "overlong headers" errors with CDNs that return oversized headers.
if (IsHttpInput(streamUrlResult.SelectedUrl))
if (ShouldUseCurlPipe(streamUrlResult.SelectedUrl, streamUrlResult.SelectedProtocol))
{
var curlProcess = new Process
{
@@ -295,6 +301,21 @@ public sealed partial class FfmpegService : IFfmpegService
}
var process = runtime.Process;
var curlProcess = runtime.CurlProcess;
if (curlProcess is not null)
{
try
{
if (!curlProcess.HasExited)
{
curlProcess.Kill(true);
}
}
catch (InvalidOperationException)
{
}
}
if (process is not null)
{
try
@@ -313,7 +334,178 @@ public sealed partial class FfmpegService : IFfmpegService
return await WaitForExitAsync(runtime, timeout, cancellationToken);
}
public async Task<bool> TryReconcileInactiveSessionAsync(Guid recordSessionId, CancellationToken cancellationToken = default)
public async Task<int> StopAllAndWaitAsync(
TimeSpan gracefulTimeout,
TimeSpan forceKillTimeout,
CancellationToken cancellationToken = default)
{
var runtimes = _processes.Values.ToArray();
foreach (var runtime in runtimes)
{
// Mark the captured runtime before looking it up again. The process may exit
// between the snapshot and the stop signal, but its exit handler must still
// observe that this was an application shutdown rather than a stream failure.
runtime.MarkShutdownRequested();
}
await Task.WhenAll(runtimes.Select(runtime =>
RequestStopAsync(
runtime.RecordSessionId,
markAsCompletedOnExit: true,
cancellationToken,
shutdownRequested: true)));
await WaitForRuntimeCompletionsAsync(runtimes, gracefulTimeout, cancellationToken);
foreach (var runtime in runtimes.Where(static runtime => !runtime.ExitCompletion.Task.IsCompleted))
{
var process = runtime.Process;
try
{
if (process is not null && !process.HasExited)
{
runtime.MarkForceKilled();
process.Kill(true);
}
}
catch (InvalidOperationException)
{
}
}
await WaitForRuntimeCompletionsAsync(runtimes, forceKillTimeout, CancellationToken.None);
_shutdownCts.Cancel();
foreach (var process in _postProcessProcesses.Values.ToArray())
{
try
{
if (!process.HasExited)
{
process.Kill(true);
}
}
catch (InvalidOperationException)
{
}
}
await WaitForAllProcessesAsync(forceKillTimeout, CancellationToken.None);
return runtimes.Length;
}
private static async Task WaitForRuntimeCompletionsAsync(
IReadOnlyCollection<SessionProcessRuntime> runtimes,
TimeSpan timeout,
CancellationToken cancellationToken)
{
if (runtimes.Count == 0 || runtimes.All(static runtime => runtime.ExitCompletion.Task.IsCompleted))
{
return;
}
var completionTask = Task.WhenAll(runtimes.Select(static runtime => runtime.ExitCompletion.Task));
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var delayTask = Task.Delay(timeout, timeoutCts.Token);
if (await Task.WhenAny(completionTask, delayTask) == completionTask)
{
timeoutCts.Cancel();
await completionTask;
}
}
private async Task WaitForAllProcessesAsync(TimeSpan timeout, CancellationToken cancellationToken)
{
var deadline = DateTimeOffset.UtcNow + timeout;
while ((!_processes.IsEmpty || !_postProcessProcesses.IsEmpty || !_postProcessStates.IsEmpty) &&
DateTimeOffset.UtcNow < deadline)
{
await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken);
}
}
public Task<bool> TryReconcileInactiveSessionAsync(Guid recordSessionId, CancellationToken cancellationToken = default) =>
ReconcileInactiveSessionAsync(recordSessionId, allowTerminalSession: false, cancellationToken);
public async Task<int> RecoverOrphanedTerminalSessionTasksAsync(CancellationToken cancellationToken = default)
{
if (!await _orphanRecoveryGate.WaitAsync(0, cancellationToken))
{
return 0;
}
try
{
using var scope = _serviceScopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
var candidateIds = await dbContext.RecordSessions
.AsNoTracking()
.Where(session =>
(session.Status == RecordSessionStatus.Completed ||
session.Status == RecordSessionStatus.Failed ||
session.Status == RecordSessionStatus.Stopped) &&
session.RecordTasks.Any(task =>
task.Status == RecordTaskStatus.Starting ||
task.Status == RecordTaskStatus.Running ||
task.Status == RecordTaskStatus.Stopping))
.OrderBy(session => session.UpdatedAt)
.Select(session => session.Id)
.Take(20)
.ToListAsync(cancellationToken);
var recovered = 0;
foreach (var candidateId in candidateIds)
{
if (await ReconcileInactiveSessionAsync(candidateId, allowTerminalSession: true, cancellationToken))
{
recovered++;
}
}
return recovered;
}
finally
{
_orphanRecoveryGate.Release();
}
}
public async Task<bool> TryRecoverOrphanedTerminalTaskAsync(
Guid recordTaskId,
CancellationToken cancellationToken = default)
{
await _orphanRecoveryGate.WaitAsync(cancellationToken);
try
{
using var lookupScope = _serviceScopeFactory.CreateScope();
var lookupDbContext = lookupScope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
var sessionId = await lookupDbContext.RecordTasks
.AsNoTracking()
.Where(task =>
task.Id == recordTaskId &&
(task.Status == RecordTaskStatus.Starting ||
task.Status == RecordTaskStatus.Running ||
task.Status == RecordTaskStatus.Stopping) &&
task.RecordSession != null &&
(task.RecordSession.Status == RecordSessionStatus.Completed ||
task.RecordSession.Status == RecordSessionStatus.Failed ||
task.RecordSession.Status == RecordSessionStatus.Stopped))
.Select(task => (Guid?)task.RecordSessionId)
.FirstOrDefaultAsync(cancellationToken);
return sessionId.HasValue &&
await ReconcileInactiveSessionAsync(sessionId.Value, allowTerminalSession: true, cancellationToken);
}
finally
{
_orphanRecoveryGate.Release();
}
}
private async Task<bool> ReconcileInactiveSessionAsync(
Guid recordSessionId,
bool allowTerminalSession,
CancellationToken cancellationToken)
{
if (IsRunning(recordSessionId))
{
@@ -330,11 +522,53 @@ public sealed partial class FfmpegService : IFfmpegService
.ThenInclude(item => item.Result)
.FirstOrDefaultAsync(item => item.Id == recordSessionId, cancellationToken);
if (recordSession is null || !IsActiveSessionStatus(recordSession.Status))
if (recordSession is null ||
(!IsActiveSessionStatus(recordSession.Status) &&
!(allowTerminalSession && IsTerminalSessionStatus(recordSession.Status))))
{
return false;
}
var hasActiveTasks = recordSession.RecordTasks.Any(task => IsActiveTaskStatus(task.Status));
if (allowTerminalSession && !hasActiveTasks)
{
return false;
}
var discoveredSegments = DiscoverRecoverableSegments(recordSession.OutputPathPattern, recordSession.OutputFormat, recordSession.SaveMode);
var existingSegmentIndexes = recordSession.RecordTasks
.Select(static task => task.SegmentIndex)
.ToHashSet();
var addedMissingTasks = false;
foreach (var segment in discoveredSegments.Where(segment => !existingSegmentIndexes.Contains(segment.SegmentIndex)))
{
var discoveredAt = File.GetLastWriteTimeUtc(segment.RecorderPath);
var createdAt = discoveredAt == DateTime.MinValue
? DateTimeOffset.UtcNow
: new DateTimeOffset(DateTime.SpecifyKind(discoveredAt, DateTimeKind.Utc));
var missingTask = new RecordTask(
recordSession.LiveRoomId,
recordSession.Id,
segment.SegmentIndex,
recordSession.PreferredQuality,
recordSession.OutputFormat,
createdAt);
var recoveryStartedAt = DateTimeOffset.UtcNow;
missingTask.MarkStarting(recordSession.StreamUrl ?? string.Empty, segment.OutputPath, recoveryStartedAt);
missingTask.MarkRunning(recoveryStartedAt);
await dbContext.RecordTasks.AddAsync(missingTask, cancellationToken);
recordSession.RecordTasks.Add(missingTask);
existingSegmentIndexes.Add(segment.SegmentIndex);
addedMissingTasks = true;
}
// RecordResult is upserted with raw SQL below, so newly discovered tasks must
// exist first to satisfy the RecordTaskId foreign key.
if (addedMissingTasks)
{
await dbContext.SaveChangesAsync(cancellationToken);
}
var settings = await settingsService.GetAsync(cancellationToken);
var endedAt = DateTimeOffset.UtcNow;
var tasks = recordSession.RecordTasks
@@ -345,6 +579,7 @@ public sealed partial class FfmpegService : IFfmpegService
var anyUsableOutput = tasks.Any(static item => item.Status == RecordTaskStatus.Completed);
var hasBackgroundPostProcessing = false;
string? sessionFinalizationError = null;
var newlyCompletedTasks = new List<(RecordTask Task, string OutputPath)>();
foreach (var task in tasks)
{
@@ -372,11 +607,21 @@ public sealed partial class FfmpegService : IFfmpegService
cancellationToken);
var effectiveOutputPath = finalizationResult.OutputPath;
var taskFinalizationError = finalizationResult.ErrorMessage;
var mediaValidation = await ValidateMediaArtifactAsync(
effectiveOutputPath,
recordSession.OutputFormat,
cancellationToken);
durationSeconds = mediaValidation.DurationSeconds;
if (!IsLowStoragePauseError(taskFinalizationError))
{
sessionFinalizationError ??= taskFinalizationError;
}
if (string.IsNullOrWhiteSpace(taskFinalizationError) && !mediaValidation.IsValid)
{
sessionFinalizationError ??= mediaValidation.ErrorMessage;
}
var fileSize = CalculateFileSize(effectiveOutputPath);
var danmakuPath = task.Result?.DanmakuFilePath ?? GuessDanmakuPath(task.OutputFilePath);
var danmakuCount = CountDanmakuMessages(danmakuPath);
@@ -388,23 +633,34 @@ public sealed partial class FfmpegService : IFfmpegService
}
else if (!string.IsNullOrWhiteSpace(taskFinalizationError))
{
task.MarkFailed(taskFinalizationError, endedAt);
task.MarkFailed(taskFinalizationError, endedAt, durationSeconds);
}
else if (HasUsableOutput(effectiveOutputPath, fileSize))
else if (mediaValidation.IsValid)
{
task.MarkCompleted(endedAt, durationSeconds);
anyUsableOutput = true;
newlyCompletedTasks.Add((task, effectiveOutputPath));
}
else
{
task.MarkStopped(endedAt, durationSeconds, "Recording process was no longer running when the session was reconciled.");
task.MarkFailed(mediaValidation.ErrorMessage!, endedAt, durationSeconds);
}
await UpsertRecordResultAsync(task, dbContext, effectiveOutputPath, fileSize, durationSeconds, danmakuPath, danmakuCount, endedAt, cancellationToken);
await UpsertRecordResultAsync(
task,
dbContext,
effectiveOutputPath,
fileSize,
durationSeconds,
danmakuPath,
danmakuCount,
endedAt,
mediaValidatedForDispatch: mediaValidation.IsValid,
cancellationToken: cancellationToken);
ClearPostProcessState(task.Id);
}
recordSession.SyncSegmentCount(tasks.Count, endedAt);
recordSession.SyncSegmentCount(tasks.Count == 0 ? 0 : tasks.Max(static task => task.SegmentIndex), endedAt);
if (!string.IsNullOrWhiteSpace(sessionFinalizationError))
{
recordSession.MarkFailed(sessionFinalizationError, endedAt);
@@ -425,6 +681,26 @@ public sealed partial class FfmpegService : IFfmpegService
}
await dbContext.SaveChangesAsync(cancellationToken);
if (newlyCompletedTasks.Count > 0 && recordSession.LiveRoom is not null)
{
var completionDispatchService = scope.ServiceProvider.GetRequiredService<CompletionDispatchService>();
foreach (var completed in newlyCompletedTasks)
{
try
{
await completionDispatchService.TryDispatchTaskAsync(completed.Task.Id, cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Recovered segment completion hooks failed for task {RecordTaskId}",
completed.Task.Id);
}
}
}
return true;
}
@@ -459,8 +735,8 @@ public sealed partial class FfmpegService : IFfmpegService
return false;
}
var recorderOutputPath = ResolveManualFinalizeSourcePath(recordTask, recordTask.RecordSession);
if (!File.Exists(recorderOutputPath))
var recorderOutputPaths = ResolveManualFinalizeSourcePaths(recordTask, recordTask.RecordSession);
if (recorderOutputPaths.Count == 0)
{
return false;
}
@@ -548,9 +824,10 @@ public sealed partial class FfmpegService : IFfmpegService
settings.Mp4FinalizeTimeoutMinutes,
syntheticSessionId,
syntheticTaskId,
absoluteSourcePath,
[absoluteSourcePath],
absoluteTargetPath,
expectedDurationSeconds: null,
segmentsManifestPath: null,
CancellationToken.None);
using var scope = _serviceScopeFactory.CreateScope();
@@ -610,32 +887,30 @@ public sealed partial class FfmpegService : IFfmpegService
var settings = await settingsService.GetAsync(cancellationToken);
var storageCheck = _storageGuardService.CheckCanStartOrResume(settings);
if (storageCheck.ShouldPauseActive)
{
await logService.WriteAsync(
SystemLogLevel.Warning,
"Storage",
"MP4 finalization remains paused because storage tier is Red.",
storageCheck.Message,
cancellationToken: cancellationToken);
return 0;
}
var candidates = await dbContext.RecordTasks
.Include(item => item.RecordSession)
.ThenInclude(item => item!.RecordTasks)
.Include(item => item.Result)
.Where(item => item.OutputFormat == RecordOutputFormat.Mp4 &&
(item.Status == RecordTaskStatus.Processing ||
item.Status == RecordTaskStatus.Completed))
item.Status == RecordTaskStatus.Completed ||
item.Status == RecordTaskStatus.Failed &&
item.ErrorMessage == FfprobeUnreadableArtifactError))
// Deployment-paused work must not be starved by a large backlog of
// older legacy failures that may no longer have local source files.
.OrderBy(static item => item.Status == RecordTaskStatus.Processing
? 0
: item.Status == RecordTaskStatus.Completed
? 1
: 2)
.ThenBy(static item => item.UpdatedAt)
.Take(100)
.ToListAsync(cancellationToken);
candidates = candidates
.OrderBy(static item => item.UpdatedAt)
.Take(100)
.ToList();
var queuedTaskIds = new List<Guid>();
var recoveredValidatedTaskIds = new List<Guid>();
var repairedInterruptedTasks = 0;
var repairedLegacyShutdownFailures = 0;
var now = DateTimeOffset.UtcNow;
foreach (var candidate in candidates)
{
@@ -645,6 +920,58 @@ public sealed partial class FfmpegService : IFfmpegService
continue;
}
if (candidate.Status == RecordTaskStatus.Failed)
{
var recoverySources = candidate.RecordSession is null
? Array.Empty<string>()
: ResolveManualFinalizeSourcePaths(candidate, candidate.RecordSession);
if (recoverySources.Count > 0)
{
candidate.MarkProcessing(
"A deployment-interrupted MP4 finalization was recovered and queued after restart.",
now);
candidate.RecordSession!.MarkStopped(
now,
"A deployment-interrupted MP4 finalization is continuing in the background.");
queuedTaskIds.Add(candidate.Id);
repairedLegacyShutdownFailures++;
continue;
}
var existingOutputPath = candidate.Result?.FilePath ?? candidate.OutputFilePath;
var mediaValidation = await ValidateMediaArtifactAsync(
existingOutputPath,
candidate.OutputFormat,
cancellationToken);
if (!mediaValidation.IsValid)
{
continue;
}
candidate.MarkCompleted(now, mediaValidation.DurationSeconds);
if (candidate.RecordSession is not null &&
candidate.RecordSession.RecordTasks.All(static task =>
task.Status is RecordTaskStatus.Completed or RecordTaskStatus.Stopped))
{
candidate.RecordSession.MarkCompleted(now);
}
await UpsertRecordResultAsync(
candidate,
dbContext,
existingOutputPath,
CalculateFileSize(existingOutputPath),
mediaValidation.DurationSeconds,
candidate.Result?.DanmakuFilePath,
candidate.Result?.DanmakuMessageCount ?? 0,
now,
mediaValidatedForDispatch: true,
cancellationToken: cancellationToken);
recoveredValidatedTaskIds.Add(candidate.Id);
repairedLegacyShutdownFailures++;
continue;
}
if (!NeedsInterruptedMp4Finalization(candidate))
{
continue;
@@ -655,13 +982,24 @@ public sealed partial class FfmpegService : IFfmpegService
repairedInterruptedTasks++;
}
if (repairedInterruptedTasks > 0)
if (repairedInterruptedTasks > 0 || repairedLegacyShutdownFailures > 0)
{
await dbContext.SaveChangesAsync(cancellationToken);
}
if (recoveredValidatedTaskIds.Count > 0)
{
var completionDispatchService = scope.ServiceProvider.GetRequiredService<CompletionDispatchService>();
foreach (var taskId in recoveredValidatedTaskIds)
{
await completionDispatchService.TryDispatchTaskAsync(taskId, cancellationToken);
}
}
var started = 0;
foreach (var taskId in queuedTaskIds.Take(20))
foreach (var taskId in queuedTaskIds
.Where(taskId => !IsTaskUnderPostProcessing(taskId))
.Take(20))
{
if (await StartManualFinalizeTaskAsync(taskId, cancellationToken))
{
@@ -669,13 +1007,13 @@ public sealed partial class FfmpegService : IFfmpegService
}
}
if (started > 0)
if (started > 0 || repairedLegacyShutdownFailures > 0)
{
await logService.WriteAsync(
SystemLogLevel.Info,
"Storage",
"Storage is available. Resumed paused MP4 finalization tasks.",
$"count={started}; repairedInterrupted={repairedInterruptedTasks}; {storageCheck.Message}",
"Resumed paused MP4 finalizations and repaired deployment-interrupted recording results.",
$"count={started}; repairedInterrupted={repairedInterruptedTasks}; repairedLegacyShutdown={repairedLegacyShutdownFailures}; {storageCheck.Message}",
cancellationToken: cancellationToken);
}
@@ -699,12 +1037,23 @@ public sealed partial class FfmpegService : IFfmpegService
return false;
}
var recorderOutputPath = ResolveManualFinalizeSourcePath(recordTask, recordTask.RecordSession);
return File.Exists(recorderOutputPath);
return ResolveManualFinalizeSourcePaths(recordTask, recordTask.RecordSession).Count > 0;
}
private static string ResolveManualFinalizeSourcePath(RecordTask recordTask, RecordSession recordSession)
=> ResolveManualFinalizeSourcePaths(recordTask, recordSession).FirstOrDefault() ?? string.Empty;
private static IReadOnlyList<string> ResolveManualFinalizeSourcePaths(RecordTask recordTask, RecordSession recordSession)
{
if (!string.IsNullOrWhiteSpace(recordTask.OutputFilePath))
{
var manifestPaths = ReadRecorderSegmentsManifest(recordTask.OutputFilePath);
if (manifestPaths.Count > 0)
{
return manifestPaths;
}
}
var resultPath = recordTask.Result?.FilePath;
if (!string.IsNullOrWhiteSpace(resultPath) &&
resultPath.EndsWith(".ts", StringComparison.OrdinalIgnoreCase))
@@ -712,15 +1061,16 @@ public sealed partial class FfmpegService : IFfmpegService
var normalizedResultPath = NormalizeAbsolutePath(resultPath);
if (File.Exists(normalizedResultPath))
{
return normalizedResultPath;
return [normalizedResultPath];
}
}
return NormalizeAbsolutePath(
var defaultPath = NormalizeAbsolutePath(
GetRecorderOutputPath(
recordTask.OutputFilePath ?? resultPath ?? string.Empty,
recordSession.OutputFormat,
recordSession.SaveMode));
return File.Exists(defaultPath) ? [defaultPath] : [];
}
private async Task PersistStartupProfileAsync(SessionProcessRuntime runtime, CancellationToken cancellationToken)
@@ -731,7 +1081,7 @@ public sealed partial class FfmpegService : IFfmpegService
SystemLogLevel.Info,
"FFmpeg",
$"ffmpeg input profile={runtime.InputOptionProfile}.",
detail: $"stream={runtime.SelectedProtocol}:{runtime.SelectedQuality}; videoCodec={runtime.SelectedVideoCodec ?? "unknown"}; compatibilityRetry={runtime.HasRetriedWithCompatibilityProfile}; refreshRetry={runtime.HasRetriedWithRefreshedStream}",
detail: $"stream={runtime.SelectedProtocol}:{runtime.SelectedQuality}; videoCodec={runtime.SelectedVideoCodec ?? "unknown"}; recoveryEncoder={runtime.RecoveryVideoEncoder.Kind}; recoveryDevice={runtime.RecoveryVideoEncoder.DevicePath ?? "default"}; attempt={runtime.RetryAttemptCount}/{MaxInSessionRetryAttempts}; compatibilityRetry={runtime.HasRetriedWithCompatibilityProfile}; refreshRetry={runtime.HasRetriedWithRefreshedStream}; alternateProtocolRetry={runtime.HasRetriedWithAlternateProtocol}; softwareEncoderRetry={runtime.RecoveryContext.HasRetriedWithSoftwareEncoder}",
liveRoomId: runtime.LiveRoomId,
recordSessionId: runtime.RecordSessionId,
recordTaskId: runtime.CurrentTaskId,
@@ -756,14 +1106,25 @@ public sealed partial class FfmpegService : IFfmpegService
return false;
}
private async Task RequestStopAsync(Guid recordSessionId, bool markAsCompletedOnExit, CancellationToken cancellationToken)
private async Task RequestStopAsync(
Guid recordSessionId,
bool markAsCompletedOnExit,
CancellationToken cancellationToken,
bool shutdownRequested = false)
{
if (!_processes.TryGetValue(recordSessionId, out var runtime))
{
return;
}
runtime.MarkStopRequested(markAsCompletedOnExit);
if (shutdownRequested)
{
runtime.MarkShutdownRequested();
}
else
{
runtime.MarkStopRequested(markAsCompletedOnExit);
}
var process = runtime.Process;
if (process is null)
{
@@ -919,6 +1280,41 @@ public sealed partial class FfmpegService : IFfmpegService
}
}
private TimeSpan RecordRuntimeFailure(Guid liveRoomId)
{
var now = DateTimeOffset.UtcNow;
var state = _roomRuntimeFailureStates.AddOrUpdate(
liveRoomId,
_ => new RoomStartupFailureState { ConsecutiveFailures = 1, FirstFailureAt = now, LastFailureAt = now },
(_, existing) =>
{
existing.ConsecutiveFailures++;
existing.LastFailureAt = now;
return existing;
});
var backoffSeconds = RuntimeFailureBaseBackoff.TotalSeconds *
Math.Pow(2, Math.Min(state.ConsecutiveFailures - 1, 5));
var backoff = TimeSpan.FromSeconds(Math.Min(backoffSeconds, RuntimeFailureMaxBackoff.TotalSeconds));
_logger.LogWarning(
"Short runtime failure backoff for room {LiveRoomId}: {ConsecutiveFailures} consecutive failures, next poll delayed by {BackoffSeconds:F0}s",
liveRoomId,
state.ConsecutiveFailures,
backoff.TotalSeconds);
return backoff;
}
private void ResetRuntimeFailureBackoff(Guid liveRoomId)
{
if (_roomRuntimeFailureStates.TryRemove(liveRoomId, out var state) && state.ConsecutiveFailures > 1)
{
_logger.LogInformation(
"Short runtime failure backoff reset for room {LiveRoomId} after {ConsecutiveFailures} failures",
liveRoomId,
state.ConsecutiveFailures);
}
}
/// <summary>
/// Returns true if the failure notification for this room should be throttled
/// (i.e., at most one notification per <see cref="StartupFailureNotificationCooldown"/>).
@@ -0,0 +1,33 @@
using LiveRecorder.Application.Abstractions.Recording;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace LiveRecorder.Infrastructure.Services;
public sealed class FfmpegShutdownHostedService : IHostedService
{
private static readonly TimeSpan GracefulTimeout = TimeSpan.FromSeconds(20);
private static readonly TimeSpan ForceKillTimeout = TimeSpan.FromSeconds(10);
private readonly IFfmpegService _ffmpegService;
private readonly ILogger<FfmpegShutdownHostedService> _logger;
public FfmpegShutdownHostedService(
IFfmpegService ffmpegService,
ILogger<FfmpegShutdownHostedService> logger)
{
_ffmpegService = ffmpegService;
_logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Stopping active recording and post-processing child processes.");
var stopped = await _ffmpegService.StopAllAndWaitAsync(
GracefulTimeout,
ForceKillTimeout,
cancellationToken);
_logger.LogInformation("Recording shutdown coordination completed for {SessionCount} sessions.", stopped);
}
}
@@ -1,12 +1,22 @@
using System.Diagnostics;
using System.Globalization;
using System.Text.Json;
using System.Text.RegularExpressions;
using LiveRecorder.Application.Abstractions.Recording;
using Microsoft.Extensions.Logging;
namespace LiveRecorder.Infrastructure.Services;
public sealed class FfmpegVideoMetadataService : IVideoMetadataService
{
private const string ThumbnailsSubDir = ".thumbnails";
private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(60);
private readonly ILogger<FfmpegVideoMetadataService> _logger;
public FfmpegVideoMetadataService(ILogger<FfmpegVideoMetadataService> logger)
{
_logger = logger;
}
public async Task<VideoMetadata?> ExtractMetadataAsync(string filePath, CancellationToken cancellationToken = default)
{
@@ -17,12 +27,11 @@ public sealed class FfmpegVideoMetadataService : IVideoMetadataService
try
{
var process = new Process
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = GetFfprobePath(),
Arguments = $"-v quiet -print_format json -show_format -show_streams \"{filePath}\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
@@ -30,21 +39,55 @@ public sealed class FfmpegVideoMetadataService : IVideoMetadataService
}
};
process.Start();
var output = await process.StandardOutput.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken);
if (process.ExitCode != 0 || string.IsNullOrWhiteSpace(output))
foreach (var argument in new[] { "-v", "error", "-print_format", "json", "-show_format", "-show_streams", filePath })
{
return null;
process.StartInfo.ArgumentList.Add(argument);
}
return ParseFfprobeOutput(output);
SanitizeFfprobeProcessEnvironment(process.StartInfo);
process.Start();
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(ProbeTimeout);
var outputTask = process.StandardOutput.ReadToEndAsync(timeoutCts.Token);
var errorTask = process.StandardError.ReadToEndAsync(timeoutCts.Token);
await process.WaitForExitAsync(timeoutCts.Token);
var output = await outputTask;
var error = await errorTask;
if (process.ExitCode == 0 && !string.IsNullOrWhiteSpace(output))
{
var metadata = ParseFfprobeOutput(output);
if (metadata is not null)
{
return metadata;
}
}
else
{
_logger.LogWarning(
"ffprobe failed for {FilePath}: exitCode={ExitCode}; stderr={Error}",
filePath,
process.ExitCode,
string.IsNullOrWhiteSpace(error) ? "(empty)" : error.Trim());
}
}
catch
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
_logger.LogWarning("ffprobe timed out after {TimeoutSeconds}s for {FilePath}", ProbeTimeout.TotalSeconds, filePath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "ffprobe could not be started or read media metadata for {FilePath}", filePath);
}
if (cancellationToken.IsCancellationRequested)
{
return null;
}
_logger.LogWarning("Falling back to ffmpeg header probing for {FilePath}", filePath);
return await ExtractMetadataWithFfmpegAsync(filePath, cancellationToken);
}
public async Task<string?> GenerateThumbnailAsync(string filePath, string outputDir, CancellationToken cancellationToken = default)
@@ -93,7 +136,10 @@ public sealed class FfmpegVideoMetadataService : IVideoMetadataService
};
process.Start();
var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken);
await Task.WhenAll(outputTask, errorTask);
if (process.ExitCode == 0 && File.Exists(thumbPath) && new FileInfo(thumbPath).Length > 0)
{
@@ -165,6 +211,155 @@ public sealed class FfmpegVideoMetadataService : IVideoMetadataService
}
}
private async Task<VideoMetadata?> ExtractMetadataWithFfmpegAsync(
string filePath,
CancellationToken cancellationToken)
{
try
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = GetFfmpegPath(),
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
foreach (var argument in new[] { "-hide_banner", "-i", filePath, "-t", "0", "-f", "null", "-" })
{
process.StartInfo.ArgumentList.Add(argument);
}
// The recorder already proves that fnOS can launch this ffmpeg with
// the inherited application environment. Do not apply the ffprobe-
// specific library cleanup to this compatibility fallback.
process.Start();
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(ProbeTimeout);
var outputTask = process.StandardOutput.ReadToEndAsync(timeoutCts.Token);
var errorTask = process.StandardError.ReadToEndAsync(timeoutCts.Token);
await process.WaitForExitAsync(timeoutCts.Token);
await outputTask;
var error = await errorTask;
var metadata = ParseFfmpegHeaderOutput(error);
if (process.ExitCode == 0 && metadata is not null)
{
_logger.LogInformation("ffmpeg header probing succeeded for {FilePath}", filePath);
return metadata;
}
_logger.LogWarning(
"ffmpeg header probing failed for {FilePath}: exitCode={ExitCode}; stderr={Error}",
filePath,
process.ExitCode,
string.IsNullOrWhiteSpace(error) ? "(empty)" : error.Trim());
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
_logger.LogWarning(
"ffmpeg header probing timed out after {TimeoutSeconds}s for {FilePath}",
ProbeTimeout.TotalSeconds,
filePath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "ffmpeg header probing could not read media metadata for {FilePath}", filePath);
}
return null;
}
internal static VideoMetadata? ParseFfmpegHeaderOutput(string output)
{
if (string.IsNullOrWhiteSpace(output))
{
return null;
}
var durationMatch = Regex.Match(
output,
@"Duration:\s*(?<hours>\d+):(?<minutes>\d{2}):(?<seconds>\d{2}(?:\.\d+)?)",
RegexOptions.CultureInvariant);
if (!durationMatch.Success ||
!double.TryParse(durationMatch.Groups["hours"].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var hours) ||
!double.TryParse(durationMatch.Groups["minutes"].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var minutes) ||
!double.TryParse(durationMatch.Groups["seconds"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds))
{
return null;
}
var durationSeconds = hours * 3600 + minutes * 60 + seconds;
if (durationSeconds <= 0)
{
return null;
}
string? videoLine = null;
string? audioLine = null;
foreach (var line in output.Split('\n'))
{
if (line.Contains("Stream mapping:", StringComparison.Ordinal))
{
break;
}
if (videoLine is null && line.Contains("Video:", StringComparison.Ordinal))
{
videoLine = line;
}
else if (audioLine is null && line.Contains("Audio:", StringComparison.Ordinal))
{
audioLine = line;
}
}
var videoCodec = MatchValue(videoLine, @"Video:\s*(?<value>[^\s,(]+)");
var audioCodec = MatchValue(audioLine, @"Audio:\s*(?<value>[^\s,(]+)");
var dimensions = videoLine is null
? Match.Empty
: Regex.Match(videoLine, @"(?<!\d)(?<width>\d{2,5})x(?<height>\d{2,5})(?!\d)", RegexOptions.CultureInvariant);
var frameRate = MatchDouble(videoLine, @",\s*(?<value>\d+(?:\.\d+)?)\s+fps(?:,|\s)");
var bitRateKbps = MatchDouble(
output,
@"Duration:[^\r\n]*bitrate:\s*(?<value>\d+(?:\.\d+)?)\s*kb/s");
int? width = dimensions.Success && int.TryParse(dimensions.Groups["width"].Value, out var parsedWidth)
? parsedWidth
: null;
int? height = dimensions.Success && int.TryParse(dimensions.Groups["height"].Value, out var parsedHeight)
? parsedHeight
: null;
long? bitRate = bitRateKbps.HasValue
? (long)Math.Round(bitRateKbps.Value * 1000, MidpointRounding.AwayFromZero)
: null;
return new VideoMetadata(durationSeconds, width, height, videoCodec, audioCodec, frameRate, bitRate);
}
private static string? MatchValue(string? input, string pattern)
{
if (string.IsNullOrWhiteSpace(input))
{
return null;
}
var match = Regex.Match(input, pattern, RegexOptions.CultureInvariant);
return match.Success ? match.Groups["value"].Value : null;
}
private static double? MatchDouble(string? input, string pattern)
{
var value = MatchValue(input, pattern);
return double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)
? parsed
: null;
}
private static double? ParseFrameRate(string fraction)
{
var parts = fraction.Split('/');
@@ -181,4 +376,12 @@ public sealed class FfmpegVideoMetadataService : IVideoMetadataService
private static string GetFfmpegPath() => "ffmpeg";
private static string GetFfprobePath() => "ffprobe";
internal static void SanitizeFfprobeProcessEnvironment(ProcessStartInfo startInfo)
{
// The fnOS package ships private Debian libraries for its bundled curl.
// Inheriting that LD_LIBRARY_PATH into the host's ffmpeg/ffprobe can make
// otherwise valid system binaries fail with incompatible shared libraries.
startInfo.Environment.Remove("LD_LIBRARY_PATH");
}
}
@@ -74,6 +74,13 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
var settingsService = scope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
var settings = await settingsService.GetAsync(stoppingToken);
var ffmpegService = scope.ServiceProvider.GetRequiredService<IFfmpegService>();
var recoveredOrphanedSessions = await ffmpegService.RecoverOrphanedTerminalSessionTasksAsync(stoppingToken);
if (recoveredOrphanedSessions > 0)
{
_logger.LogWarning(
"Recovered {SessionCount} terminal recording sessions that still contained active segment tasks.",
recoveredOrphanedSessions);
}
// Always try to resume paused MP4 finalizations — even (especially) under the Red
// tier. Finalization is what flips a task to Completed, which fires the
// segment_completed script (upload + delete source) that frees disk space. Skipping
@@ -431,28 +438,37 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
return;
}
var hasRunningSession = await dbContext.RecordSessions.AnyAsync(
var activeSessionId = await dbContext.RecordSessions
.Where(
item => item.LiveRoomId == liveRoom.Id &&
(item.Status == RecordSessionStatus.Starting ||
item.Status == RecordSessionStatus.Running ||
item.Status == RecordSessionStatus.Stopping),
cancellationToken);
item.Status == RecordSessionStatus.Stopping))
.OrderByDescending(static item => item.CreatedAt)
.Select(static item => (Guid?)item.Id)
.FirstOrDefaultAsync(cancellationToken);
if (hasRunningSession)
if (activeSessionId.HasValue)
{
await UpdateAutoStartDecisionAsync(
var decisionChanged = await UpdateAutoStartDecisionIfChangedAsync(
dbContext,
liveRoom,
AutoStartDecisionCodes.SkippedActiveSession,
"Auto-start skipped because an active recording session already exists.",
detail: null,
$"activeSessionId={activeSessionId.Value}",
cancellationToken);
await logService.WriteAsync(
SystemLogLevel.Info,
"Scheduler",
"Auto-start skipped because an active recording session already exists.",
liveRoomId: liveRoom.Id,
cancellationToken: cancellationToken);
if (decisionChanged)
{
await logService.WriteAsync(
SystemLogLevel.Info,
"Scheduler",
"Auto-start skipped because an active recording session already exists.",
$"activeSessionId={activeSessionId.Value}",
liveRoomId: liveRoom.Id,
recordSessionId: activeSessionId.Value,
cancellationToken: cancellationToken);
}
return;
}
@@ -860,6 +876,40 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
await SaveChangesWithRetryAsync(dbContext, cancellationToken);
}
private static async Task<bool> UpdateAutoStartDecisionIfChangedAsync(
LiveRecorderDbContext dbContext,
Domain.Entities.LiveRoom liveRoom,
string code,
string summary,
string? detail,
CancellationToken cancellationToken)
{
var normalizedCode = Truncate(code, 64);
var normalizedSummary = Truncate(summary, 256);
var normalizedDetail = Truncate(detail, 2048);
if (!HasAutoStartDecisionChanged(liveRoom, normalizedCode, normalizedSummary, normalizedDetail))
{
return false;
}
liveRoom.SetLastAutoStartDecision(
normalizedCode,
normalizedSummary,
normalizedDetail,
DateTimeOffset.UtcNow);
await SaveChangesWithRetryAsync(dbContext, cancellationToken);
return true;
}
internal static bool HasAutoStartDecisionChanged(
Domain.Entities.LiveRoom liveRoom,
string? code,
string? summary,
string? detail) =>
!string.Equals(liveRoom.LastAutoStartDecisionCode, code, StringComparison.Ordinal) ||
!string.Equals(liveRoom.LastAutoStartDecisionSummary, summary, StringComparison.Ordinal) ||
!string.Equals(liveRoom.LastAutoStartDecisionDetail, detail, StringComparison.Ordinal);
private static async Task SaveChangesWithRetryAsync(LiveRecorderDbContext dbContext, CancellationToken cancellationToken)
{
for (var attempt = 1; attempt <= 5; attempt++)
@@ -38,6 +38,23 @@ public interface IOpenListClient
OpenListConnectionRequest connection,
string taskId,
CancellationToken cancellationToken = default);
Task<bool> TryCancelCopyTaskAsync(
OpenListConnectionRequest connection,
string taskId,
CancellationToken cancellationToken = default);
Task RenameAsync(
OpenListConnectionRequest connection,
string path,
string newName,
CancellationToken cancellationToken = default);
Task MoveAsync(
OpenListConnectionRequest connection,
string sourcePath,
string targetDirectory,
CancellationToken cancellationToken = default);
}
public sealed record OpenListObjectInfo(
@@ -400,6 +417,67 @@ public sealed class OpenListClient : IOpenListClient
return new OpenListTaskInfo(id, state, progress, status, error);
}
public async Task<bool> TryCancelCopyTaskAsync(
OpenListConnectionRequest connection,
string taskId,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(taskId))
{
return true;
}
var encodedTaskId = Uri.EscapeDataString(taskId.Trim());
var envelope = await SendAuthorizedAsync(
connection,
() => new HttpRequestMessage(HttpMethod.Post, $"/api/task/copy/cancel?tid={encodedTaskId}"),
cancellationToken);
return envelope.Code == 200 || envelope.Code == 404 || ContainsAny(envelope.Message, "not found", "finished");
}
public async Task RenameAsync(
OpenListConnectionRequest connection,
string path,
string newName,
CancellationToken cancellationToken = default)
{
path = NormalizePath(path);
if (string.IsNullOrWhiteSpace(newName) || newName.Contains('/') || newName.Contains('\\'))
{
throw new InvalidOperationException("OpenList 新文件名无效。");
}
var envelope = await SendAuthorizedAsync(
connection,
() => CreateJsonRequest(HttpMethod.Post, "/api/fs/rename", new { path, name = newName.Trim() }),
cancellationToken);
EnsureSuccess(envelope, $"OpenList rename '{path}'");
}
public async Task MoveAsync(
OpenListConnectionRequest connection,
string sourcePath,
string targetDirectory,
CancellationToken cancellationToken = default)
{
sourcePath = NormalizePath(sourcePath);
targetDirectory = NormalizePath(targetDirectory);
var envelope = await SendAuthorizedAsync(
connection,
() => CreateJsonRequest(
HttpMethod.Post,
"/api/fs/move",
new
{
src_dir = GetDirectoryName(sourcePath),
dst_dir = targetDirectory,
names = new[] { GetFileName(sourcePath) },
overwrite = false
}),
cancellationToken);
EnsureSuccess(envelope, $"OpenList move '{sourcePath}' to '{targetDirectory}'");
}
public static string NormalizeBaseUrl(string baseUrl)
{
if (!Uri.TryCreate(baseUrl?.Trim(), UriKind.Absolute, out var uri) ||
@@ -1,5 +1,6 @@
using System.Security.Cryptography;
using LiveRecorder.Application.Abstractions.Logging;
using LiveRecorder.Application.Abstractions.Recording;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Models.RecordTasks;
using LiveRecorder.Application.Models.Settings;
@@ -16,8 +17,11 @@ namespace LiveRecorder.Infrastructure.Services;
public sealed class OpenListUploadQueueService
{
private const int MaxAttempts = 6;
private const int MaxAutomaticRecoveryBatchSize = 100;
private static readonly TimeSpan VerificationTimeout = TimeSpan.FromMinutes(2);
private static readonly TimeSpan ExternalTaskTimeout = TimeSpan.FromHours(24);
private static readonly TimeSpan ExternalTaskStallTimeout = TimeSpan.FromMinutes(60);
private static readonly TimeSpan CleanupRetryDelay = TimeSpan.FromHours(1);
private static readonly TimeSpan[] RetryDelays =
[
TimeSpan.FromMinutes(1),
@@ -31,17 +35,20 @@ public sealed class OpenListUploadQueueService
private readonly ISystemSettingsService _settingsService;
private readonly IOpenListClient _openListClient;
private readonly ISystemLogService _systemLogService;
private readonly IVideoMetadataService _videoMetadataService;
public OpenListUploadQueueService(
LiveRecorderDbContext dbContext,
ISystemSettingsService settingsService,
IOpenListClient openListClient,
ISystemLogService systemLogService)
ISystemLogService systemLogService,
IVideoMetadataService videoMetadataService)
{
_dbContext = dbContext;
_settingsService = settingsService;
_openListClient = openListClient;
_systemLogService = systemLogService;
_videoMetadataService = videoMetadataService;
}
public async Task<RecordArtifactUploadItemResultDto?> TryEnqueueAutomaticAsync(
@@ -59,6 +66,69 @@ public sealed class OpenListUploadQueueService
return await EnqueueInternalAsync(recordTaskId, settings, cancellationToken);
}
public async Task<int> RecoverPendingAutomaticUploadsAsync(
int take = MaxAutomaticRecoveryBatchSize,
CancellationToken cancellationToken = default)
{
var settings = await _settingsService.GetAsync(cancellationToken);
if (!settings.EnableFileUpload ||
!settings.EnableAutoUpload ||
settings.UploadTarget != UploadTargetType.OpenList)
{
return 0;
}
var completedBefore = DateTimeOffset.UtcNow.AddSeconds(-30);
var taskIds = await _dbContext.RecordTasks
.AsNoTracking()
.Where(item =>
(item.Status == RecordTaskStatus.Completed || item.Status == RecordTaskStatus.Stopped) &&
item.Result != null &&
item.Result.UploadStatus == RecordArtifactUploadStatus.NotUploaded &&
item.UploadJob == null &&
item.UpdatedAt <= completedBefore &&
!string.IsNullOrWhiteSpace(item.Result.FilePath))
.OrderBy(static item => item.UpdatedAt)
.Select(static item => item.Id)
.Take(Math.Clamp(take, 1, MaxAutomaticRecoveryBatchSize))
.ToArrayAsync(cancellationToken);
var recovered = 0;
foreach (var taskId in taskIds)
{
try
{
var result = await EnqueueInternalAsync(taskId, settings, cancellationToken);
if (result.Success && result.UploadStatus == RecordArtifactUploadStatus.Queued)
{
recovered++;
}
}
catch (Exception ex)
{
await _systemLogService.WriteAsync(
SystemLogLevel.Warning,
"Upload",
"跳过一个无法恢复的 OpenList 自动上传任务。",
$"recordTaskId={taskId}; error={ex.Message}",
recordTaskId: taskId,
cancellationToken: cancellationToken);
}
}
if (recovered > 0)
{
await _systemLogService.WriteAsync(
SystemLogLevel.Warning,
"Upload",
"Recovered completed recording segments that had not entered the automatic OpenList upload queue.",
$"count={recovered}",
cancellationToken: cancellationToken);
}
return recovered;
}
public async Task<RecordArtifactUploadItemResultDto> EnqueueAsync(
Guid recordTaskId,
CancellationToken cancellationToken = default)
@@ -145,6 +215,16 @@ public sealed class OpenListUploadQueueService
}
var result = job.RecordTask.Result;
var validationError = await GetUploadValidationErrorAsync(
job.RecordTask,
NormalizeAbsolutePath(result.FilePath),
cancellationToken);
if (!string.IsNullOrWhiteSpace(validationError))
{
await MarkFailedAsync(job, result, validationError, cancellationToken);
return true;
}
if (job.Status != RecordArtifactUploadStatus.Uploading)
{
job.BeginAttempt(now);
@@ -206,6 +286,12 @@ public sealed class OpenListUploadQueueService
return Failure(recordTaskId, "本地视频文件不存在,不能加入上传队列。", "openlist");
}
var validationError = await GetUploadValidationErrorAsync(recordTask, localVideoPath, cancellationToken);
if (!string.IsNullOrWhiteSpace(validationError))
{
return Failure(recordTaskId, validationError, "openlist");
}
var outputRoot = Path.GetFullPath(settings.OutputRoot, AppContext.BaseDirectory);
var videoRelativePath = GetSafeRelativePath(outputRoot, localVideoPath);
var sourceVideoPath = OpenListClient.CombinePath(settings.OpenListUpload.SourcePath, videoRelativePath);
@@ -285,6 +371,46 @@ public sealed class OpenListUploadQueueService
return QueuedResult(recordTaskId, result, job, "已加入 OpenList 上传队列。");
}
private async Task<string?> GetUploadValidationErrorAsync(
RecordTask recordTask,
string localVideoPath,
CancellationToken cancellationToken)
{
if (recordTask.Status is not (RecordTaskStatus.Completed or RecordTaskStatus.Stopped))
{
return "只有已完成或已停止且媒体有效的录制任务可以上传。";
}
if (recordTask.OutputFormat == RecordOutputFormat.Mp4 &&
!localVideoPath.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase))
{
return "MP4 收尾未完成,仅保留了中间文件,已阻止自动上传。";
}
if (!File.Exists(localVideoPath) || new FileInfo(localVideoPath).Length <= 0)
{
return "本地视频文件不存在或为空,不能上传。";
}
var metadata = await _videoMetadataService.ExtractMetadataAsync(localVideoPath, cancellationToken);
if (metadata is null)
{
return "ffprobe 无法读取媒体信息,已阻止上传并保留本地文件。";
}
if (string.IsNullOrWhiteSpace(metadata.VideoCodec))
{
return "文件不包含视频流,已阻止上传并保留本地文件。";
}
if (!metadata.DurationSeconds.HasValue || metadata.DurationSeconds.Value < 5)
{
return $"实际媒体时长不足 5 秒({metadata.DurationSeconds.GetValueOrDefault():0.###} 秒),已阻止上传并保留本地文件。";
}
return null;
}
private async Task ProcessJobStepAsync(
RecordUploadJob job,
RecordResult result,
@@ -299,21 +425,34 @@ public sealed class OpenListUploadQueueService
var now = DateTimeOffset.UtcNow;
var targetPath = job.GetCurrentTargetPath();
var transferTargetPath = job.GetCurrentTransferTargetPath();
var expectedSize = job.GetCurrentSizeBytes();
var localPath = GetCurrentLocalPath(job, result);
if (job.VerificationStartedAt.HasValue)
{
var verification = await VerifyTargetAsync(connection, targetPath, localPath, expectedSize, cancellationToken);
var verification = await VerifyTargetAsync(connection, transferTargetPath, localPath, expectedSize, allowSizeOnlyMatch: true, cancellationToken);
if (verification == TargetVerification.Match)
{
if (!string.Equals(transferTargetPath, targetPath, StringComparison.Ordinal))
{
await PromoteStagedTransferAsync(job, connection, transferTargetPath, targetPath, now, cancellationToken);
return;
}
await CompleteCurrentArtifactAsync(job, result, cancellationToken);
return;
}
if (verification == TargetVerification.Conflict)
{
throw new OpenListUploadConflictException($"目标文件 '{targetPath}' 已存在但内容不一致。");
throw new OpenListUploadConflictException($"目标文件 '{transferTargetPath}' 已存在但内容不一致。");
}
if (!string.Equals(transferTargetPath, targetPath, StringComparison.Ordinal) &&
await TryRecoverInterruptedPromotionAsync(job, connection, transferTargetPath, targetPath, localPath, expectedSize, now, cancellationToken))
{
return;
}
if (now - job.VerificationStartedAt.Value < VerificationTimeout)
@@ -321,25 +460,49 @@ public sealed class OpenListUploadQueueService
return;
}
await ScheduleRetryOrFailAsync(job, result, $"OpenList 任务结束后两分钟内仍未发现目标文件 '{targetPath}'。", true, cancellationToken);
await ScheduleRetryOrFailAsync(job, result, $"OpenList 任务结束后两分钟内仍未发现目标文件 '{transferTargetPath}'。", true, cancellationToken);
return;
}
if (!string.IsNullOrWhiteSpace(job.ExternalTaskId))
{
var lastProgressAt = job.LastProgressAt ?? job.ExternalTaskStartedAt;
if (lastProgressAt.HasValue && now - lastProgressAt.Value > ExternalTaskStallTimeout)
{
var cancelled = await _openListClient.TryCancelCopyTaskAsync(connection, job.ExternalTaskId, cancellationToken);
if (cancelled)
{
await ScheduleRetryOrFailAsync(job, result, $"OpenList 复制任务连续 60 分钟没有进度,已取消并准备重试:{job.ExternalTaskId}", true, cancellationToken);
}
else
{
await MarkFailedAsync(job, result, $"OpenList 复制任务连续 60 分钟没有进度且无法取消,需要人工检查:{job.ExternalTaskId}", cancellationToken);
}
return;
}
if (job.ExternalTaskStartedAt.HasValue && now - job.ExternalTaskStartedAt.Value > ExternalTaskTimeout)
{
await ScheduleRetryOrFailAsync(job, result, $"OpenList 复制任务运行超过 24 小时:{job.ExternalTaskId}", true, cancellationToken);
_ = await _openListClient.TryCancelCopyTaskAsync(connection, job.ExternalTaskId, cancellationToken);
await ScheduleRetryOrFailAsync(job, result, $"OpenList 复制任务运行超过 24 小时,已停止跟踪并准备重试:{job.ExternalTaskId}", true, cancellationToken);
return;
}
var task = await _openListClient.TryGetCopyTaskAsync(connection, job.ExternalTaskId, cancellationToken);
if (task is null)
{
var missingTaskVerification = await VerifyTargetAsync(connection, targetPath, localPath, expectedSize, cancellationToken);
var missingTaskVerification = await VerifyTargetAsync(connection, transferTargetPath, localPath, expectedSize, allowSizeOnlyMatch: true, cancellationToken);
if (missingTaskVerification == TargetVerification.Match)
{
await CompleteCurrentArtifactAsync(job, result, cancellationToken);
if (!string.Equals(transferTargetPath, targetPath, StringComparison.Ordinal))
{
await PromoteStagedTransferAsync(job, connection, transferTargetPath, targetPath, now, cancellationToken);
}
else
{
await CompleteCurrentArtifactAsync(job, result, cancellationToken);
}
}
else if (missingTaskVerification == TargetVerification.Conflict)
{
@@ -380,7 +543,7 @@ public sealed class OpenListUploadQueueService
// first target probe so a new recording path can be uploaded normally.
await _openListClient.EnsureDirectoryAsync(connection, GetDirectoryName(targetPath), cancellationToken);
var existingTarget = await VerifyTargetAsync(connection, targetPath, localPath, expectedSize, cancellationToken);
var existingTarget = await VerifyTargetAsync(connection, targetPath, localPath, expectedSize, allowSizeOnlyMatch: false, cancellationToken);
if (existingTarget == TargetVerification.Match)
{
await CompleteCurrentArtifactAsync(job, result, cancellationToken);
@@ -389,7 +552,20 @@ public sealed class OpenListUploadQueueService
if (existingTarget == TargetVerification.Conflict)
{
throw new OpenListUploadConflictException($"目标文件 '{targetPath}' 已存在但内容不一致。");
if (HasTaskConflictSuffix(targetPath, job.RecordTaskId))
{
throw new OpenListUploadConflictException($"目标文件 '{targetPath}' 已存在但内容不一致。");
}
var conflictTargetPath = AppendTaskConflictSuffix(targetPath, job.RecordTaskId);
var stagingDirectory = OpenListClient.CombinePath(
GetDirectoryName(targetPath),
$".liverecorder-staging-{job.Id.ToString("N")[..8]}");
var stagedTargetPath = OpenListClient.CombinePath(stagingDirectory, GetFileName(job.GetCurrentSourcePath()));
await _openListClient.EnsureDirectoryAsync(connection, stagingDirectory, cancellationToken);
job.ResolveCurrentTargetConflict(conflictTargetPath, stagedTargetPath, now);
await _dbContext.SaveChangesAsync(cancellationToken);
return;
}
result.MarkUploadStarted("openlist", now);
@@ -407,7 +583,11 @@ public sealed class OpenListUploadQueueService
throw new InvalidOperationException($"OpenList 源文件大小不一致:期望 {expectedSize},实际 {sourceObject.Size},路径 {sourcePath}");
}
var copyResult = await _openListClient.CopyFileAsync(connection, sourcePath, targetPath, cancellationToken);
if (!string.Equals(GetDirectoryName(transferTargetPath), GetDirectoryName(targetPath), StringComparison.Ordinal))
{
await _openListClient.EnsureDirectoryAsync(connection, GetDirectoryName(transferTargetPath), cancellationToken);
}
var copyResult = await _openListClient.CopyFileAsync(connection, sourcePath, transferTargetPath, cancellationToken);
if (copyResult.TaskIds.Count == 0)
{
job.StartVerification(DateTimeOffset.UtcNow);
@@ -420,6 +600,74 @@ public sealed class OpenListUploadQueueService
await _dbContext.SaveChangesAsync(cancellationToken);
}
private async Task PromoteStagedTransferAsync(
RecordUploadJob job,
OpenListConnectionRequest connection,
string stagedPath,
string targetPath,
DateTimeOffset now,
CancellationToken cancellationToken)
{
var targetFileName = GetFileName(targetPath);
var renamedStagedPath = stagedPath;
if (!string.Equals(GetFileName(stagedPath), targetFileName, StringComparison.Ordinal))
{
await _openListClient.RenameAsync(connection, stagedPath, targetFileName, cancellationToken);
renamedStagedPath = OpenListClient.CombinePath(GetDirectoryName(stagedPath), targetFileName);
job.UpdateTransferTargetPath(renamedStagedPath, DateTimeOffset.UtcNow);
await _dbContext.SaveChangesAsync(cancellationToken);
}
await _openListClient.MoveAsync(connection, renamedStagedPath, GetDirectoryName(targetPath), cancellationToken);
job.CompleteTransferPromotion(now);
job.StartVerification(now);
await _dbContext.SaveChangesAsync(cancellationToken);
}
private async Task<bool> TryRecoverInterruptedPromotionAsync(
RecordUploadJob job,
OpenListConnectionRequest connection,
string stagedPath,
string targetPath,
string localPath,
long expectedSize,
DateTimeOffset now,
CancellationToken cancellationToken)
{
var finalVerification = await VerifyTargetAsync(
connection,
targetPath,
localPath,
expectedSize,
allowSizeOnlyMatch: true,
cancellationToken);
if (finalVerification == TargetVerification.Match)
{
job.CompleteTransferPromotion(now);
job.StartVerification(now);
await _dbContext.SaveChangesAsync(cancellationToken);
return true;
}
var renamedStagedPath = OpenListClient.CombinePath(GetDirectoryName(stagedPath), GetFileName(targetPath));
var renamedVerification = await VerifyTargetAsync(
connection,
renamedStagedPath,
localPath,
expectedSize,
allowSizeOnlyMatch: true,
cancellationToken);
if (renamedVerification != TargetVerification.Match)
{
return false;
}
job.UpdateTransferTargetPath(renamedStagedPath, now);
await _dbContext.SaveChangesAsync(cancellationToken);
await PromoteStagedTransferAsync(job, connection, renamedStagedPath, targetPath, now, cancellationToken);
return true;
}
private async Task CompleteCurrentArtifactAsync(
RecordUploadJob job,
RecordResult result,
@@ -461,6 +709,20 @@ public sealed class OpenListUploadQueueService
{
cleanupWarning = ex.Message;
}
if (!deletedLocalFiles && cleanupWarning is null)
{
cleanupWarning = "本地文件删除后仍然存在。";
}
}
if (cleanupWarning is not null)
{
var nextAttemptAt = now.Add(CleanupRetryDelay);
job.ScheduleRetry($"远端上传已完成,本地清理失败:{cleanupWarning}", nextAttemptAt, now, clearExternalTask: true);
result.MarkUploadWaitingRetry("openlist", job.ErrorMessage, now);
await _dbContext.SaveChangesAsync(cancellationToken);
return;
}
job.MarkSucceeded(now);
@@ -542,6 +804,7 @@ public sealed class OpenListUploadQueueService
string targetPath,
string localPath,
long expectedSize,
bool allowSizeOnlyMatch,
CancellationToken cancellationToken)
{
var remote = await _openListClient.TryGetObjectAsync(connection, targetPath, cancellationToken);
@@ -561,7 +824,7 @@ public sealed class OpenListUploadQueueService
pair.Key.Equals("md5", StringComparison.OrdinalIgnoreCase));
if (string.IsNullOrWhiteSpace(comparableHash.Key) || string.IsNullOrWhiteSpace(comparableHash.Value))
{
return TargetVerification.Match;
return allowSizeOnlyMatch ? TargetVerification.Match : TargetVerification.Conflict;
}
var localHash = await ComputeFileHashAsync(localPath, comparableHash.Key, cancellationToken);
@@ -679,6 +942,26 @@ public sealed class OpenListUploadQueueService
return index <= 0 ? "/" : normalized[..index];
}
private static string GetFileName(string path)
{
var normalized = OpenListClient.NormalizePath(path);
var index = normalized.LastIndexOf('/');
return index < 0 ? normalized : normalized[(index + 1)..];
}
private static string AppendTaskConflictSuffix(string path, Guid recordTaskId)
{
var directory = GetDirectoryName(path);
var fileName = GetFileName(path);
var extension = Path.GetExtension(fileName);
var stem = extension.Length == 0 ? fileName : fileName[..^extension.Length];
return OpenListClient.CombinePath(directory, $"{stem}_{recordTaskId.ToString("N")[..8]}{extension}");
}
private static bool HasTaskConflictSuffix(string path, Guid recordTaskId) =>
Path.GetFileNameWithoutExtension(GetFileName(path))
.EndsWith($"_{recordTaskId.ToString("N")[..8]}", StringComparison.OrdinalIgnoreCase);
private static RecordArtifactUploadItemResultDto Failure(Guid recordTaskId, string message, string provider) => new()
{
RecordTaskId = recordTaskId,
@@ -738,6 +1021,7 @@ public sealed class OpenListUploadQueueService
public sealed class OpenListUploadBackgroundService : BackgroundService
{
private static readonly TimeSpan IdleDelay = TimeSpan.FromSeconds(2);
private static readonly TimeSpan RecoveryInterval = TimeSpan.FromMinutes(1);
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<OpenListUploadBackgroundService> _logger;
@@ -751,21 +1035,21 @@ public sealed class OpenListUploadBackgroundService : BackgroundService
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var nextRecoveryAt = DateTimeOffset.MinValue;
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _scopeFactory.CreateScope();
var queue = scope.ServiceProvider.GetRequiredService<OpenListUploadQueueService>();
var processed = await queue.ProcessNextAsync(stoppingToken);
if (!processed)
_ = await queue.ProcessNextAsync(stoppingToken);
if (DateTimeOffset.UtcNow >= nextRecoveryAt)
{
await Task.Delay(IdleDelay, stoppingToken);
}
else
{
await Task.Delay(IdleDelay, stoppingToken);
await queue.RecoverPendingAutomaticUploadsAsync(cancellationToken: stoppingToken);
nextRecoveryAt = DateTimeOffset.UtcNow.Add(RecoveryInterval);
}
await Task.Delay(IdleDelay, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
@@ -48,6 +48,17 @@ public sealed class RecordSessionCleanupResolver
{
var cutoff = DateTimeOffset.UtcNow.AddDays(-Math.Max(1, filters.RetentionDays));
var nonEmptySessionIds = await ResolveConditionalSessionIdsAsync(filters, cutoff, cancellationToken);
if (filters.RequireUploadSuccess && nonEmptySessionIds.Count > 0)
{
nonEmptySessionIds = await _dbContext.RecordSessions
.AsNoTracking()
.Where(session => nonEmptySessionIds.Contains(session.Id) &&
_dbContext.RecordTasks
.Where(task => task.RecordSessionId == session.Id)
.All(task => task.Result != null && task.Result.UploadStatus == RecordArtifactUploadStatus.Succeeded))
.Select(static session => session.Id)
.ToArrayAsync(cancellationToken);
}
var emptySessionIds = await ResolveEmptySessionIdsAsync(cutoff, cancellationToken);
return nonEmptySessionIds
@@ -75,8 +86,14 @@ public sealed class RecordSessionCleanupResolver
IQueryable<RecordSession> query = _dbContext.RecordSessions
.AsNoTracking()
.Where(static item => item.Status != RecordSessionStatus.Starting &&
item.Status != RecordSessionStatus.Pending &&
item.Status != RecordSessionStatus.Running &&
item.Status != RecordSessionStatus.Stopping)
.Where(item => !_dbContext.RecordTasks.Any(task =>
task.RecordSessionId == item.Id && task.UploadJob != null &&
(task.UploadJob.Status == RecordArtifactUploadStatus.Queued ||
task.UploadJob.Status == RecordArtifactUploadStatus.Uploading ||
task.UploadJob.Status == RecordArtifactUploadStatus.WaitingRetry)))
.Where(item => _dbContext.RecordTasks.Any(task => task.RecordSessionId == item.Id));
if (createdBeforeUtc.HasValue)
@@ -133,6 +150,7 @@ public sealed class RecordSessionCleanupResolver
IQueryable<RecordSession> query = _dbContext.RecordSessions
.AsNoTracking()
.Where(static item => item.Status != RecordSessionStatus.Starting &&
item.Status != RecordSessionStatus.Pending &&
item.Status != RecordSessionStatus.Running &&
item.Status != RecordSessionStatus.Stopping)
.Where(item => !_dbContext.RecordTasks.Any(task => task.RecordSessionId == item.Id));
@@ -153,6 +171,7 @@ public sealed class RecordSessionCleanupResolver
var activeSessionIds = await _dbContext.RecordSessions
.AsNoTracking()
.Where(static item => item.Status == RecordSessionStatus.Starting ||
item.Status == RecordSessionStatus.Pending ||
item.Status == RecordSessionStatus.Running ||
item.Status == RecordSessionStatus.Stopping)
.Select(static item => item.Id)
@@ -46,13 +46,19 @@ public sealed class RecoveryService
Storage = new StorageGuardStatusDto
{
IsEnabled = storage.IsEnabled,
IsAvailable = storage.IsAvailable,
HasEnoughSpace = storage.HasEnoughSpace,
CheckedPath = storage.CheckedPath,
TotalBytes = storage.TotalBytes,
UsedBytes = storage.UsedBytes,
AvailableBytes = storage.AvailableBytes,
RequiredBytes = storage.RequiredBytes,
Message = storage.Message,
Tier = storage.Tier.ToString(),
UsagePercent = storage.UsagePercent
UsagePercent = storage.UsagePercent,
FreePercent = storage.FreePercent,
GreenThresholdPercent = storage.GreenThresholdPercent,
RedThresholdPercent = storage.RedThresholdPercent
},
LiveRooms = liveRooms,
Finalizations = finalizations
@@ -64,17 +70,17 @@ public sealed class RecoveryService
var room = await _dbContext.LiveRooms.FirstOrDefaultAsync(item => item.Id == liveRoomId, cancellationToken);
if (room is null)
{
return FailureResult("Live room was not found.");
return FailureResult("未找到该直播间。");
}
if (!room.IsEnabled)
{
return FailureResult("Live room is disabled and cannot be retried.");
return FailureResult("该直播间已禁用,无法重试开录。");
}
if (room.AvailabilityStatus != LiveRoomAvailabilityStatus.Live)
{
return FailureResult("Live room is not currently online.");
return FailureResult("该直播间当前未开播,无法重试开录。");
}
var hasActiveSession = await _dbContext.RecordSessions.AnyAsync(
@@ -88,11 +94,11 @@ public sealed class RecoveryService
{
room.SetLastAutoStartDecision(
AutoStartDecisionCodes.SkippedActiveSession,
"Auto-start skipped because an active recording session already exists.",
"已有活动录制会话,本次自动开录已跳过。",
null,
DateTimeOffset.UtcNow);
await _dbContext.SaveChangesAsync(cancellationToken);
return FailureResult("An active recording session already exists for this live room.");
return FailureResult("该直播间已经存在活动录制会话。");
}
try
@@ -114,14 +120,14 @@ public sealed class RecoveryService
Messages =
[
started
? $"Recording retry started for room {room.RoomId}."
: $"Recording retry did not start for room {room.RoomId}. Status={task.Status}; Error={task.ErrorMessage ?? "n/a"}"
? $"直播间 {room.RoomId} 已开始重试录制。"
: $"直播间 {room.RoomId} 未能开始录制。状态={task.Status};错误={task.ErrorMessage ?? ""}"
]
};
}
catch (Exception ex)
{
return FailureResult($"Recording retry failed for room {room.RoomId}: {ex.Message}");
return FailureResult($"直播间 {room.RoomId} 重试录制失败:{ex.Message}");
}
}
@@ -135,7 +141,7 @@ public sealed class RecoveryService
RequestedCount = 0,
SuccessCount = 0,
FailedCount = 0,
Messages = ["No live rooms currently require retry."]
Messages = ["当前没有需要重试开录的直播间。"]
};
}
@@ -159,7 +165,8 @@ public sealed class RecoveryService
public async Task<RecoveryActionResultDto> ResumeFinalizationAsync(Guid recordTaskId, CancellationToken cancellationToken = default)
{
var started = await _ffmpegService.StartManualFinalizeTaskAsync(recordTaskId, cancellationToken);
var recoveredOrphan = await _ffmpegService.TryRecoverOrphanedTerminalTaskAsync(recordTaskId, cancellationToken);
var started = recoveredOrphan || await _ffmpegService.StartManualFinalizeTaskAsync(recordTaskId, cancellationToken);
return new RecoveryActionResultDto
{
RequestedCount = 1,
@@ -168,8 +175,10 @@ public sealed class RecoveryService
Messages =
[
started
? $"MP4 finalization resumed for task {recordTaskId}."
: $"MP4 finalization could not be resumed for task {recordTaskId}."
? recoveredOrphan
? $"任务 {recordTaskId} 的遗留分片已恢复,并已进入后续上传流程。"
: $"任务 {recordTaskId} 已恢复 MP4 转码。"
: $"任务 {recordTaskId} 无法恢复 MP4 转码。"
]
};
}
@@ -184,7 +193,7 @@ public sealed class RecoveryService
RequestedCount = 0,
SuccessCount = 0,
FailedCount = 0,
Messages = ["No MP4 finalization tasks currently require recovery."]
Messages = ["当前没有需要恢复的 MP4 转码任务。"]
};
}
@@ -192,7 +201,8 @@ public sealed class RecoveryService
var messages = new List<string>();
foreach (var item in finalizations)
{
var started = await _ffmpegService.StartManualFinalizeTaskAsync(item.RecordTaskId, cancellationToken);
var recoveredOrphan = await _ffmpegService.TryRecoverOrphanedTerminalTaskAsync(item.RecordTaskId, cancellationToken);
var started = recoveredOrphan || await _ffmpegService.StartManualFinalizeTaskAsync(item.RecordTaskId, cancellationToken);
if (started)
{
successCount++;
@@ -200,8 +210,10 @@ public sealed class RecoveryService
messages.Add(
started
? $"MP4 finalization resumed for task {item.RecordTaskId}."
: $"MP4 finalization could not be resumed for task {item.RecordTaskId}.");
? recoveredOrphan
? $"任务 {item.RecordTaskId} 的遗留分片已恢复。"
: $"任务 {item.RecordTaskId} 已恢复 MP4 转码。"
: $"任务 {item.RecordTaskId} 无法恢复 MP4 转码。");
}
return new RecoveryActionResultDto
@@ -290,16 +302,6 @@ public sealed class RecoveryService
return false;
}
if (recordTask.Status == RecordTaskStatus.Processing)
{
return true;
}
if (recordTask.Status is RecordTaskStatus.Starting or RecordTaskStatus.Running or RecordTaskStatus.Stopping)
{
return false;
}
if (recordTask.RecordSession.Status is RecordSessionStatus.Starting or RecordSessionStatus.Running or RecordSessionStatus.Stopping)
{
return false;
@@ -310,8 +312,9 @@ public sealed class RecoveryService
return false;
}
var manifestSources = FfmpegService.ReadRecorderSegmentsManifest(recordTask.OutputFilePath);
var recorderOutputPath = ResolveManualFinalizeSourcePath(recordTask, recordTask.RecordSession);
if (!File.Exists(recorderOutputPath))
if (manifestSources.Count == 0 && !File.Exists(recorderOutputPath))
{
return false;
}
@@ -330,16 +333,21 @@ public sealed class RecoveryService
if (recordTask.Status == RecordTaskStatus.Processing)
{
return string.IsNullOrWhiteSpace(recordTask.ErrorMessage)
? "MP4 finalization is queued or paused and can be resumed."
? "MP4 转码正在排队或已暂停,可以继续恢复。"
: recordTask.ErrorMessage!;
}
if (recordTask.Status is RecordTaskStatus.Starting or RecordTaskStatus.Running or RecordTaskStatus.Stopping)
{
return "父会话已经结束,但该分片仍显示为录制中;可安全对账并恢复转码、上传。";
}
if (recordTask.Status == RecordTaskStatus.Completed)
{
return "The final MP4 output is missing, but the intermediate recording file is still available.";
return "最终 MP4 文件缺失,但中间录制文件仍然存在。";
}
return "Manual MP4 finalization can be retried from the intermediate recording file.";
return "可以使用保留的中间录制文件重新执行 MP4 转码。";
}
private static string ResolveManualFinalizeSourcePath(RecordTask recordTask, RecordSession recordSession)
@@ -15,77 +15,92 @@ public sealed class StorageGuardService : IStorageGuardService
}
public StorageGuardResult CheckCanStartOrResume(SystemSettingsDto settings, long additionalRequiredBytes = 0) =>
Check(settings, Math.Max(settings.PauseRecordingWhenFreeSpaceBelowMegabytes, settings.ResumeRecordingWhenFreeSpaceAboveMegabytes), additionalRequiredBytes);
Check(
settings,
Math.Max(settings.PauseRecordingWhenFreeSpaceBelowMegabytes, settings.ResumeRecordingWhenFreeSpaceAboveMegabytes),
additionalRequiredBytes);
public StorageGuardResult CheckShouldPause(SystemSettingsDto settings) =>
Check(settings, settings.PauseRecordingWhenFreeSpaceBelowMegabytes, additionalRequiredBytes: 0);
public StorageGuardResult CheckCanFinalize(SystemSettingsDto settings, long estimatedTemporaryBytes) =>
Check(settings, settings.PauseRecordingWhenFreeSpaceBelowMegabytes, Math.Max(0, estimatedTemporaryBytes));
private StorageGuardResult Check(SystemSettingsDto settings, int freeSpaceThresholdMegabytes, long additionalRequiredBytes)
{
if (!settings.EnableStorageGuard)
{
return new StorageGuardResult(false, true, ResolveOutputRoot(settings.OutputRoot), long.MaxValue, 0, "Storage guard is disabled.")
{
Tier = StorageTier.Green,
UsagePercent = 0
};
}
var checkedPath = ResolveOutputRoot(settings.OutputRoot);
var thresholdBytes = Math.Max(0, freeSpaceThresholdMegabytes) * Megabyte;
var requiredBytes = thresholdBytes + Math.Max(0, additionalRequiredBytes);
var isEnabled = settings.EnableStorageGuard;
var pauseThresholdBytes = isEnabled
? Math.Max(0, settings.PauseRecordingWhenFreeSpaceBelowMegabytes) * Megabyte
: 0;
var resumeThresholdBytes = isEnabled
? Math.Max(settings.PauseRecordingWhenFreeSpaceBelowMegabytes, settings.ResumeRecordingWhenFreeSpaceAboveMegabytes) * Megabyte
: 0;
var thresholdBytes = isEnabled ? Math.Max(0, freeSpaceThresholdMegabytes) * Megabyte : 0;
var requiredBytes = thresholdBytes + (isEnabled ? Math.Max(0, additionalRequiredBytes) : 0);
var greenThreshold = Math.Clamp(settings.StorageGreenThresholdPercent, 10, 90);
var redThreshold = Math.Clamp(settings.StorageRedThresholdPercent, 5, greenThreshold - 5);
var checkedPath = settings.OutputRoot?.Trim() ?? string.Empty;
try
{
checkedPath = ResolveOutputRoot(settings.OutputRoot ?? string.Empty);
var drive = new DriveInfo(Path.GetPathRoot(checkedPath) ?? checkedPath);
var availableBytes = drive.AvailableFreeSpace;
var totalBytes = drive.TotalSize;
var usedBytes = Math.Max(0, totalBytes - availableBytes);
var usagePercent = totalBytes > 0 ? (double)usedBytes / totalBytes * 100.0 : 0;
var freePercent = 100.0 - usagePercent;
// Determine tier using configurable thresholds
var greenThreshold = Math.Clamp(settings.StorageGreenThresholdPercent, 5, 90);
var redThreshold = Math.Clamp(settings.StorageRedThresholdPercent, 1, greenThreshold - 1);
var freePercent = totalBytes > 0 ? (double)availableBytes / totalBytes * 100.0 : 0;
StorageTier tier;
if (freePercent >= greenThreshold)
{
tier = StorageTier.Green;
}
else if (freePercent >= redThreshold)
{
tier = StorageTier.Yellow;
}
else
if (freePercent < redThreshold || availableBytes < pauseThresholdBytes)
{
tier = StorageTier.Red;
}
var hasEnoughSpace = availableBytes >= requiredBytes;
var message = tier switch
else if (freePercent >= greenThreshold && availableBytes >= resumeThresholdBytes)
{
StorageTier.Green => $"Storage is healthy. free={FormatBytes(availableBytes)} ({freePercent:F1}%), required={FormatBytes(requiredBytes)}, path={checkedPath}",
StorageTier.Yellow => $"Storage is low. free={FormatBytes(availableBytes)} ({freePercent:F1}%), required={FormatBytes(requiredBytes)}, path={checkedPath}. New recordings paused, existing recordings continue.",
StorageTier.Red => $"Storage is critically low. free={FormatBytes(availableBytes)} ({freePercent:F1}%), required={FormatBytes(requiredBytes)}, path={checkedPath}. All recordings paused, uploads continue.",
tier = StorageTier.Green;
}
else
{
tier = StorageTier.Yellow;
}
var hasEnoughSpace = !isEnabled || availableBytes >= requiredBytes;
var message = !isEnabled
? $"存储保护已关闭。可用={FormatBytes(availableBytes)}{freePercent:F1}%),路径={checkedPath}"
: tier switch
{
StorageTier.Green => $"存储空间正常。可用={FormatBytes(availableBytes)}{freePercent:F1}%),本次需要={FormatBytes(requiredBytes)},路径={checkedPath}",
StorageTier.Yellow => $"存储空间偏低。可用={FormatBytes(availableBytes)}{freePercent:F1}%),本次需要={FormatBytes(requiredBytes)},路径={checkedPath}。暂停新录制,已有录制继续。",
StorageTier.Red => $"存储空间严重不足。可用={FormatBytes(availableBytes)}{freePercent:F1}%),本次需要={FormatBytes(requiredBytes)},路径={checkedPath}。暂停录制;空间足够保留安全余量时仍允许 MP4 收尾,上传继续。",
_ => hasEnoughSpace
? $"Storage is available. free={FormatBytes(availableBytes)}, required={FormatBytes(requiredBytes)}, path={checkedPath}"
: $"Storage is below threshold. free={FormatBytes(availableBytes)}, required={FormatBytes(requiredBytes)}, path={checkedPath}"
? $"存储空间可用。可用={FormatBytes(availableBytes)},本次需要={FormatBytes(requiredBytes)},路径={checkedPath}"
: $"存储空间低于阈值。可用={FormatBytes(availableBytes)},本次需要={FormatBytes(requiredBytes)},路径={checkedPath}"
};
return new StorageGuardResult(true, hasEnoughSpace, checkedPath, availableBytes, requiredBytes, message)
return new StorageGuardResult(isEnabled, hasEnoughSpace, checkedPath, availableBytes, requiredBytes, message)
{
IsAvailable = totalBytes > 0,
Tier = tier,
UsagePercent = Math.Round(usagePercent, 1)
TotalBytes = totalBytes,
UsedBytes = usedBytes,
UsagePercent = Math.Round(Math.Clamp(usagePercent, 0, 100), 1),
FreePercent = Math.Round(Math.Clamp(freePercent, 0, 100), 1),
GreenThresholdPercent = greenThreshold,
RedThresholdPercent = redThreshold
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Storage guard failed to inspect output root {OutputRoot}", checkedPath);
return new StorageGuardResult(true, false, checkedPath, 0, requiredBytes, $"Unable to inspect storage path {checkedPath}: {ex.Message}")
return new StorageGuardResult(isEnabled, !isEnabled, checkedPath, 0, requiredBytes, $"无法检查存储路径 {checkedPath}{ex.Message}")
{
IsAvailable = false,
Tier = StorageTier.Red,
UsagePercent = 0
UsagePercent = 0,
FreePercent = 0,
GreenThresholdPercent = greenThreshold,
RedThresholdPercent = redThreshold
};
}
}
@@ -0,0 +1,81 @@
using LiveRecorder.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace LiveRecorder.Infrastructure.Services;
public sealed class SystemLogRetentionBackgroundService : BackgroundService
{
private const int BatchSize = 1000;
private static readonly TimeSpan Retention = TimeSpan.FromDays(90);
private static readonly TimeSpan Interval = TimeSpan.FromHours(24);
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<SystemLogRetentionBackgroundService> _logger;
public SystemLogRetentionBackgroundService(
IServiceScopeFactory scopeFactory,
ILogger<SystemLogRetentionBackgroundService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
var deleted = await DeleteExpiredLogsAsync(stoppingToken);
if (deleted > 0)
{
_logger.LogInformation("Deleted {LogCount} system log entries older than 90 days.", deleted);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "System log retention cleanup failed.");
}
await Task.Delay(Interval, stoppingToken);
}
}
private async Task<int> DeleteExpiredLogsAsync(CancellationToken cancellationToken)
{
var cutoff = DateTimeOffset.UtcNow.Subtract(Retention);
var total = 0;
while (!cancellationToken.IsCancellationRequested)
{
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
var ids = await dbContext.SystemLogEntries
.AsNoTracking()
.Where(log => log.CreatedAt < cutoff)
.OrderBy(static log => log.CreatedAt)
.Select(static log => log.Id)
.Take(BatchSize)
.ToArrayAsync(cancellationToken);
if (ids.Length == 0)
{
return total;
}
total += await dbContext.SystemLogEntries
.Where(log => ids.Contains(log.Id))
.ExecuteDeleteAsync(cancellationToken);
if (ids.Length < BatchSize)
{
return total;
}
}
return total;
}
}
@@ -14,8 +14,7 @@ namespace LiveRecorder.WebApi.Controllers;
[Route("api/record-sessions")]
public sealed class RecordSessionsController : ControllerBase
{
private static readonly TimeSpan StreamInterval = TimeSpan.FromSeconds(2);
private static readonly JsonSerializerOptions StreamJsonOptions = new(JsonSerializerDefaults.Web);
private static readonly TimeSpan StreamInterval = TimeSpan.FromSeconds(5);
private readonly RecordSessionService _recordSessionService;
private readonly RecordUploadService _recordUploadService;
private readonly CleanupOperationCoordinator _cleanupOperationCoordinator;
@@ -37,6 +36,16 @@ public sealed class RecordSessionsController : ControllerBase
public async Task<ActionResult<IReadOnlyList<RecordSessionDto>>> List([FromQuery] Guid? liveRoomId, CancellationToken cancellationToken) =>
Ok(await _recordSessionService.ListAsync(liveRoomId, cancellationToken));
[HttpGet("page")]
public async Task<ActionResult<RecordSessionListResponse>> ListPage(
[FromQuery] Guid? liveRoomId,
[FromQuery] string? state,
[FromQuery] string? search,
[FromQuery] int skip = 0,
[FromQuery] int take = 20,
CancellationToken cancellationToken = default) =>
Ok(await _recordSessionService.ListPageAsync(liveRoomId, state, search, skip, take, cancellationToken));
[HttpGet("stream")]
public async Task Stream([FromQuery] Guid? liveRoomId, CancellationToken cancellationToken)
{
@@ -45,28 +54,18 @@ public sealed class RecordSessionsController : ControllerBase
Response.Headers.Append("X-Accel-Buffering", "no");
Response.ContentType = "text/event-stream";
string? lastPayload = null;
try
{
await Response.WriteAsync($": connected {DateTimeOffset.UtcNow:O}{Environment.NewLine}{Environment.NewLine}", cancellationToken);
await Response.Body.FlushAsync(cancellationToken);
while (!cancellationToken.IsCancellationRequested)
{
var sessions = await _recordSessionService.ListAsync(liveRoomId, cancellationToken);
var payload = JsonSerializer.Serialize(sessions, StreamJsonOptions);
if (!string.Equals(payload, lastPayload, StringComparison.Ordinal))
{
await Response.WriteAsync($"event: sessions{Environment.NewLine}", cancellationToken);
await Response.WriteAsync($"data: {payload}{Environment.NewLine}{Environment.NewLine}", cancellationToken);
lastPayload = payload;
}
else
{
await Response.WriteAsync($": keepalive {DateTimeOffset.UtcNow:O}{Environment.NewLine}{Environment.NewLine}", cancellationToken);
}
await Response.Body.FlushAsync(cancellationToken);
await Task.Delay(StreamInterval, cancellationToken);
var payload = JsonSerializer.Serialize(new { at = DateTimeOffset.UtcNow });
await Response.WriteAsync($"event: refresh{Environment.NewLine}", cancellationToken);
await Response.WriteAsync($"data: {payload}{Environment.NewLine}{Environment.NewLine}", cancellationToken);
await Response.Body.FlushAsync(cancellationToken);
}
}
catch (OperationCanceledException)
@@ -53,7 +53,8 @@ public sealed class RecordTasksController : ControllerBase
var items = await _recordResultRepository.ListUploadStatusAsync(filter, skip, take, cancellationToken);
var totalCount = await _recordResultRepository.CountUploadStatusAsync(filter, cancellationToken);
var notUploadedCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.NotUploaded, cancellationToken);
var notUploadedCount = await _recordResultRepository.CountPendingUploadAsync(cancellationToken);
var failedArtifactCount = await _recordResultRepository.CountFailedArtifactAsync(cancellationToken);
var succeededCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.Succeeded, cancellationToken);
var failedCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.Failed, cancellationToken);
var queuedCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.Queued, cancellationToken);
@@ -65,6 +66,7 @@ public sealed class RecordTasksController : ControllerBase
Items = items.Select(MapUploadTaskItem).ToList(),
TotalCount = totalCount,
NotUploadedCount = notUploadedCount,
FailedArtifactCount = failedArtifactCount,
SucceededCount = succeededCount,
FailedCount = failedCount,
QueuedCount = queuedCount,
+34
View File
@@ -37,6 +37,7 @@ using Microsoft.OpenApi.Models;
using Npgsql;
var builder = WebApplication.CreateBuilder(args);
builder.Host.ConfigureHostOptions(options => options.ShutdownTimeout = TimeSpan.FromSeconds(45));
var resetRecordingData = args.Contains("--reset-recording-data", StringComparer.OrdinalIgnoreCase);
var migrateSqlitePath = GetOptionValue(args, "--migrate-sqlite");
var corsOrigins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? ["http://localhost:5173"];
@@ -180,7 +181,9 @@ builder.Services.AddScoped<ILiveRoomRepository, LiveRoomRepository>();
builder.Services.AddScoped<IRecordSessionRepository, RecordSessionRepository>();
builder.Services.AddScoped<IRecordTaskRepository, RecordTaskRepository>();
builder.Services.AddScoped<IRecordResultRepository, RecordResultRepository>();
builder.Services.AddScoped<IRecordingStartLock, PostgresRecordingStartLock>();
builder.Services.AddScoped<ISystemLogRepository, SystemLogRepository>();
builder.Services.AddScoped<IOperationsMetricsRepository, OperationsMetricsRepository>();
builder.Services.AddScoped<IUserAccountRepository, UserAccountRepository>();
builder.Services.AddScoped<IUserSessionRepository, UserSessionRepository>();
@@ -207,6 +210,7 @@ builder.Services.AddScoped<StoppedOrphanRecordSessionCleanupService>();
builder.Services.AddScoped<PlatformHttpClientFactory>();
builder.Services.AddScoped<PlatformHttpRequestService>();
builder.Services.AddScoped<RecordUploadService>();
builder.Services.AddScoped<CompletionDispatchService>();
builder.Services.AddScoped<OpenListUploadQueueService>();
builder.Services.AddSingleton<IOpenListClient, OpenListClient>();
builder.Services.AddScoped<IDanmakuService, DanmakuService>();
@@ -241,10 +245,13 @@ builder.Services.AddSingleton<IStorageGuardService, StorageGuardService>();
builder.Services.AddScoped<IRecordMediaService, RecordMediaService>();
builder.Services.AddSingleton<LiveRoomPollingBackgroundService>();
builder.Services.AddSingleton<ILiveRoomPollingSignal>(provider => provider.GetRequiredService<LiveRoomPollingBackgroundService>());
builder.Services.AddHostedService<FfmpegShutdownHostedService>();
builder.Services.AddHostedService(provider => provider.GetRequiredService<LiveRoomPollingBackgroundService>());
builder.Services.AddHostedService<CleanupOperationBackgroundService>();
builder.Services.AddHostedService<RetentionCleanupBackgroundService>();
builder.Services.AddHostedService<OpenListUploadBackgroundService>();
builder.Services.AddHostedService<CompletionDispatchBackgroundService>();
builder.Services.AddHostedService<SystemLogRetentionBackgroundService>();
var app = builder.Build();
var webRootPath = app.Environment.WebRootPath ?? Path.Combine(app.Environment.ContentRootPath, "wwwroot");
@@ -324,6 +331,33 @@ app.MapGet("/health/ready", async (CancellationToken cancellationToken) =>
}
});
app.MapGet("/api/operations/health", async (
IOperationsMetricsRepository metricsRepository,
ISystemSettingsService settingsService,
IStorageGuardService storageGuardService,
CancellationToken cancellationToken) =>
{
var metrics = await metricsRepository.GetAsync(cancellationToken);
var settings = await settingsService.GetAsync(cancellationToken);
var storage = storageGuardService.CheckCanStartOrResume(settings);
var degraded = storage.Tier == StorageTier.Red ||
metrics.StalledUploadCount > 0 ||
metrics.CleanupFailureCount > 0;
return Results.Ok(new
{
status = degraded ? "degraded" : "healthy",
timestamp = DateTimeOffset.UtcNow,
storage = new
{
tier = storage.Tier.ToString(),
storage.FreePercent,
storage.AvailableBytes,
storage.Message
},
operations = metrics
});
});
using (var scope = app.Services.CreateScope())
{
var initializer = scope.ServiceProvider.GetRequiredService<DatabaseInitializer>();
@@ -315,7 +315,7 @@ public sealed class PostgresAdminService
{
await using var connection = await OpenAsync("postgres", cancellationToken);
await using var command = new NpgsqlCommand("""
SELECT pid, COALESCE(datname, ''), usename, COALESCE(state, ''),
SELECT pid, COALESCE(datname, ''), COALESCE(usename, ''), COALESCE(state, ''),
left(query, 1000), query_start
FROM pg_stat_activity
WHERE pid <> pg_backend_pid()
@@ -0,0 +1,329 @@
using LiveRecorder.Domain.Enums;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Infrastructure.Services;
using System.Diagnostics;
namespace LiveRecorder.Tests;
public sealed class FfmpegFailureClassificationTests
{
[Fact]
public void FfprobeProcess_DoesNotInheritBundledCurlLibraries()
{
var startInfo = new ProcessStartInfo();
startInfo.Environment["LD_LIBRARY_PATH"] = "/app/runtime/lib";
FfmpegVideoMetadataService.SanitizeFfprobeProcessEnvironment(startInfo);
Assert.False(startInfo.Environment.ContainsKey("LD_LIBRARY_PATH"));
}
[Fact]
public void FfmpegHeaderFallback_ParsesValidMediaMetadata()
{
const string output = """
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from '/records/sample.mp4':
Duration: 00:02:36.64, start: 0.090000, bitrate: 2788 kb/s
Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(tv, progressive), 1088x1920, 2668 kb/s, 22 fps, 22 tbr, 90k tbn (default)
Stream #0:1[0x2](und): Audio: aac (LC) (mp4a / 0x6134706D), 48000 Hz, stereo, fltp, 115 kb/s (default)
Stream mapping:
""";
var metadata = FfmpegVideoMetadataService.ParseFfmpegHeaderOutput(output);
Assert.NotNull(metadata);
Assert.Equal(156.64, metadata.DurationSeconds);
Assert.Equal(1088, metadata.Width);
Assert.Equal(1920, metadata.Height);
Assert.Equal("h264", metadata.VideoCodec);
Assert.Equal("aac", metadata.AudioCodec);
Assert.Equal(22, metadata.FrameRate);
Assert.Equal(2_788_000, metadata.BitRate);
}
[Theory]
[InlineData("")]
[InlineData("Duration: N/A")]
[InlineData("Duration: 00:00:00.00, bitrate: N/A")]
public void FfmpegHeaderFallback_RejectsUnreadableMedia(string output)
{
Assert.Null(FfmpegVideoMetadataService.ParseFfmpegHeaderOutput(output));
}
[Theory]
[InlineData("pipe:0: Invalid data found when processing input")]
[InlineData("Error opening input file pipe:0.")]
[InlineData("Error writing trailer: Invalid argument")]
[InlineData("Error muxing a packet")]
[InlineData("Conversion failed!")]
public void RecoverableFfmpegLines_ArePersistedAsWarnings(string line)
{
Assert.True(FfmpegService.IsRecoverableFfmpegWarningLine(line));
Assert.True(FfmpegService.TryClassifyPersistedFfmpegLine(line, isError: true, out var level));
Assert.Equal(SystemLogLevel.Warning, level);
}
[Theory]
[InlineData("Error writing trailer: Invalid argument")]
[InlineData("Error muxing a packet")]
[InlineData("Conversion failed!")]
public void MuxingFailures_TriggerRepairTranscodeFallback(string detail)
{
Assert.True(FfmpegService.IsRepairableMp4FinalizeError(detail));
}
[Theory]
[InlineData("Application provided invalid, non monotonically increasing dts to muxer in stream 1")]
[InlineData("Non-monotonous DTS in output stream 0:1")]
public void TimestampDiscontinuityFailures_EnableTimestampRepair(string line)
{
Assert.True(FfmpegService.IsTimestampDiscontinuityFailureLine(line));
}
[Theory]
[InlineData("Error submitting a packet to the muxer: Invalid argument")]
[InlineData("Error muxing a packet")]
[InlineData("Task finished with error code: -22")]
public void TimestampRepairMuxerFailures_EnableTranscodeFallback(string line)
{
Assert.True(FfmpegService.IsTimestampMuxerFailureLine(line));
}
[Theory]
[InlineData(0.2, 85_000, false)]
[InlineData(5, 85_000, true)]
[InlineData(0.2, 1_048_576, false)]
public void UnexpectedExitArtifacts_RequireMeaningfulMediaDuration(
double durationSeconds,
long fileSizeBytes,
bool expected)
{
Assert.Equal(
expected,
FfmpegService.IsMeaningfulUnexpectedExitArtifact(durationSeconds, fileSizeBytes));
}
[Theory]
[InlineData("https://example.test/live.flv", "flv", true)]
[InlineData("https://example.test/live.m3u8", "hls", false)]
[InlineData("https://example.test/playlist", "hls", false)]
public void HttpInput_UsesCurlExceptForHls(string url, string protocol, bool expected)
{
Assert.Equal(expected, FfmpegService.ShouldUseCurlPipe(url, protocol));
}
[Fact]
public void NativeHlsInput_PreservesRequestHeaders()
{
var headers = new StreamInputHeaders(
"RecorderTest/1.0",
"https://live.example.test/room",
"session=abc",
new Dictionary<string, string> { ["X-Test"] = "yes" });
var arguments = FfmpegService.BuildArgumentList(
"https://cdn.example.test/live/index.m3u8",
"/tmp/output.ts",
RecordOutputFormat.Ts,
RecordSaveMode.SingleFile,
1,
RecordingTemplateType.StreamCopy,
true,
10,
30_000_000,
30,
headers,
"hls",
"h264",
FfmpegService.FfmpegInputOptionProfile.Baseline);
Assert.Contains("https://cdn.example.test/live/index.m3u8", arguments);
Assert.DoesNotContain("pipe:0", arguments);
Assert.Contains("-user_agent", arguments);
Assert.Contains("RecorderTest/1.0", arguments);
Assert.Contains("-referer", arguments);
Assert.Contains(arguments, item => item.Contains("Cookie: session=abc", StringComparison.Ordinal));
Assert.Contains(arguments, item => item.Contains("X-Test: yes", StringComparison.Ordinal));
}
[Fact]
public void TimestampTranscodeProfile_RebuildsVideoAndAudioTimestamps()
{
var arguments = FfmpegService.BuildArgumentList(
"https://cdn.example.test/live.flv",
"/tmp/output.ts",
RecordOutputFormat.Ts,
RecordSaveMode.Segmented,
1,
RecordingTemplateType.StreamCopy,
true,
10,
30_000_000,
30,
null,
"flv",
"h264",
FfmpegService.FfmpegInputOptionProfile.TimestampTranscode);
Assert.Contains("settb=AVTB,setpts=PTS-STARTPTS", arguments);
Assert.Contains("libx264", arguments);
Assert.Contains("aresample=async=1:first_pts=0,asetpts=PTS-STARTPTS", arguments);
Assert.Contains("make_zero", arguments);
Assert.Contains("-reset_timestamps", arguments);
}
[Fact]
public void RecoveryContext_PreservesBudgetAcrossHlsFallbackAndReachesTimestampTranscode()
{
var repairProfile = FfmpegService.ResolveRetryInputOptionProfile(
FfmpegService.FfmpegInputOptionProfile.Baseline,
hasTimestampDiscontinuityFailure: true,
hasTimestampMuxerFailure: false);
var repairHls = FfmpegService.AdvanceRecoveryContext(
FfmpegService.InitialRecoveryContext,
repairProfile,
"flv",
"hls");
Assert.Equal(FfmpegService.FfmpegInputOptionProfile.TimestampRepair, repairHls.InputOptionProfile);
Assert.Equal(1, repairHls.AttemptCount);
Assert.True(repairHls.HasRetriedWithAlternateProtocol);
Assert.True(FfmpegService.ShouldImmediatelyFallbackFromHls("hls", hasHlsOverlongHeadersFailure: true));
var repairFlv = FfmpegService.AdvanceRecoveryContext(
repairHls,
repairHls.InputOptionProfile,
"hls",
"flv");
var transcodeProfile = FfmpegService.ResolveRetryInputOptionProfile(
repairFlv.InputOptionProfile,
hasTimestampDiscontinuityFailure: false,
hasTimestampMuxerFailure: true);
var transcodeFlv = FfmpegService.AdvanceRecoveryContext(
repairFlv,
transcodeProfile,
"flv",
"flv");
Assert.Equal(2, repairFlv.AttemptCount);
Assert.Equal(3, transcodeFlv.AttemptCount);
Assert.Equal(FfmpegService.FfmpegInputOptionProfile.TimestampTranscode, transcodeFlv.InputOptionProfile);
Assert.True(transcodeFlv.HasRetriedWithAlternateProtocol);
Assert.False(transcodeFlv.HasRetriedWithRefreshedStream);
}
[Theory]
[InlineData(RecordSessionStatus.Failed, 0, true)]
[InlineData(RecordSessionStatus.Failed, 59, true)]
[InlineData(RecordSessionStatus.Failed, 60, false)]
[InlineData(RecordSessionStatus.Completed, 1, false)]
public void RuntimeFailureBackoff_UsesProcessRuntimeInsteadOfMediaDuration(
RecordSessionStatus status,
int processRuntimeSeconds,
bool expected)
{
Assert.Equal(
expected,
FfmpegService.ShouldApplyRuntimeFailureBackoff(status, TimeSpan.FromSeconds(processRuntimeSeconds)));
}
[Theory]
[InlineData(true, false, false, 2)]
[InlineData(false, true, true, 0)]
[InlineData(false, true, false, 3)]
public void DeploymentShutdown_ClassifiesFinalizedAndRecoverableArtifactsWithoutFalseFailures(
bool mediaValid,
bool hasFinalizationError,
bool hasRecoverableIntermediateOutput,
int expected)
{
var disposition = FfmpegService.ClassifyExitedRecording(
shutdownRequested: true,
stopRequested: false,
finalizationPaused: false,
hasFinalizationError,
mediaValid,
exitCode: 0,
completionRequested: true,
hasUsableOutput: true,
hasRecoverableIntermediateOutput);
Assert.Equal((ExitedRecordingDisposition)expected, disposition);
}
[Fact]
public void DeploymentShutdown_InterruptedFinalizationRemainsRecoverable()
{
var disposition = FfmpegService.ClassifyExitedRecording(
shutdownRequested: true,
stopRequested: false,
finalizationPaused: true,
hasFinalizationError: true,
mediaValid: false,
exitCode: 0,
completionRequested: true,
hasUsableOutput: true,
hasRecoverableIntermediateOutput: true);
Assert.Equal(ExitedRecordingDisposition.Processing, disposition);
}
[Theory]
[InlineData("Unknown encoder h264_nvenc")]
[InlineData("Cannot load libcuda.so.1")]
[InlineData("No VA display found for device /dev/dri/renderD128")]
[InlineData("Error initializing an internal MFX session")]
[InlineData("Impossible to convert between the formats supported by the filter")]
public void HardwareEncoderFailures_TriggerSoftwareFallback(string line)
{
Assert.True(FfmpegService.IsHardwareEncoderFailureLine(line));
}
[Fact]
public void HardwareRecoveryEncoders_UseExpectedCodecAndDeviceArguments()
{
var nvenc = new RecoveryVideoEncoderSelection(RecoveryVideoEncoderKind.Nvenc, null);
var qsv = new RecoveryVideoEncoderSelection(RecoveryVideoEncoderKind.Qsv, "/dev/dri/renderD128");
var vaapi = new RecoveryVideoEncoderSelection(RecoveryVideoEncoderKind.Vaapi, "/dev/dri/renderD129");
var nvencArguments = FfmpegService.BuildRecoveryEncoderProbeArgumentList(nvenc);
var qsvArguments = FfmpegService.BuildRecoveryEncoderProbeArgumentList(qsv);
var vaapiArguments = FfmpegService.BuildRecoveryEncoderProbeArgumentList(vaapi);
Assert.Contains("h264_nvenc", nvencArguments);
Assert.Contains("h264_qsv", qsvArguments);
Assert.Contains("-qsv_device", qsvArguments);
Assert.Contains("/dev/dri/renderD128", qsvArguments);
Assert.Contains("h264_vaapi", vaapiArguments);
Assert.Contains("-vaapi_device", vaapiArguments);
Assert.Contains("/dev/dri/renderD129", vaapiArguments);
}
[Fact]
public void TimestampTranscode_WithQsv_PlacesDeviceBeforeInputAndKeepsTimestampFilters()
{
var arguments = FfmpegService.BuildArgumentList(
"https://cdn.example.test/live.flv",
"/tmp/output.ts",
RecordOutputFormat.Ts,
RecordSaveMode.Segmented,
1,
RecordingTemplateType.StreamCopy,
true,
10,
30_000_000,
30,
null,
"flv",
"h264",
FfmpegService.FfmpegInputOptionProfile.TimestampTranscode,
new RecoveryVideoEncoderSelection(RecoveryVideoEncoderKind.Qsv, "/dev/dri/renderD128"));
var argumentList = arguments.ToList();
Assert.True(argumentList.IndexOf("-qsv_device") < argumentList.IndexOf("-i"));
Assert.Contains("h264_qsv", arguments);
Assert.Contains(arguments, item => item.Contains("setpts=PTS-STARTPTS", StringComparison.Ordinal));
Assert.Contains(arguments, item => item.Contains("hwupload", StringComparison.Ordinal));
Assert.Contains("aresample=async=1:first_pts=0,asetpts=PTS-STARTPTS", arguments);
}
}
@@ -0,0 +1,71 @@
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
using LiveRecorder.Infrastructure.Services;
namespace LiveRecorder.Tests;
public sealed class LiveRoomPollingDecisionTests
{
private const string ActiveCode = "skipped_active_session";
private const string ActiveSummary = "Auto-start skipped because an active recording session already exists.";
[Fact]
public void HasAutoStartDecisionChanged_ReturnsFalseForSameActiveSession()
{
var room = CreateRoom();
var sessionId = Guid.NewGuid();
room.SetLastAutoStartDecision(
ActiveCode,
ActiveSummary,
$"activeSessionId={sessionId}",
DateTimeOffset.UtcNow.AddMinutes(-1));
var changed = LiveRoomPollingBackgroundService.HasAutoStartDecisionChanged(
room,
ActiveCode,
ActiveSummary,
$"activeSessionId={sessionId}");
Assert.False(changed);
}
[Fact]
public void HasAutoStartDecisionChanged_ReturnsTrueForNewActiveSession()
{
var room = CreateRoom();
room.SetLastAutoStartDecision(
ActiveCode,
ActiveSummary,
$"activeSessionId={Guid.NewGuid()}",
DateTimeOffset.UtcNow.AddMinutes(-1));
var changed = LiveRoomPollingBackgroundService.HasAutoStartDecisionChanged(
room,
ActiveCode,
ActiveSummary,
$"activeSessionId={Guid.NewGuid()}");
Assert.True(changed);
}
[Fact]
public void HasAutoStartDecisionChanged_ReturnsTrueForFirstSkipDecision()
{
var room = CreateRoom();
var changed = LiveRoomPollingBackgroundService.HasAutoStartDecisionChanged(
room,
ActiveCode,
ActiveSummary,
$"activeSessionId={Guid.NewGuid()}");
Assert.True(changed);
}
private static LiveRoom CreateRoom() => new(
LivePlatformType.Douyin,
"https://live.douyin.com/123456",
"123456",
"https://live.douyin.com/123456",
DateTimeOffset.UtcNow);
}
@@ -137,6 +137,7 @@ public sealed class LiveRoomStatusServiceTests
string segmentFilePath,
DateTimeOffset occurredAt,
bool forceRun = false,
Guid? eventId = null,
CancellationToken cancellationToken = default) =>
Task.FromResult<EventScriptExecutionResultDto?>(null);
+144 -8
View File
@@ -1,13 +1,16 @@
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using LiveRecorder.Application.Abstractions.Logging;
using LiveRecorder.Application.Abstractions.Recording;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Models.Logs;
using LiveRecorder.Application.Models.Settings;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
using LiveRecorder.Infrastructure.Persistence;
using LiveRecorder.Infrastructure.Persistence.Repositories;
using LiveRecorder.Infrastructure.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
@@ -16,6 +19,21 @@ namespace LiveRecorder.Tests;
public sealed class OpenListUploadTests
{
[Fact]
public async Task Enqueue_RejectsShortMediaAndKeepsItNotUploaded()
{
await using var fixture = await QueueFixture.CreateAsync(
new VideoMetadata(0.18, 1920, 1080, "h264", "aac", 30, 4_000_000));
var enqueue = await fixture.Queue.EnqueueAsync(fixture.RecordTaskId);
Assert.False(enqueue.Success);
Assert.Contains("不足 5 秒", enqueue.Message);
Assert.Null(await fixture.Context.RecordUploadJobs.SingleOrDefaultAsync());
var result = await fixture.Context.RecordResults.SingleAsync();
Assert.Equal(RecordArtifactUploadStatus.NotUploaded, result.UploadStatus);
}
[Theory]
[InlineData("https://openlist.example.com/", "https://openlist.example.com")]
[InlineData("https://openlist.example.com/base/dav/archive/file", "https://openlist.example.com/base")]
@@ -225,6 +243,9 @@ public sealed class OpenListUploadTests
Assert.Equal(RecordArtifactUploadStatus.Uploading, job.Status);
Assert.Equal(1, job.AttemptCount);
Assert.Equal(41.67, job.ProgressPercent, precision: 2);
Assert.Equal(now.AddSeconds(2), job.LastProgressAt);
job.SetProgress(job.ProgressPercent, now.AddMinutes(1));
Assert.Equal(now.AddSeconds(2), job.LastProgressAt);
job.CompleteCurrentArtifact(now.AddSeconds(3));
Assert.Equal(RecordUploadArtifactStage.Danmaku, job.CurrentArtifact);
@@ -290,7 +311,14 @@ public sealed class OpenListUploadTests
Assert.Equal("/source/Douyin/2026/08/01/主播/segment.mp4", job.SourceVideoPath);
Assert.Equal("/destination/Douyin/2026/08/01/主播/segment.mp4", job.TargetVideoPath);
fixture.OpenList.Objects[job.TargetVideoPath] = FileObject("segment.mp4", job.VideoSizeBytes);
fixture.OpenList.Objects[job.TargetVideoPath] = new OpenListObjectInfo(
"segment.mp4",
job.VideoSizeBytes,
false,
new Dictionary<string, string>
{
["sha256"] = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("video-content"))).ToLowerInvariant()
});
Assert.True(await fixture.Queue.ProcessNextAsync());
fixture.Context.ChangeTracker.Clear();
@@ -308,7 +336,7 @@ public sealed class OpenListUploadTests
}
[Fact]
public async Task Queue_RejectsSameNameConflictWithoutOverwriting()
public async Task Queue_RenamesSameNameConflictWithoutOverwriting()
{
await using var fixture = await QueueFixture.CreateAsync();
await fixture.Queue.EnqueueAsync(fixture.RecordTaskId);
@@ -318,11 +346,12 @@ public sealed class OpenListUploadTests
Assert.True(await fixture.Queue.ProcessNextAsync());
fixture.Context.ChangeTracker.Clear();
var failedJob = await fixture.Context.RecordUploadJobs.SingleAsync();
var renamedJob = await fixture.Context.RecordUploadJobs.SingleAsync();
var result = await fixture.Context.RecordResults.SingleAsync();
Assert.Equal(RecordArtifactUploadStatus.Failed, failedJob.Status);
Assert.Equal(RecordArtifactUploadStatus.Failed, result.UploadStatus);
Assert.Contains("内容不一致", failedJob.ErrorMessage);
Assert.Equal(RecordArtifactUploadStatus.Uploading, renamedJob.Status);
Assert.Equal(RecordArtifactUploadStatus.Uploading, result.UploadStatus);
Assert.EndsWith($"_{fixture.RecordTaskId.ToString("N")[..8]}.mp4", renamedJob.TargetVideoPath, StringComparison.Ordinal);
Assert.NotNull(renamedJob.TransferTargetPath);
Assert.Empty(fixture.OpenList.CopyRequests);
}
@@ -380,6 +409,57 @@ public sealed class OpenListUploadTests
Assert.Single(fixture.OpenList.CopyRequests);
}
[Fact]
public async Task AutomaticRecovery_QueuesCompletedTaskThatWasMissedAfterRestart_OnlyOnce()
{
await using var fixture = await QueueFixture.CreateAsync();
var task = await fixture.Context.RecordTasks.SingleAsync();
task.MarkCompleted(DateTimeOffset.UtcNow.AddMinutes(-1), 60);
await fixture.Context.SaveChangesAsync();
Assert.Equal(1, await fixture.Context.RecordCompletionDispatches.CountAsync());
var recovered = await fixture.Queue.RecoverPendingAutomaticUploadsAsync();
Assert.Equal(1, recovered);
var job = await fixture.Context.RecordUploadJobs.AsNoTracking().SingleAsync();
var result = await fixture.Context.RecordResults.AsNoTracking().SingleAsync();
Assert.Equal(task.Id, job.RecordTaskId);
Assert.Equal(RecordArtifactUploadStatus.Queued, job.Status);
Assert.Equal(RecordArtifactUploadStatus.Queued, result.UploadStatus);
fixture.Context.ChangeTracker.Clear();
Assert.Equal(0, await fixture.Queue.RecoverPendingAutomaticUploadsAsync());
Assert.Equal(1, await fixture.Context.RecordUploadJobs.CountAsync());
}
[Fact]
public async Task CompletionOutbox_IsCreatedWhenRecordResultWasWrittenOutsideEfTracking()
{
await using var fixture = await QueueFixture.CreateAsync();
fixture.Context.ChangeTracker.Clear();
var task = await fixture.Context.RecordTasks.SingleAsync();
task.MarkCompleted(DateTimeOffset.UtcNow, 60);
await fixture.Context.SaveChangesAsync();
var dispatch = await fixture.Context.RecordCompletionDispatches.AsNoTracking().SingleAsync();
Assert.Equal(task.Id, dispatch.RecordTaskId);
}
[Fact]
public async Task PendingUploadMetrics_SeparateFailedFragmentsFromEligibleArtifacts()
{
await using var fixture = await QueueFixture.CreateAsync();
var task = await fixture.Context.RecordTasks.SingleAsync();
task.MarkFailed("broken timestamps", DateTimeOffset.UtcNow);
await fixture.Context.SaveChangesAsync();
var repository = new RecordResultRepository(fixture.Context);
Assert.Equal(0, await repository.CountPendingUploadAsync());
Assert.Equal(0, await repository.SumPendingUploadBytesAsync());
Assert.Equal(1, await repository.CountFailedArtifactAsync());
}
private static OpenListClient CreateClient(HttpMessageHandler handler) =>
new(new StubHttpClientFactory(handler));
@@ -442,12 +522,14 @@ public sealed class OpenListUploadTests
{
private readonly DbContextOptions<LiveRecorderDbContext> _options;
private readonly FixedSettingsService _settingsService;
private readonly IVideoMetadataService _videoMetadataService;
private readonly string _temporaryRoot;
private QueueFixture(
DbContextOptions<LiveRecorderDbContext> options,
LiveRecorderDbContext context,
FixedSettingsService settingsService,
IVideoMetadataService videoMetadataService,
FakeOpenListClient openList,
string temporaryRoot,
Guid recordTaskId)
@@ -455,6 +537,7 @@ public sealed class OpenListUploadTests
_options = options;
Context = context;
_settingsService = settingsService;
_videoMetadataService = videoMetadataService;
OpenList = openList;
_temporaryRoot = temporaryRoot;
RecordTaskId = recordTaskId;
@@ -469,7 +552,7 @@ public sealed class OpenListUploadTests
public Guid RecordTaskId { get; }
public static async Task<QueueFixture> CreateAsync()
public static async Task<QueueFixture> CreateAsync(VideoMetadata? metadata = null)
{
var temporaryRoot = Path.Combine(Path.GetTempPath(), $"live-recorder-openlist-{Guid.NewGuid():N}");
var recordDirectory = Path.Combine(temporaryRoot, "Douyin", "2026", "08", "01", "主播");
@@ -504,6 +587,8 @@ public sealed class OpenListUploadTests
"origin",
RecordOutputFormat.Mp4,
now);
task.MarkStarting("https://cdn.example/stream.flv", videoPath, now);
task.MarkCompleted(now.AddMinutes(1), 60);
var result = new RecordResult(
task.Id,
videoPath,
@@ -533,11 +618,14 @@ public sealed class OpenListUploadTests
}
};
var settingsService = new FixedSettingsService(settings);
var videoMetadataService = new FixedVideoMetadataService(
metadata ?? new VideoMetadata(60, 1920, 1080, "h264", "aac", 30, 4_000_000));
var openList = new FakeOpenListClient();
return new QueueFixture(
options,
context,
settingsService,
videoMetadataService,
openList,
temporaryRoot,
task.Id);
@@ -560,7 +648,25 @@ public sealed class OpenListUploadTests
}
private OpenListUploadQueueService CreateQueue(LiveRecorderDbContext context) =>
new(context, _settingsService, OpenList, new NullSystemLogService());
new(context, _settingsService, OpenList, new NullSystemLogService(), _videoMetadataService);
}
private sealed class FixedVideoMetadataService : IVideoMetadataService
{
private readonly VideoMetadata? _metadata;
public FixedVideoMetadataService(VideoMetadata? metadata)
{
_metadata = metadata;
}
public Task<VideoMetadata?> ExtractMetadataAsync(string filePath, CancellationToken cancellationToken = default) =>
Task.FromResult(_metadata);
public Task<string?> GenerateThumbnailAsync(
string filePath,
string outputDir,
CancellationToken cancellationToken = default) => Task.FromResult<string?>(null);
}
private sealed class FixedSettingsService : ISystemSettingsService
@@ -660,5 +766,35 @@ public sealed class OpenListUploadTests
string taskId,
CancellationToken cancellationToken = default) =>
Task.FromResult(Tasks.GetValueOrDefault(taskId));
public Task<bool> TryCancelCopyTaskAsync(
OpenListConnectionRequest connection,
string taskId,
CancellationToken cancellationToken = default)
{
Operations.Add($"cancel:{taskId}");
Tasks.Remove(taskId);
return Task.FromResult(true);
}
public Task RenameAsync(
OpenListConnectionRequest connection,
string path,
string newName,
CancellationToken cancellationToken = default)
{
Operations.Add($"rename:{path}->{newName}");
return Task.CompletedTask;
}
public Task MoveAsync(
OpenListConnectionRequest connection,
string sourcePath,
string targetDirectory,
CancellationToken cancellationToken = default)
{
Operations.Add($"move:{sourcePath}->{targetDirectory}");
return Task.CompletedTask;
}
}
}
@@ -0,0 +1,78 @@
using LiveRecorder.Domain.Enums;
using LiveRecorder.Infrastructure.Services;
namespace LiveRecorder.Tests;
public sealed class OrphanedSegmentRecoveryTests : IDisposable
{
private readonly string _temporaryDirectory = Path.Combine(
Path.GetTempPath(),
$"liverecorder-orphan-recovery-{Guid.NewGuid():N}");
[Fact]
public void DiscoverRecoverableSegments_FindsExactNonEmptySegmentFilesInOrder()
{
Directory.CreateDirectory(_temporaryDirectory);
var pattern = Path.Combine(_temporaryDirectory, "224332_186东北男大_%05d.mp4");
File.WriteAllBytes(Path.Combine(_temporaryDirectory, "224332_186东北男大_00003.ts"), [3]);
File.WriteAllBytes(Path.Combine(_temporaryDirectory, "224332_186东北男大_00001.ts"), [1]);
File.WriteAllBytes(Path.Combine(_temporaryDirectory, "224332_186东北男大_00002.ts"), [2]);
File.WriteAllBytes(Path.Combine(_temporaryDirectory, "other_00004.ts"), [4]);
File.WriteAllBytes(Path.Combine(_temporaryDirectory, "224332_186东北男大_00004.ts"), []);
File.WriteAllText(Path.Combine(_temporaryDirectory, "224332_186东北男大_00001.xml"), "<i />");
var segments = FfmpegService.DiscoverRecoverableSegments(
pattern,
RecordOutputFormat.Mp4,
RecordSaveMode.Segmented);
Assert.Equal([1, 2, 3], segments.Select(segment => segment.SegmentIndex));
Assert.Equal(
Path.Combine(_temporaryDirectory, "224332_186东北男大_00002.ts"),
segments[1].RecorderPath);
Assert.Equal(
Path.Combine(_temporaryDirectory, "224332_186东北男大_00002.mp4"),
segments[1].OutputPath);
}
[Theory]
[InlineData(RecordOutputFormat.Ts, RecordSaveMode.Segmented)]
[InlineData(RecordOutputFormat.Mp4, RecordSaveMode.SingleFile)]
public void DiscoverRecoverableSegments_RejectsUnsupportedRecordingModes(
RecordOutputFormat outputFormat,
RecordSaveMode saveMode)
{
Directory.CreateDirectory(_temporaryDirectory);
var pattern = Path.Combine(_temporaryDirectory, "record_%05d.mp4");
File.WriteAllBytes(Path.Combine(_temporaryDirectory, "record_00001.ts"), [1]);
var segments = FfmpegService.DiscoverRecoverableSegments(pattern, outputFormat, saveMode);
Assert.Empty(segments);
}
[Fact]
public void DiscoverRecoverableSegments_IsIdempotentAndDoesNotModifySourceFiles()
{
Directory.CreateDirectory(_temporaryDirectory);
var sourcePath = Path.Combine(_temporaryDirectory, "record_00001.ts");
var pattern = Path.Combine(_temporaryDirectory, "record_%05d.mp4");
File.WriteAllBytes(sourcePath, [1, 2, 3]);
var first = FfmpegService.DiscoverRecoverableSegments(pattern, RecordOutputFormat.Mp4, RecordSaveMode.Segmented);
var second = FfmpegService.DiscoverRecoverableSegments(pattern, RecordOutputFormat.Mp4, RecordSaveMode.Segmented);
Assert.Equal(first, second);
Assert.True(File.Exists(sourcePath));
Assert.Equal(3, new FileInfo(sourcePath).Length);
Assert.False(File.Exists(Path.ChangeExtension(sourcePath, ".mp4")));
}
public void Dispose()
{
if (Directory.Exists(_temporaryDirectory))
{
Directory.Delete(_temporaryDirectory, recursive: true);
}
}
}
@@ -0,0 +1,227 @@
using LiveRecorder.Application.Abstractions.Persistence;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
using LiveRecorder.Infrastructure.Persistence;
using LiveRecorder.Infrastructure.Persistence.Repositories;
using Microsoft.EntityFrameworkCore;
namespace LiveRecorder.Tests;
public sealed class RecordSessionRepositoryTests
{
[Fact]
public async Task Overview_IsBoundedButAlwaysIncludesOlderActiveSessions()
{
var options = new DbContextOptionsBuilder<LiveRecorderDbContext>()
.UseInMemoryDatabase($"record-session-overview-{Guid.NewGuid():N}")
.Options;
await using var context = new LiveRecorderDbContext(options);
var now = DateTimeOffset.UtcNow;
var room = new LiveRoom(
LivePlatformType.Douyin,
"https://live.example/room",
"room-1",
"https://live.example/room",
now.AddDays(-2));
var oldActive = new RecordSession(
room.Id,
"origin",
RecordOutputFormat.Mp4,
RecordSaveMode.Segmented,
now.AddDays(-1));
oldActive.MarkStarting("https://stream.example/live", "/records/active.mp4", now.AddDays(-1));
oldActive.MarkRunning(now.AddDays(-1));
var completed = Enumerable.Range(0, 205)
.Select(index =>
{
var createdAt = now.AddMinutes(index);
var session = new RecordSession(
room.Id,
"origin",
RecordOutputFormat.Mp4,
RecordSaveMode.Segmented,
createdAt);
session.MarkCompleted(createdAt.AddMinutes(1));
return session;
})
.ToArray();
context.Add(room);
context.Add(oldActive);
context.AddRange(completed);
await context.SaveChangesAsync();
var repository = new RecordSessionRepository(context);
var overview = await repository.ListOverviewAsync(null, 200);
var activeIds = await repository.ListActiveIdsAsync();
Assert.Equal(201, overview.Count);
Assert.Contains(overview, item => item.Id == oldActive.Id);
Assert.Equal([oldActive.Id], activeIds);
Assert.True(overview.SequenceEqual(overview.OrderByDescending(static item => item.CreatedAt)));
}
[Fact]
public async Task Page_IsBoundedFilterableAndReturnsGlobalTotals()
{
var options = new DbContextOptionsBuilder<LiveRecorderDbContext>()
.UseInMemoryDatabase($"record-session-page-{Guid.NewGuid():N}")
.Options;
await using var context = new LiveRecorderDbContext(options);
var now = DateTimeOffset.UtcNow;
var room = new LiveRoom(
LivePlatformType.Douyin,
"https://live.example/page-room",
"page-room",
"https://live.example/page-room",
now.AddDays(-3));
room.UpdateMetadata("分页测试直播间", "分页主播", null, null, null, now.AddDays(-3));
var oldActive = new RecordSession(
room.Id,
"origin",
RecordOutputFormat.Mp4,
RecordSaveMode.Segmented,
now.AddDays(-2));
oldActive.MarkStarting("https://stream.example/live", "/records/active.mp4", now.AddDays(-2));
oldActive.MarkRunning(now.AddDays(-2));
var completed = Enumerable.Range(0, 29)
.Select(index =>
{
var createdAt = now.AddMinutes(index);
var session = new RecordSession(
room.Id,
"origin",
RecordOutputFormat.Mp4,
RecordSaveMode.Segmented,
createdAt);
session.MarkCompleted(createdAt.AddMinutes(1));
return session;
})
.ToArray();
var searchableTask = new RecordTask(
room.Id,
completed[0].Id,
1,
"origin",
RecordOutputFormat.Mp4,
now);
searchableTask.MarkStarting("https://stream.example/archive", "/records/needle-video.mp4", now);
searchableTask.MarkCompleted(now.AddMinutes(1), 60);
var result = new RecordResult(
searchableTask.Id,
"/records/needle-video.mp4",
1024,
60,
"/records/needle-video.xml",
37,
RecordTaskStatus.Completed,
null,
now.AddMinutes(1));
context.Add(room);
context.Add(oldActive);
context.AddRange(completed);
context.Add(searchableTask);
context.Add(result);
await context.SaveChangesAsync();
var repository = new RecordSessionRepository(context);
var firstPage = await repository.ListPageAsync(null, null, null, 0, 10);
var completedPage = await repository.ListPageAsync(
null,
[RecordSessionStatus.Completed],
null,
0,
10);
var searchPage = await repository.ListPageAsync(null, null, "needle-video", 0, 10);
var totals = await repository.GetOverviewTotalsAsync();
Assert.Equal(30, firstPage.TotalCount);
Assert.Equal(10, firstPage.Items.Count);
Assert.Equal(oldActive.Id, firstPage.Items[0].Id);
Assert.Equal(29, completedPage.TotalCount);
Assert.DoesNotContain(completedPage.Items, item => item.Id == oldActive.Id);
Assert.Single(searchPage.Items);
Assert.Equal(completed[0].Id, searchPage.Items[0].Id);
Assert.Equal(new RecordSessionOverviewTotals(30, 1, 1, 37), totals);
}
[Fact]
public async Task TrackedBatchLookup_ReusesLiveRoomAfterTasksAreDeleted()
{
var options = new DbContextOptionsBuilder<LiveRecorderDbContext>()
.UseInMemoryDatabase($"record-session-delete-{Guid.NewGuid():N}")
.Options;
await using var context = new LiveRecorderDbContext(options);
var now = DateTimeOffset.UtcNow;
var room = new LiveRoom(
LivePlatformType.Douyin,
"https://live.example/delete-room",
"delete-room",
"https://live.example/delete-room",
now.AddHours(-2));
var sessions = Enumerable.Range(0, 2)
.Select(index =>
{
var createdAt = now.AddMinutes(index - 10);
var session = new RecordSession(
room.Id,
"origin",
RecordOutputFormat.Mp4,
RecordSaveMode.Segmented,
createdAt);
session.MarkCompleted(createdAt.AddMinutes(1));
return session;
})
.ToArray();
var tasks = sessions
.Select((session, index) =>
{
var task = new RecordTask(
room.Id,
session.Id,
1,
"origin",
RecordOutputFormat.Mp4,
now.AddMinutes(index - 10));
task.MarkStarting(
"https://stream.example/archive",
$"/records/delete-{index}.mp4",
now.AddMinutes(index - 10));
task.MarkFailed("invalid media", now.AddMinutes(index - 9), durationSeconds: null);
return task;
})
.ToArray();
context.Add(room);
context.AddRange(sessions);
context.AddRange(tasks);
await context.SaveChangesAsync();
context.ChangeTracker.Clear();
var taskRepository = new RecordTaskRepository(context);
var sessionRepository = new RecordSessionRepository(context);
var trackedTasks = await taskRepository.GetByIdsAsync(tasks.Select(static item => item.Id).ToArray());
var trackedRoom = Assert.Single(trackedTasks.Select(static item => item.LiveRoom).Distinct());
taskRepository.RemoveRange(trackedTasks);
await context.SaveChangesAsync();
var trackedSessions = await sessionRepository.GetByIdsAsync(
sessions.Select(static item => item.Id).ToArray());
Assert.Equal(2, trackedSessions.Count);
Assert.All(trackedSessions, session =>
{
Assert.Same(trackedRoom, session.LiveRoom);
Assert.Empty(session.RecordTasks);
sessionRepository.Remove(session);
});
await context.SaveChangesAsync();
Assert.Empty(await context.RecordSessions.ToListAsync());
Assert.Empty(await context.RecordTasks.ToListAsync());
Assert.Single(await context.LiveRooms.ToListAsync());
}
}
@@ -0,0 +1,80 @@
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
using LiveRecorder.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace LiveRecorder.Tests;
public sealed class ShutdownRecoveryDispatchTests : IDisposable
{
private readonly string _temporaryDirectory = Path.Combine(
Path.GetTempPath(),
$"liverecorder-shutdown-dispatch-{Guid.NewGuid():N}");
[Fact]
public async Task RecoveredProcessingTask_CreatesDurableCompletionDispatch()
{
Directory.CreateDirectory(_temporaryDirectory);
var outputPath = Path.Combine(_temporaryDirectory, "recovered.mp4");
await File.WriteAllBytesAsync(outputPath, [1, 2, 3, 4]);
var options = new DbContextOptionsBuilder<LiveRecorderDbContext>()
.UseInMemoryDatabase($"shutdown-recovery-dispatch-{Guid.NewGuid():N}")
.Options;
await using var context = new LiveRecorderDbContext(options);
var now = DateTimeOffset.UtcNow;
var room = new LiveRoom(
LivePlatformType.Douyin,
"https://live.example/recovery",
"recovery-room",
"https://live.example/recovery",
now.AddHours(-1));
var session = new RecordSession(
room.Id,
"origin",
RecordOutputFormat.Mp4,
RecordSaveMode.Segmented,
now.AddMinutes(-30));
session.MarkStopped(now, "Application shutdown deferred MP4 finalization.");
var task = new RecordTask(
room.Id,
session.Id,
1,
"origin",
RecordOutputFormat.Mp4,
now.AddMinutes(-30));
task.MarkStarting("https://stream.example/recovery", outputPath, now.AddMinutes(-30));
task.MarkProcessing("Waiting for restart recovery.", now);
var result = new RecordResult(
task.Id,
outputPath,
new FileInfo(outputPath).Length,
1_800,
null,
0,
RecordTaskStatus.Processing,
task.ErrorMessage,
now);
context.AddRange(room, session, task, result);
await context.SaveChangesAsync();
Assert.Empty(context.RecordCompletionDispatches);
task.MarkCompleted(now.AddMinutes(1), 1_800);
session.MarkCompleted(now.AddMinutes(1));
await context.SaveChangesAsync();
var dispatch = await context.RecordCompletionDispatches.SingleAsync();
Assert.Equal(task.Id, dispatch.RecordTaskId);
Assert.False(dispatch.UploadDispatched);
Assert.Null(dispatch.CompletedAt);
}
public void Dispose()
{
if (Directory.Exists(_temporaryDirectory))
{
Directory.Delete(_temporaryDirectory, recursive: true);
}
}
}
@@ -0,0 +1,116 @@
using LiveRecorder.Application.Abstractions.Storage;
using LiveRecorder.Application.Models.Settings;
using LiveRecorder.Infrastructure.Services;
using Microsoft.Extensions.Logging.Abstractions;
namespace LiveRecorder.Tests;
public sealed class StorageGuardServiceTests
{
private readonly StorageGuardService _service = new(NullLogger<StorageGuardService>.Instance);
[Fact]
public void DisabledProtection_StillReportsActualCapacityWithoutBlockingRecording()
{
var settings = CreateSettings(enableStorageGuard: false);
var result = _service.CheckCanStartOrResume(settings);
Assert.False(result.IsEnabled);
Assert.True(result.IsAvailable);
Assert.True(result.HasEnoughSpace);
Assert.True(result.CanStartNewRecording);
Assert.False(result.ShouldPauseActive);
Assert.True(result.TotalBytes > 0);
Assert.Equal(result.TotalBytes, result.UsedBytes + result.AvailableBytes);
Assert.InRange(result.UsagePercent, 0, 100);
Assert.InRange(result.FreePercent, 0, 100);
Assert.InRange(result.UsagePercent + result.FreePercent, 99.8, 100.2);
}
[Fact]
public void EnabledProtection_ReportsConfiguredThresholdsAndConsistentPercentages()
{
var settings = CreateSettings(enableStorageGuard: true);
settings.StorageGreenThresholdPercent = 35;
settings.StorageRedThresholdPercent = 12;
var result = _service.CheckCanStartOrResume(settings);
Assert.True(result.IsEnabled);
Assert.True(result.IsAvailable);
Assert.Equal(35, result.GreenThresholdPercent);
Assert.Equal(12, result.RedThresholdPercent);
Assert.Equal(result.TotalBytes, result.UsedBytes + result.AvailableBytes);
Assert.InRange(result.UsagePercent + result.FreePercent, 99.8, 100.2);
}
[Fact]
public void InvalidPath_ReturnsUnavailableStateInsteadOfThrowing()
{
var settings = CreateSettings(enableStorageGuard: true);
settings.OutputRoot = "invalid\0path";
var result = _service.CheckCanStartOrResume(settings);
Assert.True(result.IsEnabled);
Assert.False(result.IsAvailable);
Assert.False(result.HasEnoughSpace);
Assert.False(result.CanStartNewRecording);
Assert.Equal(StorageTier.Red, result.Tier);
Assert.Equal(0, result.TotalBytes);
}
[Fact]
public void RedPercentageTier_DoesNotBlockFinalizeWhenAbsoluteTemporarySpaceIsAvailable()
{
var settings = CreateSettings(enableStorageGuard: true);
settings.PauseRecordingWhenFreeSpaceBelowMegabytes = 0;
settings.ResumeRecordingWhenFreeSpaceAboveMegabytes = 0;
settings.StorageGreenThresholdPercent = 90;
settings.StorageRedThresholdPercent = 85;
var result = _service.CheckCanFinalize(settings, estimatedTemporaryBytes: 1);
Assert.Equal(StorageTier.Red, result.Tier);
Assert.True(result.HasEnoughSpace);
Assert.True(result.ShouldPauseActive);
}
[Fact]
public void Finalize_ReservesSourceEstimateInAdditionToPauseThreshold()
{
var settings = CreateSettings(enableStorageGuard: true);
settings.PauseRecordingWhenFreeSpaceBelowMegabytes = int.MaxValue;
var result = _service.CheckCanFinalize(settings, estimatedTemporaryBytes: 1024);
Assert.False(result.HasEnoughSpace);
Assert.True(result.RequiredBytes > 1024);
}
[Fact]
public void MultiSegmentFinalize_UsesConcatDemuxerWithoutMaterializingCombinedTs()
{
var arguments = FfmpegService.BuildMp4FinalizeArgumentList(
"/records/input.ffconcat",
"/records/output.remux.mp4",
strategy: default,
useConcatDemuxer: true);
Assert.Contains("concat", arguments);
Assert.Contains("-safe", arguments);
Assert.Contains("/records/input.ffconcat", arguments);
Assert.DoesNotContain(".concat.ts", arguments);
}
private static SystemSettingsDto CreateSettings(bool enableStorageGuard) => new()
{
OutputRoot = Path.GetTempPath(),
EnableStorageGuard = enableStorageGuard,
PauseRecordingWhenFreeSpaceBelowMegabytes = 1024,
ResumeRecordingWhenFreeSpaceAboveMegabytes = 4096,
StorageGreenThresholdPercent = 30,
StorageRedThresholdPercent = 10
};
}
@@ -63,6 +63,34 @@ public sealed class SystemSettingsServiceTests
Assert.Contains(allSettings, static item => item.Key == "platform_request.twitch.cookie" && item.Value == "auth-token=123");
}
[Fact]
public async Task StorageThresholds_NormalizeUnsafeLegacyValuesAndKeepSafetyGap()
{
var repository = new InMemoryAppSettingRepository(
[
new AppSetting("storage.guard.green_threshold_percent", "5", DateTimeOffset.UtcNow),
new AppSetting("storage.guard.red_threshold_percent", "1", DateTimeOffset.UtcNow)
]);
var service = new SystemSettingsService(repository, new NoOpUnitOfWork());
var settings = await service.GetAsync();
Assert.Equal(10, settings.StorageGreenThresholdPercent);
Assert.Equal(5, settings.StorageRedThresholdPercent);
var request = new Application.Models.Settings.UpdateSystemSettingsRequest
{
StorageGreenThresholdPercent = 20,
StorageRedThresholdPercent = 19,
PlatformRequestSettings = Application.Models.Settings.SystemSettingsDto.CreatePlatformRequestSettingsMap()
};
await service.UpdateAsync(request);
var updated = await service.GetAsync();
Assert.Equal(20, updated.StorageGreenThresholdPercent);
Assert.Equal(15, updated.StorageRedThresholdPercent);
}
private sealed class InMemoryAppSettingRepository : IAppSettingRepository
{
private readonly Dictionary<string, AppSetting> _items;