feat: harden recording lifecycle and refresh fnOS UI
This commit is contained in:
@@ -6,6 +6,8 @@
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/dist-postgres/
|
||||
frontend/test-results/
|
||||
frontend/playwright-report/
|
||||
**/.dotnet-cli-home/
|
||||
.codex-temp/
|
||||
.tools/
|
||||
|
||||
@@ -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 Recorder;fnOS 会通过 `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 完整部署验证通过
|
||||
|
||||
## 后续规划
|
||||
|
||||
@@ -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,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
@@ -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,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
@@ -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
@@ -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,
|
||||
|
||||
Generated
+64
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 } }
|
||||
}))
|
||||
});
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+151
@@ -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" });
|
||||
}
|
||||
}
|
||||
+67
-2
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user