feat: improve recording automation and task workflows

This commit is contained in:
2026-04-23 23:18:11 +08:00
parent 1c892259a9
commit 23ead56781
88 changed files with 9579 additions and 1581 deletions
-7
View File
@@ -1,7 +0,0 @@
// index.js
const sign = require('./xbogus');
module.exports = function (url, userAgent) {
return sign(url.split("?")[1], userAgent);
};
-80
View File
@@ -1,80 +0,0 @@
const fs = require('fs');
const path = require('path');
const sqlite3 = require('node:sqlite');
const https = require('https');
const sign = require(path.join(process.argv[2], 'index.js'));
const db = new sqlite3.DatabaseSync(process.argv[3]);
const rows = db.prepare("select Key, Value from AppSettings where Key in ('douyin.cookie','douyin.user_agent')").all();
const settings = Object.fromEntries(rows.map(r => [r.Key, r.Value]));
const webRid = '24482384478';
function request(url, headers) {
return new Promise((resolve, reject) => {
https.get(url, { headers }, res => {
const chunks = [];
res.on('data', chunk => chunks.push(chunk));
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
}).on('error', reject);
});
}
(async () => {
const headers = {
'user-agent': settings['douyin.user_agent'] || 'Mozilla/5.0',
'referer': `https://live.douyin.com/${webRid}`,
'origin': 'https://live.douyin.com',
'cookie': settings['douyin.cookie'] || ''
};
const html = await request(`https://live.douyin.com/${webRid}`, headers);
const match = html.match(/<script\snonce="\S+?"\s>self\.__pace_f\.push\(\[1,"[a-z]?:\[\\"\$\\",\\"\$L\d+\\",null,([\s\S]+?state[\s\S]+?)\]\\n"\]\)<\/script>/);
const json = match ? match[1].replace(/\\{1,7}"/g, '"') : '';
const roomId = (json.match(/\"roomId\":\"(\d+)\"/) || [])[1];
const uniqueId = '7300000000000000000';
const msToken = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuv';
const params = new URLSearchParams({
aid: '6383',
app_name: 'douyin_web',
browser_language: 'zh-CN',
browser_name: 'Mozilla',
browser_online: 'true',
browser_platform: 'Win32',
browser_version: settings['douyin.user_agent'] || 'Mozilla/5.0',
cookie_enabled: 'true',
cursor: '',
device_id: '',
device_platform: 'web',
did_rule: '3',
endpoint: 'live_pc',
fetch_rule: '1',
identity: 'audience',
insert_task_id: '',
internal_ext: '',
last_rtt: '0',
live_id: '1',
live_reason: '',
need_persist_msg_count: '15',
resp_content_type: 'protobuf',
screen_height: '1080',
screen_width: '1920',
support_wrds: '1',
tz_name: 'Asia/Shanghai',
version_code: '180800',
room_id: roomId,
user_unique_id: uniqueId,
live_pc: roomId,
msToken
});
const rawQuery = params.toString();
const xb = sign(`https://live.douyin.com/webcast/im/fetch/?${rawQuery}`, headers['user-agent']);
params.set('a_bogus', xb);
const url = `https://live.douyin.com/webcast/im/fetch/?${params.toString()}`;
const body = await new Promise((resolve, reject) => {
https.get(url, { headers: { ...headers, accept: 'application/protobuf, application/octet-stream, */*' } }, res => {
const chunks = [];
res.on('data', chunk => chunks.push(chunk));
res.on('end', () => resolve(Buffer.concat(chunks)));
}).on('error', reject);
});
console.log('ROOM_ID', roomId);
console.log('A_BOGUS_LEN', xb.length);
console.log('RESP_LEN', body.length);
console.log('RESP_HEAD', Array.from(body.subarray(0, 48)));
})();
File diff suppressed because one or more lines are too long
+63
View File
@@ -0,0 +1,63 @@
.git
.git/
.gitignore
.vs
.vs/
.codex-temp
.codex-temp/
.codex_tmp_danmaku
.codex_tmp_danmaku/
artifacts
artifacts/
ConsoleApp1
ConsoleApp1/
dycast_repo
dycast_repo/
tools/**/bin
tools/**/obj
# Local build logs and exported archives.
*.log
*.zip
build.log
webapi-build.log
webapi-build-no-restore.log
# .NET build outputs.
bin/
obj/
**/bin/
**/obj/
# Frontend is built by the separate frontend image; the API image does not need it.
frontend/
# Runtime persistence directories mounted by docker-compose. Never send recordings into build context.
data/
records/
docker-data/
src/LiveRecorder.WebApi/data/
src/LiveRecorder.WebApi/records/
src/LiveRecorder.WebApi/live-recorder.db
src/LiveRecorder.WebApi/live-recorder.db-*
# Generated media and database files if they are placed anywhere else by mistake.
*.db
*.db-*
*.sqlite
*.sqlite3
*.mp4
*.ts
*.flv
*.m4s
*.m3u8
records/**/*.xml
src/LiveRecorder.WebApi/records/**/*.xml
# Node/Vite outputs in case they are copied outside frontend context.
node_modules/
dist/
**/node_modules/
**/dist/
**/.DS_Store
+16
View File
@@ -9,3 +9,19 @@ build.log
webapi-build.log
webapi-build-no-restore.log
artifacts/
data/
records/
docker-data/
src/LiveRecorder.WebApi/data/
src/LiveRecorder.WebApi/records/
src/LiveRecorder.WebApi/live-recorder.db*
*.db
*.db-*
*.sqlite
*.sqlite3
*.mp4
*.ts
*.flv
*.m4s
*.m3u8
*.zip
-14
View File
@@ -1,14 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="WebSocketSharp-NetStandard" Version="1.0.1" />
</ItemGroup>
</Project>
-40
View File
@@ -1,40 +0,0 @@
using WebSocketSharp;
using WebSocketSharp.Server;
namespace ConsoleApp1
{
internal class Program
{
static void Main(string[] args)
{
var wssv = new WebSocketServer("ws://0.0.0.0:8080");
wssv.AddWebSocketService<WsHandler>("/ws");
wssv.Start();
Console.WriteLine("WebSocket服务器已启动 ws://localhost:8080/ws");
Console.ReadKey();
wssv.Stop();
}
}
// 处理连接
public class WsHandler : WebSocketBehavior
{
protected override void OnOpen()
{
Console.WriteLine("客户端连接");
}
protected override void OnMessage(MessageEventArgs e)
{
Console.WriteLine($"收到消息: {e.Data}");
}
protected override void OnClose(CloseEventArgs e)
{
Console.WriteLine("客户端断开");
}
}
}
-15
View File
@@ -13,8 +13,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveRecorder.Application",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveRecorder.Infrastructure", "src\LiveRecorder.Infrastructure\LiveRecorder.Infrastructure.csproj", "{A502FCC8-83F9-402B-A027-D020D34624E1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConsoleApp1", "ConsoleApp1\ConsoleApp1.csproj", "{A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -73,18 +71,6 @@ Global
{A502FCC8-83F9-402B-A027-D020D34624E1}.Release|x64.Build.0 = Release|Any CPU
{A502FCC8-83F9-402B-A027-D020D34624E1}.Release|x86.ActiveCfg = Release|Any CPU
{A502FCC8-83F9-402B-A027-D020D34624E1}.Release|x86.Build.0 = Release|Any CPU
{A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Debug|x64.ActiveCfg = Debug|Any CPU
{A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Debug|x64.Build.0 = Debug|Any CPU
{A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Debug|x86.ActiveCfg = Debug|Any CPU
{A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Debug|x86.Build.0 = Debug|Any CPU
{A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Release|Any CPU.Build.0 = Release|Any CPU
{A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Release|x64.ActiveCfg = Release|Any CPU
{A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Release|x64.Build.0 = Release|Any CPU
{A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Release|x86.ActiveCfg = Release|Any CPU
{A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -94,6 +80,5 @@ Global
{CEE984AA-CA08-48B3-B341-BD2C1C68CC1F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{3B807934-A995-4F7F-8A4E-878D161F95A1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{A502FCC8-83F9-402B-A027-D020D34624E1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection
EndGlobal
+34
View File
@@ -0,0 +1,34 @@
services:
api:
build:
context: .
dockerfile: src/LiveRecorder.WebApi/Dockerfile
network: host
args:
DOTNET_SDK_IMAGE: ${DOTNET_SDK_IMAGE:-mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim}
DOTNET_RUNTIME_IMAGE: ${DOTNET_RUNTIME_IMAGE:-mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim}
restart: unless-stopped
environment:
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_URLS: http://+:8080
ConnectionStrings__DefaultConnection: Data Source=/app/data/live-recorder.db
volumes:
- ./data:/app/data
- ./records:/app/records
expose:
- "8080"
nginx:
build:
context: ./frontend
dockerfile: Dockerfile
network: host
args:
NODE_IMAGE: ${NODE_IMAGE:-node:22-alpine}
NGINX_IMAGE: ${NGINX_IMAGE:-nginx:1.27-alpine}
VITE_API_BASE_URL: /api
restart: unless-stopped
depends_on:
- api
ports:
- "${HTTP_PORT:-8080}:80"
Submodule dycast_repo deleted from dc3ab9ad46
+3
View File
@@ -0,0 +1,3 @@
node_modules
dist
npm-debug.log
+23
View File
@@ -0,0 +1,23 @@
ARG NODE_IMAGE=node:22-alpine
ARG NGINX_IMAGE=nginx:1.27-alpine
FROM ${NODE_IMAGE} AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
ARG VITE_API_BASE_URL=/api
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
RUN npm run build
FROM ${NGINX_IMAGE} AS runtime
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+39
View File
@@ -0,0 +1,39 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 256m;
location = /api {
return 301 /api/;
}
location /api/ {
proxy_pass http://api:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /swagger {
return 301 /swagger/;
}
location /swagger/ {
proxy_pass http://api:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+157 -2
View File
@@ -1,10 +1,153 @@
import axios from "axios";
import { ElNotification } from "element-plus";
import { markBackendAvailable, markBackendUnavailable } from "@/composables/useBackendStatus";
export const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "/api";
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL ?? "/api",
baseURL: apiBaseUrl,
timeout: 30000
});
let lastBackendUnavailableNotificationAt = 0;
const backendUnavailableNotificationIntervalMs = 8000;
function isDefaultAxiosMessage(message: string) {
return (
/^Request failed with status code \d+$/i.test(message) ||
message === "Network Error" ||
message.toLowerCase().includes("timeout")
);
}
function joinApiUrl(path: string) {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
const base = apiBaseUrl.replace(/\/+$/, "");
return `${base}${normalizedPath}`;
}
function getStatusFallbackMessage(status?: number) {
switch (status) {
case 400:
return "请求参数有误,请检查后重试。";
case 401:
return "登录状态已失效,请重新登录。";
case 403:
return "当前没有权限执行该操作。";
case 404:
return "请求的接口不存在,请确认前后端版本一致。";
case 409:
return "请求发生冲突,请刷新后重试。";
case 422:
return "提交的数据格式不正确,请检查后重试。";
case 500:
return "后端服务发生内部错误(500),请查看后端日志。";
case 502:
case 503:
case 504:
return "后端服务暂时不可用,请稍后重试。";
default:
return status && status >= 500
? `后端服务发生异常(${status}),请查看后端日志。`
: "请求失败,请稍后重试。";
}
}
export function isBackendUnavailableError(error: unknown) {
if (!axios.isAxiosError(error)) {
return false;
}
const status = error.response?.status;
return (
!error.response ||
error.code === "ERR_NETWORK" ||
error.code === "ECONNABORTED" ||
status === 502 ||
status === 503 ||
status === 504
);
}
export function getBackendUnavailableMessage() {
return "无法连接后端服务,请确认后端已启动后再重试。";
}
export function buildApiUrl(
path: string,
query?: Record<string, string | number | boolean | null | undefined>
) {
const url = new URL(joinApiUrl(path), window.location.origin);
if (query) {
Object.entries(query).forEach(([key, value]) => {
if (value === null || value === undefined || value === "") {
return;
}
url.searchParams.set(key, String(value));
});
}
return url.toString();
}
export function getApiErrorMessage(error: unknown, fallback = "请求失败,请稍后重试。") {
if (isBackendUnavailableError(error)) {
return getBackendUnavailableMessage();
}
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const responseData = error.response?.data as
| { message?: string; title?: string; detail?: string; error?: string }
| string
| undefined;
if (typeof responseData === "string" && responseData.trim().length > 0 && !isDefaultAxiosMessage(responseData)) {
return responseData;
}
if (responseData && typeof responseData === "object") {
const candidates = [responseData.message, responseData.title, responseData.detail, responseData.error];
const responseMessage = candidates.find(
(item): item is string => typeof item === "string" && item.trim().length > 0 && !isDefaultAxiosMessage(item)
);
if (responseMessage) {
return responseMessage;
}
}
if (typeof error.message === "string" && error.message.trim().length > 0 && !isDefaultAxiosMessage(error.message)) {
return error.message;
}
return getStatusFallbackMessage(status) || fallback;
}
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
return fallback;
}
function notifyBackendUnavailable(message: string) {
const now = Date.now();
if (now - lastBackendUnavailableNotificationAt < backendUnavailableNotificationIntervalMs) {
return;
}
lastBackendUnavailableNotificationAt = now;
ElNotification({
title: "后端连接异常",
message,
type: "error",
duration: 5000
});
}
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem("live-recorder-token");
if (token) {
@@ -15,9 +158,13 @@ apiClient.interceptors.request.use((config) => {
});
apiClient.interceptors.response.use(
(response) => response,
(response) => {
markBackendAvailable();
return response;
},
(error) => {
if (error.response?.status === 401) {
markBackendAvailable();
localStorage.removeItem("live-recorder-token");
localStorage.removeItem("live-recorder-user");
@@ -26,6 +173,14 @@ apiClient.interceptors.response.use(
}
}
if (isBackendUnavailableError(error)) {
const message = getBackendUnavailableMessage();
markBackendUnavailable(message);
notifyBackendUnavailable(message);
} else if (error.response) {
markBackendAvailable();
}
return Promise.reject(error);
}
);
+202 -35
View File
@@ -1,22 +1,43 @@
<script setup lang="ts">
import { computed } from "vue";
import { computed, ref, watch } from "vue";
import { useRouter, useRoute } from "vue-router";
import { useAuthStore } from "@/stores/auth";
import { useBackendStatus } from "@/composables/useBackendStatus";
import { useViewport } from "@/composables/useViewport";
import {
House,
VideoCamera,
Tickets,
Setting,
SwitchButton
SwitchButton,
Operation
} from "@element-plus/icons-vue";
const router = useRouter();
const route = useRoute();
const authStore = useAuthStore();
const { isMobile } = useViewport();
const { backendUnavailable, backendMessage } = useBackendStatus();
const mobileNavVisible = ref(false);
const displayName = computed(() => authStore.user?.displayName ?? "Operator");
const menuItems = [
{ index: "/live-rooms", label: "直播间管理", icon: House },
{ index: "/record-tasks", label: "录制任务", icon: VideoCamera },
{ index: "/logs", label: "系统日志", icon: Tickets },
{ index: "/settings", label: "系统设置", icon: Setting }
];
watch(
() => route.path,
() => {
mobileNavVisible.value = false;
}
);
async function handleLogout() {
mobileNavVisible.value = false;
await authStore.logout();
await router.push({ name: "login" });
}
@@ -24,31 +45,19 @@ async function handleLogout() {
<template>
<el-container class="shell">
<el-aside width="240px" class="sidebar">
<el-aside v-if="!isMobile" width="240px" class="sidebar">
<div class="brand">
<div class="brand__mark">LR</div>
<div>
<div class="brand__title">Live Recorder</div>
<div class="brand__subtitle">录制控制台</div>
<div class="brand__subtitle">直播录制控制台</div>
</div>
</div>
<el-menu :default-active="route.path" router class="menu">
<el-menu-item index="/live-rooms">
<el-icon><House /></el-icon>
<span>直播间管理</span>
</el-menu-item>
<el-menu-item index="/record-tasks">
<el-icon><VideoCamera /></el-icon>
<span>录制任务</span>
</el-menu-item>
<el-menu-item index="/logs">
<el-icon><Tickets /></el-icon>
<span>系统日志</span>
</el-menu-item>
<el-menu-item index="/settings">
<el-icon><Setting /></el-icon>
<span>系统设置</span>
<el-menu-item v-for="item in menuItems" :key="item.index" :index="item.index">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.label }}</span>
</el-menu-item>
</el-menu>
@@ -59,15 +68,72 @@ async function handleLogout() {
</div>
</el-aside>
<el-drawer
v-model="mobileNavVisible"
class="mobile-nav-drawer"
direction="ltr"
size="292px"
:with-header="false"
>
<div class="mobile-drawer">
<div class="brand brand--drawer">
<div class="brand__mark">LR</div>
<div>
<div class="brand__title">Live Recorder</div>
<div class="brand__subtitle">直播录制控制台</div>
</div>
</div>
<el-menu :default-active="route.path" router class="menu menu--drawer">
<el-menu-item v-for="item in menuItems" :key="item.index" :index="item.index">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.label }}</span>
</el-menu-item>
</el-menu>
<div class="sidebar-footer sidebar-footer--drawer">
<div class="sidebar-footer__label">当前账户</div>
<div class="sidebar-footer__name">{{ displayName }}</div>
<el-button :icon="SwitchButton" text @click="handleLogout">退出登录</el-button>
</div>
</div>
</el-drawer>
<el-container>
<el-header class="header">
<div>
<div class="header__title">直播录制控制台</div>
<div class="header__subtitle">平台适配巡检录制和结果统一管理</div>
<div class="header__shell">
<div class="header__lead">
<el-button
v-if="isMobile"
class="mobile-nav-trigger"
:icon="Operation"
@click="mobileNavVisible = true"
>
菜单
</el-button>
<div>
<div class="header__title">直播录制控制台</div>
<div class="header__subtitle">平台适配巡检录制和结果统一管理</div>
</div>
</div>
<div v-if="isMobile" class="header__account">
{{ displayName }}
</div>
</div>
</el-header>
<el-main class="main">
<el-alert
v-if="backendUnavailable"
class="backend-alert"
type="error"
:closable="false"
show-icon
title="后端服务不可用"
:description="backendMessage"
/>
<router-view />
</el-main>
</el-container>
@@ -87,6 +153,15 @@ async function handleLogout() {
background: transparent;
}
.mobile-drawer {
display: flex;
min-height: 100%;
flex-direction: column;
padding: 20px 16px 20px 20px;
background:
linear-gradient(180deg, rgba(250, 248, 243, 0.98), rgba(246, 244, 239, 0.98));
}
.brand {
display: flex;
align-items: center;
@@ -96,6 +171,11 @@ async function handleLogout() {
border-bottom: 1px solid rgba(82, 74, 63, 0.08);
}
.brand--drawer {
padding-left: 4px;
padding-right: 4px;
}
.brand__mark {
width: 42px;
height: 42px;
@@ -132,6 +212,10 @@ async function handleLogout() {
box-shadow: 0 14px 28px rgba(31, 24, 18, 0.05);
}
.menu--drawer {
flex: 1;
}
.menu :deep(.el-menu-item) {
height: 46px;
margin-bottom: 6px;
@@ -164,6 +248,10 @@ async function handleLogout() {
box-shadow: 0 12px 24px rgba(31, 24, 18, 0.04);
}
.sidebar-footer--drawer {
margin-top: 18px;
}
.sidebar-footer__label {
font-size: 12px;
letter-spacing: 0.08em;
@@ -179,10 +267,12 @@ async function handleLogout() {
}
.header {
height: auto;
flex: 0 0 auto;
padding: 22px 32px 0;
}
.header > div {
.header__shell {
display: flex;
align-items: center;
justify-content: space-between;
@@ -194,6 +284,17 @@ async function handleLogout() {
box-shadow: 0 16px 34px rgba(31, 24, 18, 0.05);
}
.header__lead {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.header__lead > div:last-child {
min-width: 0;
}
.header__title {
font-size: 22px;
font-weight: 700;
@@ -207,31 +308,97 @@ async function handleLogout() {
font-size: 13px;
}
.header__account {
flex-shrink: 0;
max-width: 120px;
color: #6f675f;
font-size: 12px;
text-align: right;
word-break: break-word;
}
.mobile-nav-trigger {
flex-shrink: 0;
}
.main {
min-height: 0;
padding: 28px 32px 36px;
}
@media (max-width: 960px) {
.shell {
flex-direction: column;
}
.backend-alert {
margin-bottom: 18px;
border-radius: 14px;
}
.sidebar {
width: 100% !important;
padding: 18px;
}
:deep(.mobile-nav-drawer .el-drawer__body) {
padding: 0;
}
@media (max-width: 768px) {
.header {
padding: 8px 18px 0;
padding: 12px 14px 0;
}
.header > div {
.header__shell {
align-items: center;
gap: 12px;
padding: 14px 16px;
}
.header__lead {
width: 100%;
align-items: flex-start;
flex-direction: column;
justify-content: space-between;
flex-direction: row;
gap: 12px;
}
.header__title {
font-size: 18px;
line-height: 1.12;
}
.header__subtitle {
max-width: 240px;
font-size: 12px;
line-height: 1.45;
}
.header__account {
display: none;
}
.mobile-nav-trigger {
min-width: 84px;
padding-inline: 14px;
}
.main {
padding: 22px 18px 24px;
padding: 18px 14px 24px;
}
.backend-alert {
margin-bottom: 14px;
}
}
@media (max-width: 480px) {
.header__shell {
padding: 12px 14px;
}
.header__title {
font-size: 17px;
}
.header__subtitle {
display: none;
}
.mobile-nav-trigger {
min-width: 72px;
padding-inline: 12px;
}
}
</style>
+44 -2
View File
@@ -66,6 +66,7 @@ body {
linear-gradient(180deg, #faf8f3 0%, #f6f4ef 42%, #f1eee9 100%);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow-x: hidden;
}
body,
@@ -106,7 +107,7 @@ select:focus-visible {
font-size: clamp(28px, 3vw, 38px);
font-weight: 700;
letter-spacing: -0.04em;
line-height: 1.02;
line-height: 1.06;
color: var(--text-primary);
}
@@ -252,6 +253,14 @@ select:focus-visible {
.monospace {
font-family: "SF Mono", "Cascadia Code", "JetBrains Mono", "Consolas", monospace;
word-break: break-all;
}
.table-scroll-shell {
width: 100%;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
}
.el-card {
@@ -536,10 +545,12 @@ select:focus-visible {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 14px;
}
.page-title {
font-size: 30px;
font-size: clamp(22px, 7vw, 28px);
line-height: 1.08;
}
.stats-grid {
@@ -549,4 +560,35 @@ select:focus-visible {
.surface-card .el-card__body {
padding: 18px;
}
.page-subtitle {
margin-top: 10px;
font-size: 12px;
line-height: 1.62;
}
.el-dialog {
width: min(100vw - 20px, 560px) !important;
margin: 6vh auto 0 !important;
}
.el-dialog__header {
padding: 18px 18px 8px;
}
.el-dialog__body {
padding: 10px 18px 4px;
}
.el-dialog__footer {
padding: 12px 18px 18px;
}
.el-space {
width: 100%;
}
.el-space__item {
max-width: 100%;
}
}
+82 -1
View File
@@ -21,7 +21,11 @@ export interface LiveRoom {
normalizedUrl: string;
title?: string;
anchorName?: string;
anchorId?: string;
avatarUrl?: string;
coverUrl?: string;
overrides: LiveRoomSettingsOverrides;
effectiveSettings: LiveRoomEffectiveSettings;
isEnabled: boolean;
availabilityStatus: number;
lastCheckedAt?: string;
@@ -29,6 +33,69 @@ export interface LiveRoom {
updatedAt: string;
}
export interface ImportLiveRoomsResult {
totalCount: number;
successCount: number;
failedCount: number;
createdCount: number;
updatedCount: number;
items: ImportLiveRoomItemResult[];
}
export interface ImportLiveRoomItemResult {
lineNumber: number;
rawLine: string;
url?: string;
anchorName?: string;
success: boolean;
created: boolean;
errorMessage?: string;
liveRoom?: LiveRoom;
}
export interface BatchLiveRoomsResult {
requestedCount: number;
successCount: number;
failedCount: number;
items: BatchLiveRoomItemResult[];
}
export interface BatchLiveRoomItemResult {
liveRoomId: string;
success: boolean;
errorMessage?: string;
}
export interface LiveRoomSettingsOverrides {
preferredQuality?: string | null;
outputFormat?: number | null;
saveMode?: number | null;
recordingTemplate?: number | null;
segmentDurationMinutes?: number | null;
enableAutoReconnect?: boolean | null;
reconnectDelayMaxSeconds?: number | null;
readWriteTimeoutMilliseconds?: number | null;
enableDanmakuRecording?: boolean | null;
danmakuIncludeNonChatEvents?: boolean | null;
danmakuMinPollIntervalMilliseconds?: number | null;
danmakuRetryDelayMaxSeconds?: number | null;
}
export interface LiveRoomEffectiveSettings {
preferredQuality: string;
outputFormat: number;
saveMode: number;
recordingTemplate: number;
segmentDurationMinutes: number;
enableAutoReconnect: boolean;
reconnectDelayMaxSeconds: number;
readWriteTimeoutMilliseconds: number;
enableDanmakuRecording: boolean;
danmakuIncludeNonChatEvents: boolean;
danmakuMinPollIntervalMilliseconds: number;
danmakuRetryDelayMaxSeconds: number;
}
export interface RecordTask {
id: string;
liveRoomId: string;
@@ -48,6 +115,9 @@ export interface RecordTask {
startedAt?: string;
endedAt?: string;
durationSeconds?: number;
postProcessStage?: string;
postProcessProgressPercent?: number;
postProcessDetail?: string;
}
export interface RecordResult {
@@ -130,6 +200,11 @@ export interface SystemSettings {
saveMode: number;
recordingTemplate: number;
segmentDurationMinutes: number;
maxConcurrentFfmpegTranscodeTasks: number;
mp4FinalizeTimeoutMinutes: number;
enableStorageGuard: boolean;
pauseRecordingWhenFreeSpaceBelowMegabytes: number;
resumeRecordingWhenFreeSpaceAboveMegabytes: number;
enableAutoReconnect: boolean;
reconnectDelayMaxSeconds: number;
readWriteTimeoutMilliseconds: number;
@@ -140,6 +215,11 @@ export interface SystemSettings {
enableBackgroundPolling: boolean;
autoStartRecordingOnLive: boolean;
pollingIntervalSeconds: number;
enableEventScripts: boolean;
liveStartedScriptPath: string;
liveEndedScriptPath: string;
segmentCompletedScriptPath: string;
eventScriptTimeoutSeconds: number;
enableEmailNotification: boolean;
emailSmtpHost: string;
emailSmtpPort: number;
@@ -173,7 +253,8 @@ export const taskStatusLabelMap: Record<number, string> = {
3: "Stopping",
4: "Completed",
5: "Failed",
6: "Stopped"
6: "Stopped",
7: "Processing"
};
export const sessionStatusLabelMap = taskStatusLabelMap;
File diff suppressed because it is too large Load Diff
+48 -5
View File
@@ -3,11 +3,14 @@ import { reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { Lock, User } from "@element-plus/icons-vue";
import { getApiErrorMessage, isBackendUnavailableError } from "@/api/client";
import { useBackendStatus } from "@/composables/useBackendStatus";
import { useAuthStore } from "@/stores/auth";
const router = useRouter();
const authStore = useAuthStore();
const loading = ref(false);
const { backendUnavailable, backendMessage } = useBackendStatus();
const form = reactive({
username: "admin",
@@ -22,7 +25,11 @@ async function handleLogin() {
ElMessage.success("登录成功");
await router.push({ name: "live-rooms" });
} catch (error) {
ElMessage.error("登录失败,请检查账号密码");
ElMessage.error(
isBackendUnavailableError(error)
? getApiErrorMessage(error)
: "登录失败,请检查账号密码"
);
} finally {
loading.value = false;
}
@@ -35,20 +42,30 @@ async function handleLogin() {
<div class="login-copy__eyebrow">Live Recorder</div>
<h1>把直播录制做成可长期维护的系统而不是一次性的脚本</h1>
<p>
平台适配后台巡检录制任务日志和系统设置统一收口
平台适配后台巡检录制任务系统日志和设置统一管理
默认账号为 <span class="monospace">admin / Admin@123</span>
</p>
<div class="login-copy__list">
<span>抖音 API 适配</span>
<span>多平台直播适配</span>
<span>自动开播巡检</span>
<span>ffmpeg 录制编排</span>
<span>邮件通知</span>
<span>邮件与异常通知</span>
</div>
</div>
<el-card class="login-card surface-card" shadow="never">
<el-alert
v-if="backendUnavailable"
class="login-card__alert"
type="error"
:closable="false"
show-icon
title="后端服务不可用"
:description="backendMessage"
/>
<h3 class="section-title">登录</h3>
<p class="section-subtitle">进入控制台后可管理直播间录制任务日志和系统设置</p>
<p class="section-subtitle">登录后即可进入控制台管理直播间录制任务系统日志和基础设置</p>
<el-form label-position="top" @submit.prevent="handleLogin">
<el-form-item label="用户名">
@@ -168,6 +185,11 @@ async function handleLogin() {
padding: 14px;
}
.login-card__alert {
margin-bottom: 16px;
border-radius: 14px;
}
.login-card__submit {
width: 100%;
margin-top: 12px;
@@ -189,4 +211,25 @@ async function handleLogin() {
margin: 0;
}
}
@media (max-width: 640px) {
.login-page {
gap: 22px;
padding: 22px 14px;
}
.login-copy h1 {
margin-top: 18px;
font-size: clamp(30px, 10vw, 40px);
}
.login-copy p {
font-size: 14px;
line-height: 1.75;
}
.login-card {
padding: 8px;
}
}
</style>
+98 -26
View File
@@ -1,31 +1,44 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import apiClient from "@/api/client";
import apiClient, { getApiErrorMessage } from "@/api/client";
import { useViewport } from "@/composables/useViewport";
import type { SystemLog } from "@/types";
import { logLevelLabelMap } from "@/types";
const loading = ref(false);
const logs = ref<SystemLog[]>([]);
const loadError = ref("");
const { isMobile } = useViewport();
const filters = reactive({
liveRoomId: "",
level: undefined as number | undefined,
recordTaskId: "",
take: 200
});
const logLevelOptions = Object.entries(logLevelLabelMap).map(([value, label]) => ({
value: Number(value),
label
}));
async function loadLogs() {
loading.value = true;
loadError.value = "";
try {
const { data } = await apiClient.get<SystemLog[]>("/logs", {
params: {
liveRoomId: filters.liveRoomId || undefined,
level: typeof filters.level === "number" ? filters.level : undefined,
recordTaskId: filters.recordTaskId || undefined,
take: filters.take
}
});
logs.value = data;
} catch (error) {
loadError.value = getApiErrorMessage(error, "系统日志加载失败,请稍后重试。");
} finally {
loading.value = false;
}
@@ -55,18 +68,32 @@ onMounted(loadLogs);
<div class="page-header">
<div>
<h1 class="page-title">系统日志</h1>
<p class="page-subtitle">支持按直播间或任务过滤日志便于定位解析失败巡检异常和 ffmpeg 输出问题</p>
<p class="page-subtitle">
支持按直播间任务和日志级别筛选便于快速定位解析失败巡检异常和 ffmpeg 输出问题
</p>
</div>
</div>
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<el-card class="surface-card filter-card" shadow="never">
<h3 class="section-title">日志过滤</h3>
<p class="section-subtitle">可选传入直播间 ID 或录制任务 ID数量默认取最近 200 </p>
<p class="section-subtitle">默认拉取最近 200 条日志可按直播间 ID任务 ID 和级别缩小范围</p>
<el-form class="filter-form">
<el-form-item label="直播间 ID">
<el-input v-model="filters.liveRoomId" placeholder="可选" />
</el-form-item>
<el-form-item label="日志级别">
<el-select v-model="filters.level" clearable placeholder="全部级别">
<el-option
v-for="option in logLevelOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<el-form-item label="任务 ID">
<el-input v-model="filters.recordTaskId" placeholder="可选" />
</el-form-item>
@@ -81,30 +108,38 @@ onMounted(loadLogs);
<el-card class="surface-card table-card" shadow="never">
<h3 class="section-title">日志列表</h3>
<p class="section-subtitle">按时间倒序排列便于快速回看最近一次异常</p>
<p class="section-subtitle">按时间倒序排列优先展示最近发生的事件</p>
<el-table :data="logs" v-loading="loading" height="720" class="premium-table">
<el-table-column label="级别" width="100">
<template #default="{ row }">
<el-tag :type="levelTagType(row.level)">
{{ logLevelLabelMap[row.level] }}
</el-tag>
</template>
</el-table-column>
<div class="table-scroll-shell logs-table-shell">
<el-table
:data="logs"
v-loading="loading"
:height="isMobile ? undefined : 720"
class="premium-table logs-table"
table-layout="auto"
>
<el-table-column label="级别" width="100">
<template #default="{ row }">
<el-tag :type="levelTagType(row.level)">
{{ logLevelLabelMap[row.level] }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="分类" width="120" prop="category" />
<el-table-column label="消息" min-width="240" prop="message" />
<el-table-column label="详情" min-width="360">
<template #default="{ row }">
<span class="monospace log-detail">{{ row.detail || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="时间" width="180">
<template #default="{ row }">
{{ formatDate(row.createdAt) }}
</template>
</el-table-column>
</el-table>
<el-table-column label="分类" width="128" prop="category" />
<el-table-column label="消息" min-width="240" prop="message" />
<el-table-column label="详情" min-width="360">
<template #default="{ row }">
<span class="monospace log-detail">{{ row.detail || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="时间" width="180">
<template #default="{ row }">
<span class="log-date-text">{{ formatDate(row.createdAt) }}</span>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</div>
</template>
@@ -115,6 +150,10 @@ onMounted(loadLogs);
gap: 24px;
}
.page-error-alert {
border-radius: 14px;
}
.filter-card :deep(.el-card__body),
.table-card :deep(.el-card__body) {
padding-top: 20px;
@@ -122,7 +161,7 @@ onMounted(loadLogs);
.filter-form {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px 12px;
align-items: end;
}
@@ -134,6 +173,35 @@ onMounted(loadLogs);
.log-detail {
font-size: 12px;
color: #6d665b;
line-height: 1.6;
}
.log-date-text {
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.logs-table-shell {
margin-inline: -4px;
padding-inline: 4px;
}
.logs-table {
min-width: 980px;
}
.logs-table :deep(.el-table__cell) {
vertical-align: top;
}
.logs-table :deep(.cell) {
overflow: visible;
}
@media (max-width: 1200px) {
.filter-form {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
@@ -150,5 +218,9 @@ onMounted(loadLogs);
.filter-form {
grid-template-columns: 1fr;
}
.filter-form :deep(.el-button) {
width: 100%;
}
}
</style>
+162 -10
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { onMounted, ref, watch } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import apiClient from "@/api/client";
import { ElMessage } from "element-plus";
import apiClient, { getApiErrorMessage } from "@/api/client";
import { useViewport } from "@/composables/useViewport";
import type { RecordPreviewTicket, RecordTaskDetail } from "@/types";
import {
logLevelLabelMap,
@@ -17,18 +19,38 @@ const props = defineProps<{
const router = useRouter();
const loading = ref(false);
const previewLoading = ref(false);
const manualTranscodeLoading = ref(false);
const detail = ref<RecordTaskDetail | null>(null);
const loadError = ref("");
const previewUrl = ref("");
const previewExpiresAt = ref("");
const previewMessage = ref("");
const { isMobile } = useViewport();
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;
const result = detail.value?.result;
if (!task || task.outputFormat !== 0 || task.postProcessStage || activeTaskStatuses.has(task.status)) {
return false;
}
const resultPath = result?.filePath?.toLowerCase() ?? "";
const errorText = `${task.errorMessage ?? ""} ${result?.errorMessage ?? ""}`.toLowerCase();
return resultPath.endsWith(".ts") || errorText.includes("finaliz") || errorText.includes("intermediate ts");
});
async function loadDetailAndPreview() {
loading.value = true;
loadError.value = "";
try {
const { data } = await apiClient.get<RecordTaskDetail>(`/record-tasks/${props.id}`);
detail.value = data;
await loadPreview();
} catch (error) {
loadError.value = getApiErrorMessage(error, "任务详情加载失败,请稍后重试。");
} finally {
loading.value = false;
}
@@ -43,8 +65,10 @@ async function loadPreview() {
return;
}
if (detail.value.task.status !== 4) {
previewMessage.value = "录制未完成时不提供视频预览。";
if (detail.value.task.postProcessStage) {
previewMessage.value = detail.value.task.postProcessDetail
? `${detail.value.task.postProcessStage}${detail.value.task.postProcessDetail}`
: `${detail.value.task.postProcessStage},完成后即可预览并拖动进度条。`;
return;
}
@@ -58,6 +82,23 @@ async function loadPreview() {
return;
}
if (detail.value.result.filePath.toLowerCase().endsWith(".ts")) {
previewMessage.value = canManualTranscode.value
? "当前分片仍保留中间 TS 文件,可手动触发转码后再预览。"
: "当前分片仍保留中间 TS 文件,暂时无法直接预览。";
return;
}
if (detail.value.task.status !== 4 && detail.value.task.status !== 6) {
previewMessage.value = "当前仅支持已完成收尾的 MP4 分片预览。";
return;
}
if (!detail.value.result.filePath.toLowerCase().endsWith(".mp4")) {
previewMessage.value = "当前仅支持 MP4 分片预览。";
return;
}
previewLoading.value = true;
try {
@@ -71,11 +112,30 @@ async function loadPreview() {
}
}
async function startManualTranscode() {
manualTranscodeLoading.value = true;
try {
const { data } = await apiClient.post<RecordTaskDetail>(`/record-tasks/${props.id}/transcode`);
detail.value = data;
await loadPreview();
ElMessage.success("已开始手动转码,请稍后刷新查看进度。");
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "手动转码启动失败,请稍后重试。"));
} finally {
manualTranscodeLoading.value = false;
}
}
function statusTagType(status: number) {
if (status === 2) {
return "success";
}
if (status === 7) {
return "warning";
}
if (status === 5) {
return "danger";
}
@@ -123,6 +183,10 @@ function formatFileSize(bytes?: number) {
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
function formatProgress(value?: number) {
return typeof value === "number" && Number.isFinite(value) ? `${value.toFixed(0)}%` : "-";
}
watch(() => props.id, loadDetailAndPreview);
onMounted(loadDetailAndPreview);
</script>
@@ -143,10 +207,12 @@ onMounted(loadDetailAndPreview);
</el-space>
</div>
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<el-skeleton v-if="loading && !detail" animated :rows="8" />
<template v-else-if="detail">
<el-row :gutter="18">
<el-row :gutter="rowGutter">
<el-col :lg="12" :sm="24">
<el-card class="surface-card detail-card" shadow="never">
<h3 class="section-title">任务信息</h3>
@@ -173,6 +239,22 @@ onMounted(loadDetailAndPreview);
<el-descriptions-item label="输出文件">
<span class="monospace">{{ detail.task.outputFilePath || "-" }}</span>
</el-descriptions-item>
<el-descriptions-item label="后处理状态">
<template v-if="detail.task.postProcessStage">
<div class="detail-postprocess">
<div class="detail-postprocess__stage">{{ detail.task.postProcessStage }}</div>
<el-progress
:percentage="Math.max(0, Math.min(100, detail.task.postProcessProgressPercent ?? 0))"
:stroke-width="6"
:show-text="false"
/>
<div class="detail-postprocess__detail">
{{ detail.task.postProcessDetail || formatProgress(detail.task.postProcessProgressPercent) }}
</div>
</div>
</template>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="错误信息">
{{ detail.task.errorMessage || "-" }}
</el-descriptions-item>
@@ -223,11 +305,22 @@ onMounted(loadDetailAndPreview);
<div>
<h3 class="section-title">视频预览</h3>
<p class="section-subtitle">
仅支持已完成的 MP4 分片预览票据会短期失效过期后刷新即可重新获取
仅支持已完成收尾 MP4 分片预览票据会短期失效过期后刷新即可重新获取
</p>
</div>
<div class="preview-meta" v-if="previewExpiresAt">
票据有效至 {{ formatDate(previewExpiresAt) }}
<div class="preview-actions">
<div class="preview-meta" v-if="previewExpiresAt">
票据有效至 {{ formatDate(previewExpiresAt) }}
</div>
<el-button
v-if="canManualTranscode"
type="primary"
plain
:loading="manualTranscodeLoading"
@click="startManualTranscode"
>
手动转码
</el-button>
</div>
</div>
@@ -249,7 +342,8 @@ onMounted(loadDetailAndPreview);
<h3 class="section-title">关联日志</h3>
<p class="section-subtitle">日志已带上会话与任务关联可看到 ffmpeg巡检和弹幕采集的上下文</p>
<el-table :data="detail.logs" height="420" class="premium-table">
<div class="table-scroll-shell detail-logs-shell">
<el-table :data="detail.logs" :height="logTableHeight" class="premium-table detail-logs-table">
<el-table-column label="级别" width="100">
<template #default="{ row }">
<el-tag :type="logTagType(row.level)">
@@ -270,7 +364,8 @@ onMounted(loadDetailAndPreview);
{{ formatDate(row.createdAt) }}
</template>
</el-table-column>
</el-table>
</el-table>
</div>
</el-card>
</template>
</div>
@@ -282,10 +377,23 @@ onMounted(loadDetailAndPreview);
gap: 24px;
}
.page-error-alert {
border-radius: 14px;
}
.header-actions {
align-self: center;
}
.detail-logs-shell {
margin-inline: -4px;
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) {
@@ -307,6 +415,12 @@ onMounted(loadDetailAndPreview);
font-size: 12px;
}
.preview-actions {
display: flex;
align-items: center;
gap: 12px;
}
.preview-player {
width: 100%;
max-height: 560px;
@@ -326,15 +440,53 @@ onMounted(loadDetailAndPreview);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.76), rgba(246, 243, 238, 0.78));
}
.detail-postprocess {
display: grid;
gap: 8px;
}
.detail-postprocess__stage {
font-size: 13px;
font-weight: 600;
color: #31465b;
}
.detail-postprocess__detail {
font-size: 12px;
color: #6c7a86;
line-height: 1.4;
}
.log-detail {
font-size: 12px;
color: #6d665b;
}
@media (max-width: 768px) {
.header-actions {
width: 100%;
justify-content: stretch;
}
.header-actions :deep(.el-button) {
flex: 1 1 0;
margin: 0;
}
.preview-header {
flex-direction: column;
align-items: flex-start;
margin-bottom: 16px;
padding-bottom: 16px;
}
.preview-actions {
width: 100%;
justify-content: space-between;
}
.preview-player {
max-height: 360px;
}
}
</style>
+344 -101
View File
@@ -1,8 +1,14 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElNotification } from "element-plus";
import apiClient from "@/api/client";
import apiClient, {
buildApiUrl,
getApiErrorMessage,
getBackendUnavailableMessage
} from "@/api/client";
import { markBackendAvailable, markBackendUnavailable } from "@/composables/useBackendStatus";
import { useViewport } from "@/composables/useViewport";
import type { DeleteCompletedRecordTasksResult, RecordSession, RecordTask } from "@/types";
import {
outputFormatLabelMap,
@@ -20,8 +26,14 @@ const deleteDialogMode = ref<"tasks" | "sessions">("tasks");
const deleteDialogTaskIds = ref<string[]>([]);
const deleteDialogSessionIds = ref<string[]>([]);
const sessions = ref<RecordSession[]>([]);
const loadError = ref("");
const activeSessionPanels = ref<string[]>([]);
const selectedTaskMap = ref<Record<string, RecordTask>>({});
const realtimeConnected = ref(false);
const realtimeError = ref("");
const { isMobile } = useViewport();
let sessionsEventSource: EventSource | null = null;
const selectedTasks = computed(() => Object.values(selectedTaskMap.value));
const activeSessionCount = computed(() => sessions.value.filter((item) => isActiveStatus(item.status)).length);
@@ -29,12 +41,13 @@ const totalTaskCount = computed(() => sessions.value.reduce((sum, item) => sum +
const totalDanmakuCount = computed(() => sessions.value.reduce((sum, item) => sum + item.totalDanmakuMessageCount, 0));
const deleteDialogTaskCount = computed(() => deleteDialogTaskIds.value.length);
const deleteDialogSessionCount = computed(() => deleteDialogSessionIds.value.length);
const deleteDialogEyebrow = computed(() => deleteDialogMode.value === "sessions" ? "会话删除" : "删除确认");
const deleteDialogTitle = computed(() => deleteDialogMode.value === "sessions" ? "删除录制会话" : "删除分片任务");
const deleteDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 560px)" : "clamp(480px, 48vw, 560px)"));
const deleteDialogEyebrow = computed(() => (deleteDialogMode.value === "sessions" ? "会话删除" : "删除确认"));
const deleteDialogTitle = computed(() => (deleteDialogMode.value === "sessions" ? "删除录制会话" : "删除分片任务"));
const deleteDialogLead = computed(() =>
deleteDialogMode.value === "sessions"
? `将删除 ${deleteDialogSessionCount.value} 个录制会话。删除会先停止当前录制,再清理该会话下的分片记录;你也可以选择同时删除本地视频和弹幕 XML 文件。`
: `将删除 ${deleteDialogTaskCount.value} 个已结束分片任务。你可以只移除数据库记录,也可以同时清理本地视频和弹幕 XML 文件。`
: `将删除 ${deleteDialogTaskCount.value} 个已选择分片任务。你可以只移除数据库记录,也可以同时清理本地视频和弹幕 XML 文件。`
);
const deleteDialogNote = computed(() =>
deleteDialogMode.value === "sessions"
@@ -42,21 +55,8 @@ const deleteDialogNote = computed(() =>
: "“记录 + 文件”会尝试删除视频文件和对应弹幕 XML。文件不存在时不会阻断删除,但会返回警告信息。"
);
async function loadSessions() {
loading.value = true;
try {
const { data } = await apiClient.get<RecordSession[]>("/record-sessions");
sessions.value = data;
activeSessionPanels.value = data.slice(0, 4).map((item) => item.id);
selectedTaskMap.value = {};
} finally {
loading.value = false;
}
}
function isActiveStatus(status: number) {
return status === 1 || status === 2 || status === 3;
return status === 1 || status === 2 || status === 3 || status === 7;
}
function isDeletableTask(task: RecordTask) {
@@ -67,6 +67,123 @@ function selectableTask(row: RecordTask) {
return isDeletableTask(row);
}
function applySessionsSnapshot(
data: RecordSession[],
options?: {
resetPanels?: boolean;
resetSelection?: boolean;
}
) {
const nextSessionIds = new Set(data.map((item) => item.id));
const nextTaskMap = new Map<string, RecordTask>();
data.forEach((session) => {
session.tasks.forEach((task) => {
nextTaskMap.set(task.id, task);
});
});
sessions.value = data;
if (options?.resetPanels || activeSessionPanels.value.length === 0) {
activeSessionPanels.value = data.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 (options?.resetSelection) {
selectedTaskMap.value = {};
return;
}
selectedTaskMap.value = Object.fromEntries(
Object.entries(selectedTaskMap.value)
.map(([taskId]) => {
const task = nextTaskMap.get(taskId);
return task && isDeletableTask(task) ? [taskId, task] : null;
})
.filter((item): item is [string, RecordTask] => Boolean(item))
);
}
async function loadSessions(options?: { resetPanels?: boolean; resetSelection?: boolean }) {
loading.value = true;
loadError.value = "";
try {
const { data } = await apiClient.get<RecordSession[]>("/record-sessions");
applySessionsSnapshot(data, {
resetPanels: options?.resetPanels ?? true,
resetSelection: options?.resetSelection ?? true
});
markBackendAvailable();
} catch (error) {
loadError.value = getApiErrorMessage(error, "录制会话加载失败,请稍后重试。");
} finally {
loading.value = false;
}
}
function connectRealtimeUpdates() {
if (typeof window === "undefined" || typeof EventSource === "undefined") {
realtimeError.value = "当前浏览器不支持实时更新,仍可手动刷新列表。";
return;
}
const token = localStorage.getItem("live-recorder-token");
if (!token) {
realtimeError.value = "未检测到登录凭证,实时更新未启用。";
return;
}
closeRealtimeUpdates();
sessionsEventSource = new EventSource(
buildApiUrl("/record-sessions/stream", {
access_token: token
})
);
sessionsEventSource.onopen = () => {
realtimeConnected.value = true;
realtimeError.value = "";
loadError.value = "";
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.onerror = () => {
realtimeConnected.value = false;
realtimeError.value = "实时更新连接中断,正在自动重连。";
markBackendUnavailable(getBackendUnavailableMessage());
};
}
function closeRealtimeUpdates() {
if (!sessionsEventSource) {
return;
}
sessionsEventSource.close();
sessionsEventSource = null;
}
function handleSelectionChange(session: RecordSession, selection: RecordTask[]) {
const nextMap = { ...selectedTaskMap.value };
session.tasks.forEach((task) => {
@@ -87,8 +204,8 @@ async function stopSession(session: RecordSession) {
try {
await apiClient.post(`/record-sessions/${session.id}/stop`);
ElMessage.success("已发送停止会话请求");
await loadSessions();
ElMessage.success("已发送停止会话请求");
await loadSessions({ resetPanels: false, resetSelection: false });
} finally {
stoppingSessionId.value = null;
}
@@ -100,7 +217,7 @@ function openDetail(task: RecordTask) {
function openDeleteTaskDialog(taskIds: string[]) {
if (taskIds.length === 0) {
ElMessage.warning("请选择可删除的分片任务");
ElMessage.warning("请选择可删除的分片任务");
return;
}
@@ -112,7 +229,7 @@ function openDeleteTaskDialog(taskIds: string[]) {
function openDeleteSessionDialog(sessionIds: string[]) {
if (sessionIds.length === 0) {
ElMessage.warning("请选择要删除的录制会话");
ElMessage.warning("请选择要删除的录制会话");
return;
}
@@ -177,15 +294,15 @@ async function confirmDelete(deleteFiles: boolean) {
ElMessage.success(
deletingSessions
? `已删除 ${data.deletedSessionIds.length} 个录制会话`
: `已删除 ${data.deletedTaskIds.length} 个分片任务`
? `已删除 ${data.deletedSessionIds.length} 个录制会话`
: `已删除 ${data.deletedTaskIds.length} 个分片任务`
);
applyDeleteResult(data);
if (data.warnings.length > 0) {
ElNotification({
title: "删除完成,但有提示",
title: "删除完成,但有提示",
message: data.warnings.join("\n"),
type: "warning",
duration: 8000
@@ -194,7 +311,7 @@ async function confirmDelete(deleteFiles: boolean) {
deleteDialogVisible.value = false;
resetDeleteDialogState();
await loadSessions();
await loadSessions({ resetPanels: false, resetSelection: false });
} finally {
deleting.value = false;
}
@@ -220,6 +337,14 @@ function taskTagType(status: number) {
return sessionTagType(status);
}
function hasPostProcess(task: RecordTask) {
return Boolean(task.postProcessStage);
}
function formatProgress(value?: number) {
return typeof value === "number" && Number.isFinite(value) ? `${value.toFixed(0)}%` : "处理中";
}
function formatDate(value?: string) {
return value ? new Date(value).toLocaleString() : "-";
}
@@ -248,7 +373,14 @@ function formatFileSize(bytes?: number) {
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
onMounted(loadSessions);
onMounted(async () => {
await loadSessions();
connectRealtimeUpdates();
});
onBeforeUnmount(() => {
closeRealtimeUpdates();
});
</script>
<template>
@@ -257,12 +389,12 @@ onMounted(loadSessions);
<div>
<h1 class="page-title">录制任务</h1>
<p class="page-subtitle">
每一场直播会话分组展示分段模式下一个分片就是一个任务结束后的分片可按需删除整场会话也支持直接清理
直播会话聚合展示分片任务列表会自动接收状态转码进度和分片变更保留手动刷新入口用于兜底
</p>
</div>
<el-space wrap class="header-actions">
<el-button @click="loadSessions">刷新列表</el-button>
<el-button @click="loadSessions()">刷新列表</el-button>
<el-button
type="danger"
plain
@@ -270,11 +402,22 @@ onMounted(loadSessions);
:loading="deleting"
@click="openDeleteTaskDialog(selectedTasks.map((item) => item.id))"
>
批量删除已结束任务
删除已选分片{{ selectedTasks.length > 0 ? `${selectedTasks.length}` : "" }}
</el-button>
</el-space>
</div>
<el-alert
v-if="realtimeError"
class="page-stream-alert"
type="warning"
:closable="false"
show-icon
:title="realtimeError"
/>
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<div class="stats-grid">
<div class="stat-card">
<div class="stat-card__label">录制会话</div>
@@ -284,7 +427,7 @@ onMounted(loadSessions);
<div class="stat-card">
<div class="stat-card__label">活跃会话</div>
<div class="stat-card__value">{{ activeSessionCount }}</div>
<div class="stat-card__hint">Starting / Running / Stopping</div>
<div class="stat-card__hint">Starting / Running / Stopping / Processing</div>
</div>
<div class="stat-card">
<div class="stat-card__label">分片任务</div>
@@ -303,7 +446,7 @@ onMounted(loadSessions);
<div>
<h3 class="section-title">会话列表</h3>
<p class="section-subtitle">
会话层负责整场直播分片层负责单个视频文件停止入口作用于整个会话详情入口作用于单个分片
会话层代表整场录制分片层代表单个视频文件停止入口作用于整个会话详情入口作用于单个分片
</p>
</div>
</div>
@@ -379,80 +522,96 @@ onMounted(loadSessions);
</el-button>
</div>
<el-table
:data="session.tasks"
row-key="id"
class="premium-table nested-table"
@selection-change="handleSelectionChange(session, $event)"
>
<el-table-column type="selection" width="48" :selectable="selectableTask" />
<div class="table-scroll-shell tasks-table-shell">
<el-table
:data="session.tasks"
row-key="id"
class="premium-table nested-table"
table-layout="auto"
@selection-change="handleSelectionChange(session, $event)"
>
<el-table-column type="selection" width="48" :selectable="selectableTask" :reserve-selection="true" />
<el-table-column label="分片" width="90">
<template #default="{ row }">
<span class="monospace">#{{ row.segmentIndex }}</span>
</template>
</el-table-column>
<el-table-column label="分片" width="90">
<template #default="{ row }">
<span class="monospace">#{{ row.segmentIndex }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="taskTagType(row.status)">
{{ taskStatusLabelMap[row.status] }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="220">
<template #default="{ row }">
<div class="task-status-cell">
<el-tag :type="taskTagType(row.status)">
{{ taskStatusLabelMap[row.status] }}
</el-tag>
<template v-if="hasPostProcess(row)">
<div class="task-status-cell__stage">{{ row.postProcessStage }}</div>
<el-progress
:percentage="Math.max(0, Math.min(100, row.postProcessProgressPercent ?? 0))"
:stroke-width="6"
:show-text="false"
/>
<div class="task-status-cell__detail">
{{ row.postProcessDetail || formatProgress(row.postProcessProgressPercent) }}
</div>
</template>
</div>
</template>
</el-table-column>
<el-table-column label="清晰度" width="120">
<template #default="{ row }">
<span class="monospace">{{ row.preferredQuality }}</span>
</template>
</el-table-column>
<el-table-column label="清晰度" width="120">
<template #default="{ row }">
<span class="monospace">{{ row.preferredQuality }}</span>
</template>
</el-table-column>
<el-table-column label="输出文件" min-width="320">
<template #default="{ row }">
<div class="monospace path-text">{{ row.outputFilePath || "-" }}</div>
</template>
</el-table-column>
<el-table-column label="输出文件" min-width="320">
<template #default="{ row }">
<div class="monospace path-text">{{ row.outputFilePath || "-" }}</div>
</template>
</el-table-column>
<el-table-column label="开始时间" width="180">
<template #default="{ row }">
{{ formatDate(row.startedAt || row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="开始时间" width="180">
<template #default="{ row }">
<div class="task-date-text">{{ formatDate(row.startedAt || row.createdAt) }}</div>
</template>
</el-table-column>
<el-table-column label="时长" width="100">
<template #default="{ row }">
{{ formatDuration(row.durationSeconds) }}
</template>
</el-table-column>
<el-table-column label="时长" width="100">
<template #default="{ row }">
{{ formatDuration(row.durationSeconds) }}
</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-space wrap>
<el-button size="small" @click="openDetail(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-space>
</template>
</el-table-column>
</el-table>
<el-table-column label="操作" width="220">
<template #default="{ row }">
<div class="task-actions-cell">
<el-button size="small" @click="openDetail(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>
</div>
</template>
</el-table-column>
</el-table>
</div>
</div>
</el-collapse-item>
</el-collapse>
@@ -461,7 +620,7 @@ onMounted(loadSessions);
<el-dialog
v-model="deleteDialogVisible"
class="task-delete-dialog"
width="clamp(480px, 48vw, 560px)"
:width="deleteDialogWidth"
:show-close="!deleting"
:close-on-click-modal="!deleting"
:close-on-press-escape="!deleting"
@@ -507,6 +666,16 @@ onMounted(loadSessions);
align-self: center;
}
.page-error-alert,
.page-stream-alert {
border-radius: 14px;
}
.tasks-table-shell {
margin-inline: -4px;
padding-inline: 4px;
}
.sessions-card :deep(.el-card__body) {
padding-top: 18px;
}
@@ -620,11 +789,56 @@ onMounted(loadSessions);
.nested-table {
border-radius: 12px;
min-width: 1220px;
}
.nested-table :deep(.el-table__cell) {
vertical-align: top;
}
.nested-table :deep(.cell) {
overflow: visible;
}
.path-text {
font-size: 12px;
color: #65717c;
line-height: 1.6;
}
.task-date-text {
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.task-status-cell {
display: grid;
gap: 8px;
min-width: 0;
}
.task-status-cell__stage {
font-size: 12px;
font-weight: 600;
color: #31465b;
}
.task-status-cell__detail {
font-size: 12px;
color: #6c7a86;
line-height: 1.4;
}
.task-actions-cell {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
white-space: nowrap;
}
.task-actions-cell :deep(.el-button) {
margin: 0;
}
.delete-dialog__header {
@@ -707,6 +921,16 @@ onMounted(loadSessions);
}
@media (max-width: 960px) {
.header-actions {
align-self: stretch;
justify-content: stretch;
}
.header-actions :deep(.el-button) {
flex: 1 1 0;
margin: 0;
}
.session-title {
display: grid;
}
@@ -718,9 +942,28 @@ onMounted(loadSessions);
.session-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.session-actions {
justify-content: stretch;
flex-wrap: wrap;
}
.session-actions :deep(.el-button) {
flex: 1 1 calc(50% - 6px);
margin: 0;
}
}
@media (max-width: 640px) {
.header-actions :deep(.el-space__item) {
width: 100%;
}
.header-actions :deep(.el-button) {
width: 100%;
margin: 0;
}
.session-summary {
grid-template-columns: 1fr;
}
+133 -1
View File
@@ -1,13 +1,14 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import apiClient from "@/api/client";
import apiClient, { getApiErrorMessage } from "@/api/client";
import type { SystemSettings } from "@/types";
import { outputFormatLabelMap, recordingTemplateLabelMap, saveModeLabelMap } from "@/types";
const loading = ref(false);
const saving = ref(false);
const testingEmail = ref(false);
const loadError = ref("");
const form = reactive<SystemSettings>({
ffmpegPath: "ffmpeg",
@@ -19,6 +20,11 @@ const form = reactive<SystemSettings>({
saveMode: 0,
recordingTemplate: 0,
segmentDurationMinutes: 30,
maxConcurrentFfmpegTranscodeTasks: 1,
mp4FinalizeTimeoutMinutes: 60,
enableStorageGuard: true,
pauseRecordingWhenFreeSpaceBelowMegabytes: 1024,
resumeRecordingWhenFreeSpaceAboveMegabytes: 4096,
enableAutoReconnect: true,
reconnectDelayMaxSeconds: 5,
readWriteTimeoutMilliseconds: 15000000,
@@ -29,6 +35,11 @@ const form = reactive<SystemSettings>({
enableBackgroundPolling: true,
autoStartRecordingOnLive: true,
pollingIntervalSeconds: 60,
enableEventScripts: false,
liveStartedScriptPath: "",
liveEndedScriptPath: "",
segmentCompletedScriptPath: "",
eventScriptTimeoutSeconds: 60,
enableEmailNotification: false,
emailSmtpHost: "",
emailSmtpPort: 587,
@@ -120,10 +131,13 @@ const nestedSegmentedExamplePath = computed(() => {
async function loadSettings() {
loading.value = true;
loadError.value = "";
try {
const { data } = await apiClient.get<SystemSettings>("/settings");
Object.assign(form, data);
} catch (error) {
loadError.value = getApiErrorMessage(error, "系统设置加载失败,请稍后重试。");
} finally {
loading.value = false;
}
@@ -185,6 +199,8 @@ onMounted(loadSettings);
<el-button type="primary" :loading="saving" @click="saveSettings">保存设置</el-button>
</div>
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<div class="settings-grid" v-loading="loading">
<el-card class="surface-card settings-card" shadow="never">
<h3 class="section-title">录制基础</h3>
@@ -248,6 +264,16 @@ onMounted(loadSettings);
<el-input-number v-model="form.segmentDurationMinutes" :min="1" :max="720" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="同时转码任务数">
<el-input-number v-model="form.maxConcurrentFfmpegTranscodeTasks" :min="1" :max="16" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="MP4 转码超时(分钟)">
<el-input-number v-model="form.mp4FinalizeTimeoutMinutes" :min="1" :max="1440" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="最大重连延迟(秒)">
<el-input-number v-model="form.reconnectDelayMaxSeconds" :min="1" :max="300" />
@@ -267,6 +293,45 @@ onMounted(loadSettings);
</el-form>
</el-card>
<el-card class="surface-card settings-card" shadow="never">
<h3 class="section-title">存储保护</h3>
<p class="section-subtitle">磁盘空间低于暂停阈值时会停止活跃录制并暂停 MP4 转码空间恢复到恢复阈值后后台巡检会重新启动录制并补转码</p>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="启用存储空间保护">
<el-switch v-model="form.enableStorageGuard" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="低于该空间暂停录制(MB">
<el-input-number
v-model="form.pauseRecordingWhenFreeSpaceBelowMegabytes"
:min="0"
:max="1048576"
:disabled="!form.enableStorageGuard"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="高于该空间恢复录制/转码(MB)">
<el-input-number
v-model="form.resumeRecordingWhenFreeSpaceAboveMegabytes"
:min="0"
:max="1048576"
:disabled="!form.enableStorageGuard"
/>
</el-form-item>
</el-col>
</el-row>
</el-form>
<div class="helper-panel">
恢复阈值建议大于暂停阈值避免磁盘空间在临界值附近反复暂停和恢复MP4 转码会额外预留源 TS 文件大小避免再次出现 No space left on device
</div>
</el-card>
<el-card class="surface-card settings-card" shadow="never">
<h3 class="section-title">弹幕录制</h3>
<p class="section-subtitle">控制是否并行录制弹幕 XML是否保留非聊天事件以及轮询和失败重连节奏</p>
@@ -375,6 +440,50 @@ onMounted(loadSettings);
</el-form>
</el-card>
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">事件脚本</h3>
<p class="section-subtitle">主播开播下播分片完成时可执行自定义脚本脚本通过环境变量获取上下文分片完成时可读取 LIVE_RECORDER_SEGMENT_FILE_PATH</p>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="启用事件脚本">
<el-switch v-model="form.enableEventScripts" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="脚本超时(秒)">
<el-input-number
v-model="form.eventScriptTimeoutSeconds"
:min="1"
:max="3600"
:disabled="!form.enableEventScripts"
/>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="开播脚本路径">
<el-input v-model="form.liveStartedScriptPath" :disabled="!form.enableEventScripts" placeholder="/app/scripts/live-started.sh" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="下播脚本路径">
<el-input v-model="form.liveEndedScriptPath" :disabled="!form.enableEventScripts" placeholder="/app/scripts/live-ended.sh" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="分片完成脚本路径">
<el-input v-model="form.segmentCompletedScriptPath" :disabled="!form.enableEventScripts" placeholder="/app/scripts/segment-completed.sh" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<div class="helper-panel">
常用环境变量LIVE_RECORDER_EVENTLIVE_RECORDER_PLATFORMLIVE_RECORDER_ROOM_IDLIVE_RECORDER_ANCHORLIVE_RECORDER_RECORD_SESSION_IDLIVE_RECORDER_RECORD_TASK_IDLIVE_RECORDER_SEGMENT_INDEXLIVE_RECORDER_SEGMENT_FILE_PATHLIVE_RECORDER_DANMAKU_FILE_PATH
</div>
</el-card>
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">邮件通知</h3>
<p class="section-subtitle">支持 SMTP开播提醒和异常提醒 HTML 模板测试发送会直接使用当前表单中的 SMTP 与模板</p>
@@ -531,6 +640,10 @@ onMounted(loadSettings);
gap: 24px;
}
.page-error-alert {
border-radius: 14px;
}
.settings-grid {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(320px, 0.85fr);
@@ -649,9 +762,28 @@ onMounted(loadSettings);
}
@media (max-width: 900px) {
.page-header :deep(.el-button) {
width: 100%;
}
.email-actions {
flex-direction: column;
align-items: flex-start;
}
}
@media (max-width: 768px) {
.settings-card :deep(.el-col) {
flex: 0 0 100%;
max-width: 100%;
}
.template-section {
padding: 16px 16px 2px;
}
.email-actions :deep(.el-button) {
width: 100%;
}
}
</style>
@@ -19,6 +19,7 @@ public interface ISystemLogService
Guid? liveRoomId = null,
Guid? recordSessionId = null,
Guid? recordTaskId = null,
SystemLogLevel? level = null,
int take = 200,
CancellationToken cancellationToken = default);
}
@@ -91,6 +91,7 @@ public interface ISystemLogRepository
Guid? liveRoomId = null,
Guid? recordSessionId = null,
Guid? recordTaskId = null,
SystemLogLevel? level = null,
int take = 200,
CancellationToken cancellationToken = default);
@@ -35,6 +35,8 @@ public sealed record LiveStatusSnapshot(
bool IsLive,
string? Title,
string? AnchorName,
string? AnchorId,
string? AvatarUrl,
string? CoverUrl,
int? StatusCode,
string? RawStatus);
@@ -57,4 +59,5 @@ public sealed record StreamUrlResult(
string SelectedProtocol,
string SelectedUrl,
StreamInputHeaders? InputHeaders,
IReadOnlyList<StreamQualityOption> AvailableQualities);
IReadOnlyList<StreamQualityOption> AvailableQualities,
string? SelectedVideoCodec = null);
@@ -1,5 +1,6 @@
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Application.Abstractions.Recording;
@@ -9,6 +10,7 @@ public interface IFfmpegService
RecordSession recordSession,
RecordTask initialTask,
StreamUrlResult streamUrlResult,
RecordingExecutionSettings recordingSettings,
CancellationToken cancellationToken = default);
Task CompleteAsync(Guid recordSessionId, CancellationToken cancellationToken = default);
@@ -28,5 +30,31 @@ public interface IFfmpegService
Task<bool> TryReconcileInactiveSessionAsync(Guid recordSessionId, CancellationToken cancellationToken = default);
Task<bool> StartManualFinalizeTaskAsync(Guid recordTaskId, CancellationToken cancellationToken = default);
Task<int> ResumePausedFinalizationsAsync(CancellationToken cancellationToken = default);
bool IsRunning(Guid recordSessionId);
IReadOnlyDictionary<Guid, RecordTaskRuntimeState> GetTaskRuntimeStates(IReadOnlyCollection<Guid> recordTaskIds);
}
public sealed record RecordTaskRuntimeState(
RecordTaskStatus Status,
string Stage,
double? ProgressPercent,
string? Detail);
public sealed record RecordingExecutionSettings(
string PreferredQuality,
RecordOutputFormat OutputFormat,
RecordSaveMode SaveMode,
RecordingTemplateType RecordingTemplate,
int SegmentDurationMinutes,
bool EnableAutoReconnect,
int ReconnectDelayMaxSeconds,
int ReadWriteTimeoutMilliseconds,
bool EnableDanmakuRecording,
bool DanmakuIncludeNonChatEvents,
int DanmakuMinPollIntervalMilliseconds,
int DanmakuRetryDelayMaxSeconds);
@@ -0,0 +1,20 @@
using LiveRecorder.Domain.Entities;
namespace LiveRecorder.Application.Abstractions.Scripting;
public interface IEventScriptService
{
Task RunLiveStartedAsync(LiveRoom liveRoom, DateTimeOffset occurredAt, CancellationToken cancellationToken = default);
Task RunLiveEndedAsync(LiveRoom liveRoom, DateTimeOffset occurredAt, CancellationToken cancellationToken = default);
Task RunSegmentCompletedAsync(
LiveRoom? liveRoom,
RecordSession recordSession,
RecordTask recordTask,
RecordResult? recordResult,
string segmentFilePath,
DateTimeOffset occurredAt,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,19 @@
using LiveRecorder.Application.Models.Settings;
namespace LiveRecorder.Application.Abstractions.Storage;
public interface IStorageGuardService
{
StorageGuardResult CheckCanStartOrResume(SystemSettingsDto settings, long additionalRequiredBytes = 0);
StorageGuardResult CheckShouldPause(SystemSettingsDto settings);
}
public sealed record StorageGuardResult(
bool IsEnabled,
bool HasEnoughSpace,
string CheckedPath,
long AvailableBytes,
long RequiredBytes,
string Message);
@@ -7,6 +7,81 @@ public sealed class CreateLiveRoomRequest
public string Url { get; set; } = string.Empty;
public LivePlatformType? PlatformOverride { get; set; }
public string? AnchorName { get; set; }
}
public sealed class ImportLiveRoomsRequest
{
public string Content { get; set; } = string.Empty;
public LivePlatformType? PlatformOverride { get; set; }
}
public sealed class ImportLiveRoomsResultDto
{
public int TotalCount { get; init; }
public int SuccessCount { get; init; }
public int FailedCount { get; init; }
public int CreatedCount { get; init; }
public int UpdatedCount { get; init; }
public required IReadOnlyList<ImportLiveRoomItemResultDto> Items { get; init; }
}
public sealed class ImportLiveRoomItemResultDto
{
public int LineNumber { get; init; }
public required string RawLine { get; init; }
public string? Url { get; init; }
public string? AnchorName { get; init; }
public bool Success { get; init; }
public bool Created { get; init; }
public string? ErrorMessage { get; init; }
public LiveRoomDto? LiveRoom { get; init; }
}
public sealed class BatchSetLiveRoomsEnabledRequest
{
public IReadOnlyList<Guid> LiveRoomIds { get; set; } = [];
public bool IsEnabled { get; set; }
}
public sealed class BatchDeleteLiveRoomsRequest
{
public IReadOnlyList<Guid> LiveRoomIds { get; set; } = [];
}
public sealed class BatchLiveRoomsResultDto
{
public int RequestedCount { get; init; }
public int SuccessCount { get; init; }
public int FailedCount { get; init; }
public required IReadOnlyList<BatchLiveRoomItemResultDto> Items { get; init; }
}
public sealed class BatchLiveRoomItemResultDto
{
public Guid LiveRoomId { get; init; }
public bool Success { get; init; }
public string? ErrorMessage { get; init; }
}
public sealed class LiveRoomDto
@@ -27,8 +102,16 @@ public sealed class LiveRoomDto
public string? AnchorName { get; init; }
public string? AnchorId { get; init; }
public string? AvatarUrl { get; init; }
public string? CoverUrl { get; init; }
public required LiveRoomSettingsOverridesDto Overrides { get; init; }
public required LiveRoomEffectiveSettingsDto EffectiveSettings { get; init; }
public bool IsEnabled { get; init; }
public required LiveRoomAvailabilityStatus AvailabilityStatus { get; init; }
@@ -44,3 +127,84 @@ public sealed class SetLiveRoomEnabledRequest
{
public bool IsEnabled { get; set; }
}
public sealed class UpdateLiveRoomSettingsRequest
{
public string? PreferredQualityOverride { get; set; }
public RecordOutputFormat? OutputFormatOverride { get; set; }
public RecordSaveMode? SaveModeOverride { get; set; }
public RecordingTemplateType? RecordingTemplateOverride { get; set; }
public int? SegmentDurationMinutesOverride { get; set; }
public bool? EnableAutoReconnectOverride { get; set; }
public int? ReconnectDelayMaxSecondsOverride { get; set; }
public int? ReadWriteTimeoutMillisecondsOverride { get; set; }
public bool? EnableDanmakuRecordingOverride { get; set; }
public bool? DanmakuIncludeNonChatEventsOverride { get; set; }
public int? DanmakuMinPollIntervalMillisecondsOverride { get; set; }
public int? DanmakuRetryDelayMaxSecondsOverride { get; set; }
}
public sealed class LiveRoomSettingsOverridesDto
{
public string? PreferredQuality { get; init; }
public RecordOutputFormat? OutputFormat { get; init; }
public RecordSaveMode? SaveMode { get; init; }
public RecordingTemplateType? RecordingTemplate { get; init; }
public int? SegmentDurationMinutes { get; init; }
public bool? EnableAutoReconnect { get; init; }
public int? ReconnectDelayMaxSeconds { get; init; }
public int? ReadWriteTimeoutMilliseconds { get; init; }
public bool? EnableDanmakuRecording { get; init; }
public bool? DanmakuIncludeNonChatEvents { get; init; }
public int? DanmakuMinPollIntervalMilliseconds { get; init; }
public int? DanmakuRetryDelayMaxSeconds { get; init; }
}
public sealed class LiveRoomEffectiveSettingsDto
{
public required string PreferredQuality { get; init; }
public required RecordOutputFormat OutputFormat { get; init; }
public required RecordSaveMode SaveMode { get; init; }
public required RecordingTemplateType RecordingTemplate { get; init; }
public required int SegmentDurationMinutes { get; init; }
public required bool EnableAutoReconnect { get; init; }
public required int ReconnectDelayMaxSeconds { get; init; }
public required int ReadWriteTimeoutMilliseconds { get; init; }
public required bool EnableDanmakuRecording { get; init; }
public required bool DanmakuIncludeNonChatEvents { get; init; }
public required int DanmakuMinPollIntervalMilliseconds { get; init; }
public required int DanmakuRetryDelayMaxSeconds { get; init; }
}
@@ -70,6 +70,12 @@ public sealed class RecordTaskDto
public DateTimeOffset? EndedAt { get; init; }
public double? DurationSeconds { get; init; }
public string? PostProcessStage { get; init; }
public double? PostProcessProgressPercent { get; init; }
public string? PostProcessDetail { get; init; }
}
public sealed class RecordTaskDetailDto
@@ -22,6 +22,16 @@ public sealed class SystemSettingsDto
public int SegmentDurationMinutes { get; set; } = 30;
public int MaxConcurrentFfmpegTranscodeTasks { get; set; } = 1;
public int Mp4FinalizeTimeoutMinutes { get; set; } = 60;
public bool EnableStorageGuard { get; set; } = true;
public int PauseRecordingWhenFreeSpaceBelowMegabytes { get; set; } = 1024;
public int ResumeRecordingWhenFreeSpaceAboveMegabytes { get; set; } = 4096;
public bool EnableAutoReconnect { get; set; } = true;
public int ReconnectDelayMaxSeconds { get; set; } = 5;
@@ -42,6 +52,16 @@ public sealed class SystemSettingsDto
public int PollingIntervalSeconds { get; set; } = 60;
public bool EnableEventScripts { get; set; } = false;
public string LiveStartedScriptPath { get; set; } = string.Empty;
public string LiveEndedScriptPath { get; set; } = string.Empty;
public string SegmentCompletedScriptPath { get; set; } = string.Empty;
public int EventScriptTimeoutSeconds { get; set; } = 60;
public bool EnableEmailNotification { get; set; } = false;
public string EmailSmtpHost { get; set; } = string.Empty;
@@ -127,6 +147,16 @@ public sealed class UpdateSystemSettingsRequest
public int SegmentDurationMinutes { get; set; } = 30;
public int MaxConcurrentFfmpegTranscodeTasks { get; set; } = 1;
public int Mp4FinalizeTimeoutMinutes { get; set; } = 60;
public bool EnableStorageGuard { get; set; } = true;
public int PauseRecordingWhenFreeSpaceBelowMegabytes { get; set; } = 1024;
public int ResumeRecordingWhenFreeSpaceAboveMegabytes { get; set; } = 4096;
public bool EnableAutoReconnect { get; set; } = true;
public int ReconnectDelayMaxSeconds { get; set; } = 5;
@@ -147,6 +177,16 @@ public sealed class UpdateSystemSettingsRequest
public int PollingIntervalSeconds { get; set; } = 60;
public bool EnableEventScripts { get; set; } = false;
public string LiveStartedScriptPath { get; set; } = string.Empty;
public string LiveEndedScriptPath { get; set; } = string.Empty;
public string SegmentCompletedScriptPath { get; set; } = string.Empty;
public int EventScriptTimeoutSeconds { get; set; } = 60;
public bool EnableEmailNotification { get; set; } = false;
public string EmailSmtpHost { get; set; } = string.Empty;
@@ -0,0 +1,89 @@
using LiveRecorder.Application.Abstractions.Recording;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Models.LiveRooms;
using LiveRecorder.Application.Models.Settings;
using LiveRecorder.Domain.Entities;
namespace LiveRecorder.Application.Services;
public sealed class LiveRoomRecordingSettingsResolver
{
private readonly ISystemSettingsService _systemSettingsService;
public LiveRoomRecordingSettingsResolver(ISystemSettingsService systemSettingsService)
{
_systemSettingsService = systemSettingsService;
}
public async Task<RecordingExecutionSettings> ResolveAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(liveRoom);
var systemSettings = await _systemSettingsService.GetAsync(cancellationToken);
return Resolve(liveRoom, systemSettings);
}
public RecordingExecutionSettings Resolve(LiveRoom liveRoom, SystemSettingsDto systemSettings)
{
ArgumentNullException.ThrowIfNull(liveRoom);
ArgumentNullException.ThrowIfNull(systemSettings);
return new RecordingExecutionSettings(
PreferredQuality: string.IsNullOrWhiteSpace(liveRoom.PreferredQualityOverride)
? systemSettings.DefaultQuality
: liveRoom.PreferredQualityOverride.Trim(),
OutputFormat: liveRoom.OutputFormatOverride ?? systemSettings.DefaultOutputFormat,
SaveMode: liveRoom.SaveModeOverride ?? systemSettings.SaveMode,
RecordingTemplate: liveRoom.RecordingTemplateOverride ?? systemSettings.RecordingTemplate,
SegmentDurationMinutes: liveRoom.SegmentDurationMinutesOverride ?? systemSettings.SegmentDurationMinutes,
EnableAutoReconnect: liveRoom.EnableAutoReconnectOverride ?? systemSettings.EnableAutoReconnect,
ReconnectDelayMaxSeconds: liveRoom.ReconnectDelayMaxSecondsOverride ?? systemSettings.ReconnectDelayMaxSeconds,
ReadWriteTimeoutMilliseconds: liveRoom.ReadWriteTimeoutMillisecondsOverride ?? systemSettings.ReadWriteTimeoutMilliseconds,
EnableDanmakuRecording: liveRoom.EnableDanmakuRecordingOverride ?? systemSettings.EnableDanmakuRecording,
DanmakuIncludeNonChatEvents: liveRoom.DanmakuIncludeNonChatEventsOverride ?? systemSettings.DanmakuIncludeNonChatEvents,
DanmakuMinPollIntervalMilliseconds: liveRoom.DanmakuMinPollIntervalMillisecondsOverride ?? systemSettings.DanmakuMinPollIntervalMilliseconds,
DanmakuRetryDelayMaxSeconds: liveRoom.DanmakuRetryDelayMaxSecondsOverride ?? systemSettings.DanmakuRetryDelayMaxSeconds);
}
public LiveRoomSettingsOverridesDto BuildOverridesDto(LiveRoom liveRoom)
{
ArgumentNullException.ThrowIfNull(liveRoom);
return new LiveRoomSettingsOverridesDto
{
PreferredQuality = liveRoom.PreferredQualityOverride,
OutputFormat = liveRoom.OutputFormatOverride,
SaveMode = liveRoom.SaveModeOverride,
RecordingTemplate = liveRoom.RecordingTemplateOverride,
SegmentDurationMinutes = liveRoom.SegmentDurationMinutesOverride,
EnableAutoReconnect = liveRoom.EnableAutoReconnectOverride,
ReconnectDelayMaxSeconds = liveRoom.ReconnectDelayMaxSecondsOverride,
ReadWriteTimeoutMilliseconds = liveRoom.ReadWriteTimeoutMillisecondsOverride,
EnableDanmakuRecording = liveRoom.EnableDanmakuRecordingOverride,
DanmakuIncludeNonChatEvents = liveRoom.DanmakuIncludeNonChatEventsOverride,
DanmakuMinPollIntervalMilliseconds = liveRoom.DanmakuMinPollIntervalMillisecondsOverride,
DanmakuRetryDelayMaxSeconds = liveRoom.DanmakuRetryDelayMaxSecondsOverride
};
}
public LiveRoomEffectiveSettingsDto BuildEffectiveDto(RecordingExecutionSettings settings)
{
ArgumentNullException.ThrowIfNull(settings);
return new LiveRoomEffectiveSettingsDto
{
PreferredQuality = settings.PreferredQuality,
OutputFormat = settings.OutputFormat,
SaveMode = settings.SaveMode,
RecordingTemplate = settings.RecordingTemplate,
SegmentDurationMinutes = settings.SegmentDurationMinutes,
EnableAutoReconnect = settings.EnableAutoReconnect,
ReconnectDelayMaxSeconds = settings.ReconnectDelayMaxSeconds,
ReadWriteTimeoutMilliseconds = settings.ReadWriteTimeoutMilliseconds,
EnableDanmakuRecording = settings.EnableDanmakuRecording,
DanmakuIncludeNonChatEvents = settings.DanmakuIncludeNonChatEvents,
DanmakuMinPollIntervalMilliseconds = settings.DanmakuMinPollIntervalMilliseconds,
DanmakuRetryDelayMaxSeconds = settings.DanmakuRetryDelayMaxSeconds
};
}
}
@@ -1,6 +1,8 @@
using LiveRecorder.Application.Abstractions.Logging;
using LiveRecorder.Application.Abstractions.Persistence;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Application.Abstractions.Recording;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Models.LiveRooms;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
@@ -12,6 +14,8 @@ public sealed class LiveRoomService
private readonly ILiveRoomRepository _liveRoomRepository;
private readonly ILivePlatformAdapterFactory _livePlatformAdapterFactory;
private readonly LiveRoomStatusService _liveRoomStatusService;
private readonly LiveRoomRecordingSettingsResolver _liveRoomRecordingSettingsResolver;
private readonly ISystemSettingsService _systemSettingsService;
private readonly StoppedOrphanRecordSessionCleanupService _stoppedOrphanRecordSessionCleanupService;
private readonly IUnitOfWork _unitOfWork;
private readonly ISystemLogService _systemLogService;
@@ -20,6 +24,8 @@ public sealed class LiveRoomService
ILiveRoomRepository liveRoomRepository,
ILivePlatformAdapterFactory livePlatformAdapterFactory,
LiveRoomStatusService liveRoomStatusService,
LiveRoomRecordingSettingsResolver liveRoomRecordingSettingsResolver,
ISystemSettingsService systemSettingsService,
StoppedOrphanRecordSessionCleanupService stoppedOrphanRecordSessionCleanupService,
IUnitOfWork unitOfWork,
ISystemLogService systemLogService)
@@ -27,6 +33,8 @@ public sealed class LiveRoomService
_liveRoomRepository = liveRoomRepository;
_livePlatformAdapterFactory = livePlatformAdapterFactory;
_liveRoomStatusService = liveRoomStatusService;
_liveRoomRecordingSettingsResolver = liveRoomRecordingSettingsResolver;
_systemSettingsService = systemSettingsService;
_stoppedOrphanRecordSessionCleanupService = stoppedOrphanRecordSessionCleanupService;
_unitOfWork = unitOfWork;
_systemLogService = systemLogService;
@@ -35,30 +43,146 @@ public sealed class LiveRoomService
public async Task<IReadOnlyList<LiveRoomDto>> ListAsync(CancellationToken cancellationToken = default)
{
var rooms = await _liveRoomRepository.ListAsync(cancellationToken);
var effectiveSettings = await BuildEffectiveSettingsLookupAsync(rooms, cancellationToken);
return rooms
.OrderByDescending(static item => item.UpdatedAt)
.Select(Map)
.Select(item => Map(item, effectiveSettings[item.Id]))
.ToList();
}
public async Task<LiveRoomDto?> GetAsync(Guid id, CancellationToken cancellationToken = default)
{
var room = await _liveRoomRepository.GetByIdAsync(id, cancellationToken);
return room is null ? null : Map(room);
if (room is null)
{
return null;
}
var effectiveSettings = await _liveRoomRecordingSettingsResolver.ResolveAsync(room, cancellationToken);
return Map(room, effectiveSettings);
}
public async Task<LiveRoomDto> CreateAsync(CreateLiveRoomRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var input = request.Url.Trim();
var (liveRoom, effectiveSettings, _) = await CreateOrUpdateAsync(
request.Url,
request.PlatformOverride,
request.AnchorName,
cancellationToken);
return Map(liveRoom, effectiveSettings);
}
public async Task<ImportLiveRoomsResultDto> ImportAsync(
ImportLiveRoomsRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var results = new List<ImportLiveRoomItemResultDto>();
var lines = request.Content
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace('\r', '\n')
.Split('\n')
.Select((line, index) => new { RawLine = line, LineNumber = index + 1 })
.Where(item => !string.IsNullOrWhiteSpace(item.RawLine))
.ToArray();
foreach (var line in lines)
{
cancellationToken.ThrowIfCancellationRequested();
if (!TryParseImportLine(line.RawLine, out var url, out var anchorName, out var parseError))
{
results.Add(new ImportLiveRoomItemResultDto
{
LineNumber = line.LineNumber,
RawLine = line.RawLine,
Url = url,
AnchorName = anchorName,
Success = false,
Created = false,
ErrorMessage = parseError
});
continue;
}
try
{
var (liveRoom, effectiveSettings, created) = await CreateOrUpdateAsync(
url,
request.PlatformOverride,
anchorName,
cancellationToken);
results.Add(new ImportLiveRoomItemResultDto
{
LineNumber = line.LineNumber,
RawLine = line.RawLine,
Url = url,
AnchorName = anchorName,
Success = true,
Created = created,
LiveRoom = Map(liveRoom, effectiveSettings)
});
}
catch (Exception ex)
{
results.Add(new ImportLiveRoomItemResultDto
{
LineNumber = line.LineNumber,
RawLine = line.RawLine,
Url = url,
AnchorName = anchorName,
Success = false,
Created = false,
ErrorMessage = ex.Message
});
}
}
var successCount = results.Count(static item => item.Success);
var createdCount = results.Count(static item => item.Success && item.Created);
return new ImportLiveRoomsResultDto
{
TotalCount = results.Count,
SuccessCount = successCount,
FailedCount = results.Count - successCount,
CreatedCount = createdCount,
UpdatedCount = successCount - createdCount,
Items = results
};
}
public async Task<string> ExportAsync(CancellationToken cancellationToken = default)
{
var rooms = await _liveRoomRepository.ListAsync(cancellationToken);
var lines = rooms
.OrderBy(static item => item.Platform)
.ThenBy(static item => item.AnchorName)
.ThenBy(static item => item.RoomId)
.Select(FormatExportLine)
.ToArray();
return string.Join(Environment.NewLine, lines);
}
private async Task<(LiveRoom LiveRoom, RecordingExecutionSettings EffectiveSettings, bool Created)> CreateOrUpdateAsync(
string rawInput,
LivePlatformType? platformOverride,
string? fallbackAnchorName,
CancellationToken cancellationToken)
{
var input = rawInput.Trim();
if (string.IsNullOrWhiteSpace(input))
{
throw new InvalidOperationException("Live room URL is required.");
}
var adapter = request.PlatformOverride.HasValue && request.PlatformOverride.Value != LivePlatformType.Unknown
? _livePlatformAdapterFactory.GetByPlatform(request.PlatformOverride.Value)
var adapter = platformOverride.HasValue && platformOverride.Value != LivePlatformType.Unknown
? _livePlatformAdapterFactory.GetByPlatform(platformOverride.Value)
: _livePlatformAdapterFactory.GetByInput(input);
var parsedRoom = await adapter.ParseRoomAsync(input, cancellationToken);
@@ -66,10 +190,12 @@ public sealed class LiveRoomService
var now = DateTimeOffset.UtcNow;
var liveRoom = await _liveRoomRepository.GetByPlatformRoomIdAsync(parsedRoom.PlatformType, parsedRoom.RoomId, cancellationToken);
var created = liveRoom is null;
if (liveRoom is null)
{
liveRoom = new LiveRoom(parsedRoom.PlatformType, parsedRoom.SourceUrl, parsedRoom.RoomId, parsedRoom.NormalizedUrl, now);
await _liveRoomStatusService.ApplySnapshotAsync(liveRoom, liveStatus, now, cancellationToken);
liveRoom.UpdateMetadata(null, fallbackAnchorName, null, null, null, now);
await _liveRoomRepository.AddAsync(liveRoom, cancellationToken);
}
@@ -78,6 +204,7 @@ public sealed class LiveRoomService
liveRoom.UpdateSource(parsedRoom.SourceUrl, parsedRoom.NormalizedUrl, now);
liveRoom.UpdateRoomId(parsedRoom.RoomId, now);
await _liveRoomStatusService.ApplySnapshotAsync(liveRoom, liveStatus, now, cancellationToken);
liveRoom.UpdateMetadata(null, fallbackAnchorName, null, null, null, now);
}
await _unitOfWork.SaveChangesAsync(cancellationToken);
@@ -88,7 +215,8 @@ public sealed class LiveRoomService
liveRoomId: liveRoom.Id,
cancellationToken: cancellationToken);
return Map(liveRoom);
var effectiveSettings = await _liveRoomRecordingSettingsResolver.ResolveAsync(liveRoom, cancellationToken);
return (liveRoom, effectiveSettings, created);
}
public async Task<LiveRoomDto> RefreshStatusAsync(Guid id, CancellationToken cancellationToken = default)
@@ -111,7 +239,8 @@ public sealed class LiveRoomService
liveRoomId: room.Id,
cancellationToken: cancellationToken);
return Map(room);
var effectiveSettings = await _liveRoomRecordingSettingsResolver.ResolveAsync(room, cancellationToken);
return Map(room, effectiveSettings);
}
public async Task<LiveRoomDto> SetEnabledAsync(Guid id, bool isEnabled, CancellationToken cancellationToken = default)
@@ -131,7 +260,80 @@ public sealed class LiveRoomService
liveRoomId: room.Id,
cancellationToken: cancellationToken);
return Map(room);
var effectiveSettings = await _liveRoomRecordingSettingsResolver.ResolveAsync(room, cancellationToken);
return Map(room, effectiveSettings);
}
public async Task<BatchLiveRoomsResultDto> SetEnabledBatchAsync(
BatchSetLiveRoomsEnabledRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var results = new List<BatchLiveRoomItemResultDto>();
foreach (var liveRoomId in request.LiveRoomIds.Distinct())
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await SetEnabledAsync(liveRoomId, request.IsEnabled, cancellationToken);
results.Add(new BatchLiveRoomItemResultDto
{
LiveRoomId = liveRoomId,
Success = true
});
}
catch (Exception ex)
{
results.Add(new BatchLiveRoomItemResultDto
{
LiveRoomId = liveRoomId,
Success = false,
ErrorMessage = ex.Message
});
}
}
return BuildBatchResult(request.LiveRoomIds.Count, results);
}
public async Task<LiveRoomDto> UpdateSettingsAsync(
Guid id,
UpdateLiveRoomSettingsRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var room = await _liveRoomRepository.GetByIdAsync(id, cancellationToken)
?? throw new KeyNotFoundException("Live room was not found.");
room.UpdateRecordingSettingsOverrides(
request.PreferredQualityOverride,
request.OutputFormatOverride,
request.SaveModeOverride,
request.RecordingTemplateOverride,
ClampNullable(request.SegmentDurationMinutesOverride, 1, 720),
request.EnableAutoReconnectOverride,
ClampNullable(request.ReconnectDelayMaxSecondsOverride, 1, 300),
ClampNullable(request.ReadWriteTimeoutMillisecondsOverride, 1000, 60000000),
request.EnableDanmakuRecordingOverride,
request.DanmakuIncludeNonChatEventsOverride,
ClampNullable(request.DanmakuMinPollIntervalMillisecondsOverride, 100, 60000),
ClampNullable(request.DanmakuRetryDelayMaxSecondsOverride, 1, 300),
DateTimeOffset.UtcNow);
await _unitOfWork.SaveChangesAsync(cancellationToken);
await _systemLogService.WriteAsync(
SystemLogLevel.Info,
"LiveRoom",
$"Recording settings updated for room {room.RoomId}.",
liveRoomId: room.Id,
cancellationToken: cancellationToken);
var effectiveSettings = await _liveRoomRecordingSettingsResolver.ResolveAsync(room, cancellationToken);
return Map(room, effectiveSettings);
}
public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default)
@@ -155,7 +357,55 @@ public sealed class LiveRoomService
cancellationToken: cancellationToken);
}
private static LiveRoomDto Map(LiveRoom room) => new()
public async Task<BatchLiveRoomsResultDto> DeleteBatchAsync(
BatchDeleteLiveRoomsRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var results = new List<BatchLiveRoomItemResultDto>();
foreach (var liveRoomId in request.LiveRoomIds.Distinct())
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await DeleteAsync(liveRoomId, cancellationToken);
results.Add(new BatchLiveRoomItemResultDto
{
LiveRoomId = liveRoomId,
Success = true
});
}
catch (Exception ex)
{
results.Add(new BatchLiveRoomItemResultDto
{
LiveRoomId = liveRoomId,
Success = false,
ErrorMessage = ex.Message
});
}
}
return BuildBatchResult(request.LiveRoomIds.Count, results);
}
private async Task<Dictionary<Guid, RecordingExecutionSettings>> BuildEffectiveSettingsLookupAsync(
IReadOnlyCollection<LiveRoom> rooms,
CancellationToken cancellationToken)
{
if (rooms.Count == 0)
{
return [];
}
var systemSettings = await _systemSettingsService.GetAsync(cancellationToken);
return rooms.ToDictionary(item => item.Id, item => _liveRoomRecordingSettingsResolver.Resolve(item, systemSettings));
}
private LiveRoomDto Map(LiveRoom room, RecordingExecutionSettings effectiveSettings) => new()
{
Id = room.Id,
Platform = room.Platform,
@@ -165,11 +415,109 @@ public sealed class LiveRoomService
NormalizedUrl = room.NormalizedUrl,
Title = room.Title,
AnchorName = room.AnchorName,
AnchorId = room.AnchorId,
AvatarUrl = room.AvatarUrl,
CoverUrl = room.CoverUrl,
Overrides = _liveRoomRecordingSettingsResolver.BuildOverridesDto(room),
EffectiveSettings = _liveRoomRecordingSettingsResolver.BuildEffectiveDto(effectiveSettings),
IsEnabled = room.IsEnabled,
AvailabilityStatus = room.AvailabilityStatus,
LastCheckedAt = room.LastCheckedAt,
CreatedAt = room.CreatedAt,
UpdatedAt = room.UpdatedAt
};
private static int? ClampNullable(int? value, int min, int max) =>
value.HasValue ? Math.Clamp(value.Value, min, max) : null;
private static BatchLiveRoomsResultDto BuildBatchResult(
int requestedCount,
IReadOnlyList<BatchLiveRoomItemResultDto> results)
{
var successCount = results.Count(static item => item.Success);
return new BatchLiveRoomsResultDto
{
RequestedCount = requestedCount,
SuccessCount = successCount,
FailedCount = results.Count - successCount,
Items = results
};
}
private static bool TryParseImportLine(
string rawLine,
out string url,
out string? anchorName,
out string? errorMessage)
{
url = string.Empty;
anchorName = null;
errorMessage = null;
var line = rawLine.Trim();
if (string.IsNullOrWhiteSpace(line))
{
errorMessage = "Import line is empty.";
return false;
}
var markerIndex = IndexOfAnchorMarker(line);
if (markerIndex >= 0)
{
var markerLength = line.AsSpan(markerIndex).StartsWith("主播:", StringComparison.OrdinalIgnoreCase)
? "主播:".Length
: "主播:".Length;
url = line[..markerIndex].Trim().TrimEnd(',', '', ';', '', '\t', ' ');
anchorName = line[(markerIndex + markerLength)..].Trim().Trim(',', '', ';', '', '\t', ' ');
}
else
{
url = line.Trim().TrimEnd(',', '', ';', '');
}
if (string.IsNullOrWhiteSpace(url))
{
errorMessage = "Import line does not contain a live room URL.";
return false;
}
if (string.IsNullOrWhiteSpace(anchorName))
{
anchorName = null;
}
return true;
}
private static int IndexOfAnchorMarker(string line)
{
var halfWidthIndex = line.IndexOf("主播:", StringComparison.OrdinalIgnoreCase);
var fullWidthIndex = line.IndexOf("主播:", StringComparison.OrdinalIgnoreCase);
if (halfWidthIndex < 0)
{
return fullWidthIndex;
}
if (fullWidthIndex < 0)
{
return halfWidthIndex;
}
return Math.Min(halfWidthIndex, fullWidthIndex);
}
private static string FormatExportLine(LiveRoom room)
{
var url = string.IsNullOrWhiteSpace(room.NormalizedUrl) ? room.SourceUrl : room.NormalizedUrl;
var anchorName = NormalizeSingleLine(room.AnchorName);
return string.IsNullOrWhiteSpace(anchorName)
? NormalizeSingleLine(url)
: $"{NormalizeSingleLine(url)},主播: {anchorName}";
}
private static string NormalizeSingleLine(string? value) =>
string.IsNullOrWhiteSpace(value)
? string.Empty
: value.Trim().Replace("\r", " ", StringComparison.Ordinal).Replace("\n", " ", StringComparison.Ordinal);
}
@@ -1,5 +1,6 @@
using LiveRecorder.Application.Abstractions.Notifications;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Application.Abstractions.Scripting;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
@@ -8,10 +9,14 @@ namespace LiveRecorder.Application.Services;
public sealed class LiveRoomStatusService
{
private readonly IEmailNotificationService _emailNotificationService;
private readonly IEventScriptService _eventScriptService;
public LiveRoomStatusService(IEmailNotificationService emailNotificationService)
public LiveRoomStatusService(
IEmailNotificationService emailNotificationService,
IEventScriptService eventScriptService)
{
_emailNotificationService = emailNotificationService;
_eventScriptService = eventScriptService;
}
public async Task ApplySnapshotAsync(
@@ -23,17 +28,32 @@ public sealed class LiveRoomStatusService
ArgumentNullException.ThrowIfNull(liveRoom);
ArgumentNullException.ThrowIfNull(liveStatus);
liveRoom.UpdateMetadata(liveStatus.Title, liveStatus.AnchorName, liveStatus.CoverUrl, observedAt);
var wasLive = liveRoom.AvailabilityStatus == LiveRoomAvailabilityStatus.Live;
liveRoom.UpdateMetadata(
liveStatus.Title,
liveStatus.AnchorName,
liveStatus.AnchorId,
liveStatus.AvatarUrl,
liveStatus.CoverUrl,
observedAt);
liveRoom.UpdateAvailability(
liveStatus.IsLive ? LiveRoomAvailabilityStatus.Live : LiveRoomAvailabilityStatus.Offline,
observedAt);
if (wasLive && !liveStatus.IsLive)
{
await _eventScriptService.RunLiveEndedAsync(liveRoom, observedAt, cancellationToken);
return;
}
if (!liveStatus.IsLive || liveRoom.HasSentLiveNotificationForCurrentSession)
{
return;
}
await _emailNotificationService.SendLiveStartedAsync(liveRoom, cancellationToken);
await _eventScriptService.RunLiveStartedAsync(liveRoom, observedAt, cancellationToken);
liveRoom.MarkLiveNotificationSent(observedAt);
}
}
@@ -1,3 +1,4 @@
using LiveRecorder.Application.Abstractions.Recording;
using LiveRecorder.Application.Models.RecordTasks;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
@@ -6,7 +7,7 @@ namespace LiveRecorder.Application.Services;
internal static class RecordModelMapper
{
public static RecordTaskDto MapTask(RecordTask recordTask) => new()
public static RecordTaskDto MapTask(RecordTask recordTask, RecordTaskRuntimeState? runtimeState = null) => new()
{
Id = recordTask.Id,
LiveRoomId = recordTask.LiveRoomId,
@@ -15,7 +16,7 @@ internal static class RecordModelMapper
LiveRoomTitle = recordTask.LiveRoom?.Title ?? recordTask.LiveRoom?.AnchorName ?? recordTask.LiveRoom?.RoomId ?? "Unknown Room",
Platform = recordTask.LiveRoom?.Platform ?? LivePlatformType.Unknown,
RoomId = recordTask.LiveRoom?.RoomId ?? string.Empty,
Status = recordTask.Status,
Status = runtimeState?.Status ?? recordTask.Status,
PreferredQuality = recordTask.PreferredQuality,
OutputFormat = recordTask.OutputFormat,
StreamUrl = recordTask.StreamUrl,
@@ -25,7 +26,10 @@ internal static class RecordModelMapper
CreatedAt = recordTask.CreatedAt,
StartedAt = recordTask.StartedAt,
EndedAt = recordTask.EndedAt,
DurationSeconds = recordTask.DurationSeconds
DurationSeconds = recordTask.DurationSeconds,
PostProcessStage = runtimeState?.Stage,
PostProcessProgressPercent = runtimeState?.ProgressPercent,
PostProcessDetail = runtimeState?.Detail
};
public static RecordResultDto MapResult(RecordResult recordResult) => new()
@@ -41,7 +45,9 @@ internal static class RecordModelMapper
CreatedAt = recordResult.CreatedAt
};
public static RecordSessionDto MapSession(RecordSession recordSession)
public static RecordSessionDto MapSession(
RecordSession recordSession,
IReadOnlyDictionary<Guid, RecordTaskRuntimeState>? runtimeStates = null)
{
var orderedTasks = recordSession.RecordTasks
.OrderBy(static item => item.SegmentIndex)
@@ -74,11 +80,19 @@ internal static class RecordModelMapper
EndedAt = recordSession.EndedAt,
TotalFileSizeBytes = totalFileSizeBytes,
TotalDanmakuMessageCount = totalDanmakuMessageCount,
Tasks = orderedTasks.Select(item => MapTaskWithFallback(item, recordSession)).ToList()
Tasks = orderedTasks.Select(item => MapTaskWithFallback(
item,
recordSession,
runtimeStates is not null && runtimeStates.TryGetValue(item.Id, out var runtimeState)
? runtimeState
: null)).ToList()
};
}
private static RecordTaskDto MapTaskWithFallback(RecordTask recordTask, RecordSession recordSession)
private static RecordTaskDto MapTaskWithFallback(
RecordTask recordTask,
RecordSession recordSession,
RecordTaskRuntimeState? runtimeState)
{
var liveRoom = recordTask.LiveRoom ?? recordSession.LiveRoom;
return new RecordTaskDto
@@ -90,7 +104,7 @@ internal static class RecordModelMapper
LiveRoomTitle = liveRoom?.Title ?? liveRoom?.AnchorName ?? liveRoom?.RoomId ?? "Unknown Room",
Platform = liveRoom?.Platform ?? LivePlatformType.Unknown,
RoomId = liveRoom?.RoomId ?? string.Empty,
Status = recordTask.Status,
Status = runtimeState?.Status ?? recordTask.Status,
PreferredQuality = recordTask.PreferredQuality,
OutputFormat = recordTask.OutputFormat,
StreamUrl = recordTask.StreamUrl,
@@ -100,7 +114,10 @@ internal static class RecordModelMapper
CreatedAt = recordTask.CreatedAt,
StartedAt = recordTask.StartedAt,
EndedAt = recordTask.EndedAt,
DurationSeconds = recordTask.DurationSeconds
DurationSeconds = recordTask.DurationSeconds,
PostProcessStage = runtimeState?.Stage,
PostProcessProgressPercent = runtimeState?.ProgressPercent,
PostProcessDetail = runtimeState?.Detail
};
}
}
@@ -5,6 +5,7 @@ using LiveRecorder.Application.Abstractions.Persistence;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Application.Abstractions.Recording;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Abstractions.Storage;
using LiveRecorder.Application.Models.RecordTasks;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
@@ -25,6 +26,8 @@ public sealed class RecordService
private readonly ISystemLogService _systemLogService;
private readonly IEmailNotificationService _emailNotificationService;
private readonly LiveRoomStatusService _liveRoomStatusService;
private readonly LiveRoomRecordingSettingsResolver _liveRoomRecordingSettingsResolver;
private readonly IStorageGuardService _storageGuardService;
private readonly IUnitOfWork _unitOfWork;
public RecordService(
@@ -40,6 +43,8 @@ public sealed class RecordService
ISystemLogService systemLogService,
IEmailNotificationService emailNotificationService,
LiveRoomStatusService liveRoomStatusService,
LiveRoomRecordingSettingsResolver liveRoomRecordingSettingsResolver,
IStorageGuardService storageGuardService,
IUnitOfWork unitOfWork)
{
_liveRoomRepository = liveRoomRepository;
@@ -54,6 +59,8 @@ public sealed class RecordService
_systemLogService = systemLogService;
_emailNotificationService = emailNotificationService;
_liveRoomStatusService = liveRoomStatusService;
_liveRoomRecordingSettingsResolver = liveRoomRecordingSettingsResolver;
_storageGuardService = storageGuardService;
_unitOfWork = unitOfWork;
}
@@ -62,9 +69,12 @@ public sealed class RecordService
await ReconcileActiveSessionsAsync(liveRoomId, cancellationToken);
var tasks = await _recordTaskRepository.ListAsync(liveRoomId, cancellationToken);
var runtimeStates = _ffmpegService.GetTaskRuntimeStates(tasks.Select(static item => item.Id).ToArray());
return tasks
.OrderByDescending(static item => item.CreatedAt)
.Select(RecordModelMapper.MapTask)
.Select(item => RecordModelMapper.MapTask(
item,
runtimeStates.TryGetValue(item.Id, out var runtimeState) ? runtimeState : null))
.ToList();
}
@@ -92,10 +102,13 @@ public sealed class RecordService
recordTaskId: id,
take: 300,
cancellationToken: cancellationToken);
var runtimeStates = _ffmpegService.GetTaskRuntimeStates([id]);
return new RecordTaskDetailDto
{
Task = RecordModelMapper.MapTask(recordTask),
Task = RecordModelMapper.MapTask(
recordTask,
runtimeStates.TryGetValue(id, out var runtimeState) ? runtimeState : null),
Result = recordResult is null ? null : RecordModelMapper.MapResult(recordResult),
Logs = logs
};
@@ -120,10 +133,26 @@ public sealed class RecordService
}
var settings = await _systemSettingsService.GetAsync(cancellationToken);
var storageCheck = _storageGuardService.CheckCanStartOrResume(settings);
if (!storageCheck.HasEnoughSpace)
{
await _systemLogService.WriteAsync(
SystemLogLevel.Warning,
"Storage",
"Recording start paused because storage is below threshold.",
storageCheck.Message,
liveRoomId: liveRoom.Id,
cancellationToken: cancellationToken);
throw new InvalidOperationException(storageCheck.Message);
}
var effectiveSettings = _liveRoomRecordingSettingsResolver.Resolve(liveRoom, settings);
var adapter = _livePlatformAdapterFactory.GetByPlatform(liveRoom.Platform);
var preferredQuality = string.IsNullOrWhiteSpace(request.PreferredQuality) ? settings.DefaultQuality : request.PreferredQuality.Trim();
var outputFormat = request.OutputFormat ?? settings.DefaultOutputFormat;
var saveMode = settings.SaveMode;
var preferredQuality = string.IsNullOrWhiteSpace(request.PreferredQuality)
? effectiveSettings.PreferredQuality
: request.PreferredQuality.Trim();
var outputFormat = request.OutputFormat ?? effectiveSettings.OutputFormat;
var saveMode = effectiveSettings.SaveMode;
var now = DateTimeOffset.UtcNow;
var recordSession = new RecordSession(liveRoom.Id, preferredQuality, outputFormat, saveMode, now);
@@ -175,7 +204,7 @@ public sealed class RecordService
initialTask.MarkStarting(streamResult.SelectedUrl, initialOutputPath, now);
await _unitOfWork.SaveChangesAsync(cancellationToken);
await _ffmpegService.StartAsync(recordSession, initialTask, streamResult, cancellationToken);
await _ffmpegService.StartAsync(recordSession, initialTask, streamResult, effectiveSettings, cancellationToken);
recordSession.MarkRunning(DateTimeOffset.UtcNow);
initialTask.MarkRunning(DateTimeOffset.UtcNow);
@@ -349,6 +378,40 @@ public sealed class RecordService
};
}
public async Task<RecordTaskDetailDto> StartManualTranscodeAsync(Guid id, CancellationToken cancellationToken = default)
{
var recordTask = await _recordTaskRepository.GetByIdAsync(id, cancellationToken)
?? throw new KeyNotFoundException("Recording task was not found.");
if (recordTask.OutputFormat != RecordOutputFormat.Mp4)
{
throw new InvalidOperationException("Only MP4 tasks support manual transcoding.");
}
if (IsActiveTaskStatus(recordTask.Status))
{
throw new InvalidOperationException("The task is still active. Stop the recording before starting manual transcoding.");
}
var started = await _ffmpegService.StartManualFinalizeTaskAsync(id, cancellationToken);
if (!started)
{
throw new InvalidOperationException("No intermediate recording file is available for manual transcoding.");
}
await _systemLogService.WriteAsync(
SystemLogLevel.Info,
"FFmpeg",
"Manual MP4 finalization was requested.",
liveRoomId: recordTask.LiveRoomId,
recordSessionId: recordTask.RecordSessionId,
recordTaskId: recordTask.Id,
cancellationToken: cancellationToken);
return (await GetDetailAsync(id, cancellationToken))
?? throw new KeyNotFoundException("Recording task was not found after starting manual transcoding.");
}
public async Task<RecordTaskDto> StopAsync(Guid id, CancellationToken cancellationToken = default)
{
var recordTask = await _recordTaskRepository.GetByIdAsync(id, cancellationToken)
@@ -404,7 +467,10 @@ public sealed class RecordService
};
private static bool IsActiveTaskStatus(RecordTaskStatus status) =>
status is RecordTaskStatus.Starting or RecordTaskStatus.Running or RecordTaskStatus.Stopping;
status is RecordTaskStatus.Starting
or RecordTaskStatus.Running
or RecordTaskStatus.Stopping
or RecordTaskStatus.Processing;
private static bool IsActiveSessionStatus(RecordSessionStatus status) =>
status is RecordSessionStatus.Starting or RecordSessionStatus.Running or RecordSessionStatus.Stopping;
@@ -551,7 +617,8 @@ public sealed class RecordService
? (forPathSegment ? "{platform}/{yyyy}/{MM}/{dd}/{anchor}" : "{HHmmss}_{anchor}_{title}_{roomId}{segmentSuffix}")
: template;
var tokens = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
// Path tokens must stay case-sensitive so {MM} (month) and {mm} (minute) don't collide.
var tokens = new Dictionary<string, string>(StringComparer.Ordinal)
{
["platform"] = platform.ToString(),
["roomId"] = roomId,
@@ -610,6 +677,7 @@ public sealed class RecordService
if (!string.IsNullOrWhiteSpace(outputPath))
{
TryDeletePath(outputPath, warnings, deletedFilePaths, $"output for task {recordTask.Id}");
TryDeleteIntermediateRecordingArtifacts(outputPath, recordTask.OutputFormat, warnings, deletedFilePaths, recordTask.Id);
}
var danmakuPath = recordTask.Result?.DanmakuFilePath;
@@ -619,6 +687,43 @@ public sealed class RecordService
}
}
private static void TryDeleteIntermediateRecordingArtifacts(
string finalOutputPath,
RecordOutputFormat outputFormat,
List<string> warnings,
List<string> deletedFilePaths,
Guid recordTaskId)
{
if (outputFormat != RecordOutputFormat.Mp4)
{
return;
}
var absoluteFinalPath = Path.IsPathRooted(finalOutputPath)
? finalOutputPath
: Path.GetFullPath(finalOutputPath, AppContext.BaseDirectory);
var intermediateCandidates = new[]
{
Path.ChangeExtension(absoluteFinalPath, ".ts"),
Path.Combine(
Path.GetDirectoryName(absoluteFinalPath) ?? string.Empty,
$"{Path.GetFileNameWithoutExtension(absoluteFinalPath)}.recording.ts")
};
foreach (var candidate in intermediateCandidates
.Where(static path => !string.IsNullOrWhiteSpace(path))
.Distinct(StringComparer.OrdinalIgnoreCase))
{
if (string.Equals(candidate, absoluteFinalPath, StringComparison.OrdinalIgnoreCase) || !File.Exists(candidate))
{
continue;
}
TryDeletePath(candidate, warnings, deletedFilePaths, $"intermediate output for task {recordTaskId}");
}
}
private static void TryDeletePath(
string path,
List<string> warnings,
@@ -51,9 +51,11 @@ public sealed class RecordSessionService
await ReconcileActiveSessionsAsync(liveRoomId, cancellationToken);
var sessions = await _recordSessionRepository.ListAsync(liveRoomId, cancellationToken);
var runtimeStates = _ffmpegService.GetTaskRuntimeStates(
sessions.SelectMany(static item => item.RecordTasks).Select(static item => item.Id).ToArray());
return sessions
.OrderByDescending(static item => item.CreatedAt)
.Select(RecordModelMapper.MapSession)
.Select(item => RecordModelMapper.MapSession(item, runtimeStates))
.ToList();
}
@@ -76,9 +78,11 @@ public sealed class RecordSessionService
}
var logs = await _systemLogService.ListAsync(recordSessionId: id, take: 500, cancellationToken: cancellationToken);
var runtimeStates = _ffmpegService.GetTaskRuntimeStates(
session.RecordTasks.Select(static item => item.Id).ToArray());
return new RecordSessionDetailDto
{
Session = RecordModelMapper.MapSession(session),
Session = RecordModelMapper.MapSession(session, runtimeStates),
Logs = logs
};
}
@@ -105,7 +109,9 @@ public sealed class RecordSessionService
recordSessionId: session.Id,
cancellationToken: cancellationToken);
return RecordModelMapper.MapSession(session);
var runtimeStates = _ffmpegService.GetTaskRuntimeStates(
session.RecordTasks.Select(static item => item.Id).ToArray());
return RecordModelMapper.MapSession(session, runtimeStates);
}
public async Task<DeleteCompletedRecordTasksResultDto> DeleteAsync(
@@ -4,6 +4,7 @@ using LiveRecorder.Application.Models.Logs;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace LiveRecorder.Application.Services;
@@ -11,13 +12,16 @@ public sealed class SystemLogService : ISystemLogService
{
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly ISystemLogRepository _systemLogRepository;
private readonly ILogger<SystemLogService> _logger;
public SystemLogService(
IServiceScopeFactory serviceScopeFactory,
ISystemLogRepository systemLogRepository)
ISystemLogRepository systemLogRepository,
ILogger<SystemLogService> logger)
{
_serviceScopeFactory = serviceScopeFactory;
_systemLogRepository = systemLogRepository;
_logger = logger;
}
public async Task WriteAsync(
@@ -30,23 +34,46 @@ public sealed class SystemLogService : ISystemLogService
Guid? recordTaskId = null,
CancellationToken cancellationToken = default)
{
var entry = new SystemLogEntry(level, category, message, detail, liveRoomId, recordSessionId, recordTaskId, DateTimeOffset.UtcNow);
try
{
var entry = new SystemLogEntry(level, category, message, detail, liveRoomId, recordSessionId, recordTaskId, DateTimeOffset.UtcNow);
using var scope = _serviceScopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<ISystemLogRepository>();
var unitOfWork = scope.ServiceProvider.GetRequiredService<IUnitOfWork>();
await repository.AddAsync(entry, cancellationToken);
await unitOfWork.SaveChangesAsync(cancellationToken);
using var scope = _serviceScopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<ISystemLogRepository>();
var unitOfWork = scope.ServiceProvider.GetRequiredService<IUnitOfWork>();
await repository.AddAsync(entry, cancellationToken);
await unitOfWork.SaveChangesAsync(cancellationToken);
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
{
// Logging must be best-effort. If SQLite is locked/full, throwing from here
// causes the scheduler to turn a log persistence failure into an email storm.
_logger.LogWarning(
ex,
"System log write failed. Category={Category}; Message={Message}; LiveRoomId={LiveRoomId}; RecordSessionId={RecordSessionId}; RecordTaskId={RecordTaskId}",
category,
message,
liveRoomId,
recordSessionId,
recordTaskId);
}
}
public async Task<IReadOnlyList<SystemLogDto>> ListAsync(
Guid? liveRoomId = null,
Guid? recordSessionId = null,
Guid? recordTaskId = null,
SystemLogLevel? level = null,
int take = 200,
CancellationToken cancellationToken = default)
{
var entries = await _systemLogRepository.ListAsync(liveRoomId, recordSessionId, recordTaskId, take, cancellationToken);
var entries = await _systemLogRepository.ListAsync(
liveRoomId,
recordSessionId,
recordTaskId,
level,
take,
cancellationToken);
return entries
.Select(static item => new SystemLogDto
{
@@ -17,6 +17,11 @@ public sealed class SystemSettingsService : ISystemSettingsService
private const string SaveModeKey = "recording.save_mode";
private const string RecordingTemplateKey = "recording.template";
private const string SegmentDurationMinutesKey = "recording.segment_duration_minutes";
private const string MaxConcurrentFfmpegTranscodeTasksKey = "recording.max_concurrent_ffmpeg_transcode_tasks";
private const string Mp4FinalizeTimeoutMinutesKey = "recording.mp4_finalize_timeout_minutes";
private const string EnableStorageGuardKey = "storage.guard.enabled";
private const string PauseRecordingWhenFreeSpaceBelowMegabytesKey = "storage.guard.pause_recording_below_mb";
private const string ResumeRecordingWhenFreeSpaceAboveMegabytesKey = "storage.guard.resume_recording_above_mb";
private const string EnableReconnectKey = "recording.enable_auto_reconnect";
private const string ReconnectDelayMaxSecondsKey = "recording.reconnect_delay_max_seconds";
private const string ReadWriteTimeoutMillisecondsKey = "recording.read_write_timeout_milliseconds";
@@ -27,6 +32,11 @@ public sealed class SystemSettingsService : ISystemSettingsService
private const string EnableBackgroundPollingKey = "scheduler.enable_background_polling";
private const string AutoStartRecordingOnLiveKey = "scheduler.auto_start_recording_on_live";
private const string PollingIntervalSecondsKey = "scheduler.polling_interval_seconds";
private const string EnableEventScriptsKey = "event_scripts.enabled";
private const string LiveStartedScriptPathKey = "event_scripts.live_started.path";
private const string LiveEndedScriptPathKey = "event_scripts.live_ended.path";
private const string SegmentCompletedScriptPathKey = "event_scripts.segment_completed.path";
private const string EventScriptTimeoutSecondsKey = "event_scripts.timeout_seconds";
private const string EnableEmailNotificationKey = "notification.email.enabled";
private const string EmailSmtpHostKey = "notification.email.smtp_host";
private const string EmailSmtpPortKey = "notification.email.smtp_port";
@@ -77,6 +87,11 @@ public sealed class SystemSettingsService : ISystemSettingsService
? recordingTemplate
: RecordingTemplateType.StreamCopy,
SegmentDurationMinutes = GetIntValue(lookup, SegmentDurationMinutesKey, 30, 1, 720),
MaxConcurrentFfmpegTranscodeTasks = GetIntValue(lookup, MaxConcurrentFfmpegTranscodeTasksKey, 1, 1, 16),
Mp4FinalizeTimeoutMinutes = GetIntValue(lookup, Mp4FinalizeTimeoutMinutesKey, 60, 1, 1440),
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),
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),
@@ -87,6 +102,11 @@ public sealed class SystemSettingsService : ISystemSettingsService
EnableBackgroundPolling = bool.TryParse(GetValue(lookup, EnableBackgroundPollingKey, "true"), out var enableBackgroundPolling) && enableBackgroundPolling,
AutoStartRecordingOnLive = bool.TryParse(GetValue(lookup, AutoStartRecordingOnLiveKey, "true"), out var autoStartRecordingOnLive) && autoStartRecordingOnLive,
PollingIntervalSeconds = GetIntValue(lookup, PollingIntervalSecondsKey, 60, 10, 3600),
EnableEventScripts = bool.TryParse(GetValue(lookup, EnableEventScriptsKey, "false"), out var enableEventScripts) && enableEventScripts,
LiveStartedScriptPath = GetValue(lookup, LiveStartedScriptPathKey, string.Empty),
LiveEndedScriptPath = GetValue(lookup, LiveEndedScriptPathKey, string.Empty),
SegmentCompletedScriptPath = GetValue(lookup, SegmentCompletedScriptPathKey, string.Empty),
EventScriptTimeoutSeconds = GetIntValue(lookup, EventScriptTimeoutSecondsKey, 60, 1, 3600),
EnableEmailNotification = bool.TryParse(GetValue(lookup, EnableEmailNotificationKey, "false"), out var enableEmailNotification) && enableEmailNotification,
EmailSmtpHost = GetValue(lookup, EmailSmtpHostKey, string.Empty),
EmailSmtpPort = GetIntValue(lookup, EmailSmtpPortKey, 587, 1, 65535),
@@ -159,6 +179,27 @@ public sealed class SystemSettingsService : ISystemSettingsService
await UpsertAsync(SaveModeKey, request.SaveMode.ToString(), now, cancellationToken);
await UpsertAsync(RecordingTemplateKey, request.RecordingTemplate.ToString(), now, cancellationToken);
await UpsertAsync(SegmentDurationMinutesKey, request.SegmentDurationMinutes.ToString(), now, cancellationToken);
await UpsertAsync(
MaxConcurrentFfmpegTranscodeTasksKey,
Math.Clamp(request.MaxConcurrentFfmpegTranscodeTasks, 1, 16).ToString(),
now,
cancellationToken);
await UpsertAsync(
Mp4FinalizeTimeoutMinutesKey,
Math.Clamp(request.Mp4FinalizeTimeoutMinutes, 1, 1440).ToString(),
now,
cancellationToken);
await UpsertAsync(EnableStorageGuardKey, request.EnableStorageGuard.ToString(), now, cancellationToken);
await UpsertAsync(
PauseRecordingWhenFreeSpaceBelowMegabytesKey,
Math.Clamp(request.PauseRecordingWhenFreeSpaceBelowMegabytes, 0, 1048576).ToString(),
now,
cancellationToken);
await UpsertAsync(
ResumeRecordingWhenFreeSpaceAboveMegabytesKey,
Math.Clamp(request.ResumeRecordingWhenFreeSpaceAboveMegabytes, 0, 1048576).ToString(),
now,
cancellationToken);
await UpsertAsync(EnableReconnectKey, request.EnableAutoReconnect.ToString(), now, cancellationToken);
await UpsertAsync(ReconnectDelayMaxSecondsKey, request.ReconnectDelayMaxSeconds.ToString(), now, cancellationToken);
await UpsertAsync(ReadWriteTimeoutMillisecondsKey, request.ReadWriteTimeoutMilliseconds.ToString(), now, cancellationToken);
@@ -169,6 +210,11 @@ public sealed class SystemSettingsService : ISystemSettingsService
await UpsertAsync(EnableBackgroundPollingKey, request.EnableBackgroundPolling.ToString(), now, cancellationToken);
await UpsertAsync(AutoStartRecordingOnLiveKey, request.AutoStartRecordingOnLive.ToString(), now, cancellationToken);
await UpsertAsync(PollingIntervalSecondsKey, request.PollingIntervalSeconds.ToString(), now, cancellationToken);
await UpsertAsync(EnableEventScriptsKey, request.EnableEventScripts.ToString(), now, cancellationToken);
await UpsertAsync(LiveStartedScriptPathKey, request.LiveStartedScriptPath.Trim(), now, cancellationToken);
await UpsertAsync(LiveEndedScriptPathKey, request.LiveEndedScriptPath.Trim(), now, cancellationToken);
await UpsertAsync(SegmentCompletedScriptPathKey, request.SegmentCompletedScriptPath.Trim(), now, cancellationToken);
await UpsertAsync(EventScriptTimeoutSecondsKey, Math.Clamp(request.EventScriptTimeoutSeconds, 1, 3600).ToString(), now, cancellationToken);
await UpsertAsync(EnableEmailNotificationKey, request.EnableEmailNotification.ToString(), now, cancellationToken);
await UpsertAsync(EmailSmtpHostKey, request.EmailSmtpHost.Trim(), now, cancellationToken);
await UpsertAsync(EmailSmtpPortKey, request.EmailSmtpPort.ToString(), now, cancellationToken);
+70 -4
View File
@@ -40,8 +40,36 @@ public class LiveRoom
public string? AnchorName { get; private set; }
public string? AnchorId { get; private set; }
public string? AvatarUrl { get; private set; }
public string? CoverUrl { get; private set; }
public string? PreferredQualityOverride { get; private set; }
public RecordOutputFormat? OutputFormatOverride { get; private set; }
public RecordSaveMode? SaveModeOverride { get; private set; }
public RecordingTemplateType? RecordingTemplateOverride { get; private set; }
public int? SegmentDurationMinutesOverride { get; private set; }
public bool? EnableAutoReconnectOverride { get; private set; }
public int? ReconnectDelayMaxSecondsOverride { get; private set; }
public int? ReadWriteTimeoutMillisecondsOverride { get; private set; }
public bool? EnableDanmakuRecordingOverride { get; private set; }
public bool? DanmakuIncludeNonChatEventsOverride { get; private set; }
public int? DanmakuMinPollIntervalMillisecondsOverride { get; private set; }
public int? DanmakuRetryDelayMaxSecondsOverride { get; private set; }
public bool IsEnabled { get; private set; }
public bool HasSentLiveNotificationForCurrentSession { get; private set; }
@@ -69,11 +97,13 @@ public class LiveRoom
UpdatedAt = updatedAt;
}
public void UpdateMetadata(string? title, string? anchorName, string? coverUrl, DateTimeOffset updatedAt)
public void UpdateMetadata(string? title, string? anchorName, string? anchorId, string? avatarUrl, string? coverUrl, DateTimeOffset updatedAt)
{
Title = title;
AnchorName = anchorName;
CoverUrl = coverUrl;
Title = PreferIncomingValue(title, Title);
AnchorName = PreferIncomingValue(anchorName, AnchorName);
AnchorId = PreferIncomingValue(anchorId, AnchorId);
AvatarUrl = PreferIncomingValue(avatarUrl, AvatarUrl);
CoverUrl = PreferIncomingValue(coverUrl, CoverUrl);
UpdatedAt = updatedAt;
}
@@ -100,4 +130,40 @@ public class LiveRoom
HasSentLiveNotificationForCurrentSession = true;
UpdatedAt = updatedAt;
}
public void UpdateRecordingSettingsOverrides(
string? preferredQualityOverride,
RecordOutputFormat? outputFormatOverride,
RecordSaveMode? saveModeOverride,
RecordingTemplateType? recordingTemplateOverride,
int? segmentDurationMinutesOverride,
bool? enableAutoReconnectOverride,
int? reconnectDelayMaxSecondsOverride,
int? readWriteTimeoutMillisecondsOverride,
bool? enableDanmakuRecordingOverride,
bool? danmakuIncludeNonChatEventsOverride,
int? danmakuMinPollIntervalMillisecondsOverride,
int? danmakuRetryDelayMaxSecondsOverride,
DateTimeOffset updatedAt)
{
PreferredQualityOverride = NormalizeNullable(preferredQualityOverride);
OutputFormatOverride = outputFormatOverride;
SaveModeOverride = saveModeOverride;
RecordingTemplateOverride = recordingTemplateOverride;
SegmentDurationMinutesOverride = segmentDurationMinutesOverride;
EnableAutoReconnectOverride = enableAutoReconnectOverride;
ReconnectDelayMaxSecondsOverride = reconnectDelayMaxSecondsOverride;
ReadWriteTimeoutMillisecondsOverride = readWriteTimeoutMillisecondsOverride;
EnableDanmakuRecordingOverride = enableDanmakuRecordingOverride;
DanmakuIncludeNonChatEventsOverride = danmakuIncludeNonChatEventsOverride;
DanmakuMinPollIntervalMillisecondsOverride = danmakuMinPollIntervalMillisecondsOverride;
DanmakuRetryDelayMaxSecondsOverride = danmakuRetryDelayMaxSecondsOverride;
UpdatedAt = updatedAt;
}
private static string? PreferIncomingValue(string? incomingValue, string? existingValue) =>
string.IsNullOrWhiteSpace(incomingValue) ? existingValue : incomingValue;
private static string? NormalizeNullable(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
@@ -108,6 +108,14 @@ public class RecordTask
UpdatedAt = updatedAt;
}
public void MarkProcessing(string? statusMessage, DateTimeOffset updatedAt)
{
Status = RecordTaskStatus.Processing;
ErrorMessage = statusMessage;
RecorderProcessId = null;
UpdatedAt = updatedAt;
}
public void MarkCompleted(DateTimeOffset endedAt, double? durationSeconds)
{
Status = RecordTaskStatus.Completed;
@@ -8,5 +8,6 @@ public enum RecordTaskStatus
Stopping = 3,
Completed = 4,
Failed = 5,
Stopped = 6
Stopped = 6,
Processing = 7
}
@@ -18,6 +18,8 @@ public sealed class DatabaseInitializer
public async Task InitializeAsync(CancellationToken cancellationToken = default)
{
await _dbContext.Database.EnsureCreatedAsync(cancellationToken);
await _dbContext.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL;", cancellationToken);
await _dbContext.Database.ExecuteSqlRawAsync("PRAGMA synchronous=NORMAL;", cancellationToken);
await EnsureSchemaAsync(cancellationToken);
await BackfillRecordSessionsAsync(cancellationToken);
@@ -39,6 +41,11 @@ public sealed class DatabaseInitializer
["recording.save_mode"] = "SingleFile",
["recording.template"] = "StreamCopy",
["recording.segment_duration_minutes"] = "30",
["recording.max_concurrent_ffmpeg_transcode_tasks"] = "1",
["recording.mp4_finalize_timeout_minutes"] = "60",
["storage.guard.enabled"] = "True",
["storage.guard.pause_recording_below_mb"] = "1024",
["storage.guard.resume_recording_above_mb"] = "4096",
["recording.enable_auto_reconnect"] = "True",
["recording.reconnect_delay_max_seconds"] = "5",
["recording.read_write_timeout_milliseconds"] = "15000000",
@@ -49,6 +56,11 @@ public sealed class DatabaseInitializer
["scheduler.enable_background_polling"] = "True",
["scheduler.auto_start_recording_on_live"] = "True",
["scheduler.polling_interval_seconds"] = "60",
["event_scripts.enabled"] = "False",
["event_scripts.live_started.path"] = string.Empty,
["event_scripts.live_ended.path"] = string.Empty,
["event_scripts.segment_completed.path"] = string.Empty,
["event_scripts.timeout_seconds"] = "60",
["notification.email.enabled"] = "False",
["notification.email.smtp_host"] = string.Empty,
["notification.email.smtp_port"] = "587",
@@ -132,6 +144,21 @@ public sealed class DatabaseInitializer
{
}
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN AvatarUrl TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN AnchorId TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN PreferredQualityOverride TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN OutputFormatOverride INTEGER NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN SaveModeOverride INTEGER NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN RecordingTemplateOverride INTEGER NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN SegmentDurationMinutesOverride INTEGER NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN EnableAutoReconnectOverride INTEGER NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN ReconnectDelayMaxSecondsOverride INTEGER NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN ReadWriteTimeoutMillisecondsOverride INTEGER NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN EnableDanmakuRecordingOverride INTEGER NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN DanmakuIncludeNonChatEventsOverride INTEGER NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN DanmakuMinPollIntervalMillisecondsOverride INTEGER NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN DanmakuRetryDelayMaxSecondsOverride INTEGER NULL;", cancellationToken);
await _dbContext.Database.ExecuteSqlRawAsync(
"""
CREATE TABLE IF NOT EXISTS RecordSessions (
@@ -41,7 +41,13 @@ public sealed class LiveRecorderDbContext : DbContext, IUnitOfWork
builder.Property(static x => x.RoomId).HasMaxLength(128);
builder.Property(static x => x.Title).HasMaxLength(256);
builder.Property(static x => x.AnchorName).HasMaxLength(128);
builder.Property(static x => x.AnchorId).HasMaxLength(128);
builder.Property(static x => x.AvatarUrl).HasMaxLength(512);
builder.Property(static x => x.CoverUrl).HasMaxLength(512);
builder.Property(static x => x.PreferredQualityOverride).HasMaxLength(64);
builder.Property(static x => x.OutputFormatOverride).HasConversion<int?>();
builder.Property(static x => x.SaveModeOverride).HasConversion<int?>();
builder.Property(static x => x.RecordingTemplateOverride).HasConversion<int?>();
builder.Property(static x => x.IsEnabled).HasDefaultValue(true);
builder.Property(static x => x.HasSentLiveNotificationForCurrentSession).HasDefaultValue(false);
});
@@ -257,6 +257,7 @@ public sealed class SystemLogRepository : ISystemLogRepository
Guid? liveRoomId = null,
Guid? recordSessionId = null,
Guid? recordTaskId = null,
SystemLogLevel? level = null,
int take = 200,
CancellationToken cancellationToken = default)
{
@@ -277,6 +278,11 @@ public sealed class SystemLogRepository : ISystemLogRepository
query = query.Where(item => item.RecordSessionId == recordSessionId.Value);
}
if (level.HasValue)
{
query = query.Where(item => item.Level == level.Value);
}
var items = await query.ToListAsync(cancellationToken);
return items
.OrderByDescending(static item => item.CreatedAt)
@@ -0,0 +1,800 @@
using System.Net;
using System.Text.Json;
using System.Text.RegularExpressions;
using LiveRecorder.Application.Abstractions.Platforms;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
namespace LiveRecorder.Infrastructure.Platforms.Bilibili;
public sealed class BilibiliHttpClient
{
private static readonly Regex RoomIdRegex = new(
"""^(?:(?:https?:\/\/)?live\.bilibili\.com\/(?:blanc\/|h5\/)?)?(?<id>\d+)\/?(?:[#\?].*)?$""",
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private readonly IHttpClientFactory _httpClientFactory;
private readonly BilibiliWbiSigner _wbiSigner;
private readonly ILogger<BilibiliHttpClient> _logger;
private readonly string _buvid3 = BilibiliRequestDefaults.GenerateBuvid3();
public BilibiliHttpClient(
IHttpClientFactory httpClientFactory,
BilibiliWbiSigner wbiSigner,
ILogger<BilibiliHttpClient> logger)
{
_httpClientFactory = httpClientFactory;
_wbiSigner = wbiSigner;
_logger = logger;
}
internal async Task<BilibiliResolvedRoom> ResolveRoomAsync(string input, CancellationToken cancellationToken = default)
{
var trimmedInput = input.Trim();
var roomReference = ExtractRoomReference(trimmedInput);
if (string.IsNullOrWhiteSpace(roomReference))
{
throw new InvalidOperationException("Unable to extract a Bilibili live room id from the input.");
}
var roomInit = await GetRoomInitAsync(roomReference, cancellationToken);
if (string.IsNullOrWhiteSpace(roomInit.RoomId))
{
throw new InvalidOperationException("Bilibili did not return a valid room id.");
}
return new BilibiliResolvedRoom(
roomInit.RoomId,
roomInit.ShortRoomId,
$"https://live.bilibili.com/{roomInit.RoomId}");
}
internal async Task<BilibiliRoomInfo> GetRoomInfoAsync(string roomId, CancellationToken cancellationToken = default)
{
using var document = await GetSignedApiDocumentAsync(
$"{BilibiliRequestDefaults.LiveApiHost}/xlive/web-room/v1/index/getInfoByRoom",
[
new("room_id", roomId),
new("web_location", "444.8")
],
cancellationToken);
var data = GetRequiredProperty(document.RootElement, "data");
var roomInfo = GetRequiredProperty(data, "room_info");
var anchorInfo = TryGetNestedProperty(data, out var anchorValue, "anchor_info", "base_info")
? anchorValue
: default;
return new BilibiliRoomInfo(
GetNumericLikeString(roomInfo, "room_id") ?? roomId,
GetNumericLikeString(roomInfo, "short_id"),
GetInt(roomInfo, "live_status") ?? 0,
GetString(roomInfo, "title"),
GetString(anchorInfo, "uname"),
GetNumericLikeString(anchorInfo, "uid"),
GetString(anchorInfo, "face") ?? GetString(anchorInfo, "head_photo"),
GetString(roomInfo, "cover") ?? GetString(roomInfo, "keyframe"));
}
internal async Task<BilibiliStreamSelection> GetStreamUrlAsync(
string roomId,
string? preferredQuality,
CancellationToken cancellationToken = default)
{
var requestQn = MapRequestedQn(preferredQuality);
using var document = await GetSignedApiDocumentAsync(
$"{BilibiliRequestDefaults.LiveApiHost}/xlive/web-room/v2/index/getRoomPlayInfo",
[
new("room_id", roomId),
new("no_playurl", "0"),
new("mask", "1"),
new("qn", requestQn.ToString()),
new("platform", "web"),
new("protocol", "0,1"),
new("format", "0,1,2"),
new("codec", "0,1,2"),
new("dolby", "5"),
new("panorama", "1"),
new("hdr_type", "0,1"),
new("web_location", "444.8")
],
cancellationToken);
var data = GetRequiredProperty(document.RootElement, "data");
var liveStatus = GetInt(data, "live_status") ?? 0;
if (liveStatus != 1)
{
throw new InvalidOperationException($"Bilibili room {roomId} is not currently live.");
}
if (!TryGetNestedProperty(data, out var streams, "playurl_info", "playurl", "stream") ||
streams.ValueKind != JsonValueKind.Array)
{
throw new InvalidOperationException("Bilibili did not return any playable stream entries.");
}
var candidates = new List<BilibiliStreamCandidate>();
foreach (var stream in streams.EnumerateArray())
{
var protocolName = GetString(stream, "protocol_name") ?? string.Empty;
if (!TryGetProperty(stream, "format", out var formatArray) || formatArray.ValueKind != JsonValueKind.Array)
{
continue;
}
foreach (var format in formatArray.EnumerateArray())
{
var formatName = GetString(format, "format_name") ?? string.Empty;
if (!TryGetProperty(format, "codec", out var codecArray) || codecArray.ValueKind != JsonValueKind.Array)
{
continue;
}
foreach (var codec in codecArray.EnumerateArray())
{
var baseUrl = GetString(codec, "base_url");
if (string.IsNullOrWhiteSpace(baseUrl))
{
continue;
}
var codecName = GetString(codec, "codec_name") ?? string.Empty;
var currentQn = GetInt(codec, "current_qn") ?? requestQn;
if (!TryGetProperty(codec, "url_info", out var urlInfoArray) || urlInfoArray.ValueKind != JsonValueKind.Array)
{
continue;
}
foreach (var urlInfo in urlInfoArray.EnumerateArray())
{
var host = GetString(urlInfo, "host");
var extra = GetString(urlInfo, "extra") ?? string.Empty;
if (string.IsNullOrWhiteSpace(host))
{
continue;
}
var url = BuildPlayableUrl(host, baseUrl, extra);
var qualityKey = GetQualityKey(currentQn);
candidates.Add(new BilibiliStreamCandidate(
qualityKey,
GetQualityName(currentQn),
currentQn,
InferProtocol(protocolName, formatName, url),
url,
protocolName,
formatName,
codecName,
ComputeRank(currentQn, protocolName, formatName, codecName)));
}
}
}
}
var orderedCandidates = candidates
.DistinctBy(static item => item.Url)
.OrderByDescending(static item => item.Rank)
.ThenBy(static item => item.Protocol)
.ToList();
if (orderedCandidates.Count == 0)
{
throw new InvalidOperationException("Bilibili did not return any playable stream URL.");
}
var selected = SelectCandidate(orderedCandidates, preferredQuality);
var compatibilityFallback = SelectRecorderCompatibleCandidate(orderedCandidates, preferredQuality);
if (compatibilityFallback is not null &&
!string.Equals(selected.Url, compatibilityFallback.Url, StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation(
"Bilibili stream selection fell back to a recorder-compatible stream. RequestedQuality={RequestedQuality}; SelectedCodec={SelectedCodec}; SelectedProtocol={SelectedProtocol}; FallbackCodec={FallbackCodec}; FallbackProtocol={FallbackProtocol}; FallbackQuality={FallbackQuality}",
preferredQuality ?? "auto",
selected.CodecName,
selected.Protocol,
compatibilityFallback.CodecName,
compatibilityFallback.Protocol,
compatibilityFallback.QualityKey);
selected = compatibilityFallback;
}
var availableQualities = orderedCandidates
.GroupBy(static item => item.QualityKey, StringComparer.OrdinalIgnoreCase)
.Select(static group =>
{
var first = group.OrderByDescending(static item => item.Rank).First();
return new StreamQualityOption(
first.QualityKey,
first.QualityName,
first.Url,
first.Protocol,
first.Rank);
})
.OrderByDescending(static item => item.Rank)
.ToList();
return new BilibiliStreamSelection(
selected,
availableQualities,
GetStreamInputHeaders(roomId));
}
internal async Task<BilibiliDanmakuServerInfo> GetDanmakuServerAsync(
string roomId,
CancellationToken cancellationToken = default)
{
using var document = await GetSignedApiDocumentAsync(
$"{BilibiliRequestDefaults.LiveApiHost}/xlive/web-room/v1/index/getDanmuInfo",
[
new("id", roomId),
new("type", "0"),
new("web_location", "444.8")
],
cancellationToken);
var data = GetRequiredProperty(document.RootElement, "data");
if (!TryGetProperty(data, "host_list", out var hostList) || hostList.ValueKind != JsonValueKind.Array)
{
throw new InvalidOperationException("Bilibili danmaku server response did not include a host list.");
}
foreach (var host in hostList.EnumerateArray())
{
var hostname = GetString(host, "host");
var wssPort = GetInt(host, "wss_port");
var wssPortValue = wssPort.GetValueOrDefault();
if (!string.IsNullOrWhiteSpace(hostname) && wssPortValue > 0)
{
return new BilibiliDanmakuServerInfo(
new Uri($"wss://{hostname}:{wssPortValue}/sub"),
GetString(data, "token") ?? string.Empty,
_buvid3,
0);
}
}
foreach (var host in hostList.EnumerateArray())
{
var hostname = GetString(host, "host");
var wsPort = GetInt(host, "ws_port") ?? GetInt(host, "port");
var wsPortValue = wsPort.GetValueOrDefault();
if (!string.IsNullOrWhiteSpace(hostname) && wsPortValue > 0)
{
return new BilibiliDanmakuServerInfo(
new Uri($"ws://{hostname}:{wsPortValue}/sub"),
GetString(data, "token") ?? string.Empty,
_buvid3,
0);
}
}
throw new InvalidOperationException("Bilibili danmaku server response did not contain a usable websocket endpoint.");
}
internal StreamInputHeaders GetStreamInputHeaders(string roomId)
{
return new StreamInputHeaders(
BilibiliRequestDefaults.UserAgent,
$"{BilibiliRequestDefaults.RefererBase}{roomId}",
$"buvid3={_buvid3}",
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["Origin"] = BilibiliRequestDefaults.Origin
});
}
internal CookieContainer CreateCookieContainer()
{
var container = new CookieContainer();
container.Add(new Cookie("buvid3", _buvid3, "/", ".bilibili.com"));
return container;
}
private async Task<BilibiliRoomInit> GetRoomInitAsync(string roomReference, CancellationToken cancellationToken)
{
using var document = await GetApiDocumentAsync(
$"{BilibiliRequestDefaults.LiveApiHost}/room/v1/Room/room_init",
[
new("id", roomReference)
],
cancellationToken);
var data = GetRequiredProperty(document.RootElement, "data");
return new BilibiliRoomInit(
GetNumericLikeString(data, "room_id") ?? roomReference,
GetNumericLikeString(data, "short_id"),
GetInt(data, "live_status") ?? 0);
}
private async Task<JsonDocument> GetSignedApiDocumentAsync(
string endpoint,
IEnumerable<KeyValuePair<string, string?>> queryParameters,
CancellationToken cancellationToken)
{
var signedQuery = await _wbiSigner.SignAsync(queryParameters, cancellationToken);
return await GetApiDocumentAsync(
endpoint,
signedQuery.Select(static item => new KeyValuePair<string, string?>(item.Key, item.Value)),
cancellationToken);
}
private async Task<JsonDocument> GetApiDocumentAsync(
string endpoint,
IEnumerable<KeyValuePair<string, string?>> queryParameters,
CancellationToken cancellationToken)
{
var requestUri = BuildUri(endpoint, queryParameters);
using var response = await SendWithRetryAsync(
() =>
{
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
BilibiliRequestDefaults.Apply(request);
request.Headers.TryAddWithoutValidation("Cookie", $"buvid3={_buvid3}");
return request;
},
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
EnsureApiSuccess(document.RootElement, requestUri);
return document;
}
private async Task<HttpResponseMessage> SendWithRetryAsync(
Func<HttpRequestMessage> requestFactory,
HttpCompletionOption completionOption,
CancellationToken cancellationToken)
{
Exception? lastException = null;
for (var attempt = 1; attempt <= 3; attempt++)
{
try
{
var response = await _httpClientFactory
.CreateClient(BilibiliRequestDefaults.ClientName)
.SendAsync(requestFactory(), completionOption, cancellationToken);
if (attempt < 3 && IsTransientStatusCode(response.StatusCode))
{
response.Dispose();
await Task.Delay(TimeSpan.FromMilliseconds(250 * attempt), cancellationToken);
continue;
}
response.EnsureSuccessStatusCode();
return response;
}
catch (Exception ex) when (attempt < 3 && IsTransientException(ex, cancellationToken))
{
lastException = ex;
_logger.LogDebug(ex, "Retrying Bilibili request attempt {Attempt}", attempt);
await Task.Delay(TimeSpan.FromMilliseconds(250 * attempt), cancellationToken);
}
}
throw lastException ?? new InvalidOperationException("Bilibili request failed.");
}
private static string? ExtractRoomReference(string input)
{
var match = RoomIdRegex.Match(input);
if (match.Success)
{
return match.Groups["id"].Value;
}
if (!Uri.TryCreate(input, UriKind.Absolute, out var uri))
{
return null;
}
if (!uri.Host.Contains("live.bilibili.com", StringComparison.OrdinalIgnoreCase))
{
return null;
}
return uri.Segments
.Select(static item => item.Trim('/'))
.FirstOrDefault(static item => item.All(char.IsDigit));
}
private static string BuildPlayableUrl(string host, string baseUrl, string extra)
{
var normalizedHost = host.TrimEnd('/');
var normalizedBaseUrl = baseUrl.StartsWith('/') ? baseUrl : "/" + baseUrl;
return $"{normalizedHost}{normalizedBaseUrl}{extra}";
}
private static string InferProtocol(string protocolName, string formatName, string url)
{
if (formatName.Contains("flv", StringComparison.OrdinalIgnoreCase))
{
return "flv";
}
if (protocolName.Contains("hls", StringComparison.OrdinalIgnoreCase) ||
url.Contains(".m3u8", StringComparison.OrdinalIgnoreCase))
{
return "hls";
}
return formatName.Equals("fmp4", StringComparison.OrdinalIgnoreCase) ? "fmp4" : protocolName;
}
private static int ComputeRank(int qn, string protocolName, string formatName, string codecName)
{
var rank = qn * 100;
rank += formatName.Contains("flv", StringComparison.OrdinalIgnoreCase) ? 30 : 0;
rank += protocolName.Contains("http_stream", StringComparison.OrdinalIgnoreCase) ? 10 : 0;
rank += codecName.Contains("avc", StringComparison.OrdinalIgnoreCase) ? 5 : 0;
return rank;
}
private static BilibiliStreamCandidate SelectCandidate(
IReadOnlyList<BilibiliStreamCandidate> candidates,
string? preferredQuality)
{
if (!string.IsNullOrWhiteSpace(preferredQuality))
{
var normalized = preferredQuality.Trim();
var preferredQn = MapRequestedQn(normalized);
var matched = candidates
.Where(item =>
item.Qn == preferredQn ||
item.QualityKey.Equals(normalized, StringComparison.OrdinalIgnoreCase) ||
item.QualityName.Equals(normalized, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(static item => item.Rank)
.FirstOrDefault();
if (matched is not null)
{
return matched;
}
}
return candidates[0];
}
private static BilibiliStreamCandidate? SelectRecorderCompatibleCandidate(
IReadOnlyList<BilibiliStreamCandidate> candidates,
string? preferredQuality)
{
var exactMatches = string.IsNullOrWhiteSpace(preferredQuality)
? Array.Empty<BilibiliStreamCandidate>()
: candidates
.Where(item => MatchesPreferredQuality(item, preferredQuality))
.ToArray();
var exactAvc = OrderRecorderCompatibility(exactMatches)
.FirstOrDefault(static item => IsAvcCodec(item.CodecName));
if (exactAvc is not null)
{
return exactAvc;
}
var anyAvc = OrderRecorderCompatibility(candidates)
.FirstOrDefault(static item => IsAvcCodec(item.CodecName));
if (anyAvc is not null)
{
return anyAvc;
}
var exactHevc = OrderRecorderCompatibility(exactMatches)
.FirstOrDefault(static item => IsHevcCodec(item.CodecName));
if (exactHevc is not null)
{
return exactHevc;
}
var anyHevc = OrderRecorderCompatibility(candidates)
.FirstOrDefault(static item => IsHevcCodec(item.CodecName));
if (anyHevc is not null)
{
return anyHevc;
}
return null;
}
private static IEnumerable<BilibiliStreamCandidate> OrderRecorderCompatibility(IEnumerable<BilibiliStreamCandidate> candidates) =>
candidates
.OrderByDescending(static item => GetCodecCompatibilityScore(item.CodecName))
.ThenByDescending(static item => GetTransportCompatibilityScore(item.ProtocolName, item.FormatName, item.Protocol))
.ThenByDescending(static item => item.Qn)
.ThenByDescending(static item => item.Rank);
private static int GetCodecCompatibilityScore(string codecName)
{
if (IsAvcCodec(codecName))
{
return 3;
}
if (IsHevcCodec(codecName))
{
return 2;
}
if (IsAv1Codec(codecName))
{
return 0;
}
return 1;
}
private static int GetTransportCompatibilityScore(string protocolName, string formatName, string protocol)
{
var score = 0;
if (formatName.Contains("flv", StringComparison.OrdinalIgnoreCase))
{
score += 3;
}
else if (protocolName.Contains("http_stream", StringComparison.OrdinalIgnoreCase))
{
score += 2;
}
else if (protocol.Equals("hls", StringComparison.OrdinalIgnoreCase))
{
score += 1;
}
if (formatName.Equals("fmp4", StringComparison.OrdinalIgnoreCase))
{
score -= 1;
}
return score;
}
private static bool MatchesPreferredQuality(BilibiliStreamCandidate candidate, string preferredQuality)
{
var normalized = preferredQuality.Trim();
var preferredQn = MapRequestedQn(normalized);
return candidate.Qn == preferredQn ||
candidate.QualityKey.Equals(normalized, StringComparison.OrdinalIgnoreCase) ||
candidate.QualityName.Equals(normalized, StringComparison.OrdinalIgnoreCase);
}
private static bool IsAvcCodec(string codecName) =>
codecName.Contains("avc", StringComparison.OrdinalIgnoreCase) ||
codecName.Contains("h264", StringComparison.OrdinalIgnoreCase);
private static bool IsHevcCodec(string codecName) =>
codecName.Contains("hevc", StringComparison.OrdinalIgnoreCase) ||
codecName.Contains("h265", StringComparison.OrdinalIgnoreCase);
private static bool IsAv1Codec(string codecName) =>
codecName.Contains("av1", StringComparison.OrdinalIgnoreCase);
private static int MapRequestedQn(string? preferredQuality)
{
if (string.IsNullOrWhiteSpace(preferredQuality))
{
return 10000;
}
var normalized = preferredQuality.Trim();
if (int.TryParse(normalized, out var numeric))
{
return numeric;
}
return normalized.ToLowerInvariant() switch
{
"dolby" => 30000,
"4k" or "uhd" => 20000,
"origin" or "source" or "raw" => 10000,
"full_hd" or "fullhd" or "blue_ray" or "blueray" => 400,
"hd" or "super" => 250,
"sd" or "high" => 150,
"ld" or "smooth" or "fluency" => 80,
_ => 10000
};
}
private static string GetQualityKey(int qn) =>
qn switch
{
30000 => "dolby",
20000 => "uhd",
10000 => "origin",
400 => "full_hd",
250 => "hd",
150 => "sd",
80 => "ld",
_ => qn.ToString()
};
private static string GetQualityName(int qn) =>
qn switch
{
30000 => "Dolby",
20000 => "UHD",
10000 => "Origin",
400 => "Full HD",
250 => "HD",
150 => "SD",
80 => "Low",
_ => qn.ToString()
};
private static bool IsTransientStatusCode(HttpStatusCode statusCode) =>
statusCode == HttpStatusCode.RequestTimeout ||
statusCode == (HttpStatusCode)429 ||
(int)statusCode >= 500;
private static bool IsTransientException(Exception exception, CancellationToken cancellationToken)
{
if (exception is OperationCanceledException && cancellationToken.IsCancellationRequested)
{
return false;
}
return exception is HttpRequestException or IOException or TimeoutException ||
exception.InnerException is not null && IsTransientException(exception.InnerException, cancellationToken);
}
private static string BuildUri(string endpoint, IEnumerable<KeyValuePair<string, string?>> queryParameters)
{
var lookup = queryParameters.ToDictionary(
static item => item.Key,
static item => item.Value,
StringComparer.Ordinal);
return QueryHelpers.AddQueryString(endpoint, lookup);
}
private static void EnsureApiSuccess(JsonElement root, string requestUri)
{
var code = GetInt(root, "code") ?? 0;
if (code == 0)
{
return;
}
var message = GetString(root, "message") ?? GetString(root, "msg") ?? "unknown error";
throw new InvalidOperationException(
$"Bilibili API request failed ({code}) for request {requestUri}: {message}");
}
private static JsonElement GetRequiredProperty(JsonElement element, string propertyName)
{
if (!TryGetProperty(element, propertyName, out var value))
{
throw new InvalidOperationException($"Bilibili response is missing required property '{propertyName}'.");
}
return value;
}
private static bool TryGetProperty(JsonElement element, string propertyName, out JsonElement value)
{
if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out value))
{
return true;
}
value = default;
return false;
}
private static bool TryGetNestedProperty(JsonElement element, out JsonElement value, params string[] path)
{
value = element;
foreach (var segment in path)
{
if (!TryGetProperty(value, segment, out value))
{
return false;
}
}
return true;
}
private static string? GetString(JsonElement element, string propertyName)
{
if (!TryGetProperty(element, propertyName, out var value))
{
return null;
}
return value.ValueKind switch
{
JsonValueKind.String => value.GetString(),
JsonValueKind.Number => value.GetRawText(),
_ => null
};
}
private static int? GetInt(JsonElement element, string propertyName)
{
if (!TryGetProperty(element, propertyName, out var value))
{
return null;
}
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var intValue))
{
return intValue;
}
return value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), out intValue)
? intValue
: null;
}
private static string? GetNumericLikeString(JsonElement element, string propertyName)
{
var value = GetString(element, propertyName);
return !string.IsNullOrWhiteSpace(value) && value.All(char.IsDigit) ? value : null;
}
}
internal static class BilibiliRequestDefaults
{
public const string ClientName = "bilibili";
public const string LiveApiHost = "https://api.live.bilibili.com";
public const string Origin = "https://live.bilibili.com";
public const string RefererBase = "https://live.bilibili.com/";
public const string UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 Edg/132.0.0.0";
public static void Apply(HttpRequestMessage request)
{
request.Headers.TryAddWithoutValidation("Accept", "application/json, text/javascript, */*; q=0.01");
request.Headers.TryAddWithoutValidation("Accept-Language", "zh-CN");
request.Headers.TryAddWithoutValidation("Origin", Origin);
request.Headers.TryAddWithoutValidation("Referer", RefererBase);
request.Headers.TryAddWithoutValidation("User-Agent", UserAgent);
}
public static string GenerateBuvid3()
{
Span<byte> randomBytes = stackalloc byte[16];
Random.Shared.NextBytes(randomBytes);
var guid = new Guid(randomBytes).ToString().ToUpperInvariant();
var suffix = (DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() % 100000).ToString("D5");
return $"{guid}{suffix}infoc";
}
}
internal sealed record BilibiliResolvedRoom(
string RoomId,
string? ShortRoomId,
string NormalizedUrl);
internal sealed record BilibiliRoomInit(
string RoomId,
string? ShortRoomId,
int LiveStatus);
internal sealed record BilibiliRoomInfo(
string RoomId,
string? ShortRoomId,
int LiveStatus,
string? Title,
string? AnchorName,
string? AnchorId,
string? AvatarUrl,
string? CoverUrl);
internal sealed record BilibiliStreamCandidate(
string QualityKey,
string QualityName,
int Qn,
string Protocol,
string Url,
string ProtocolName,
string FormatName,
string CodecName,
int Rank);
internal sealed record BilibiliStreamSelection(
BilibiliStreamCandidate Selected,
IReadOnlyList<StreamQualityOption> AvailableQualities,
StreamInputHeaders InputHeaders);
internal sealed record BilibiliDanmakuServerInfo(
Uri Endpoint,
string Token,
string Buvid3,
long Uid);
@@ -5,21 +5,61 @@ namespace LiveRecorder.Infrastructure.Platforms.Bilibili;
public sealed class BilibiliLivePlatformAdapter : ILivePlatformAdapter
{
private readonly BilibiliHttpClient _bilibiliHttpClient;
public BilibiliLivePlatformAdapter(BilibiliHttpClient bilibiliHttpClient)
{
_bilibiliHttpClient = bilibiliHttpClient;
}
public LivePlatformType PlatformType => LivePlatformType.Bilibili;
public bool CanHandle(string input) =>
!string.IsNullOrWhiteSpace(input) &&
input.Contains("bilibili.com", StringComparison.OrdinalIgnoreCase);
public bool CanHandle(string input)
{
if (string.IsNullOrWhiteSpace(input))
{
return false;
}
public Task<ParsedLiveRoom> ParseRoomAsync(string input, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("Bilibili 适配器尚未实现。");
return input.Contains("live.bilibili.com", StringComparison.OrdinalIgnoreCase);
}
public Task<LiveStatusSnapshot> GetLiveStatusAsync(string roomId, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("Bilibili 适配器尚未实现。");
public async Task<ParsedLiveRoom> ParseRoomAsync(string input, CancellationToken cancellationToken = default)
{
var resolvedRoom = await _bilibiliHttpClient.ResolveRoomAsync(input, cancellationToken);
return new ParsedLiveRoom(
PlatformType,
resolvedRoom.RoomId,
input.Trim(),
resolvedRoom.NormalizedUrl);
}
public Task<StreamUrlResult> GetStreamUrlAsync(
public async Task<LiveStatusSnapshot> GetLiveStatusAsync(string roomId, CancellationToken cancellationToken = default)
{
var roomInfo = await _bilibiliHttpClient.GetRoomInfoAsync(roomId, cancellationToken);
return new LiveStatusSnapshot(
roomInfo.LiveStatus == 1,
roomInfo.Title,
roomInfo.AnchorName,
roomInfo.AnchorId,
roomInfo.AvatarUrl,
roomInfo.CoverUrl,
roomInfo.LiveStatus,
roomInfo.LiveStatus.ToString());
}
public async Task<StreamUrlResult> GetStreamUrlAsync(
string roomId,
string? preferredQuality = null,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException("Bilibili 适配器尚未实现。");
CancellationToken cancellationToken = default)
{
var selection = await _bilibiliHttpClient.GetStreamUrlAsync(roomId, preferredQuality, cancellationToken);
return new StreamUrlResult(
selection.Selected.QualityKey,
selection.Selected.Protocol,
selection.Selected.Url,
selection.InputHeaders,
selection.AvailableQualities,
selection.Selected.CodecName);
}
}
@@ -0,0 +1,136 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace LiveRecorder.Infrastructure.Platforms.Bilibili;
public sealed class BilibiliWbiSigner
{
private static readonly byte[] KeyMap =
[
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35,
27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13,
37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4,
22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52
];
private static readonly TimeSpan KeyRefreshInterval = TimeSpan.FromHours(4);
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<BilibiliWbiSigner> _logger;
private readonly SemaphoreSlim _gate = new(1, 1);
private string? _key;
private DateTimeOffset _lastUpdatedAt = DateTimeOffset.MinValue;
public BilibiliWbiSigner(
IHttpClientFactory httpClientFactory,
ILogger<BilibiliWbiSigner> logger)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
}
public async Task<IReadOnlyList<KeyValuePair<string, string>>> SignAsync(
IEnumerable<KeyValuePair<string, string?>> queryParameters,
CancellationToken cancellationToken = default)
{
await EnsureKeyAsync(cancellationToken);
var key = _key ?? throw new InvalidOperationException("Bilibili WBI key is not available.");
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var sanitized = queryParameters
.Select(static item => new KeyValuePair<string, string>(
item.Key,
SanitizeValue(item.Value)))
.Append(new KeyValuePair<string, string>("wts", timestamp))
.OrderBy(static item => item.Key, StringComparer.Ordinal)
.ToArray();
using var formContent = new FormUrlEncodedContent(sanitized);
var encoded = await formContent.ReadAsStringAsync(cancellationToken);
var hashBytes = MD5.HashData(Encoding.UTF8.GetBytes(encoded + key));
var signature = Convert.ToHexString(hashBytes).ToLowerInvariant();
return sanitized
.Append(new KeyValuePair<string, string>("w_rid", signature))
.ToArray();
}
private async Task EnsureKeyAsync(CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(_key) &&
_lastUpdatedAt + KeyRefreshInterval > DateTimeOffset.UtcNow)
{
return;
}
await _gate.WaitAsync(cancellationToken);
try
{
if (!string.IsNullOrWhiteSpace(_key) &&
_lastUpdatedAt + KeyRefreshInterval > DateTimeOffset.UtcNow)
{
return;
}
using var request = new HttpRequestMessage(HttpMethod.Get, "https://api.bilibili.com/x/web-interface/nav");
BilibiliRequestDefaults.Apply(request);
using var response = await _httpClientFactory
.CreateClient(BilibiliRequestDefaults.ClientName)
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
var root = document.RootElement;
var data = root.GetProperty("data");
var wbiImg = data.GetProperty("wbi_img");
var imgUrl = wbiImg.GetProperty("img_url").GetString();
var subUrl = wbiImg.GetProperty("sub_url").GetString();
if (string.IsNullOrWhiteSpace(imgUrl) || string.IsNullOrWhiteSpace(subUrl))
{
throw new InvalidOperationException("Bilibili WBI response did not include img/sub keys.");
}
_key = BuildKey(
Path.GetFileNameWithoutExtension(imgUrl),
Path.GetFileNameWithoutExtension(subUrl));
_lastUpdatedAt = DateTimeOffset.UtcNow;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Refresh Bilibili WBI key failed.");
throw;
}
finally
{
_gate.Release();
}
}
private static string BuildKey(string img, string sub)
{
var full = img + sub;
Span<char> buffer = stackalloc char[32];
for (var index = 0; index < buffer.Length; index++)
{
buffer[index] = full[KeyMap[index]];
}
return new string(buffer);
}
private static string SanitizeValue(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
return new string(value.Where(static ch => ch is not '!' and not '\'' and not '(' and not ')' and not '*').ToArray());
}
}
@@ -0,0 +1,279 @@
using System.Net.WebSockets;
using System.Text;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Domain.Enums;
using Microsoft.Extensions.Logging;
namespace LiveRecorder.Infrastructure.Platforms.Bilibili.Danmaku;
public sealed class BilibiliDanmakuAdapter : ILiveDanmakuAdapter
{
private readonly BilibiliHttpClient _bilibiliHttpClient;
private readonly ILoggerFactory _loggerFactory;
public BilibiliDanmakuAdapter(
BilibiliHttpClient bilibiliHttpClient,
ILoggerFactory loggerFactory)
{
_bilibiliHttpClient = bilibiliHttpClient;
_loggerFactory = loggerFactory;
}
public LivePlatformType PlatformType => LivePlatformType.Bilibili;
public bool CanHandle(LivePlatformType platformType) => platformType == LivePlatformType.Bilibili;
public Task<ILiveDanmakuConnection> ConnectAsync(
DanmakuConnectionContext context,
CancellationToken cancellationToken = default) =>
Task.FromResult<ILiveDanmakuConnection>(
new BilibiliDanmakuConnection(
_bilibiliHttpClient,
_loggerFactory.CreateLogger<BilibiliDanmakuConnection>(),
context));
}
internal sealed class BilibiliDanmakuConnection : ILiveDanmakuConnection
{
private readonly BilibiliHttpClient _bilibiliHttpClient;
private readonly ILogger<BilibiliDanmakuConnection> _logger;
private readonly DanmakuConnectionContext _context;
private readonly SemaphoreSlim _sendGate = new(1, 1);
private ClientWebSocket? _socket;
public BilibiliDanmakuConnection(
BilibiliHttpClient bilibiliHttpClient,
ILogger<BilibiliDanmakuConnection> logger,
DanmakuConnectionContext context)
{
_bilibiliHttpClient = bilibiliHttpClient;
_logger = logger;
_context = context;
}
public async Task StartAsync(Func<DanmakuEvent, Task> onEvent, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(onEvent);
var backoff = TimeSpan.FromSeconds(1);
var maxBackoff = TimeSpan.FromSeconds(Math.Max(1, _context.RetryDelayMaxSeconds));
while (!cancellationToken.IsCancellationRequested)
{
ClientWebSocket? socket = null;
CancellationTokenSource? heartbeatCancellation = null;
Task? heartbeatTask = null;
try
{
var serverInfo = await _bilibiliHttpClient.GetDanmakuServerAsync(_context.RoomId, cancellationToken);
socket = CreateSocket();
_socket = socket;
await socket.ConnectAsync(serverInfo.Endpoint, cancellationToken);
await SendPacketAsync(
socket,
BilibiliDanmakuProtocol.CreateAuthenticationPacket(
_context.RoomId,
serverInfo.Token,
serverInfo.Buvid3,
serverInfo.Uid),
cancellationToken);
heartbeatCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
heartbeatTask = RunHeartbeatAsync(socket, heartbeatCancellation.Token);
_logger.LogInformation(
"Bilibili danmaku websocket connected for room {RoomId}. Endpoint={Endpoint}",
_context.RoomId,
serverInfo.Endpoint);
backoff = TimeSpan.FromSeconds(1);
await ReceiveLoopAsync(socket, onEvent, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Bilibili danmaku websocket failed for room {RoomId}. Retrying.", _context.RoomId);
}
finally
{
if (heartbeatCancellation is not null)
{
heartbeatCancellation.Cancel();
}
if (heartbeatTask is not null)
{
try
{
await heartbeatTask;
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Bilibili heartbeat loop ended with an error for room {RoomId}.", _context.RoomId);
}
}
await CloseSocketAsync(socket, cancellationToken);
if (ReferenceEquals(_socket, socket))
{
_socket = null;
}
heartbeatCancellation?.Dispose();
}
if (cancellationToken.IsCancellationRequested)
{
break;
}
await Task.Delay(backoff, cancellationToken);
backoff = TimeSpan.FromSeconds(Math.Min(maxBackoff.TotalSeconds, backoff.TotalSeconds * 2));
}
}
public async ValueTask DisposeAsync()
{
await CloseSocketAsync(_socket, CancellationToken.None);
_socket = null;
_sendGate.Dispose();
}
private ClientWebSocket CreateSocket()
{
var socket = new ClientWebSocket();
socket.Options.KeepAliveInterval = Timeout.InfiniteTimeSpan;
socket.Options.SetRequestHeader("Origin", BilibiliRequestDefaults.Origin);
socket.Options.SetRequestHeader("Referer", $"{BilibiliRequestDefaults.RefererBase}{_context.RoomId}");
socket.Options.SetRequestHeader("User-Agent", BilibiliRequestDefaults.UserAgent);
socket.Options.Cookies = _bilibiliHttpClient.CreateCookieContainer();
return socket;
}
private async Task ReceiveLoopAsync(
ClientWebSocket socket,
Func<DanmakuEvent, Task> onEvent,
CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested &&
socket.State is WebSocketState.Open or WebSocketState.CloseReceived)
{
var (messageType, payload) = await ReceiveMessageAsync(socket, cancellationToken);
if (messageType == WebSocketMessageType.Close)
{
_logger.LogWarning("Bilibili danmaku websocket was closed by the remote endpoint for room {RoomId}.", _context.RoomId);
return;
}
if (messageType == WebSocketMessageType.Text)
{
_logger.LogDebug(
"Bilibili danmaku websocket returned a text payload for room {RoomId}: {Payload}",
_context.RoomId,
Encoding.UTF8.GetString(payload));
continue;
}
foreach (var danmakuEvent in BilibiliDanmakuProtocol.Parse(payload))
{
await onEvent(danmakuEvent);
}
}
}
private async Task RunHeartbeatAsync(ClientWebSocket socket, CancellationToken cancellationToken)
{
var interval = TimeSpan.FromSeconds(30);
while (!cancellationToken.IsCancellationRequested && socket.State == WebSocketState.Open)
{
await Task.Delay(interval, cancellationToken);
if (cancellationToken.IsCancellationRequested || socket.State != WebSocketState.Open)
{
break;
}
await SendPacketAsync(socket, BilibiliDanmakuProtocol.CreateHeartbeatPacket(), cancellationToken);
}
}
private async Task SendPacketAsync(ClientWebSocket socket, byte[] payload, CancellationToken cancellationToken)
{
await _sendGate.WaitAsync(cancellationToken);
try
{
if (socket.State != WebSocketState.Open)
{
return;
}
await socket.SendAsync(
new ArraySegment<byte>(payload),
WebSocketMessageType.Binary,
true,
cancellationToken);
}
finally
{
_sendGate.Release();
}
}
private static async Task<(WebSocketMessageType MessageType, byte[] Payload)> ReceiveMessageAsync(
ClientWebSocket socket,
CancellationToken cancellationToken)
{
var buffer = new byte[8192];
using var output = new MemoryStream();
while (true)
{
var result = await socket.ReceiveAsync(new ArraySegment<byte>(buffer), cancellationToken);
if (result.MessageType == WebSocketMessageType.Close)
{
return (WebSocketMessageType.Close, Array.Empty<byte>());
}
if (result.Count > 0)
{
output.Write(buffer, 0, result.Count);
}
if (result.EndOfMessage)
{
return (result.MessageType, output.ToArray());
}
}
}
private static async Task CloseSocketAsync(ClientWebSocket? socket, CancellationToken cancellationToken)
{
if (socket is null)
{
return;
}
try
{
if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived)
{
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "disposed", cancellationToken);
}
}
catch
{
}
finally
{
socket.Dispose();
}
}
}
@@ -0,0 +1,395 @@
using System.Buffers.Binary;
using System.IO.Compression;
using System.Text;
using System.Text.Json;
using LiveRecorder.Application.Abstractions.Platforms;
namespace LiveRecorder.Infrastructure.Platforms.Bilibili.Danmaku;
internal static class BilibiliDanmakuProtocol
{
private const short HeaderLength = 16;
private const int OperationHeartbeat = 2;
private const int OperationAuth = 7;
private const int OperationMessage = 5;
public static byte[] CreateAuthenticationPacket(string roomId, string token, string buvid3, long uid)
{
var payload = JsonSerializer.SerializeToUtf8Bytes(new
{
uid,
roomid = long.Parse(roomId),
protover = 3,
buvid = buvid3,
platform = "web",
type = 2,
key = token
});
return CreatePacket(OperationAuth, payload);
}
public static byte[] CreateHeartbeatPacket() => CreatePacket(OperationHeartbeat, Array.Empty<byte>());
public static IReadOnlyList<DanmakuEvent> Parse(byte[] payload)
{
var events = new List<DanmakuEvent>();
ParsePackets(payload, events);
return events;
}
private static byte[] CreatePacket(int operation, byte[] payload)
{
var buffer = new byte[HeaderLength + payload.Length];
BinaryPrimitives.WriteInt32BigEndian(buffer.AsSpan(0, 4), buffer.Length);
BinaryPrimitives.WriteInt16BigEndian(buffer.AsSpan(4, 2), HeaderLength);
BinaryPrimitives.WriteInt16BigEndian(buffer.AsSpan(6, 2), 1);
BinaryPrimitives.WriteInt32BigEndian(buffer.AsSpan(8, 4), operation);
BinaryPrimitives.WriteInt32BigEndian(buffer.AsSpan(12, 4), 1);
payload.CopyTo(buffer.AsSpan(HeaderLength));
return buffer;
}
private static void ParsePackets(ReadOnlySpan<byte> buffer, ICollection<DanmakuEvent> events)
{
var offset = 0;
while (offset + HeaderLength <= buffer.Length)
{
var packetLength = BinaryPrimitives.ReadInt32BigEndian(buffer.Slice(offset, 4));
if (packetLength <= HeaderLength || offset + packetLength > buffer.Length)
{
break;
}
var headerLength = BinaryPrimitives.ReadInt16BigEndian(buffer.Slice(offset + 4, 2));
var version = BinaryPrimitives.ReadInt16BigEndian(buffer.Slice(offset + 6, 2));
var operation = BinaryPrimitives.ReadInt32BigEndian(buffer.Slice(offset + 8, 4));
var bodyOffset = offset + headerLength;
var bodyLength = packetLength - headerLength;
if (bodyLength > 0)
{
var body = buffer.Slice(bodyOffset, bodyLength);
if (operation == OperationMessage)
{
if (version == 2)
{
ParsePackets(DecompressZlib(body), events);
}
else if (version == 3)
{
ParsePackets(DecompressBrotli(body), events);
}
else
{
var danmakuEvent = ParseJsonEvent(body);
if (danmakuEvent is not null)
{
events.Add(danmakuEvent);
}
}
}
}
offset += packetLength;
}
}
private static DanmakuEvent? ParseJsonEvent(ReadOnlySpan<byte> payload)
{
var json = Encoding.UTF8.GetString(payload);
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
var cmd = GetString(root, "cmd") ?? "UNKNOWN";
var normalizedCmd = cmd.Split(':', 2)[0];
var occurredAt = ResolveOccurredAt(root);
return normalizedCmd switch
{
"DANMU_MSG" => ParseChatEvent(root, cmd, json, occurredAt),
"SEND_GIFT" => ParseGiftEvent(root, cmd, json, occurredAt),
"GUARD_BUY" => ParseGuardEvent(root, cmd, json, occurredAt),
"INTERACT_WORD" => ParseInteractEvent(root, cmd, json, occurredAt),
"LIKE_INFO_V3_CLICK" or "LIKE_INFO_V3_UPDATE" or "LIKE_INFO_V3_NOTICE" => ParseLikeEvent(root, cmd, json, occurredAt),
"SUPER_CHAT_MESSAGE" => ParseSuperChatEvent(root, cmd, json, occurredAt),
"LIVE" or "PREPARING" or "ROOM_CHANGE" or "ROOM_BLOCK_MSG" or "WARNING" => ParseStatusEvent(normalizedCmd.ToLowerInvariant(), cmd, json, occurredAt),
_ => ParseOtherEvent(root, normalizedCmd.ToLowerInvariant(), cmd, json, occurredAt)
};
}
private static DanmakuEvent ParseChatEvent(JsonElement root, string cmd, string rawPayload, DateTimeOffset occurredAt)
{
var info = TryGetArrayIndex(root, "info", 0);
var content = TryGetArrayString(GetPropertyOrDefault(root, "info"), 1);
var userElement = TryGetArrayIndex(GetPropertyOrDefault(root, "info"), 2) ?? default;
var user = TryGetArrayString(userElement, 1);
var userId = TryGetArrayString(userElement, 0);
return new DanmakuEvent(
"chat",
user,
userId,
content,
occurredAt,
rawPayload,
CreateExtra(("cmd", cmd), ("infoPresent", (info is not null).ToString())));
}
private static DanmakuEvent ParseGiftEvent(JsonElement root, string cmd, string rawPayload, DateTimeOffset occurredAt)
{
var data = GetPropertyOrDefault(root, "data");
var giftName = GetString(data, "giftName");
var count = GetString(data, "num") ?? "1";
var content = string.IsNullOrWhiteSpace(giftName) ? null : $"{giftName} x{count}";
return new DanmakuEvent(
"gift",
GetString(data, "uname"),
GetString(data, "uid"),
content,
occurredAt,
rawPayload,
CreateExtra(("cmd", cmd), ("giftName", giftName), ("count", count)));
}
private static DanmakuEvent ParseGuardEvent(JsonElement root, string cmd, string rawPayload, DateTimeOffset occurredAt)
{
var data = GetPropertyOrDefault(root, "data");
return new DanmakuEvent(
"member",
GetString(data, "username"),
GetString(data, "uid"),
$"guard x{GetString(data, "num") ?? "1"}",
occurredAt,
rawPayload,
CreateExtra(("cmd", cmd), ("guardLevel", GetString(data, "guard_level"))));
}
private static DanmakuEvent ParseInteractEvent(JsonElement root, string cmd, string rawPayload, DateTimeOffset occurredAt)
{
var data = GetPropertyOrDefault(root, "data");
var msgType = GetInt(data, "msg_type") ?? 0;
var content = msgType switch
{
1 => "entered the room",
2 => "followed the room",
3 => "shared the room",
_ => "interacted"
};
return new DanmakuEvent(
msgType is 1 ? "enter" : "member",
GetString(data, "uname"),
GetString(data, "uid"),
content,
occurredAt,
rawPayload,
CreateExtra(("cmd", cmd), ("msgType", msgType.ToString())));
}
private static DanmakuEvent ParseLikeEvent(JsonElement root, string cmd, string rawPayload, DateTimeOffset occurredAt)
{
var data = GetPropertyOrDefault(root, "data");
return new DanmakuEvent(
"like",
GetString(data, "uname"),
GetString(data, "uid"),
GetString(data, "like_text") ?? GetString(data, "click_count") ?? "liked",
occurredAt,
rawPayload,
CreateExtra(("cmd", cmd)));
}
private static DanmakuEvent ParseSuperChatEvent(JsonElement root, string cmd, string rawPayload, DateTimeOffset occurredAt)
{
var data = GetPropertyOrDefault(root, "data");
var userInfo = GetPropertyOrDefault(data, "user_info");
return new DanmakuEvent(
"superchat",
GetString(userInfo, "uname"),
GetString(data, "uid"),
GetString(data, "message"),
occurredAt,
rawPayload,
CreateExtra(("cmd", cmd), ("price", GetString(data, "price"))));
}
private static DanmakuEvent ParseStatusEvent(string type, string cmd, string rawPayload, DateTimeOffset occurredAt)
{
return new DanmakuEvent(
type,
null,
null,
null,
occurredAt,
rawPayload,
CreateExtra(("cmd", cmd)));
}
private static DanmakuEvent ParseOtherEvent(JsonElement root, string type, string cmd, string rawPayload, DateTimeOffset occurredAt)
{
var data = GetPropertyOrDefault(root, "data");
return new DanmakuEvent(
string.IsNullOrWhiteSpace(type) ? "other" : type,
GetString(data, "uname") ?? GetString(data, "username"),
GetString(data, "uid"),
GetString(data, "msg") ?? GetString(data, "giftName") ?? GetString(data, "message"),
occurredAt,
rawPayload,
CreateExtra(("cmd", cmd)));
}
private static DateTimeOffset ResolveOccurredAt(JsonElement root)
{
var candidates = new[]
{
GetInt64(root, "timestamp"),
GetInt64(root, "ts"),
GetNestedInt64(root, "data", "timestamp"),
GetNestedInt64(root, "data", "send_time"),
GetNestedInt64(root, "data", "start_time"),
GetNestedInt64(root, "data", "time")
};
foreach (var candidate in candidates)
{
if (!candidate.HasValue || candidate <= 0)
{
continue;
}
return candidate > 9_999_999_999
? DateTimeOffset.FromUnixTimeMilliseconds(candidate.Value)
: DateTimeOffset.FromUnixTimeSeconds(candidate.Value);
}
return DateTimeOffset.UtcNow;
}
private static byte[] DecompressZlib(ReadOnlySpan<byte> payload)
{
using var input = payload.Length > 2 && payload[0] == 0x78
? new MemoryStream(payload[2..].ToArray(), writable: false)
: new MemoryStream(payload.ToArray(), writable: false);
using var deflate = new DeflateStream(input, CompressionMode.Decompress);
using var output = new MemoryStream();
deflate.CopyTo(output);
return output.ToArray();
}
private static byte[] DecompressBrotli(ReadOnlySpan<byte> payload)
{
using var input = new MemoryStream(payload.ToArray(), writable: false);
using var brotli = new BrotliStream(input, CompressionMode.Decompress);
using var output = new MemoryStream();
brotli.CopyTo(output);
return output.ToArray();
}
private static IReadOnlyDictionary<string, string>? CreateExtra(params (string Key, string? Value)[] pairs)
{
var dictionary = pairs
.Where(static pair => !string.IsNullOrWhiteSpace(pair.Value))
.ToDictionary(static pair => pair.Key, static pair => pair.Value!, StringComparer.OrdinalIgnoreCase);
return dictionary.Count == 0 ? null : dictionary;
}
private static bool TryGetProperty(JsonElement element, string propertyName, out JsonElement value)
{
if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out value))
{
return true;
}
value = default;
return false;
}
private static JsonElement GetPropertyOrDefault(JsonElement element, string propertyName) =>
TryGetProperty(element, propertyName, out var value) ? value : default;
private static JsonElement? TryGetArrayIndex(JsonElement element, string propertyName, int index)
{
if (!TryGetProperty(element, propertyName, out var value) || value.ValueKind != JsonValueKind.Array || value.GetArrayLength() <= index)
{
return null;
}
return value[index];
}
private static JsonElement? TryGetArrayIndex(JsonElement element, int index)
{
if (element.ValueKind != JsonValueKind.Array || element.GetArrayLength() <= index)
{
return null;
}
return element[index];
}
private static string? TryGetArrayString(JsonElement element, int index)
{
if (element.ValueKind != JsonValueKind.Array || element.GetArrayLength() <= index)
{
return null;
}
return ReadScalarString(element[index]);
}
private static string? GetString(JsonElement element, string propertyName) =>
TryGetProperty(element, propertyName, out var value) ? ReadScalarString(value) : null;
private static int? GetInt(JsonElement element, string propertyName)
{
if (!TryGetProperty(element, propertyName, out var value))
{
return null;
}
return value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var intValue)
? intValue
: int.TryParse(ReadScalarString(value), out intValue)
? intValue
: null;
}
private static long? GetInt64(JsonElement element, string propertyName)
{
if (!TryGetProperty(element, propertyName, out var value))
{
return null;
}
return value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var longValue)
? longValue
: long.TryParse(ReadScalarString(value), out longValue)
? longValue
: null;
}
private static long? GetNestedInt64(JsonElement element, string parentPropertyName, string propertyName)
{
if (!TryGetProperty(element, parentPropertyName, out var parent))
{
return null;
}
return GetInt64(parent, propertyName);
}
private static string? ReadScalarString(JsonElement value) =>
value.ValueKind switch
{
JsonValueKind.String => value.GetString(),
JsonValueKind.Number => value.GetRawText(),
JsonValueKind.True => bool.TrueString.ToLowerInvariant(),
JsonValueKind.False => bool.FalseString.ToLowerInvariant(),
_ => null
};
}
@@ -1,3 +1,6 @@
using System.Net;
using System.Net.WebSockets;
using System.Text;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Domain.Enums;
using Microsoft.Extensions.Logging;
@@ -30,6 +33,9 @@ internal sealed class DouyinDanmakuConnection : ILiveDanmakuConnection
private readonly DouyinHttpClient _douyinHttpClient;
private readonly ILogger<DouyinDanmakuConnection> _logger;
private readonly DanmakuConnectionContext _context;
private readonly SemaphoreSlim _sendGate = new(1, 1);
private ClientWebSocket? _socket;
public DouyinDanmakuConnection(
DouyinHttpClient douyinHttpClient,
@@ -45,24 +51,225 @@ internal sealed class DouyinDanmakuConnection : ILiveDanmakuConnection
{
ArgumentNullException.ThrowIfNull(onEvent);
var bootstrap = await _douyinHttpClient.GetDanmakuBootstrapAsync(_context.RoomId, cancellationToken);
var backoff = TimeSpan.FromSeconds(1);
var maxBackoff = TimeSpan.FromSeconds(Math.Max(1, _context.RetryDelayMaxSeconds));
while (!cancellationToken.IsCancellationRequested)
{
ClientWebSocket? socket = null;
CancellationTokenSource? heartbeatCancellation = null;
Task? heartbeatTask = null;
try
{
var bootstrap = await _douyinHttpClient.GetDanmakuBootstrapAsync(_context.RoomId, cancellationToken);
_logger.LogInformation(
"Douyin danmaku bootstrap resolved web room {WebRoomId} to im room {DanmakuRoomId}. UserUniqueId={UserUniqueId}; PushServer={PushServer}; InitialEvents={InitialEvents}",
_context.RoomId,
bootstrap.DanmakuRoomId,
bootstrap.UserUniqueId,
bootstrap.PushServer ?? "(bootstrap-pending)",
bootstrap.InitialEvents.Count);
foreach (var danmakuEvent in bootstrap.InitialEvents)
{
await onEvent(danmakuEvent);
}
try
{
var request = await _douyinHttpClient.GetDanmakuWebSocketRequestAsync(bootstrap, cancellationToken);
socket = CreateSocket(request);
_socket = socket;
await socket.ConnectAsync(request.Uri, cancellationToken);
_logger.LogInformation(
"Douyin danmaku websocket connected for room {WebRoomId}. Endpoint={Endpoint}",
_context.RoomId,
request.Uri);
heartbeatCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
heartbeatTask = RunHeartbeatAsync(socket, request.HeartbeatInterval, heartbeatCancellation.Token);
backoff = TimeSpan.FromSeconds(1);
await ReceiveLoopAsync(socket, bootstrap, onEvent, cancellationToken);
if (!cancellationToken.IsCancellationRequested)
{
_logger.LogWarning(
"Douyin danmaku websocket ended unexpectedly for room {RoomId}. Falling back to HTTP polling.",
_context.RoomId);
await RunPollingLoopAsync(bootstrap, onEvent, cancellationToken);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Douyin danmaku websocket startup failed for room {RoomId}. Falling back to HTTP polling.",
_context.RoomId);
await RunPollingLoopAsync(bootstrap, onEvent, cancellationToken);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Douyin danmaku websocket failed for room {RoomId}. Retrying.", _context.RoomId);
}
finally
{
if (heartbeatCancellation is not null)
{
heartbeatCancellation.Cancel();
}
if (heartbeatTask is not null)
{
try
{
await heartbeatTask;
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Douyin danmaku heartbeat loop ended with an error for room {RoomId}.", _context.RoomId);
}
}
await CloseSocketAsync(socket, cancellationToken);
if (ReferenceEquals(_socket, socket))
{
_socket = null;
}
heartbeatCancellation?.Dispose();
}
if (cancellationToken.IsCancellationRequested)
{
break;
}
await Task.Delay(backoff, cancellationToken);
backoff = TimeSpan.FromSeconds(Math.Min(maxBackoff.TotalSeconds, backoff.TotalSeconds * 2));
}
}
public async ValueTask DisposeAsync()
{
await CloseSocketAsync(_socket, CancellationToken.None);
_socket = null;
_sendGate.Dispose();
}
private ClientWebSocket CreateSocket(DouyinDanmakuWebSocketRequest request)
{
var socket = new ClientWebSocket();
socket.Options.KeepAliveInterval = Timeout.InfiniteTimeSpan;
socket.Options.SetRequestHeader("Origin", "https://live.douyin.com");
socket.Options.SetRequestHeader("User-Agent", request.UserAgent);
socket.Options.SetRequestHeader("Referer", request.Referer);
var cookies = BuildCookieContainer(request.CookieHeader);
if (cookies.Count > 0)
{
socket.Options.Cookies = cookies;
}
return socket;
}
private async Task ReceiveLoopAsync(
ClientWebSocket socket,
DouyinDanmakuBootstrap bootstrap,
Func<DanmakuEvent, Task> onEvent,
CancellationToken cancellationToken)
{
string? cursor = bootstrap.Cursor;
string? internalExt = bootstrap.InternalExt;
while (!cancellationToken.IsCancellationRequested &&
socket.State is WebSocketState.Open or WebSocketState.CloseReceived)
{
var (messageType, payload) = await ReceiveMessageAsync(socket, cancellationToken);
if (messageType == WebSocketMessageType.Close)
{
_logger.LogWarning(
"Douyin danmaku websocket closed by remote endpoint for room {RoomId}. State={State}",
_context.RoomId,
socket.State);
return;
}
if (messageType == WebSocketMessageType.Text)
{
_logger.LogDebug(
"Douyin danmaku websocket returned text payload for room {RoomId}: {Payload}",
_context.RoomId,
Encoding.UTF8.GetString(payload));
continue;
}
var envelope = DouyinDanmakuProtocol.Parse(payload);
if (!string.IsNullOrWhiteSpace(envelope.Cursor))
{
cursor = envelope.Cursor;
}
if (!string.IsNullOrWhiteSpace(envelope.InternalExt))
{
internalExt = envelope.InternalExt;
}
if (envelope.NeedAck)
{
await SendFrameAsync(
socket,
DouyinDanmakuProtocol.CreateAckFrame(internalExt, envelope.LogId),
cancellationToken);
}
foreach (var danmakuEvent in envelope.Events)
{
await onEvent(danmakuEvent);
}
if (envelope.Events.Count == 0 &&
string.Equals(envelope.PayloadType, "msg", StringComparison.OrdinalIgnoreCase))
{
_logger.LogDebug(
"Douyin danmaku websocket received an empty message frame for room {RoomId}. CursorPresent={HasCursor}; InternalExtPresent={HasInternalExt}",
_context.RoomId,
!string.IsNullOrWhiteSpace(cursor),
!string.IsNullOrWhiteSpace(internalExt));
}
}
}
private async Task RunPollingLoopAsync(
DouyinDanmakuBootstrap bootstrap,
Func<DanmakuEvent, Task> onEvent,
CancellationToken cancellationToken)
{
var danmakuRoomId = bootstrap.DanmakuRoomId;
var cursor = bootstrap.Cursor ?? string.Empty;
var internalExt = bootstrap.InternalExt ?? string.Empty;
var userUniqueId = bootstrap.UserUniqueId ?? throw new InvalidOperationException("Douyin danmaku bootstrap did not provide a user unique id.");
var userUniqueId = bootstrap.UserUniqueId ?? throw new InvalidOperationException("Douyin danmaku polling fallback requires a resolved user unique id.");
var backoff = TimeSpan.FromSeconds(1);
var minPollInterval = TimeSpan.FromMilliseconds(Math.Max(100, _context.MinPollIntervalMilliseconds));
var maxBackoff = TimeSpan.FromSeconds(Math.Max(1, _context.RetryDelayMaxSeconds));
var consecutiveEmptyPolls = 0;
_logger.LogInformation(
"Douyin danmaku bootstrap resolved web room {WebRoomId} to im room {DanmakuRoomId}. CursorPresent={HasCursor}; InternalExtPresent={HasInternalExt}; UserUniqueId={UserUniqueId}",
_context.RoomId,
danmakuRoomId,
!string.IsNullOrWhiteSpace(cursor),
!string.IsNullOrWhiteSpace(internalExt),
userUniqueId);
while (!cancellationToken.IsCancellationRequested)
{
try
@@ -75,8 +282,15 @@ internal sealed class DouyinDanmakuConnection : ILiveDanmakuConnection
cancellationToken);
var envelope = DouyinDanmakuProtocol.Parse(bytes);
cursor = envelope.Cursor ?? cursor;
internalExt = envelope.InternalExt ?? internalExt;
if (!string.IsNullOrWhiteSpace(envelope.Cursor))
{
cursor = envelope.Cursor;
}
if (!string.IsNullOrWhiteSpace(envelope.InternalExt))
{
internalExt = envelope.InternalExt;
}
foreach (var danmakuEvent in envelope.Events)
{
@@ -103,7 +317,7 @@ internal sealed class DouyinDanmakuConnection : ILiveDanmakuConnection
if (!string.Equals(refreshedBootstrap.DanmakuRoomId, danmakuRoomId, StringComparison.Ordinal))
{
_logger.LogInformation(
"Douyin danmaku bootstrap refreshed im room id from {OldDanmakuRoomId} to {NewDanmakuRoomId} for web room {WebRoomId}.",
"Douyin danmaku polling refreshed im room id from {OldDanmakuRoomId} to {NewDanmakuRoomId} for web room {WebRoomId}.",
danmakuRoomId,
refreshedBootstrap.DanmakuRoomId,
_context.RoomId);
@@ -119,6 +333,11 @@ internal sealed class DouyinDanmakuConnection : ILiveDanmakuConnection
{
internalExt = refreshedBootstrap.InternalExt;
}
if (!string.IsNullOrWhiteSpace(refreshedBootstrap.UserUniqueId))
{
userUniqueId = refreshedBootstrap.UserUniqueId;
}
}
}
else
@@ -136,12 +355,132 @@ internal sealed class DouyinDanmakuConnection : ILiveDanmakuConnection
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Douyin danmaku polling failed for room {RoomId}. Retrying.", _context.RoomId);
_logger.LogWarning(ex, "Douyin danmaku polling fallback failed for room {RoomId}. Retrying.", _context.RoomId);
await Task.Delay(backoff, cancellationToken);
backoff = TimeSpan.FromSeconds(Math.Min(maxBackoff.TotalSeconds, backoff.TotalSeconds * 2));
}
}
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
private async Task RunHeartbeatAsync(ClientWebSocket socket, TimeSpan heartbeatInterval, CancellationToken cancellationToken)
{
var effectiveInterval = heartbeatInterval <= TimeSpan.Zero
? TimeSpan.FromSeconds(10)
: heartbeatInterval < TimeSpan.FromSeconds(10)
? TimeSpan.FromSeconds(10)
: heartbeatInterval;
while (!cancellationToken.IsCancellationRequested &&
socket.State == WebSocketState.Open)
{
await Task.Delay(effectiveInterval, cancellationToken);
if (cancellationToken.IsCancellationRequested || socket.State != WebSocketState.Open)
{
break;
}
await SendFrameAsync(socket, DouyinDanmakuProtocol.CreateHeartbeatFrame(), cancellationToken);
}
}
private async Task SendFrameAsync(ClientWebSocket socket, byte[] payload, CancellationToken cancellationToken)
{
await _sendGate.WaitAsync(cancellationToken);
try
{
if (socket.State != WebSocketState.Open)
{
return;
}
await socket.SendAsync(
new ArraySegment<byte>(payload),
WebSocketMessageType.Binary,
true,
cancellationToken);
}
finally
{
_sendGate.Release();
}
}
private static async Task<(WebSocketMessageType MessageType, byte[] Payload)> ReceiveMessageAsync(
ClientWebSocket socket,
CancellationToken cancellationToken)
{
var buffer = new byte[8192];
using var output = new MemoryStream();
while (true)
{
var result = await socket.ReceiveAsync(new ArraySegment<byte>(buffer), cancellationToken);
if (result.MessageType == WebSocketMessageType.Close)
{
return (WebSocketMessageType.Close, Array.Empty<byte>());
}
if (result.Count > 0)
{
output.Write(buffer, 0, result.Count);
}
if (result.EndOfMessage)
{
return (result.MessageType, output.ToArray());
}
}
}
private static CookieContainer BuildCookieContainer(string? cookieHeader)
{
var container = new CookieContainer();
if (string.IsNullOrWhiteSpace(cookieHeader))
{
return container;
}
foreach (var segment in cookieHeader.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var separatorIndex = segment.IndexOf('=');
if (separatorIndex <= 0)
{
continue;
}
var name = segment[..separatorIndex].Trim();
var value = segment[(separatorIndex + 1)..].Trim();
if (string.IsNullOrWhiteSpace(name))
{
continue;
}
container.Add(new Cookie(name, value, "/", ".douyin.com"));
}
return container;
}
private static async Task CloseSocketAsync(ClientWebSocket? socket, CancellationToken cancellationToken)
{
if (socket is null)
{
return;
}
try
{
if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived)
{
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "disposed", cancellationToken);
}
}
catch
{
}
finally
{
socket.Dispose();
}
}
}
@@ -1,4 +1,5 @@
using System.IO.Compression;
using System.Linq;
using System.Text;
using LiveRecorder.Application.Abstractions.Platforms;
@@ -10,23 +11,58 @@ internal static class DouyinDanmakuProtocol
{
if (payload.Length == 0)
{
return new DouyinDanmakuEnvelope([], null, null, TimeSpan.FromSeconds(1));
return CreateEmptyEnvelope();
}
var responseBytes = payload.AsSpan();
var pushFrame = TryParsePushFrame(responseBytes);
if (pushFrame.Payload.Length > 0)
var candidates = new List<byte[]>();
AddCandidate(candidates, payload);
DouyinPushFrame? parsedPushFrame = null;
if (TryGunzip(payload, out var gunzippedPayload))
{
responseBytes = pushFrame.Payload;
AddCandidate(candidates, gunzippedPayload);
}
if (LooksLikeGzip(responseBytes))
if (TryParsePushFrame(payload, out var pushFrame) &&
pushFrame.Payload.Length > 0)
{
responseBytes = Decompress(responseBytes);
parsedPushFrame = pushFrame;
AddCandidate(candidates, pushFrame.Payload);
if (pushFrame.IsGzipEncoded && TryGunzip(pushFrame.Payload, out var gunzippedFramePayload))
{
AddCandidate(candidates, gunzippedFramePayload);
}
}
var response = ParseResponse(responseBytes);
return response;
DouyinDanmakuEnvelope? bestEnvelope = null;
foreach (var candidate in candidates)
{
if (!TryParseResponse(candidate, out var envelope))
{
continue;
}
envelope = ApplyPushFrameMetadata(envelope, parsedPushFrame);
if (bestEnvelope is null ||
envelope.Events.Count > bestEnvelope.Events.Count ||
(envelope.Events.Count == bestEnvelope.Events.Count &&
GetEnvelopeScore(envelope) > GetEnvelopeScore(bestEnvelope)))
{
bestEnvelope = envelope;
}
if (envelope.Events.Count > 0)
{
return envelope;
}
}
return bestEnvelope ??
(parsedPushFrame is { } metadataFrame
? CreateEmptyEnvelope(metadataFrame.Cursor, metadataFrame.InternalExt, metadataFrame.LogId, metadataFrame.PayloadType)
: CreateEmptyEnvelope());
}
private static DouyinPushFrame ParsePushFrame(ReadOnlySpan<byte> data)
@@ -34,6 +70,8 @@ internal static class DouyinDanmakuProtocol
var payload = ReadOnlySpan<byte>.Empty;
var payloadEncoding = string.Empty;
var payloadType = string.Empty;
string? logId = null;
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var index = 0;
while (index < data.Length)
@@ -44,13 +82,24 @@ internal static class DouyinDanmakuProtocol
switch (fieldNumber)
{
case 2 when wireType == 0:
logId = ReadVarint(data, ref index).ToString();
break;
case 5 when wireType == 2:
var headerBytes = ReadLengthDelimited(data, ref index);
var (key, value) = ParseHeader(headerBytes);
if (string.Equals(key, "compress_type", StringComparison.OrdinalIgnoreCase))
if (!string.IsNullOrWhiteSpace(key))
{
payloadEncoding = value;
headers[key] = value;
if (string.Equals(key, "compress_type", StringComparison.OrdinalIgnoreCase))
{
payloadEncoding = value;
}
}
break;
case 6 when wireType == 2:
payloadEncoding = ReadString(data, ref index);
break;
case 7 when wireType == 2:
payloadType = ReadString(data, ref index);
@@ -69,21 +118,46 @@ internal static class DouyinDanmakuProtocol
payloadEncoding.Contains("gzip", StringComparison.OrdinalIgnoreCase) ||
payloadType.Contains("response", StringComparison.OrdinalIgnoreCase)))
{
payload = Decompress(payload);
payload = TryGunzip(payload, out var decompressedPayload) ? decompressedPayload : payload.ToArray();
}
return new DouyinPushFrame(payload.ToArray());
headers.TryGetValue("im-cursor", out var cursor);
headers.TryGetValue("im-internal_ext", out var internalExt);
return new DouyinPushFrame(
payload.ToArray(),
payloadEncoding.Contains("gzip", StringComparison.OrdinalIgnoreCase) || LooksLikeGzip(payload),
logId,
payloadType,
cursor,
internalExt);
}
private static DouyinPushFrame TryParsePushFrame(ReadOnlySpan<byte> data)
private static bool TryParsePushFrame(ReadOnlySpan<byte> data, out DouyinPushFrame pushFrame)
{
try
{
return ParsePushFrame(data);
pushFrame = ParsePushFrame(data);
return true;
}
catch
{
return new DouyinPushFrame(Array.Empty<byte>());
pushFrame = default;
return false;
}
}
private static bool TryParseResponse(byte[] data, out DouyinDanmakuEnvelope envelope)
{
try
{
envelope = ParseResponse(data);
return true;
}
catch
{
envelope = default!;
return false;
}
}
@@ -92,7 +166,11 @@ internal static class DouyinDanmakuProtocol
var events = new List<DanmakuEvent>();
string? cursor = null;
string? internalExt = null;
string? pushServer = null;
string? liveCursor = null;
TimeSpan pollInterval = TimeSpan.FromSeconds(1);
TimeSpan heartbeatDuration = TimeSpan.Zero;
var needAck = false;
var index = 0;
while (index < data.Length)
@@ -118,18 +196,70 @@ internal static class DouyinDanmakuProtocol
internalExt = Encoding.UTF8.GetString(ReadLengthDelimited(data, ref index));
break;
case 3 when wireType == 0:
case 8 when wireType == 0:
pollInterval = TimeSpan.FromMilliseconds(Math.Max(500, (long)ReadVarint(data, ref index)));
break;
case 8 when wireType == 0:
heartbeatDuration = TimeSpan.FromMilliseconds(Math.Max(0, (long)ReadVarint(data, ref index)));
break;
case 9 when wireType == 0:
needAck = ReadVarint(data, ref index) != 0;
break;
case 10 when wireType == 2:
pushServer = Encoding.UTF8.GetString(ReadLengthDelimited(data, ref index));
break;
case 11 when wireType == 2:
liveCursor = Encoding.UTF8.GetString(ReadLengthDelimited(data, ref index));
break;
default:
SkipField(data, ref index, wireType);
break;
}
}
return new DouyinDanmakuEnvelope(events, cursor, internalExt, pollInterval);
return new DouyinDanmakuEnvelope(
events,
cursor,
internalExt,
pollInterval,
needAck,
heartbeatDuration,
pushServer,
liveCursor,
null,
null);
}
private static int GetEnvelopeScore(DouyinDanmakuEnvelope envelope)
{
var score = 0;
if (!string.IsNullOrWhiteSpace(envelope.Cursor))
{
score += 2;
}
if (!string.IsNullOrWhiteSpace(envelope.InternalExt))
{
score += 2;
}
if (envelope.PollInterval > TimeSpan.Zero)
{
score += 1;
}
if (!string.IsNullOrWhiteSpace(envelope.PushServer))
{
score += 1;
}
return score;
}
public static byte[] CreateHeartbeatFrame() => CreatePushFrame("hb");
public static byte[] CreateAckFrame(string? internalExt, string? logId) =>
CreatePushFrame("ack", string.IsNullOrEmpty(internalExt) ? Array.Empty<byte>() : Encoding.UTF8.GetBytes(internalExt), logId);
private static DanmakuEvent? ParseEnvelopeMessage(ReadOnlySpan<byte> data)
{
string? method = null;
@@ -461,20 +591,131 @@ internal static class DouyinDanmakuProtocol
private static bool LooksLikeGzip(ReadOnlySpan<byte> data) =>
data.Length >= 2 && data[0] == 0x1F && data[1] == 0x8B;
private static byte[] Decompress(ReadOnlySpan<byte> data)
private static bool TryGunzip(ReadOnlySpan<byte> data, out byte[] decompressed)
{
using var input = new MemoryStream(data.ToArray());
using var gzip = new GZipStream(input, CompressionMode.Decompress);
using var output = new MemoryStream();
gzip.CopyTo(output);
return output.ToArray();
try
{
using var input = new MemoryStream(data.ToArray());
using var gzip = new GZipStream(input, CompressionMode.Decompress);
using var output = new MemoryStream();
gzip.CopyTo(output);
decompressed = output.ToArray();
return true;
}
catch
{
decompressed = Array.Empty<byte>();
return false;
}
}
private static void AddCandidate(ICollection<byte[]> candidates, byte[] candidate)
{
if (candidate.Length == 0)
{
return;
}
if (candidates.Any(existing => existing.AsSpan().SequenceEqual(candidate)))
{
return;
}
candidates.Add(candidate);
}
private static DouyinDanmakuEnvelope ApplyPushFrameMetadata(
DouyinDanmakuEnvelope envelope,
DouyinPushFrame? pushFrame)
{
if (pushFrame is not { } frame)
{
return envelope;
}
return envelope with
{
Cursor = string.IsNullOrWhiteSpace(frame.Cursor) ? envelope.Cursor : frame.Cursor,
InternalExt = string.IsNullOrWhiteSpace(frame.InternalExt) ? envelope.InternalExt : frame.InternalExt,
LogId = frame.LogId,
PayloadType = frame.PayloadType
};
}
private static DouyinDanmakuEnvelope CreateEmptyEnvelope(
string? cursor = null,
string? internalExt = null,
string? logId = null,
string? payloadType = null) =>
new([], cursor, internalExt, TimeSpan.FromSeconds(1), false, TimeSpan.Zero, null, null, logId, payloadType);
private static byte[] CreatePushFrame(string payloadType, byte[]? payload = null, string? logId = null)
{
var buffer = new List<byte>(64);
if (ulong.TryParse(logId, out var parsedLogId))
{
WriteVarintField(buffer, 2, parsedLogId);
}
WriteStringField(buffer, 7, payloadType);
if (payload is { Length: > 0 })
{
WriteBytesField(buffer, 8, payload);
}
return buffer.ToArray();
}
private static void WriteVarintField(ICollection<byte> buffer, int fieldNumber, ulong value)
{
WriteVarint(buffer, (ulong)((fieldNumber << 3) | 0));
WriteVarint(buffer, value);
}
private static void WriteStringField(ICollection<byte> buffer, int fieldNumber, string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
WriteBytesField(buffer, fieldNumber, bytes);
}
private static void WriteBytesField(ICollection<byte> buffer, int fieldNumber, byte[] value)
{
WriteVarint(buffer, (ulong)((fieldNumber << 3) | 2));
WriteVarint(buffer, (ulong)value.Length);
foreach (var item in value)
{
buffer.Add(item);
}
}
private static void WriteVarint(ICollection<byte> buffer, ulong value)
{
while (value >= 0x80)
{
buffer.Add((byte)((value & 0x7F) | 0x80));
value >>= 7;
}
buffer.Add((byte)value);
}
internal sealed record DouyinDanmakuEnvelope(
IReadOnlyList<DanmakuEvent> Events,
string? Cursor,
string? InternalExt,
TimeSpan PollInterval);
TimeSpan PollInterval,
bool NeedAck,
TimeSpan HeartbeatDuration,
string? PushServer,
string? LiveCursor,
string? LogId,
string? PayloadType);
private readonly record struct DouyinPushFrame(byte[] Payload);
private readonly record struct DouyinPushFrame(
byte[] Payload,
bool IsGzipEncoded,
string? LogId,
string? PayloadType,
string? Cursor,
string? InternalExt);
}
File diff suppressed because it is too large Load Diff
@@ -98,10 +98,12 @@ public sealed class DouyinLivePlatformAdapter : ILivePlatformAdapter
var anchorName = anchorMatch ?? GetNestedString(room, "owner", "nickname")
?? GetNestedString(room, "anchor", "nickname")
?? GetNestedString(room, "user", "nickname");
var anchorId = GetAnchorId(room);
var avatarUrl = GetAvatarUrl(room);
var coverUrl = GetCoverUrl(room);
var isLive = statusCode == 2 || GetInt(room, "live_status") is 1 or 2;
return new LiveStatusSnapshot(isLive, title, anchorName, coverUrl, statusCode, statusCode?.ToString());
return new LiveStatusSnapshot(isLive, title, anchorName, anchorId, avatarUrl, coverUrl, statusCode, statusCode?.ToString());
}
public async Task<StreamUrlResult> GetStreamUrlAsync(
@@ -137,7 +139,8 @@ public sealed class DouyinLivePlatformAdapter : ILivePlatformAdapter
selected.Protocol,
selected.Url,
inputHeaders,
ordered);
ordered,
InferVideoCodec(selected));
}
private static string? ExtractRoomId(string? text)
@@ -298,6 +301,25 @@ public sealed class DouyinLivePlatformAdapter : ILivePlatformAdapter
return options[0];
}
private static string InferVideoCodec(StreamQualityOption selected)
{
if (selected.Url.Contains("h265", StringComparison.OrdinalIgnoreCase) ||
selected.Url.Contains("hevc", StringComparison.OrdinalIgnoreCase))
{
return "hevc";
}
if (selected.Url.Contains("h264", StringComparison.OrdinalIgnoreCase) ||
selected.Url.Contains("avc", StringComparison.OrdinalIgnoreCase))
{
return "h264";
}
// Douyin's default web FLV/HLS pull stream is normally AVC/H.264. Keep this
// explicit so TS recording can safely convert AVC configuration to Annex B.
return "h264";
}
private static int GetQualityRank(string quality)
{
if (quality.Equals("origin", StringComparison.OrdinalIgnoreCase))
@@ -412,6 +434,73 @@ public sealed class DouyinLivePlatformAdapter : ILivePlatformAdapter
return null;
}
private static string? GetAvatarUrl(JsonElement room)
{
return GetImageUrl(room, "owner", "avatar_thumb")
?? GetImageUrl(room, "owner", "avatar_medium")
?? GetImageUrl(room, "owner", "avatar_large")
?? GetImageUrl(room, "anchor", "avatar_thumb")
?? GetImageUrl(room, "anchor", "avatar_medium")
?? GetImageUrl(room, "anchor", "avatar_large")
?? GetImageUrl(room, "user", "avatar_thumb")
?? GetImageUrl(room, "user", "avatar_medium")
?? GetImageUrl(room, "user", "avatar_large");
}
private static string? GetAnchorId(JsonElement room)
{
return GetNestedScalarString(room, "owner", "id_str")
?? GetNestedScalarString(room, "owner", "web_rid")
?? GetNestedScalarString(room, "owner", "sec_uid")
?? GetNestedScalarString(room, "owner", "id")
?? GetNestedScalarString(room, "anchor", "id_str")
?? GetNestedScalarString(room, "anchor", "web_rid")
?? GetNestedScalarString(room, "anchor", "sec_uid")
?? GetNestedScalarString(room, "anchor", "id")
?? GetNestedScalarString(room, "user", "id_str")
?? GetNestedScalarString(room, "user", "sec_uid")
?? GetNestedScalarString(room, "user", "id");
}
private static string? GetImageUrl(JsonElement element, params string[] path)
{
if (!TryGetNested(element, out var value, path))
{
return null;
}
if (value.ValueKind == JsonValueKind.String)
{
return value.GetString();
}
if (value.ValueKind == JsonValueKind.Object &&
TryGetProperty(value, "url_list", out var urlList) &&
urlList.ValueKind == JsonValueKind.Array &&
urlList.GetArrayLength() > 0 &&
urlList[0].ValueKind == JsonValueKind.String)
{
return urlList[0].GetString();
}
return null;
}
private static string? GetNestedScalarString(JsonElement element, params string[] path)
{
if (!TryGetNested(element, out var value, path))
{
return null;
}
return value.ValueKind switch
{
JsonValueKind.String => value.GetString(),
JsonValueKind.Number => value.GetRawText(),
_ => null
};
}
}
file static class RegexExtensions
@@ -0,0 +1,82 @@
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
namespace LiveRecorder.Infrastructure.Platforms.Douyin.Signing;
public sealed class DouyinLiveWsSignatureSigner
{
public const string WebcastSdkVersion = "1.0.15";
private readonly ILogger<DouyinLiveWsSignatureSigner> _logger;
private readonly string _signerScriptPath;
public DouyinLiveWsSignatureSigner(ILogger<DouyinLiveWsSignatureSigner> logger)
{
_logger = logger;
_signerScriptPath = Path.Combine(AppContext.BaseDirectory, "Platforms", "Douyin", "Signing", "sign-livews.js");
}
public async Task<string> SignAsync(string roomId, string userUniqueId, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(roomId))
{
throw new ArgumentException("Room id cannot be empty.", nameof(roomId));
}
if (string.IsNullOrWhiteSpace(userUniqueId))
{
throw new ArgumentException("User unique id cannot be empty.", nameof(userUniqueId));
}
if (!File.Exists(_signerScriptPath))
{
throw new FileNotFoundException("Douyin live websocket signer script is missing.", _signerScriptPath);
}
var stub = CreateMsStub(roomId, userUniqueId);
var startInfo = new ProcessStartInfo
{
FileName = "node",
RedirectStandardError = true,
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};
startInfo.ArgumentList.Add(_signerScriptPath);
startInfo.ArgumentList.Add(stub);
using var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true };
process.Start();
var standardOutputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
var standardErrorTask = process.StandardError.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken);
var standardOutput = (await standardOutputTask).Trim();
var standardError = (await standardErrorTask).Trim();
if (process.ExitCode != 0 || string.IsNullOrWhiteSpace(standardOutput))
{
_logger.LogWarning(
"Douyin live websocket signing failed. ExitCode={ExitCode}; Error={Error}",
process.ExitCode,
standardError);
throw new InvalidOperationException(
string.IsNullOrWhiteSpace(standardError)
? $"Douyin live websocket signer exited with code {process.ExitCode}."
: $"Douyin live websocket signer failed: {standardError}");
}
return standardOutput;
}
private static string CreateMsStub(string roomId, string userUniqueId)
{
var stubSource =
$"live_id=1,aid=6383,version_code=180800,webcast_sdk_version={WebcastSdkVersion},room_id={roomId},sub_room_id=,sub_channel_id=,did_rule=3,user_unique_id={userUniqueId},device_platform=web,device_type=,ac=,identity=audience";
var hash = MD5.HashData(Encoding.UTF8.GetBytes(stubSource));
return Convert.ToHexString(hash).ToLowerInvariant();
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,163 @@
const fs = require("fs");
const path = require("path");
const vm = require("vm");
const msStub = (process.argv[2] || "").trim();
if (!msStub) {
console.error("Douyin live websocket signer requires an X-MS-STUB value.");
process.exit(1);
}
const mssdkPath = path.join(__dirname, "mssdk.js");
if (!fs.existsSync(mssdkPath)) {
console.error(`Douyin live websocket signer could not find mssdk.js at ${mssdkPath}`);
process.exit(1);
}
function createCanvasElement() {
return {
style: {},
width: 300,
height: 150,
getContext(type) {
if (type === "2d") {
return {
fillRect() {},
fillText() {},
measureText() { return { width: 0 }; },
beginPath() {},
arc() {},
stroke() {},
closePath() {},
rect() {},
clearRect() {},
drawImage() {},
createLinearGradient() { return { addColorStop() {} }; },
getImageData() { return { data: [] }; }
};
}
return null;
},
toDataURL() {
return "data:image/png;base64,";
}
};
}
function createContext() {
const context = {
console,
setTimeout,
clearTimeout,
setInterval,
clearInterval,
Buffer,
URL,
URLSearchParams,
TextEncoder,
TextDecoder,
Promise,
Date,
Math,
JSON,
Array,
Object,
String,
Number,
Boolean,
RegExp,
parseInt,
parseFloat,
encodeURIComponent,
decodeURIComponent,
encodeURI,
decodeURI,
navigator: {
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36",
appVersion: "5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36",
appCodeName: "Mozilla",
language: "zh-CN",
languages: ["zh-CN", "zh"],
platform: "Win32",
onLine: true,
cookieEnabled: true,
hardwareConcurrency: 8,
maxTouchPoints: 0,
webdriver: false,
plugins: [],
mimeTypes: []
},
screen: {
width: 1920,
height: 1080,
availWidth: 1920,
availHeight: 1040,
colorDepth: 24,
pixelDepth: 24
},
location: {
href: "https://live.douyin.com/",
origin: "https://live.douyin.com",
host: "live.douyin.com",
hostname: "live.douyin.com",
protocol: "https:",
pathname: "/",
search: "",
hash: ""
},
document: {
referrer: "https://live.douyin.com/",
cookie: "",
compatMode: "CSS1Compat",
documentElement: { clientWidth: 1920, clientHeight: 969 },
body: { clientWidth: 1920, clientHeight: 969, appendChild() {}, removeChild() {} },
createElement(tag) {
if (tag === "canvas") {
return createCanvasElement();
}
return {
style: {},
appendChild() {},
removeChild() {},
setAttribute() {},
getContext() { return null; }
};
},
addEventListener() {},
removeEventListener() {},
querySelector() { return null; },
getElementsByTagName() { return []; }
}
};
context.window = context;
context.self = context;
context.parent = context;
context.globalThis = context;
return context;
}
try {
const context = createContext();
const code = fs.readFileSync(mssdkPath, "utf8");
vm.createContext(context);
vm.runInContext(code, context, { timeout: 10000, filename: "mssdk.js" });
const signer = context.byted_acrawler && context.byted_acrawler.frontierSign;
if (typeof signer !== "function") {
throw new Error("frontierSign is not available after loading mssdk.js");
}
const result = signer({ "X-MS-STUB": msStub }) || {};
const signature = (result["X-Bogus"] || "").trim();
if (!signature) {
throw new Error("frontierSign did not return an X-Bogus signature.");
}
process.stdout.write(signature);
} catch (error) {
console.error(error && error.stack ? error.stack : String(error));
process.exit(1);
}
@@ -0,0 +1,278 @@
using System.Diagnostics;
using LiveRecorder.Application.Abstractions.Logging;
using LiveRecorder.Application.Abstractions.Scripting;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
using Microsoft.Extensions.Logging;
namespace LiveRecorder.Infrastructure.Services;
public sealed class EventScriptService : IEventScriptService
{
private readonly ISystemSettingsService _settingsService;
private readonly ISystemLogService _systemLogService;
private readonly ILogger<EventScriptService> _logger;
public EventScriptService(
ISystemSettingsService settingsService,
ISystemLogService systemLogService,
ILogger<EventScriptService> logger)
{
_settingsService = settingsService;
_systemLogService = systemLogService;
_logger = logger;
}
public async Task RunLiveStartedAsync(LiveRoom liveRoom, DateTimeOffset occurredAt, CancellationToken cancellationToken = default)
{
var settings = await _settingsService.GetAsync(cancellationToken);
var environment = BuildLiveRoomEnvironment(liveRoom, occurredAt);
environment["LIVE_RECORDER_EVENT"] = "live_started";
await RunAsync(
settings.EnableEventScripts,
settings.LiveStartedScriptPath,
settings.EventScriptTimeoutSeconds,
"live_started",
environment,
liveRoom.Id,
recordSessionId: null,
recordTaskId: null,
cancellationToken);
}
public async Task RunLiveEndedAsync(LiveRoom liveRoom, DateTimeOffset occurredAt, CancellationToken cancellationToken = default)
{
var settings = await _settingsService.GetAsync(cancellationToken);
var environment = BuildLiveRoomEnvironment(liveRoom, occurredAt);
environment["LIVE_RECORDER_EVENT"] = "live_ended";
await RunAsync(
settings.EnableEventScripts,
settings.LiveEndedScriptPath,
settings.EventScriptTimeoutSeconds,
"live_ended",
environment,
liveRoom.Id,
recordSessionId: null,
recordTaskId: null,
cancellationToken);
}
public async Task RunSegmentCompletedAsync(
LiveRoom? liveRoom,
RecordSession recordSession,
RecordTask recordTask,
RecordResult? recordResult,
string segmentFilePath,
DateTimeOffset occurredAt,
CancellationToken cancellationToken = default)
{
var settings = await _settingsService.GetAsync(cancellationToken);
var environment = BuildLiveRoomEnvironment(liveRoom, occurredAt);
environment["LIVE_RECORDER_EVENT"] = "segment_completed";
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();
environment["LIVE_RECORDER_SEGMENT_FILE_PATH"] = NormalizePath(segmentFilePath);
environment["LIVE_RECORDER_DANMAKU_FILE_PATH"] = NormalizePath(recordResult?.DanmakuFilePath);
environment["LIVE_RECORDER_DURATION_SECONDS"] = recordResult?.DurationSeconds?.ToString("0.###") ?? recordTask.DurationSeconds?.ToString("0.###") ?? string.Empty;
environment["LIVE_RECORDER_FILE_SIZE_BYTES"] = recordResult?.FileSizeBytes?.ToString() ?? TryGetFileSize(segmentFilePath);
environment["LIVE_RECORDER_TASK_STATUS"] = recordTask.Status.ToString();
environment["LIVE_RECORDER_SESSION_STATUS"] = recordSession.Status.ToString();
await RunAsync(
settings.EnableEventScripts,
settings.SegmentCompletedScriptPath,
settings.EventScriptTimeoutSeconds,
"segment_completed",
environment,
liveRoom?.Id ?? recordSession.LiveRoomId,
recordSession.Id,
recordTask.Id,
cancellationToken);
}
private async Task RunAsync(
bool enabled,
string scriptPath,
int timeoutSeconds,
string eventName,
IReadOnlyDictionary<string, string> environment,
Guid? liveRoomId,
Guid? recordSessionId,
Guid? recordTaskId,
CancellationToken cancellationToken)
{
if (!enabled || string.IsNullOrWhiteSpace(scriptPath))
{
return;
}
var normalizedScriptPath = NormalizePath(scriptPath);
if (!File.Exists(normalizedScriptPath))
{
await _systemLogService.WriteAsync(
SystemLogLevel.Warning,
"Script",
$"Event script was not found for {eventName}.",
normalizedScriptPath,
liveRoomId,
recordSessionId,
recordTaskId,
cancellationToken);
return;
}
var startInfo = CreateStartInfo(normalizedScriptPath);
foreach (var pair in environment)
{
startInfo.Environment[pair.Key] = pair.Value;
}
using var process = new Process
{
StartInfo = startInfo,
EnableRaisingEvents = true
};
try
{
process.Start();
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(Math.Clamp(timeoutSeconds, 1, 3600)));
await process.WaitForExitAsync(timeoutCts.Token);
await _systemLogService.WriteAsync(
process.ExitCode == 0 ? SystemLogLevel.Info : SystemLogLevel.Warning,
"Script",
process.ExitCode == 0
? $"Event script completed for {eventName}."
: $"Event script exited with code {process.ExitCode} for {eventName}.",
normalizedScriptPath,
liveRoomId,
recordSessionId,
recordTaskId,
cancellationToken);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
TryKill(process);
await _systemLogService.WriteAsync(
SystemLogLevel.Warning,
"Script",
$"Event script timed out for {eventName}.",
normalizedScriptPath,
liveRoomId,
recordSessionId,
recordTaskId,
CancellationToken.None);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Event script failed for {EventName}", eventName);
await _systemLogService.WriteAsync(
SystemLogLevel.Warning,
"Script",
$"Event script failed for {eventName}.",
ex.ToString(),
liveRoomId,
recordSessionId,
recordTaskId,
cancellationToken);
}
}
private static Dictionary<string, string> BuildLiveRoomEnvironment(LiveRoom? liveRoom, DateTimeOffset occurredAt)
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["LIVE_RECORDER_EVENT"] = string.Empty,
["LIVE_RECORDER_PLATFORM"] = liveRoom?.Platform.ToString() ?? string.Empty,
["LIVE_RECORDER_LIVE_ROOM_ID"] = liveRoom?.Id.ToString() ?? string.Empty,
["LIVE_RECORDER_ROOM_ID"] = liveRoom?.RoomId ?? string.Empty,
["LIVE_RECORDER_TITLE"] = liveRoom?.Title ?? string.Empty,
["LIVE_RECORDER_ANCHOR"] = liveRoom?.AnchorName ?? string.Empty,
["LIVE_RECORDER_SOURCE_URL"] = liveRoom?.SourceUrl ?? liveRoom?.NormalizedUrl ?? string.Empty,
["LIVE_RECORDER_OCCURRED_AT_UTC"] = occurredAt.ToString("O")
};
}
private static ProcessStartInfo CreateStartInfo(string scriptPath)
{
var extension = Path.GetExtension(scriptPath);
var startInfo = new ProcessStartInfo
{
UseShellExecute = false,
RedirectStandardError = false,
RedirectStandardOutput = false,
CreateNoWindow = true,
WorkingDirectory = Path.GetDirectoryName(scriptPath) ?? AppContext.BaseDirectory
};
if (OperatingSystem.IsWindows() && extension.Equals(".ps1", StringComparison.OrdinalIgnoreCase))
{
startInfo.FileName = "powershell";
startInfo.ArgumentList.Add("-NoProfile");
startInfo.ArgumentList.Add("-ExecutionPolicy");
startInfo.ArgumentList.Add("Bypass");
startInfo.ArgumentList.Add("-File");
startInfo.ArgumentList.Add(scriptPath);
return startInfo;
}
if (OperatingSystem.IsWindows() && (extension.Equals(".bat", StringComparison.OrdinalIgnoreCase) || extension.Equals(".cmd", StringComparison.OrdinalIgnoreCase)))
{
startInfo.FileName = "cmd";
startInfo.ArgumentList.Add("/c");
startInfo.ArgumentList.Add(scriptPath);
return startInfo;
}
if (!OperatingSystem.IsWindows() && extension.Equals(".sh", StringComparison.OrdinalIgnoreCase))
{
startInfo.FileName = "/bin/sh";
startInfo.ArgumentList.Add(scriptPath);
return startInfo;
}
startInfo.FileName = scriptPath;
return startInfo;
}
private static string NormalizePath(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return string.Empty;
}
return Path.IsPathRooted(path)
? path
: Path.GetFullPath(path, AppContext.BaseDirectory);
}
private static string TryGetFileSize(string? path)
{
var normalizedPath = NormalizePath(path);
if (string.IsNullOrWhiteSpace(normalizedPath) || !File.Exists(normalizedPath))
{
return string.Empty;
}
return new FileInfo(normalizedPath).Length.ToString();
}
private static void TryKill(Process process)
{
try
{
if (!process.HasExited)
{
process.Kill(true);
}
}
catch
{
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,18 +1,32 @@
using System.Diagnostics;
using System.Diagnostics;
using System.Globalization;
using System.Text;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
using LiveRecorder.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace LiveRecorder.Infrastructure.Services;
public sealed partial class FfmpegService
{
private static async Task<(string OutputPath, string? ErrorMessage)> TryFinalizeMp4Async(
private const string LowStoragePauseErrorPrefix = "MP4 finalization paused because storage is below threshold.";
private static readonly TimeSpan Mp4FinalizeInactivityTimeout = TimeSpan.FromMinutes(10);
private static readonly TimeSpan Mp4FinalizePollInterval = TimeSpan.FromSeconds(1);
private async Task<(string OutputPath, string? ErrorMessage)> TryFinalizeMp4Async(
string ffmpegPath,
int maxConcurrentTranscodeTasks,
int mp4FinalizeTimeoutMinutes,
Guid recordSessionId,
Guid recordTaskId,
string sourcePath,
string targetPath)
string targetPath,
double? expectedDurationSeconds,
CancellationToken cancellationToken)
{
if (!File.Exists(sourcePath))
{
@@ -28,24 +42,286 @@ public sealed partial class FfmpegService
File.Delete(tempPath);
}
var remuxProcess = new Process
async Task<string?> GetLowStoragePauseMessageAsync()
{
StartInfo = new ProcessStartInfo
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.HasEnoughSpace
? null
: $"{LowStoragePauseErrorPrefix} {storageCheck.Message}";
}
var lowStoragePauseMessage = await GetLowStoragePauseMessageAsync();
if (!string.IsNullOrWhiteSpace(lowStoragePauseMessage))
{
SetPostProcessState(recordSessionId, recordTaskId, "Paused: Low storage", null, lowStoragePauseMessage);
return (sourcePath, lowStoragePauseMessage);
}
SetPostProcessState(
recordSessionId,
recordTaskId,
"Queued",
null,
$"Waiting for an available ffmpeg transcode slot (max {Math.Clamp(maxConcurrentTranscodeTasks, 1, 16)}).");
using var transcodeSlot = await AcquireTranscodeSlotAsync(maxConcurrentTranscodeTasks, cancellationToken);
lowStoragePauseMessage = await GetLowStoragePauseMessageAsync();
if (!string.IsNullOrWhiteSpace(lowStoragePauseMessage))
{
SetPostProcessState(recordSessionId, recordTaskId, "Paused: Low storage", null, lowStoragePauseMessage);
return (sourcePath, lowStoragePauseMessage);
}
var stderrLines = new Queue<string>();
var progressSync = new object();
double? processedSeconds = null;
var lastReportedWholePercent = -1;
var attemptStartedAt = DateTimeOffset.UtcNow;
var activeStage = "Finalizing MP4";
var maxDuration = TimeSpan.FromMinutes(Math.Clamp(mp4FinalizeTimeoutMinutes, 1, 1440));
long lastActivityTicks = attemptStartedAt.UtcTicks;
void TouchActivity()
{
System.Threading.Interlocked.Exchange(ref lastActivityTicks, DateTimeOffset.UtcNow.UtcTicks);
}
void ReportProgress(double? seconds, string? stageOverride = null)
{
lock (progressSync)
{
FileName = ffmpegPath,
Arguments = $"-hide_banner -y -i {Quote(sourcePath)} -c copy -movflags +faststart {Quote(tempPath)}",
UseShellExecute = false,
RedirectStandardError = true,
RedirectStandardOutput = true,
CreateNoWindow = true
TouchActivity();
if (seconds.HasValue)
{
processedSeconds = seconds;
}
double? progressPercent = null;
string? detail = null;
if (expectedDurationSeconds.HasValue && expectedDurationSeconds.Value > 0 && processedSeconds.HasValue)
{
progressPercent = Math.Clamp(processedSeconds.Value / expectedDurationSeconds.Value * 100d, 0d, 99d);
detail = $"Processed {processedSeconds.Value:F1}s / {expectedDurationSeconds.Value:F1}s";
}
else if (processedSeconds.HasValue)
{
detail = $"Processed {processedSeconds.Value:F1}s";
}
var wholePercent = progressPercent.HasValue ? (int)Math.Floor(progressPercent.Value) : -1;
if (stageOverride is null && wholePercent == lastReportedWholePercent)
{
return;
}
lastReportedWholePercent = wholePercent;
SetPostProcessState(
recordSessionId,
recordTaskId,
stageOverride ?? activeStage,
progressPercent,
detail ?? $"Optimizing MP4 index for {Path.GetFileName(targetPath)}");
}
};
}
remuxProcess.Start();
await remuxProcess.WaitForExitAsync();
if (remuxProcess.ExitCode == 0 && File.Exists(tempPath))
string? GetErrorDetail()
{
lock (stderrLines)
{
return stderrLines.Count == 0 ? null : string.Join(" | ", stderrLines);
}
}
void ClearErrorDetail()
{
lock (stderrLines)
{
stderrLines.Clear();
}
}
async Task<string?> RunFinalizeAttemptAsync(
Mp4FinalizeStrategy strategy,
string stage,
string detail)
{
if (File.Exists(tempPath))
{
File.Delete(tempPath);
}
ClearErrorDetail();
activeStage = stage;
processedSeconds = null;
lastReportedWholePercent = -1;
attemptStartedAt = DateTimeOffset.UtcNow;
System.Threading.Interlocked.Exchange(ref lastActivityTicks, attemptStartedAt.UtcTicks);
SetPostProcessState(recordSessionId, recordTaskId, stage, 0, detail);
using var finalizeProcess = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = ffmpegPath,
UseShellExecute = false,
RedirectStandardError = true,
RedirectStandardOutput = true,
CreateNoWindow = true
}
};
foreach (var argument in BuildMp4FinalizeArgumentList(sourcePath, tempPath, strategy))
{
finalizeProcess.StartInfo.ArgumentList.Add(argument);
}
finalizeProcess.OutputDataReceived += (_, args) =>
{
if (string.IsNullOrWhiteSpace(args.Data))
{
return;
}
TouchActivity();
if (TryParseFfmpegProgressSeconds(args.Data, out var seconds))
{
ReportProgress(seconds);
return;
}
if (args.Data.StartsWith("progress=", StringComparison.OrdinalIgnoreCase))
{
ReportProgress(processedSeconds);
}
};
finalizeProcess.ErrorDataReceived += (_, args) =>
{
if (string.IsNullOrWhiteSpace(args.Data))
{
return;
}
TouchActivity();
_logger.LogDebug("ffmpeg-postprocess[{RecordTaskId}] {Line}", recordTaskId, args.Data);
lock (stderrLines)
{
stderrLines.Enqueue(args.Data.Trim());
while (stderrLines.Count > 10)
{
stderrLines.Dequeue();
}
}
};
try
{
finalizeProcess.Start();
finalizeProcess.BeginOutputReadLine();
finalizeProcess.BeginErrorReadLine();
while (!finalizeProcess.HasExited)
{
cancellationToken.ThrowIfCancellationRequested();
var now = DateTimeOffset.UtcNow;
var lastActivityAt = new DateTimeOffset(System.Threading.Interlocked.Read(ref lastActivityTicks), TimeSpan.Zero);
if (now - attemptStartedAt > maxDuration)
{
throw new TimeoutException($"MP4 finalization exceeded the maximum allowed duration of {maxDuration.TotalMinutes:F0} minutes.");
}
if (now - lastActivityAt > Mp4FinalizeInactivityTimeout)
{
throw new TimeoutException($"MP4 finalization did not report progress for more than {Mp4FinalizeInactivityTimeout.TotalMinutes:F0} minutes.");
}
await Task.Delay(Mp4FinalizePollInterval, cancellationToken);
}
await finalizeProcess.WaitForExitAsync(cancellationToken);
}
catch (TimeoutException ex)
{
try
{
if (!finalizeProcess.HasExited)
{
finalizeProcess.Kill(true);
}
}
catch (Exception killEx)
{
_logger.LogWarning(killEx, "Timed-out MP4 finalization process could not be killed for task {RecordTaskId}", recordTaskId);
}
return ex.Message;
}
catch (Exception ex)
{
try
{
if (!finalizeProcess.HasExited)
{
finalizeProcess.Kill(true);
}
}
catch (Exception killEx)
{
_logger.LogWarning(killEx, "Failed MP4 finalization process could not be killed for task {RecordTaskId}", recordTaskId);
}
return ex.Message;
}
if (finalizeProcess.ExitCode == 0 && File.Exists(tempPath))
{
return null;
}
if (File.Exists(tempPath))
{
File.Delete(tempPath);
}
return GetErrorDetail() ?? $"ffmpeg exited with code {finalizeProcess.ExitCode}.";
}
var finalizationError = await RunFinalizeAttemptAsync(
Mp4FinalizeStrategy.StreamCopy,
"Finalizing MP4",
$"Optimizing MP4 index for {Path.GetFileName(targetPath)}");
if (IsNoSpaceLeftError(finalizationError))
{
finalizationError = $"{LowStoragePauseErrorPrefix} {finalizationError}";
}
if (!string.IsNullOrWhiteSpace(finalizationError) && IsRepairableMp4FinalizeError(finalizationError))
{
var repairError = await RunFinalizeAttemptAsync(
Mp4FinalizeStrategy.RepairTranscode,
"Repairing MP4",
$"Repairing stream metadata for {Path.GetFileName(targetPath)}");
finalizationError = string.IsNullOrWhiteSpace(repairError)
? null
: IsNoSpaceLeftError(repairError)
? $"{LowStoragePauseErrorPrefix} {repairError}"
: $"{finalizationError} | fallback repair failed: {repairError}";
}
if (string.IsNullOrWhiteSpace(finalizationError) && File.Exists(tempPath))
{
ReportProgress(expectedDurationSeconds, "Writing MP4 index");
if (File.Exists(targetPath))
{
File.Replace(tempPath, targetPath, null, ignoreMetadataErrors: true);
@@ -60,6 +336,7 @@ public sealed partial class FfmpegService
File.Delete(sourcePath);
}
SetPostProcessState(recordSessionId, recordTaskId, "Completed", 100, "MP4 seek index is ready");
return (targetPath, null);
}
@@ -69,7 +346,285 @@ public sealed partial class FfmpegService
}
var fallbackPath = File.Exists(targetPath) ? targetPath : sourcePath;
return (fallbackPath, "The MP4 file could not be finalized into a seekable output.");
string? errorDetail;
lock (stderrLines)
{
errorDetail = stderrLines.Count == 0 ? null : string.Join(" | ", stderrLines);
}
return (
fallbackPath,
string.IsNullOrWhiteSpace(finalizationError ?? errorDetail)
? "The MP4 file could not be finalized into a seekable output."
: $"The MP4 file could not be finalized into a seekable output. {finalizationError ?? errorDetail}");
}
private async Task<(string OutputPath, string? ErrorMessage)> TryFinalizeTaskOutputAsync(
string ffmpegPath,
int maxConcurrentTranscodeTasks,
int mp4FinalizeTimeoutMinutes,
RecordSession recordSession,
RecordTask recordTask,
double? expectedDurationSeconds,
CancellationToken cancellationToken = default)
=> await TryFinalizeTaskOutputAsync(
ffmpegPath,
maxConcurrentTranscodeTasks,
mp4FinalizeTimeoutMinutes,
recordSession,
recordTask,
expectedDurationSeconds,
recorderSegmentPaths: null,
cancellationToken);
private async Task<(string OutputPath, string? ErrorMessage)> TryFinalizeTaskOutputAsync(
string ffmpegPath,
int maxConcurrentTranscodeTasks,
int mp4FinalizeTimeoutMinutes,
RecordSession recordSession,
RecordTask recordTask,
double? expectedDurationSeconds,
IReadOnlyList<string>? recorderSegmentPaths,
CancellationToken cancellationToken = default)
{
var fallbackOutputPath = recordTask.OutputFilePath ?? string.Empty;
if (recordSession.OutputFormat != RecordOutputFormat.Mp4)
{
return (fallbackOutputPath, null);
}
if (recordSession.SaveMode == RecordSaveMode.SingleFile)
{
if (string.IsNullOrWhiteSpace(recordSession.OutputPathPattern))
{
return (fallbackOutputPath, null);
}
var finalOutputPath = NormalizeAbsolutePath(recordSession.OutputPathPattern);
var recorderOutputPath = GetRecorderOutputPath(finalOutputPath, recordSession.OutputFormat, recordSession.SaveMode);
if (!File.Exists(recorderOutputPath))
{
return (File.Exists(finalOutputPath) ? finalOutputPath : fallbackOutputPath, null);
}
return await TryFinalizeMp4Async(
ffmpegPath,
maxConcurrentTranscodeTasks,
mp4FinalizeTimeoutMinutes,
recordSession.Id,
recordTask.Id,
recorderOutputPath,
finalOutputPath,
expectedDurationSeconds,
cancellationToken);
}
if (string.IsNullOrWhiteSpace(recordTask.OutputFilePath))
{
return (fallbackOutputPath, null);
}
var finalSegmentOutputPath = NormalizeAbsolutePath(recordTask.OutputFilePath);
var normalizedRecorderSegmentPaths = recorderSegmentPaths?
.Where(static item => !string.IsNullOrWhiteSpace(item))
.Select(NormalizeAbsolutePath)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Where(File.Exists)
.ToArray();
if (normalizedRecorderSegmentPaths is null || normalizedRecorderSegmentPaths.Length == 0)
{
normalizedRecorderSegmentPaths =
[
NormalizeAbsolutePath(
GetRecorderOutputPath(finalSegmentOutputPath, recordSession.OutputFormat, recordSession.SaveMode))
];
}
normalizedRecorderSegmentPaths = normalizedRecorderSegmentPaths
.Where(File.Exists)
.ToArray();
if (normalizedRecorderSegmentPaths.Length == 0)
{
return (File.Exists(finalSegmentOutputPath) ? finalSegmentOutputPath : recordTask.OutputFilePath, null);
}
string? materializedSourcePath = null;
var cleanupMaterializedSource = false;
var preserveMaterializedSource = false;
try
{
materializedSourcePath = await MaterializeRecorderSegmentSourceAsync(
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);
}
}
}
private static async Task<string?> MaterializeRecorderSegmentSourceAsync(
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;
}
private static IReadOnlyList<string> BuildMp4FinalizeArgumentList(
string sourcePath,
string targetPath,
Mp4FinalizeStrategy strategy)
{
var arguments = new List<string>
{
"-hide_banner",
"-y",
"-nostats",
"-progress",
"pipe:1",
"-analyzeduration",
"100M",
"-probesize",
"100M",
"-fflags",
"+genpts+igndts+discardcorrupt",
"-err_detect",
"ignore_err",
"-i",
sourcePath,
"-map",
"0:v:0",
"-map",
"0:a:0?",
"-dn",
"-sn"
};
if (strategy == Mp4FinalizeStrategy.RepairTranscode)
{
arguments.AddRange(
[
"-c:v", "libx264",
"-preset", "veryfast",
"-crf", "23",
"-c:a", "aac",
"-b:a", "128k"
]);
}
else
{
arguments.AddRange(["-c", "copy"]);
}
arguments.AddRange(
[
"-movflags",
"+faststart",
"-avoid_negative_ts",
"make_zero",
targetPath
]);
return arguments;
}
private static bool IsRepairableMp4FinalizeError(string errorDetail)
{
if (IsLowStoragePauseError(errorDetail))
{
return false;
}
return errorDetail.Contains("dimensions not set", StringComparison.OrdinalIgnoreCase) ||
errorDetail.Contains("Could not write header", StringComparison.OrdinalIgnoreCase) ||
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);
}
private static bool IsLowStoragePauseError(string? errorDetail) =>
!string.IsNullOrWhiteSpace(errorDetail) &&
errorDetail.Contains(LowStoragePauseErrorPrefix, 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
{
StreamCopy,
RepairTranscode
}
private static IReadOnlyList<string> BuildArgumentList(
@@ -83,6 +638,8 @@ public sealed partial class FfmpegService
int readWriteTimeoutMilliseconds,
int segmentDurationMinutes,
StreamInputHeaders? inputHeaders,
string? selectedProtocol,
string? selectedVideoCodec,
FfmpegInputOptionProfile inputOptionProfile)
{
var arguments = new List<string> { "-hide_banner", "-y" };
@@ -107,7 +664,9 @@ public sealed partial class FfmpegService
}
}
if (enableReconnect && inputOptionProfile == FfmpegInputOptionProfile.Baseline)
if (enableReconnect &&
inputOptionProfile == FfmpegInputOptionProfile.Baseline &&
ShouldEnableReconnect(streamUrl, selectedProtocol))
{
arguments.AddRange(
[
@@ -121,19 +680,30 @@ public sealed partial class FfmpegService
arguments.AddRange(["-rw_timeout", readWriteTimeoutMilliseconds.ToString(), "-fflags", "+discardcorrupt+genpts", "-i", streamUrl]);
arguments.AddRange(BuildCodecArguments(recordingTemplate));
var writesTransportStream = useIntermediateTransportStream || outputFormat == RecordOutputFormat.Ts;
var bitstreamFilter = ResolveTransportStreamBitstreamFilter(recordingTemplate, writesTransportStream, selectedVideoCodec);
if (!string.IsNullOrWhiteSpace(bitstreamFilter))
{
arguments.AddRange(["-bsf:v", bitstreamFilter]);
}
if (saveMode == RecordSaveMode.Segmented)
{
var segmentFormat = writesTransportStream
? "mpegts"
: "mp4";
arguments.AddRange(
[
"-f", "segment",
"-segment_start_number", "1",
"-segment_time", Math.Max(60, segmentDurationMinutes * 60).ToString(),
"-break_non_keyframes", "0",
"-reset_timestamps", "1",
"-strftime", "0",
"-segment_format", outputFormat == RecordOutputFormat.Ts ? "mpegts" : "mp4"
"-segment_format", segmentFormat
]);
if (outputFormat == RecordOutputFormat.Mp4)
if (!useIntermediateTransportStream && outputFormat == RecordOutputFormat.Mp4)
{
arguments.AddRange(["-segment_format_options", $"movflags={BuildSegmentedMp4MovFlags()}"]);
}
@@ -173,6 +743,35 @@ public sealed partial class FfmpegService
]
};
private static string? ResolveTransportStreamBitstreamFilter(
RecordingTemplateType recordingTemplate,
bool writesTransportStream,
string? selectedVideoCodec)
{
if (!writesTransportStream || !UsesStreamCopy(recordingTemplate) || string.IsNullOrWhiteSpace(selectedVideoCodec))
{
return null;
}
var normalizedCodec = selectedVideoCodec.Trim();
if (normalizedCodec.Contains("h264", StringComparison.OrdinalIgnoreCase) ||
normalizedCodec.Contains("avc", StringComparison.OrdinalIgnoreCase))
{
return "h264_mp4toannexb";
}
if (normalizedCodec.Contains("hevc", StringComparison.OrdinalIgnoreCase) ||
normalizedCodec.Contains("h265", StringComparison.OrdinalIgnoreCase))
{
return "hevc_mp4toannexb";
}
return null;
}
private static bool UsesStreamCopy(RecordingTemplateType recordingTemplate) =>
recordingTemplate is RecordingTemplateType.StreamCopy or RecordingTemplateType.ArchiveTs;
private static long? CalculateFileSize(string? outputPath)
{
if (string.IsNullOrWhiteSpace(outputPath))
@@ -215,7 +814,7 @@ public sealed partial class FfmpegService
return false;
}
private static void UpsertRecordResult(
private static async Task UpsertRecordResultAsync(
RecordTask recordTask,
LiveRecorderDbContext dbContext,
string? effectiveOutputPath,
@@ -223,71 +822,122 @@ public sealed partial class FfmpegService
double? durationSeconds,
string? danmakuFilePath,
int danmakuMessageCount,
DateTimeOffset endedAt)
DateTimeOffset endedAt,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(effectiveOutputPath))
{
effectiveOutputPath = recordTask.OutputFilePath ?? string.Empty;
}
if (recordTask.Result is null)
{
dbContext.RecordResults.Add(new RecordResult(
recordTask.Id,
effectiveOutputPath,
fileSize,
durationSeconds,
danmakuFilePath,
danmakuMessageCount,
recordTask.Status,
recordTask.ErrorMessage,
endedAt));
return;
}
var resultId = Guid.NewGuid();
var normalizedDanmakuCount = Math.Max(0, danmakuMessageCount);
var finalStatus = (int)recordTask.Status;
recordTask.Result.Update(
effectiveOutputPath,
fileSize,
durationSeconds,
danmakuFilePath,
danmakuMessageCount,
recordTask.Status,
recordTask.ErrorMessage);
// Multiple background paths can reconcile the same segment after ffmpeg exits.
// Use SQLite's atomic upsert instead of EF Add-or-Update to avoid RecordTaskId
// unique constraint races between scoped DbContext instances.
await dbContext.Database.ExecuteSqlInterpolatedAsync($"""
INSERT INTO RecordResults
(Id, RecordTaskId, FilePath, FileSizeBytes, DurationSeconds, DanmakuFilePath, DanmakuMessageCount, FinalStatus, ErrorMessage, CreatedAt)
VALUES
({resultId}, {recordTask.Id}, {effectiveOutputPath}, {fileSize}, {durationSeconds}, {danmakuFilePath}, {normalizedDanmakuCount}, {finalStatus}, {recordTask.ErrorMessage}, {endedAt})
ON CONFLICT(RecordTaskId) DO UPDATE SET
FilePath = excluded.FilePath,
FileSizeBytes = excluded.FileSizeBytes,
DurationSeconds = excluded.DurationSeconds,
DanmakuFilePath = excluded.DanmakuFilePath,
DanmakuMessageCount = excluded.DanmakuMessageCount,
FinalStatus = excluded.FinalStatus,
ErrorMessage = excluded.ErrorMessage;
""", cancellationToken);
}
private static Task<RecordResult?> LoadRecordResultAsync(
LiveRecorderDbContext dbContext,
Guid recordTaskId,
CancellationToken cancellationToken = default) =>
dbContext.RecordResults
.AsNoTracking()
.FirstOrDefaultAsync(item => item.RecordTaskId == recordTaskId, cancellationToken);
private static string BuildSingleFileMp4MovFlags() =>
"+faststart+frag_keyframe+empty_moov+default_base_moof";
private static string BuildSegmentedMp4MovFlags() =>
"+faststart+frag_keyframe+empty_moov+default_base_moof";
"+faststart";
private static string GetRecorderOutputPath(
string finalOutputPath,
RecordOutputFormat outputFormat,
RecordSaveMode saveMode)
{
if (saveMode == RecordSaveMode.SingleFile && outputFormat == RecordOutputFormat.Mp4)
if (outputFormat != RecordOutputFormat.Mp4)
{
return finalOutputPath;
}
if (saveMode == RecordSaveMode.SingleFile)
{
return Path.Combine(
Path.GetDirectoryName(finalOutputPath)!,
$"{Path.GetFileNameWithoutExtension(finalOutputPath)}.recording.ts");
}
return finalOutputPath;
return Path.ChangeExtension(finalOutputPath, ".ts");
}
private static bool ShouldUseIntermediateTransportStream(
string outputPath,
RecordOutputFormat outputFormat,
RecordSaveMode saveMode) =>
saveMode == RecordSaveMode.SingleFile &&
outputFormat == RecordOutputFormat.Mp4 &&
(saveMode == RecordSaveMode.SingleFile || saveMode == RecordSaveMode.Segmented) &&
outputPath.EndsWith(".ts", StringComparison.OrdinalIgnoreCase);
private static string ResolveSegmentOutputPath(
string outputPathPattern,
RecordSaveMode saveMode,
int segmentIndex)
{
if (saveMode != RecordSaveMode.Segmented)
{
return outputPathPattern;
}
return outputPathPattern.Replace("%05d", $"{Math.Max(1, segmentIndex):D5}", StringComparison.OrdinalIgnoreCase);
}
private static string ResolveRecorderSegmentOutputPath(
string outputPathPattern,
RecordOutputFormat outputFormat,
RecordSaveMode saveMode,
int segmentIndex) =>
NormalizeAbsolutePath(GetRecorderOutputPath(
ResolveSegmentOutputPath(outputPathPattern, saveMode, segmentIndex),
outputFormat,
saveMode));
private static bool IsHttpInput(string streamUrl) =>
streamUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
streamUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase);
private static bool ShouldEnableReconnect(string streamUrl, string? selectedProtocol)
{
if (!string.IsNullOrWhiteSpace(selectedProtocol) &&
selectedProtocol.Equals("hls", StringComparison.OrdinalIgnoreCase))
{
return false;
}
return !streamUrl.Contains(".m3u8", StringComparison.OrdinalIgnoreCase);
}
private static string NormalizeAbsolutePath(string path) =>
Path.IsPathRooted(path)
? path
: Path.GetFullPath(path, AppContext.BaseDirectory);
private static string? BuildCustomHeaderArgument(StreamInputHeaders? inputHeaders)
{
if (inputHeaders is null)
@@ -376,13 +1026,95 @@ public sealed partial class FfmpegService
return count;
}
private static async Task WaitForFileToStabilizeAsync(string? path, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(path))
{
return;
}
var absolutePath = NormalizeAbsolutePath(path);
if (!File.Exists(absolutePath))
{
return;
}
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(4));
long? previousLength = null;
DateTime previousWriteTimeUtc = default;
var stableChecks = 0;
try
{
while (true)
{
timeoutCts.Token.ThrowIfCancellationRequested();
var fileInfo = new FileInfo(absolutePath);
if (!fileInfo.Exists)
{
return;
}
if (previousLength == fileInfo.Length && previousWriteTimeUtc == fileInfo.LastWriteTimeUtc)
{
stableChecks++;
if (stableChecks >= 2)
{
return;
}
}
else
{
previousLength = fileInfo.Length;
previousWriteTimeUtc = fileInfo.LastWriteTimeUtc;
stableChecks = 0;
}
await Task.Delay(250, timeoutCts.Token);
}
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
{
}
}
private static bool IsActiveTaskStatus(RecordTaskStatus status) =>
status is RecordTaskStatus.Starting or RecordTaskStatus.Running or RecordTaskStatus.Stopping;
private static bool IsActiveSessionStatus(RecordSessionStatus status) =>
status is RecordSessionStatus.Starting or RecordSessionStatus.Running or RecordSessionStatus.Stopping;
private static string Quote(string value) => $"\"{value.Replace("\"", "\\\"", StringComparison.Ordinal)}\"";
private static bool TryParseFfmpegProgressSeconds(string line, out double seconds)
{
if (line.StartsWith("out_time=", StringComparison.OrdinalIgnoreCase))
{
if (TimeSpan.TryParse(
line["out_time=".Length..],
CultureInfo.InvariantCulture,
out var timeSpan))
{
seconds = Math.Max(0, timeSpan.TotalSeconds);
return true;
}
}
if (line.StartsWith("out_time_ms=", StringComparison.OrdinalIgnoreCase) ||
line.StartsWith("out_time_us=", StringComparison.OrdinalIgnoreCase))
{
var raw = line[(line.IndexOf('=') + 1)..];
if (long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
{
seconds = Math.Max(0, value / 1_000_000d);
return true;
}
}
seconds = 0;
return false;
}
private enum FfmpegInputOptionProfile
{
@@ -6,6 +6,8 @@ using LiveRecorder.Application.Abstractions.Notifications;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Application.Abstractions.Recording;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Abstractions.Storage;
using LiveRecorder.Application.Services;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
using LiveRecorder.Infrastructure.Persistence;
@@ -22,19 +24,46 @@ public sealed partial class FfmpegService : IFfmpegService
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
private readonly ConcurrentDictionary<Guid, SessionProcessRuntime> _processes = new();
private readonly ConcurrentDictionary<Guid, PostProcessRuntimeEntry> _postProcessStates = new();
private readonly object _transcodeConcurrencyLock = new();
private readonly Queue<TaskCompletionSource<IDisposable>> _transcodeWaiters = new();
private int _activeTranscodeTasks;
private int _maxConcurrentTranscodeTasks = 1;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly IStorageGuardService _storageGuardService;
private readonly ILogger<FfmpegService> _logger;
public FfmpegService(
IServiceScopeFactory serviceScopeFactory,
IStorageGuardService storageGuardService,
ILogger<FfmpegService> logger)
{
_serviceScopeFactory = serviceScopeFactory;
_storageGuardService = storageGuardService;
_logger = logger;
}
public bool IsRunning(Guid recordSessionId) => _processes.ContainsKey(recordSessionId);
public IReadOnlyDictionary<Guid, RecordTaskRuntimeState> GetTaskRuntimeStates(IReadOnlyCollection<Guid> recordTaskIds)
{
if (recordTaskIds.Count == 0)
{
return new Dictionary<Guid, RecordTaskRuntimeState>();
}
var snapshot = new Dictionary<Guid, RecordTaskRuntimeState>();
foreach (var taskId in recordTaskIds)
{
if (_postProcessStates.TryGetValue(taskId, out var state))
{
snapshot[taskId] = state.State;
}
}
return snapshot;
}
public Task CompleteAsync(Guid recordSessionId, CancellationToken cancellationToken = default) =>
RequestStopAsync(recordSessionId, markAsCompletedOnExit: true, cancellationToken);
@@ -42,11 +71,13 @@ public sealed partial class FfmpegService : IFfmpegService
RecordSession recordSession,
RecordTask initialTask,
StreamUrlResult streamUrlResult,
RecordingExecutionSettings recordingSettings,
CancellationToken cancellationToken = default) =>
StartInternalAsync(
recordSession,
initialTask,
streamUrlResult,
recordingSettings,
inputOptionProfile: FfmpegInputOptionProfile.Baseline,
hasRetriedWithCompatibilityProfile: false,
hasRetriedWithRefreshedStream: false,
@@ -57,6 +88,7 @@ public sealed partial class FfmpegService : IFfmpegService
RecordSession recordSession,
RecordTask initialTask,
StreamUrlResult streamUrlResult,
RecordingExecutionSettings recordingSettings,
FfmpegInputOptionProfile inputOptionProfile,
bool hasRetriedWithCompatibilityProfile,
bool hasRetriedWithRefreshedStream,
@@ -66,6 +98,7 @@ public sealed partial class FfmpegService : IFfmpegService
ArgumentNullException.ThrowIfNull(recordSession);
ArgumentNullException.ThrowIfNull(initialTask);
ArgumentNullException.ThrowIfNull(streamUrlResult);
ArgumentNullException.ThrowIfNull(recordingSettings);
if (string.IsNullOrWhiteSpace(streamUrlResult.SelectedUrl) || string.IsNullOrWhiteSpace(recordSession.OutputPathPattern))
{
@@ -106,10 +139,12 @@ public sealed partial class FfmpegService : IFfmpegService
recordSession.SaveMode,
initialTask.Id,
Math.Max(1, initialTask.SegmentIndex),
initialTask.OutputFilePath ?? outputPathPattern,
ResolveRecorderSegmentOutputPath(outputPathPattern, recordSession.OutputFormat, recordSession.SaveMode, Math.Max(1, initialTask.SegmentIndex)),
streamUrlResult.SelectedQuality,
streamUrlResult.SelectedProtocol,
streamUrlResult.SelectedVideoCodec,
streamUrlResult.InputHeaders,
recordingSettings,
inputOptionProfile,
hasRetriedWithCompatibilityProfile,
hasRetriedWithRefreshedStream,
@@ -120,12 +155,14 @@ public sealed partial class FfmpegService : IFfmpegService
recorderOutputPath,
recordSession.OutputFormat,
recordSession.SaveMode,
settings.RecordingTemplate,
settings.EnableAutoReconnect,
settings.ReconnectDelayMaxSeconds,
settings.ReadWriteTimeoutMilliseconds,
settings.SegmentDurationMinutes,
recordingSettings.RecordingTemplate,
recordingSettings.EnableAutoReconnect,
recordingSettings.ReconnectDelayMaxSeconds,
recordingSettings.ReadWriteTimeoutMilliseconds,
recordingSettings.SegmentDurationMinutes,
streamUrlResult.InputHeaders,
streamUrlResult.SelectedProtocol,
streamUrlResult.SelectedVideoCodec,
inputOptionProfile))
{
process.StartInfo.ArgumentList.Add(argument);
@@ -191,6 +228,7 @@ public sealed partial class FfmpegService : IFfmpegService
{
if (!process.HasExited)
{
runtime.MarkForceKilled();
process.Kill(true);
}
}
@@ -204,7 +242,7 @@ public sealed partial class FfmpegService : IFfmpegService
public async Task<bool> TryReconcileInactiveSessionAsync(Guid recordSessionId, CancellationToken cancellationToken = default)
{
if (IsRunning(recordSessionId))
if (IsRunning(recordSessionId) || IsSessionUnderPostProcessing(recordSessionId))
{
return false;
}
@@ -232,37 +270,39 @@ public sealed partial class FfmpegService : IFfmpegService
.ToList();
var anyUsableOutput = false;
string? finalizationError = null;
string? sessionFinalizationError = null;
foreach (var task in tasks.Where(item => IsActiveTaskStatus(item.Status)))
{
var effectiveOutputPath = task.OutputFilePath ?? string.Empty;
if (recordSession.SaveMode == RecordSaveMode.SingleFile &&
recordSession.OutputFormat == RecordOutputFormat.Mp4 &&
!string.IsNullOrWhiteSpace(recordSession.OutputPathPattern))
var durationSeconds = task.StartedAt.HasValue
? (double?)Math.Max(0, (endedAt - task.StartedAt.Value).TotalSeconds)
: null;
var finalizationResult = await TryFinalizeTaskOutputAsync(
settings.FfmpegPath,
settings.MaxConcurrentFfmpegTranscodeTasks,
settings.Mp4FinalizeTimeoutMinutes,
recordSession,
task,
durationSeconds,
cancellationToken);
var effectiveOutputPath = finalizationResult.OutputPath;
var taskFinalizationError = finalizationResult.ErrorMessage;
if (!IsLowStoragePauseError(taskFinalizationError))
{
var finalOutputPath = Path.IsPathRooted(recordSession.OutputPathPattern)
? recordSession.OutputPathPattern
: Path.GetFullPath(recordSession.OutputPathPattern, AppContext.BaseDirectory);
var recorderOutputPath = GetRecorderOutputPath(finalOutputPath, recordSession.OutputFormat, recordSession.SaveMode);
if (File.Exists(recorderOutputPath))
{
var finalizationResult = await TryFinalizeMp4Async(settings.FfmpegPath, recorderOutputPath, finalOutputPath);
effectiveOutputPath = finalizationResult.OutputPath;
finalizationError ??= finalizationResult.ErrorMessage;
}
sessionFinalizationError ??= taskFinalizationError;
}
var fileSize = CalculateFileSize(effectiveOutputPath);
var danmakuPath = task.Result?.DanmakuFilePath ?? GuessDanmakuPath(task.OutputFilePath);
var danmakuCount = CountDanmakuMessages(danmakuPath);
var durationSeconds = task.StartedAt.HasValue
? (double?)Math.Max(0, (endedAt - task.StartedAt.Value).TotalSeconds)
: null;
if (!string.IsNullOrWhiteSpace(finalizationError))
if (IsLowStoragePauseError(taskFinalizationError))
{
task.MarkFailed(finalizationError, endedAt);
task.MarkProcessing(taskFinalizationError, endedAt);
}
else if (!string.IsNullOrWhiteSpace(taskFinalizationError))
{
task.MarkFailed(taskFinalizationError, endedAt);
}
else if (HasUsableOutput(effectiveOutputPath, fileSize))
{
@@ -274,13 +314,14 @@ public sealed partial class FfmpegService : IFfmpegService
task.MarkStopped(endedAt, durationSeconds, "Recording process was no longer running when the session was reconciled.");
}
UpsertRecordResult(task, dbContext, effectiveOutputPath, fileSize, durationSeconds, danmakuPath, danmakuCount, endedAt);
await UpsertRecordResultAsync(task, dbContext, effectiveOutputPath, fileSize, durationSeconds, danmakuPath, danmakuCount, endedAt, cancellationToken);
ClearPostProcessState(task.Id);
}
recordSession.SyncSegmentCount(tasks.Count, endedAt);
if (!string.IsNullOrWhiteSpace(finalizationError))
if (!string.IsNullOrWhiteSpace(sessionFinalizationError))
{
recordSession.MarkFailed(finalizationError, endedAt);
recordSession.MarkFailed(sessionFinalizationError, endedAt);
}
else if (anyUsableOutput)
{
@@ -295,6 +336,176 @@ public sealed partial class FfmpegService : IFfmpegService
return true;
}
public async Task<bool> StartManualFinalizeTaskAsync(Guid recordTaskId, CancellationToken cancellationToken = default)
{
if (_postProcessStates.ContainsKey(recordTaskId))
{
return false;
}
using var scope = _serviceScopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
var recordTask = await dbContext.RecordTasks
.Include(item => item.RecordSession)
.Include(item => item.Result)
.FirstOrDefaultAsync(item => item.Id == recordTaskId, cancellationToken);
if (recordTask?.RecordSession is null)
{
return false;
}
if (recordTask.OutputFormat != RecordOutputFormat.Mp4 ||
IsActiveTaskStatus(recordTask.Status) ||
IsRunning(recordTask.RecordSessionId))
{
return false;
}
if (string.IsNullOrWhiteSpace(recordTask.OutputFilePath))
{
return false;
}
var recorderOutputPath = ResolveManualFinalizeSourcePath(recordTask, recordTask.RecordSession);
if (!File.Exists(recorderOutputPath))
{
return false;
}
SetPostProcessState(
recordTask.RecordSessionId,
recordTask.Id,
"Queued",
null,
$"Manual MP4 finalization queued for {Path.GetFileName(recordTask.OutputFilePath)}");
_ = Task.Run(
async () => await RunManualFinalizeTaskAsync(recordTask.Id),
CancellationToken.None);
return true;
}
public async Task<int> ResumePausedFinalizationsAsync(CancellationToken cancellationToken = default)
{
using var scope = _serviceScopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
var settingsService = scope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
var settings = await settingsService.GetAsync(cancellationToken);
var storageCheck = _storageGuardService.CheckCanStartOrResume(settings);
if (!storageCheck.HasEnoughSpace)
{
await logService.WriteAsync(
SystemLogLevel.Warning,
"Storage",
"Paused MP4 finalization remains blocked because storage is below threshold.",
storageCheck.Message,
cancellationToken: cancellationToken);
return 0;
}
var candidates = await dbContext.RecordTasks
.Include(item => item.RecordSession)
.Include(item => item.Result)
.Where(item => item.OutputFormat == RecordOutputFormat.Mp4 &&
(item.Status == RecordTaskStatus.Processing ||
item.Status == RecordTaskStatus.Completed))
.OrderBy(static item => item.UpdatedAt)
.Take(100)
.ToListAsync(cancellationToken);
var queuedTaskIds = new List<Guid>();
var repairedInterruptedTasks = 0;
var now = DateTimeOffset.UtcNow;
foreach (var candidate in candidates)
{
if (candidate.Status == RecordTaskStatus.Processing)
{
queuedTaskIds.Add(candidate.Id);
continue;
}
if (!NeedsInterruptedMp4Finalization(candidate))
{
continue;
}
candidate.MarkProcessing("MP4 finalization was interrupted before completion. Re-queued after restart.", now);
queuedTaskIds.Add(candidate.Id);
repairedInterruptedTasks++;
}
if (repairedInterruptedTasks > 0)
{
await dbContext.SaveChangesAsync(cancellationToken);
}
var started = 0;
foreach (var taskId in queuedTaskIds.Take(20))
{
if (await StartManualFinalizeTaskAsync(taskId, cancellationToken))
{
started++;
}
}
if (started > 0)
{
await logService.WriteAsync(
SystemLogLevel.Info,
"Storage",
"Storage is available. Resumed paused MP4 finalization tasks.",
$"count={started}; repairedInterrupted={repairedInterruptedTasks}; {storageCheck.Message}",
cancellationToken: cancellationToken);
}
return started;
}
private static bool NeedsInterruptedMp4Finalization(RecordTask recordTask)
{
if (recordTask.Status != RecordTaskStatus.Completed ||
recordTask.RecordSession is null ||
recordTask.OutputFormat != RecordOutputFormat.Mp4 ||
recordTask.RecordSession.OutputFormat != RecordOutputFormat.Mp4 ||
string.IsNullOrWhiteSpace(recordTask.OutputFilePath))
{
return false;
}
var finalOutputPath = NormalizeAbsolutePath(recordTask.OutputFilePath);
if (HasUsableOutput(finalOutputPath, CalculateFileSize(finalOutputPath)))
{
return false;
}
var recorderOutputPath = ResolveManualFinalizeSourcePath(recordTask, recordTask.RecordSession);
return File.Exists(recorderOutputPath);
}
private static string ResolveManualFinalizeSourcePath(RecordTask recordTask, RecordSession recordSession)
{
var resultPath = recordTask.Result?.FilePath;
if (!string.IsNullOrWhiteSpace(resultPath) &&
resultPath.EndsWith(".ts", StringComparison.OrdinalIgnoreCase))
{
var normalizedResultPath = NormalizeAbsolutePath(resultPath);
if (File.Exists(normalizedResultPath))
{
return normalizedResultPath;
}
}
return NormalizeAbsolutePath(
GetRecorderOutputPath(
recordTask.OutputFilePath ?? resultPath ?? string.Empty,
recordSession.OutputFormat,
recordSession.SaveMode));
}
private async Task PersistStartupProfileAsync(SessionProcessRuntime runtime, CancellationToken cancellationToken)
{
using var scope = _serviceScopeFactory.CreateScope();
@@ -303,7 +514,7 @@ public sealed partial class FfmpegService : IFfmpegService
SystemLogLevel.Info,
"FFmpeg",
$"ffmpeg input profile={runtime.InputOptionProfile}.",
detail: $"stream={runtime.SelectedProtocol}:{runtime.SelectedQuality}; compatibilityRetry={runtime.HasRetriedWithCompatibilityProfile}; refreshRetry={runtime.HasRetriedWithRefreshedStream}",
detail: $"stream={runtime.SelectedProtocol}:{runtime.SelectedQuality}; videoCodec={runtime.SelectedVideoCodec ?? "unknown"}; compatibilityRetry={runtime.HasRetriedWithCompatibilityProfile}; refreshRetry={runtime.HasRetriedWithRefreshedStream}",
liveRoomId: runtime.LiveRoomId,
recordSessionId: runtime.RecordSessionId,
recordTaskId: runtime.CurrentTaskId,
@@ -351,29 +562,107 @@ public sealed partial class FfmpegService : IFfmpegService
await process.StandardInput.WriteLineAsync("q");
await process.StandardInput.FlushAsync();
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(12));
try
{
await process.WaitForExitAsync(timeoutCts.Token);
}
catch (OperationCanceledException)
{
if (!process.HasExited)
{
process.Kill(true);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Stop ffmpeg process failed for session {RecordSessionId}", recordSessionId);
}
}
if (!process.HasExited)
private void SetPostProcessState(
Guid recordSessionId,
Guid recordTaskId,
string stage,
double? progressPercent,
string? detail = null)
{
var normalizedProgress = progressPercent.HasValue
? Math.Clamp(progressPercent.Value, 0d, 100d)
: (double?)null;
_postProcessStates[recordTaskId] = new PostProcessRuntimeEntry(
recordSessionId,
new RecordTaskRuntimeState(
RecordTaskStatus.Processing,
stage,
normalizedProgress,
detail));
}
private void ClearPostProcessState(Guid recordTaskId) =>
_postProcessStates.TryRemove(recordTaskId, out _);
private bool IsSessionUnderPostProcessing(Guid recordSessionId) =>
_postProcessStates.Values.Any(entry => entry.RecordSessionId == recordSessionId);
private async Task<IDisposable> AcquireTranscodeSlotAsync(int maxConcurrentTasks, CancellationToken cancellationToken)
{
var normalizedMaxConcurrentTasks = Math.Clamp(maxConcurrentTasks, 1, 16);
TaskCompletionSource<IDisposable> waiter;
lock (_transcodeConcurrencyLock)
{
_maxConcurrentTranscodeTasks = normalizedMaxConcurrentTasks;
if (_transcodeWaiters.Count == 0 && _activeTranscodeTasks < _maxConcurrentTranscodeTasks)
{
process.Kill(true);
_activeTranscodeTasks++;
return new TranscodeSlotLease(this);
}
waiter = new TaskCompletionSource<IDisposable>(TaskCreationOptions.RunContinuationsAsynchronously);
_transcodeWaiters.Enqueue(waiter);
}
using var cancellationRegistration = cancellationToken.Register(
static state => ((TaskCompletionSource<IDisposable>)state!).TrySetCanceled(),
waiter);
return await waiter.Task.ConfigureAwait(false);
}
private void ReleaseTranscodeSlot()
{
lock (_transcodeConcurrencyLock)
{
if (_activeTranscodeTasks > 0)
{
_activeTranscodeTasks--;
}
while (_activeTranscodeTasks < _maxConcurrentTranscodeTasks && _transcodeWaiters.Count > 0)
{
var waiter = _transcodeWaiters.Dequeue();
if (waiter.Task.IsCompleted)
{
continue;
}
_activeTranscodeTasks++;
if (waiter.TrySetResult(new TranscodeSlotLease(this)))
{
return;
}
_activeTranscodeTasks--;
}
}
}
private sealed record PostProcessRuntimeEntry(Guid RecordSessionId, RecordTaskRuntimeState State);
private sealed class TranscodeSlotLease : IDisposable
{
private readonly FfmpegService _owner;
private int _disposed;
public TranscodeSlotLease(FfmpegService owner)
{
_owner = owner;
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 0)
{
_owner.ReleaseTranscodeSlot();
}
}
}
@@ -1,12 +1,16 @@
using System.Collections.Concurrent;
using LiveRecorder.Application.Abstractions.Logging;
using LiveRecorder.Application.Abstractions.Notifications;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Application.Abstractions.Recording;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Abstractions.Storage;
using LiveRecorder.Application.Models.Settings;
using LiveRecorder.Application.Models.RecordTasks;
using LiveRecorder.Application.Services;
using LiveRecorder.Domain.Enums;
using LiveRecorder.Infrastructure.Persistence;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@@ -16,8 +20,13 @@ namespace LiveRecorder.Infrastructure.Services;
public sealed class LiveRoomPollingBackgroundService : BackgroundService
{
private static readonly TimeSpan OfflineGracefulStopTimeout = TimeSpan.FromSeconds(20);
private static readonly TimeSpan OfflineForcedStopTimeout = TimeSpan.FromSeconds(8);
private static readonly TimeSpan ExceptionEmailCooldown = TimeSpan.FromHours(6);
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly ILogger<LiveRoomPollingBackgroundService> _logger;
private readonly ConcurrentDictionary<string, DateTimeOffset> _exceptionEmailSentAt = new(StringComparer.OrdinalIgnoreCase);
public LiveRoomPollingBackgroundService(
IServiceScopeFactory serviceScopeFactory,
@@ -38,9 +47,15 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
using var scope = _serviceScopeFactory.CreateScope();
var settingsService = scope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
var settings = await settingsService.GetAsync(stoppingToken);
var emailNotificationService = scope.ServiceProvider.GetRequiredService<IEmailNotificationService>();
delaySeconds = settings.PollingIntervalSeconds;
var ffmpegService = scope.ServiceProvider.GetRequiredService<IFfmpegService>();
var storageGuardService = scope.ServiceProvider.GetRequiredService<IStorageGuardService>();
if (storageGuardService.CheckCanStartOrResume(settings).HasEnoughSpace)
{
await ffmpegService.ResumePausedFinalizationsAsync(stoppingToken);
}
if (!settings.EnableBackgroundPolling)
{
await DelayAsync(delaySeconds, stoppingToken);
@@ -48,104 +63,23 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
}
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
var adapterFactory = scope.ServiceProvider.GetRequiredService<ILivePlatformAdapterFactory>();
var ffmpegService = scope.ServiceProvider.GetRequiredService<IFfmpegService>();
var recordService = scope.ServiceProvider.GetRequiredService<RecordService>();
var liveRoomStatusService = scope.ServiceProvider.GetRequiredService<LiveRoomStatusService>();
var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
var liveRooms = (await dbContext.LiveRooms.ToListAsync(stoppingToken))
.Where(static item => item.IsEnabled)
var liveRoomIds = (await dbContext.LiveRooms
.AsNoTracking()
.Where(static item => item.IsEnabled)
.Select(static item => new { item.Id, item.UpdatedAt })
.ToListAsync(stoppingToken))
.OrderBy(static item => item.UpdatedAt)
.Select(static item => item.Id)
.ToList();
foreach (var liveRoom in liveRooms)
foreach (var liveRoomId in liveRoomIds)
{
if (stoppingToken.IsCancellationRequested)
{
break;
}
try
{
var adapter = adapterFactory.GetByPlatform(liveRoom.Platform);
var liveStatus = await adapter.GetLiveStatusAsync(liveRoom.RoomId, stoppingToken);
var now = DateTimeOffset.UtcNow;
await liveRoomStatusService.ApplySnapshotAsync(liveRoom, liveStatus, now, stoppingToken);
await dbContext.SaveChangesAsync(stoppingToken);
if (!liveStatus.IsLive)
{
await CompleteActiveSessionsForOfflineRoomAsync(
dbContext,
ffmpegService,
logService,
liveRoom.Id,
stoppingToken);
continue;
}
if (!settings.AutoStartRecordingOnLive)
{
continue;
}
var hasRunningSession = await dbContext.RecordSessions.AnyAsync(
item => item.LiveRoomId == liveRoom.Id &&
(item.Status == RecordSessionStatus.Starting ||
item.Status == RecordSessionStatus.Running ||
item.Status == RecordSessionStatus.Stopping),
stoppingToken);
if (hasRunningSession)
{
continue;
}
_logger.LogInformation("Auto-start recording for live room {RoomId}", liveRoom.RoomId);
await logService.WriteAsync(
SystemLogLevel.Info,
"Scheduler",
"Live detected by background poller. Auto-starting recording task.",
liveRoomId: liveRoom.Id,
cancellationToken: stoppingToken);
await recordService.StartAsync(
new StartRecordTaskRequest
{
LiveRoomId = liveRoom.Id,
PreferredQuality = settings.DefaultQuality,
OutputFormat = settings.DefaultOutputFormat
},
stoppingToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Polling room {RoomId} failed", liveRoom.RoomId);
var isTransient = IsTransientPollingException(ex, stoppingToken);
await logService.WriteAsync(
isTransient ? SystemLogLevel.Warning : SystemLogLevel.Error,
"Scheduler",
isTransient
? "Transient background polling failure. The room will be retried on the next cycle."
: "Background polling failed for a live room.",
ex.ToString(),
liveRoomId: liveRoom.Id,
cancellationToken: stoppingToken);
if (!isTransient)
{
await emailNotificationService.SendExceptionAsync(
"Scheduler",
"Background polling failed for a live room.",
ex.ToString(),
liveRoom,
cancellationToken: stoppingToken);
}
}
await PollLiveRoomAsync(liveRoomId, settings, stoppingToken);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
@@ -160,11 +94,14 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
{
using var notificationScope = _serviceScopeFactory.CreateScope();
var emailNotificationService = notificationScope.ServiceProvider.GetRequiredService<IEmailNotificationService>();
await emailNotificationService.SendExceptionAsync(
"Scheduler",
"Background live room polling failed.",
ex.ToString(),
cancellationToken: stoppingToken);
if (ShouldSendExceptionEmail(BuildExceptionEmailKey("background-loop", ex)))
{
await emailNotificationService.SendExceptionAsync(
"Scheduler",
"Background live room polling failed.",
ex.ToString(),
cancellationToken: stoppingToken);
}
}
catch (Exception notificationEx)
{
@@ -179,6 +116,128 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
private static Task DelayAsync(int delaySeconds, CancellationToken cancellationToken) =>
Task.Delay(TimeSpan.FromSeconds(Math.Clamp(delaySeconds, 10, 3600)), cancellationToken);
private async Task PollLiveRoomAsync(Guid liveRoomId, SystemSettingsDto settings, CancellationToken cancellationToken)
{
using var scope = _serviceScopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
var adapterFactory = scope.ServiceProvider.GetRequiredService<ILivePlatformAdapterFactory>();
var ffmpegService = scope.ServiceProvider.GetRequiredService<IFfmpegService>();
var recordService = scope.ServiceProvider.GetRequiredService<RecordService>();
var liveRoomStatusService = scope.ServiceProvider.GetRequiredService<LiveRoomStatusService>();
var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
var emailNotificationService = scope.ServiceProvider.GetRequiredService<IEmailNotificationService>();
var storageGuardService = scope.ServiceProvider.GetRequiredService<IStorageGuardService>();
var liveRoom = await dbContext.LiveRooms.FirstOrDefaultAsync(item => item.Id == liveRoomId, cancellationToken);
if (liveRoom is null || !liveRoom.IsEnabled)
{
return;
}
try
{
var adapter = adapterFactory.GetByPlatform(liveRoom.Platform);
var liveStatus = await adapter.GetLiveStatusAsync(liveRoom.RoomId, cancellationToken);
var now = DateTimeOffset.UtcNow;
await liveRoomStatusService.ApplySnapshotAsync(liveRoom, liveStatus, now, cancellationToken);
await SaveChangesWithRetryAsync(dbContext, cancellationToken);
if (!liveStatus.IsLive)
{
await CompleteActiveSessionsForOfflineRoomAsync(
dbContext,
ffmpegService,
logService,
liveRoom.Id,
cancellationToken);
return;
}
var pauseCheck = storageGuardService.CheckShouldPause(settings);
if (!pauseCheck.HasEnoughSpace)
{
await PauseActiveSessionsForLowStorageAsync(
dbContext,
ffmpegService,
logService,
liveRoom.Id,
pauseCheck.Message,
cancellationToken);
}
if (!settings.AutoStartRecordingOnLive)
{
return;
}
var startCheck = storageGuardService.CheckCanStartOrResume(settings);
if (!startCheck.HasEnoughSpace)
{
await logService.WriteAsync(
SystemLogLevel.Warning,
"Storage",
"Auto-start recording skipped because storage is below resume threshold.",
startCheck.Message,
liveRoomId: liveRoom.Id,
cancellationToken: cancellationToken);
return;
}
var hasRunningSession = await dbContext.RecordSessions.AnyAsync(
item => item.LiveRoomId == liveRoom.Id &&
(item.Status == RecordSessionStatus.Starting ||
item.Status == RecordSessionStatus.Running ||
item.Status == RecordSessionStatus.Stopping),
cancellationToken);
if (hasRunningSession)
{
return;
}
_logger.LogInformation("Auto-start recording for live room {RoomId}", liveRoom.RoomId);
await logService.WriteAsync(
SystemLogLevel.Info,
"Scheduler",
"Live detected by background poller. Auto-starting recording task.",
liveRoomId: liveRoom.Id,
cancellationToken: cancellationToken);
await recordService.StartAsync(
new StartRecordTaskRequest
{
LiveRoomId = liveRoom.Id
},
cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Polling room {RoomId} failed", liveRoom.RoomId);
var isTransient = IsTransientPollingException(ex, cancellationToken);
await logService.WriteAsync(
isTransient ? SystemLogLevel.Warning : SystemLogLevel.Error,
"Scheduler",
isTransient
? "Transient background polling failure. The room will be retried on the next cycle."
: "Background polling failed for a live room.",
ex.ToString(),
liveRoomId: liveRoom.Id,
cancellationToken: cancellationToken);
if (!isTransient && ShouldSendExceptionEmail(BuildExceptionEmailKey("live-room-poll", ex, liveRoom.Id)))
{
await emailNotificationService.SendExceptionAsync(
"Scheduler",
"Background polling failed for a live room.",
ex.ToString(),
liveRoom,
cancellationToken: cancellationToken);
}
}
}
private static async Task CompleteActiveSessionsForOfflineRoomAsync(
LiveRecorderDbContext dbContext,
IFfmpegService ffmpegService,
@@ -201,7 +260,40 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
{
if (ffmpegService.IsRunning(activeSession.Id))
{
await ffmpegService.CompleteAsync(activeSession.Id, cancellationToken);
var stopped = await ffmpegService.StopAndWaitAsync(
activeSession.Id,
markAsCompletedOnExit: true,
OfflineGracefulStopTimeout,
cancellationToken);
if (!stopped)
{
await logService.WriteAsync(
SystemLogLevel.Warning,
"Scheduler",
"Live room is offline, but the recorder did not stop gracefully in time. Force killing the ffmpeg process.",
liveRoomId: liveRoomId,
recordSessionId: activeSession.Id,
cancellationToken: cancellationToken);
var killed = await ffmpegService.KillAndWaitAsync(
activeSession.Id,
OfflineForcedStopTimeout,
cancellationToken);
if (!killed)
{
await logService.WriteAsync(
SystemLogLevel.Error,
"Scheduler",
"Live room is offline, but the active recording session is still shutting down.",
liveRoomId: liveRoomId,
recordSessionId: activeSession.Id,
cancellationToken: cancellationToken);
continue;
}
}
await ffmpegService.TryReconcileInactiveSessionAsync(activeSession.Id, cancellationToken);
await logService.WriteAsync(
SystemLogLevel.Info,
"Scheduler",
@@ -225,6 +317,57 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
}
}
private static async Task PauseActiveSessionsForLowStorageAsync(
LiveRecorderDbContext dbContext,
IFfmpegService ffmpegService,
ISystemLogService logService,
Guid liveRoomId,
string detail,
CancellationToken cancellationToken)
{
var activeSessions = await dbContext.RecordSessions
.Where(item => item.LiveRoomId == liveRoomId &&
(item.Status == RecordSessionStatus.Starting ||
item.Status == RecordSessionStatus.Running ||
item.Status == RecordSessionStatus.Stopping))
.ToListAsync(cancellationToken);
foreach (var activeSession in activeSessions.OrderBy(static item => item.CreatedAt))
{
await logService.WriteAsync(
SystemLogLevel.Warning,
"Storage",
"Storage is below threshold. Pausing active recording session.",
detail,
liveRoomId: liveRoomId,
recordSessionId: activeSession.Id,
cancellationToken: cancellationToken);
if (ffmpegService.IsRunning(activeSession.Id))
{
var stopped = await ffmpegService.StopAndWaitAsync(
activeSession.Id,
markAsCompletedOnExit: false,
OfflineGracefulStopTimeout,
cancellationToken);
if (!stopped)
{
await logService.WriteAsync(
SystemLogLevel.Warning,
"Storage",
"Recorder did not stop gracefully after low storage pause. Force killing the ffmpeg process.",
detail,
liveRoomId,
activeSession.Id,
cancellationToken: cancellationToken);
await ffmpegService.KillAndWaitAsync(activeSession.Id, OfflineForcedStopTimeout, cancellationToken);
}
}
await ffmpegService.TryReconcileInactiveSessionAsync(activeSession.Id, cancellationToken);
}
}
private static bool IsTransientPollingException(Exception exception, CancellationToken cancellationToken)
{
if (exception is OperationCanceledException && cancellationToken.IsCancellationRequested)
@@ -237,7 +380,99 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
return true;
}
if (exception is DbUpdateException dbUpdateException &&
IsSqliteLockException(dbUpdateException))
{
return true;
}
if (exception is SqliteException sqliteException &&
IsSqliteLockException(sqliteException))
{
return true;
}
return exception.InnerException is not null &&
IsTransientPollingException(exception.InnerException, cancellationToken);
}
private bool ShouldSendExceptionEmail(string key)
{
var now = DateTimeOffset.UtcNow;
while (true)
{
if (_exceptionEmailSentAt.TryGetValue(key, out var lastSentAt))
{
if (now - lastSentAt < ExceptionEmailCooldown)
{
_logger.LogWarning(
"Suppressed repeated scheduler exception email. Key={Key}; CooldownMinutes={CooldownMinutes}",
key,
ExceptionEmailCooldown.TotalMinutes);
return false;
}
if (_exceptionEmailSentAt.TryUpdate(key, now, lastSentAt))
{
return true;
}
continue;
}
if (_exceptionEmailSentAt.TryAdd(key, now))
{
return true;
}
}
}
private static string BuildExceptionEmailKey(string scope, Exception exception, Guid? liveRoomId = null)
{
if (IsSqliteStorageFullException(exception))
{
return $"{scope}:sqlite-storage-full";
}
var root = exception.GetBaseException();
var message = root.Message.Length > 160 ? root.Message[..160] : root.Message;
return $"{scope}:{liveRoomId?.ToString() ?? "global"}:{root.GetType().FullName}:{message}";
}
private static bool IsSqliteStorageFullException(Exception exception)
{
if (exception is SqliteException sqliteException &&
(sqliteException.SqliteErrorCode == 13 ||
sqliteException.Message.Contains("database or disk is full", StringComparison.OrdinalIgnoreCase)))
{
return true;
}
return exception.InnerException is not null && IsSqliteStorageFullException(exception.InnerException);
}
private static async Task SaveChangesWithRetryAsync(LiveRecorderDbContext dbContext, CancellationToken cancellationToken)
{
for (var attempt = 1; attempt <= 5; attempt++)
{
try
{
await dbContext.SaveChangesAsync(cancellationToken);
return;
}
catch (DbUpdateException ex) when (attempt < 5 && IsSqliteLockException(ex))
{
await Task.Delay(TimeSpan.FromMilliseconds(300 * Math.Pow(2, attempt - 1)), cancellationToken);
}
}
}
private static bool IsSqliteLockException(DbUpdateException exception) =>
exception.InnerException is SqliteException sqliteException &&
IsSqliteLockException(sqliteException);
private static bool IsSqliteLockException(SqliteException exception) =>
exception.SqliteErrorCode is 5 or 6 ||
exception.Message.Contains("database is locked", StringComparison.OrdinalIgnoreCase) ||
exception.Message.Contains("database table is locked", StringComparison.OrdinalIgnoreCase);
}
@@ -29,9 +29,9 @@ public sealed class RecordMediaService : IRecordMediaService
var recordTask = await _recordTaskRepository.GetByIdAsync(recordTaskId, cancellationToken)
?? throw new KeyNotFoundException("Recording task was not found.");
if (recordTask.Status != RecordTaskStatus.Completed)
if (recordTask.Status is not (RecordTaskStatus.Completed or RecordTaskStatus.Stopped))
{
throw new InvalidOperationException("Preview is only available for completed tasks.");
throw new InvalidOperationException("Preview is only available for finalized MP4 tasks.");
}
if (recordTask.OutputFormat != RecordOutputFormat.Mp4)
@@ -0,0 +1,80 @@
using LiveRecorder.Application.Abstractions.Storage;
using LiveRecorder.Application.Models.Settings;
using Microsoft.Extensions.Logging;
namespace LiveRecorder.Infrastructure.Services;
public sealed class StorageGuardService : IStorageGuardService
{
private const long Megabyte = 1024L * 1024L;
private readonly ILogger<StorageGuardService> _logger;
public StorageGuardService(ILogger<StorageGuardService> logger)
{
_logger = logger;
}
public StorageGuardResult CheckCanStartOrResume(SystemSettingsDto settings, long additionalRequiredBytes = 0) =>
Check(settings, Math.Max(settings.PauseRecordingWhenFreeSpaceBelowMegabytes, settings.ResumeRecordingWhenFreeSpaceAboveMegabytes), additionalRequiredBytes);
public StorageGuardResult CheckShouldPause(SystemSettingsDto settings) =>
Check(settings, settings.PauseRecordingWhenFreeSpaceBelowMegabytes, additionalRequiredBytes: 0);
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.");
}
var checkedPath = ResolveOutputRoot(settings.OutputRoot);
var thresholdBytes = Math.Max(0, freeSpaceThresholdMegabytes) * Megabyte;
var requiredBytes = thresholdBytes + Math.Max(0, additionalRequiredBytes);
try
{
var drive = new DriveInfo(Path.GetPathRoot(checkedPath) ?? checkedPath);
var availableBytes = drive.AvailableFreeSpace;
var hasEnoughSpace = availableBytes >= requiredBytes;
var message = hasEnoughSpace
? $"Storage is available. free={FormatBytes(availableBytes)}, required={FormatBytes(requiredBytes)}, path={checkedPath}"
: $"Storage is below threshold. free={FormatBytes(availableBytes)}, required={FormatBytes(requiredBytes)}, path={checkedPath}";
return new StorageGuardResult(true, hasEnoughSpace, checkedPath, availableBytes, requiredBytes, message);
}
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}");
}
}
private static string ResolveOutputRoot(string outputRoot)
{
var root = string.IsNullOrWhiteSpace(outputRoot) ? "records" : outputRoot.Trim();
return Path.IsPathRooted(root)
? root
: Path.GetFullPath(root, AppContext.BaseDirectory);
}
private static string FormatBytes(long bytes)
{
if (bytes == long.MaxValue)
{
return "unlimited";
}
string[] units = ["B", "KB", "MB", "GB", "TB"];
var value = Math.Max(0, bytes);
var unitIndex = 0;
var display = (double)value;
while (display >= 1024 && unitIndex < units.Length - 1)
{
display /= 1024;
unitIndex++;
}
return $"{display:0.##} {units[unitIndex]}";
}
}
@@ -1,3 +1,4 @@
using System.Text;
using LiveRecorder.Application.Models.LiveRooms;
using LiveRecorder.Application.Services;
using Microsoft.AspNetCore.Mvc;
@@ -19,6 +20,17 @@ public sealed class LiveRoomsController : ControllerBase
public async Task<ActionResult<IReadOnlyList<LiveRoomDto>>> List(CancellationToken cancellationToken) =>
Ok(await _liveRoomService.ListAsync(cancellationToken));
[HttpGet("export")]
public async Task<FileContentResult> Export(CancellationToken cancellationToken)
{
var content = await _liveRoomService.ExportAsync(cancellationToken);
var fileName = $"live-rooms-{DateTimeOffset.Now:yyyyMMddHHmmss}.txt";
return File(
new UTF8Encoding(encoderShouldEmitUTF8Identifier: true).GetBytes(content),
"text/plain; charset=utf-8",
fileName);
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<LiveRoomDto>> Get(Guid id, CancellationToken cancellationToken)
{
@@ -30,6 +42,12 @@ public sealed class LiveRoomsController : ControllerBase
public async Task<ActionResult<LiveRoomDto>> Create([FromBody] CreateLiveRoomRequest request, CancellationToken cancellationToken) =>
Ok(await _liveRoomService.CreateAsync(request, cancellationToken));
[HttpPost("import")]
public async Task<ActionResult<ImportLiveRoomsResultDto>> Import(
[FromBody] ImportLiveRoomsRequest request,
CancellationToken cancellationToken) =>
Ok(await _liveRoomService.ImportAsync(request, cancellationToken));
[HttpPost("{id:guid}/refresh")]
public async Task<ActionResult<LiveRoomDto>> Refresh(Guid id, CancellationToken cancellationToken) =>
Ok(await _liveRoomService.RefreshStatusAsync(id, cancellationToken));
@@ -41,6 +59,25 @@ public sealed class LiveRoomsController : ControllerBase
CancellationToken cancellationToken) =>
Ok(await _liveRoomService.SetEnabledAsync(id, request.IsEnabled, cancellationToken));
[HttpPost("batch/enabled")]
public async Task<ActionResult<BatchLiveRoomsResultDto>> SetEnabledBatch(
[FromBody] BatchSetLiveRoomsEnabledRequest request,
CancellationToken cancellationToken) =>
Ok(await _liveRoomService.SetEnabledBatchAsync(request, cancellationToken));
[HttpPost("batch/delete")]
public async Task<ActionResult<BatchLiveRoomsResultDto>> DeleteBatch(
[FromBody] BatchDeleteLiveRoomsRequest request,
CancellationToken cancellationToken) =>
Ok(await _liveRoomService.DeleteBatchAsync(request, cancellationToken));
[HttpPut("{id:guid}/settings")]
public async Task<ActionResult<LiveRoomDto>> UpdateSettings(
Guid id,
[FromBody] UpdateLiveRoomSettingsRequest request,
CancellationToken cancellationToken) =>
Ok(await _liveRoomService.UpdateSettingsAsync(id, request, cancellationToken));
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken cancellationToken)
{
@@ -1,5 +1,6 @@
using LiveRecorder.Application.Abstractions.Logging;
using LiveRecorder.Application.Models.Logs;
using LiveRecorder.Domain.Enums;
using Microsoft.AspNetCore.Mvc;
namespace LiveRecorder.WebApi.Controllers;
@@ -20,7 +21,8 @@ public sealed class LogsController : ControllerBase
[FromQuery] Guid? liveRoomId,
[FromQuery] Guid? recordSessionId,
[FromQuery] Guid? recordTaskId,
[FromQuery] SystemLogLevel? level,
[FromQuery] int take = 200,
CancellationToken cancellationToken = default) =>
Ok(await _systemLogService.ListAsync(liveRoomId, recordSessionId, recordTaskId, take, cancellationToken));
Ok(await _systemLogService.ListAsync(liveRoomId, recordSessionId, recordTaskId, level, take, cancellationToken));
}
@@ -1,6 +1,7 @@
using LiveRecorder.Application.Models.RecordTasks;
using LiveRecorder.Application.Services;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
namespace LiveRecorder.WebApi.Controllers;
@@ -8,6 +9,8 @@ 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 readonly RecordSessionService _recordSessionService;
public RecordSessionsController(RecordSessionService recordSessionService)
@@ -19,6 +22,43 @@ public sealed class RecordSessionsController : ControllerBase
public async Task<ActionResult<IReadOnlyList<RecordSessionDto>>> List([FromQuery] Guid? liveRoomId, CancellationToken cancellationToken) =>
Ok(await _recordSessionService.ListAsync(liveRoomId, cancellationToken));
[HttpGet("stream")]
public async Task Stream([FromQuery] Guid? liveRoomId, CancellationToken cancellationToken)
{
Response.Headers.CacheControl = "no-cache";
Response.Headers.Connection = "keep-alive";
Response.Headers.Append("X-Accel-Buffering", "no");
Response.ContentType = "text/event-stream";
string? lastPayload = null;
try
{
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);
}
}
catch (OperationCanceledException)
{
}
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<RecordSessionDetailDto>> Get(Guid id, CancellationToken cancellationToken)
{
@@ -61,4 +61,8 @@ public sealed class RecordTasksController : ControllerBase
var mediaBaseUrl = baseUrl[..baseUrl.LastIndexOf('/')];
return Ok(await _recordService.CreatePreviewTicketAsync(id, mediaBaseUrl, cancellationToken));
}
[HttpPost("{id:guid}/transcode")]
public async Task<ActionResult<RecordTaskDetailDto>> StartManualTranscode(Guid id, CancellationToken cancellationToken) =>
Ok(await _recordService.StartManualTranscodeAsync(id, cancellationToken));
}
+38
View File
@@ -0,0 +1,38 @@
ARG DOTNET_SDK_IMAGE=mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim
ARG DOTNET_RUNTIME_IMAGE=mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim
FROM ${DOTNET_SDK_IMAGE} AS build
WORKDIR /src
COPY ["LiveRecorder.sln", "./"]
COPY ["src/LiveRecorder.Domain/LiveRecorder.Domain.csproj", "src/LiveRecorder.Domain/"]
COPY ["src/LiveRecorder.Application/LiveRecorder.Application.csproj", "src/LiveRecorder.Application/"]
COPY ["src/LiveRecorder.Infrastructure/LiveRecorder.Infrastructure.csproj", "src/LiveRecorder.Infrastructure/"]
COPY ["src/LiveRecorder.WebApi/LiveRecorder.WebApi.csproj", "src/LiveRecorder.WebApi/"]
RUN dotnet restore "src/LiveRecorder.WebApi/LiveRecorder.WebApi.csproj"
COPY . .
RUN dotnet publish "src/LiveRecorder.WebApi/LiveRecorder.WebApi.csproj" -c Release -o /app/publish /p:UseAppHost=false
FROM ${DOTNET_RUNTIME_IMAGE} AS runtime
RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources
RUN apt-get update -o Acquire::ForceIPv4=true \
&& apt-get install -o Acquire::ForceIPv4=true -y --no-install-recommends ffmpeg nodejs ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production
ENV ConnectionStrings__DefaultConnection=Data Source=/app/data/live-recorder.db
COPY --from=build /app/publish ./
RUN mkdir -p /app/data /app/records
VOLUME ["/app/data", "/app/records"]
EXPOSE 8080
ENTRYPOINT ["dotnet", "LiveRecorder.WebApi.dll"]
@@ -32,6 +32,11 @@ public sealed class ApiTokenAuthenticationMiddleware
? authorization["Bearer ".Length..].Trim()
: string.Empty;
if (string.IsNullOrWhiteSpace(token) && IsServerSentEventsRequest(context))
{
token = context.Request.Query["access_token"].ToString().Trim();
}
var user = await authService.ValidateTokenAsync(token, context.RequestAborted);
if (user is null)
{
@@ -46,4 +51,16 @@ public sealed class ApiTokenAuthenticationMiddleware
context.Items["CurrentUser"] = user;
await _next(context);
}
private static bool IsServerSentEventsRequest(HttpContext context)
{
if (!HttpMethods.IsGet(context.Request.Method) ||
!context.Request.Path.Equals("/api/record-sessions/stream", StringComparison.OrdinalIgnoreCase))
{
return false;
}
return context.Request.Headers.Accept.ToString()
.Contains("text/event-stream", StringComparison.OrdinalIgnoreCase);
}
}
+65 -4
View File
@@ -1,21 +1,27 @@
using System.Net;
using System.Net.Security;
using System.Security.Authentication;
using LiveRecorder.Application.Abstractions.Auth;
using LiveRecorder.Application.Abstractions.Logging;
using LiveRecorder.Application.Abstractions.Notifications;
using LiveRecorder.Application.Abstractions.Persistence;
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.Services;
using LiveRecorder.Infrastructure.Persistence;
using LiveRecorder.Infrastructure.Persistence.Repositories;
using LiveRecorder.Infrastructure.Platforms.Bilibili;
using LiveRecorder.Infrastructure.Platforms.Bilibili.Danmaku;
using LiveRecorder.Infrastructure.Platforms.Douyin;
using LiveRecorder.Infrastructure.Platforms.Douyin.Danmaku;
using LiveRecorder.Infrastructure.Platforms.Douyin.Signing;
using LiveRecorder.Infrastructure.Platforms.Huya;
using LiveRecorder.Infrastructure.Services;
using LiveRecorder.WebApi.Middleware;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.OpenApi.Models;
@@ -75,16 +81,46 @@ builder.Services.AddHttpClient("douyin", client =>
client.DefaultRequestVersion = HttpVersion.Version11;
client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
})
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
.ConfigurePrimaryHttpMessageHandler(() => CreateDouyinHttpHandler(useProxy: true));
builder.Services.AddHttpClient("douyin-direct", client =>
{
client.Timeout = TimeSpan.FromSeconds(20);
client.DefaultRequestVersion = HttpVersion.Version11;
client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
})
.ConfigurePrimaryHttpMessageHandler(() => CreateDouyinHttpHandler(useProxy: false));
builder.Services.AddHttpClient("bilibili", client =>
{
client.Timeout = TimeSpan.FromSeconds(20);
client.DefaultRequestVersion = HttpVersion.Version11;
client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
})
.ConfigurePrimaryHttpMessageHandler(static () => new SocketsHttpHandler
{
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli,
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(30),
MaxConnectionsPerServer = 8
MaxConnectionsPerServer = 8,
ConnectTimeout = TimeSpan.FromSeconds(10),
UseCookies = false,
SslOptions = new SslClientAuthenticationOptions
{
EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13
}
});
var sqliteConnectionStringBuilder = new SqliteConnectionStringBuilder(
builder.Configuration.GetConnectionString("DefaultConnection"))
{
Cache = SqliteCacheMode.Shared,
Mode = SqliteOpenMode.ReadWriteCreate,
DefaultTimeout = 30
};
builder.Services.AddDbContext<LiveRecorderDbContext>(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")));
options.UseSqlite(sqliteConnectionStringBuilder.ToString()));
builder.Services.AddScoped<IUnitOfWork>(provider => provider.GetRequiredService<LiveRecorderDbContext>());
builder.Services.AddScoped<IAppSettingRepository, AppSettingRepository>();
@@ -99,24 +135,31 @@ builder.Services.AddScoped<IUserSessionRepository, UserSessionRepository>();
builder.Services.AddScoped<ISystemSettingsService, SystemSettingsService>();
builder.Services.AddScoped<ISystemLogService, SystemLogService>();
builder.Services.AddScoped<IEmailNotificationService, EmailNotificationService>();
builder.Services.AddScoped<IEventScriptService, EventScriptService>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<LiveRoomService>();
builder.Services.AddScoped<LiveRoomStatusService>();
builder.Services.AddScoped<LiveRoomRecordingSettingsResolver>();
builder.Services.AddScoped<RecordService>();
builder.Services.AddScoped<RecordSessionService>();
builder.Services.AddScoped<StoppedOrphanRecordSessionCleanupService>();
builder.Services.AddScoped<DatabaseInitializer>();
builder.Services.AddSingleton<BilibiliWbiSigner>();
builder.Services.AddScoped<BilibiliHttpClient>();
builder.Services.AddScoped<DouyinHttpClient>();
builder.Services.AddSingleton<DouyinXBogusSigner>();
builder.Services.AddSingleton<DouyinLiveWsSignatureSigner>();
builder.Services.AddScoped<ILivePlatformAdapter, DouyinLivePlatformAdapter>();
builder.Services.AddScoped<ILivePlatformAdapter, BilibiliLivePlatformAdapter>();
builder.Services.AddScoped<ILivePlatformAdapter, HuyaLivePlatformAdapter>();
builder.Services.AddScoped<ILivePlatformAdapterFactory, LivePlatformAdapterFactory>();
builder.Services.AddScoped<ILiveDanmakuAdapter, DouyinDanmakuAdapter>();
builder.Services.AddScoped<ILiveDanmakuAdapter, BilibiliDanmakuAdapter>();
builder.Services.AddScoped<ILiveDanmakuAdapterFactory, LiveDanmakuAdapterFactory>();
builder.Services.AddSingleton<IFfmpegService, FfmpegService>();
builder.Services.AddSingleton<IStorageGuardService, StorageGuardService>();
builder.Services.AddScoped<IRecordMediaService, RecordMediaService>();
builder.Services.AddHostedService<LiveRoomPollingBackgroundService>();
@@ -179,3 +222,21 @@ static async Task<Dictionary<string, long>> ReadRecordingDataCountsAsync(LiveRec
item => item.LiveRoomId != null || item.RecordSessionId != null || item.RecordTaskId != null)
};
}
static SocketsHttpHandler CreateDouyinHttpHandler(bool useProxy)
{
return new SocketsHttpHandler
{
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli,
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(30),
MaxConnectionsPerServer = 8,
ConnectTimeout = TimeSpan.FromSeconds(10),
UseCookies = false,
UseProxy = useProxy,
SslOptions = new SslClientAuthenticationOptions
{
EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13
}
};
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,3 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<i platform="Douyin" roomId="24482384478" liveRoomId="758496da-3485-4b50-8f36-f622a04cccaa" recordSessionId="96541586-d6a4-492f-b77e-8351f907f6b2" recordTaskId="16e3ee7b-775a-4306-af9f-030d3fc16c59" segmentIndex="1" startedAt="2026-04-15T09:21:32.9557018+00:00">
</i>
@@ -1,3 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<i platform="Douyin" roomId="24482384478" liveRoomId="758496da-3485-4b50-8f36-f622a04cccaa" recordSessionId="a6b972a1-0fa4-4cb0-a4f4-49c1886f68b9" recordTaskId="16c513ce-20a4-4b68-b781-cbe0a3866bd3" segmentIndex="1" startedAt="2026-04-15T08:40:02.3186341+00:00">
</i>
@@ -1,3 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<i platform="Douyin" roomId="24482384478" liveRoomId="758496da-3485-4b50-8f36-f622a04cccaa" recordSessionId="8066da9f-7234-4bb9-b5a6-dbda093ec805" recordTaskId="275d15d5-06a3-4592-a2e4-213f2d33a05e" segmentIndex="1" startedAt="2026-04-15T08:47:17.0035883+00:00">
</i>
@@ -1,3 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<i platform="Douyin" roomId="24482384478" liveRoomId="758496da-3485-4b50-8f36-f622a04cccaa" recordSessionId="b1f625ae-9630-40cb-b332-0911cbf9495a" recordTaskId="31681d5f-32f1-4d3b-b67c-9c76bb262ae6" segmentIndex="1" startedAt="2026-04-15T08:49:24.7664120+00:00">
</i>
@@ -1,3 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<i platform="Douyin" roomId="410202829936" liveRoomId="723ddadf-f211-488a-b3c8-af8ca26ce501" recordSessionId="6edb0432-0aad-42ca-913f-f754fe1c74a2" recordTaskId="b363a8c2-4b2f-4a66-b178-7015f2ba42bf" segmentIndex="1" startedAt="2026-04-15T13:28:47.9706852+00:00">
</i>
@@ -1,3 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<i platform="Douyin" roomId="24482384478" liveRoomId="758496da-3485-4b50-8f36-f622a04cccaa" recordSessionId="710beae4-aaab-49b3-b290-cb92e8cfbf73" recordTaskId="662d9dd1-a52c-49d3-939f-acb9047118ba" segmentIndex="1" startedAt="2026-04-15T10:05:01.8366164+00:00">
</i>
@@ -1,3 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<i platform="Douyin" roomId="24482384478" liveRoomId="5fe39aa7-adc1-4f3b-8f79-a88a8a78575a" recordSessionId="a332ebc7-1ec8-479d-be4e-ae9bd0e3b492" recordTaskId="f809528f-50f0-4294-9898-2c78077e89c6" segmentIndex="1" startedAt="2026-04-16T06:19:14.1248014+00:00">
</i>
@@ -1,3 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<i platform="Douyin" roomId="24482384478" liveRoomId="5fe39aa7-adc1-4f3b-8f79-a88a8a78575a" recordSessionId="adb12db2-cb13-4220-b674-066034168179" recordTaskId="234cabf8-2b21-44e3-90e4-3eb3f972f84b" segmentIndex="1" startedAt="2026-04-16T07:19:35.5837509+00:00">
</i>
@@ -1,3 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<i platform="Douyin" roomId="24482384478" liveRoomId="758496da-3485-4b50-8f36-f622a04cccaa" recordSessionId="4339a2f9-7971-49c3-a862-51c40b5f863a" recordTaskId="e13a9515-a3b2-4926-91e8-ab5c950451f6" segmentIndex="1" startedAt="2026-04-15T09:22:36.1844012+00:00">
</i>
@@ -1,3 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<i platform="Douyin" roomId="24482384478" liveRoomId="758496da-3485-4b50-8f36-f622a04cccaa" recordSessionId="a6b25554-b0c4-401c-b609-154900b3e7ae" recordTaskId="10b39d85-ff00-411b-8aa3-1eebf8f211b2" segmentIndex="1" startedAt="2026-04-15T09:23:39.5759339+00:00">
</i>
@@ -1,3 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<i platform="Douyin" roomId="24482384478" liveRoomId="758496da-3485-4b50-8f36-f622a04cccaa" recordSessionId="bcde0a6f-36f5-49e6-9793-123771a6a073" recordTaskId="782b119c-ea9e-4854-a19e-178699feb5ca" segmentIndex="1" startedAt="2026-04-15T11:49:40.6314631+00:00">
</i>
@@ -1,3 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<i platform="Douyin" roomId="24482384478" liveRoomId="bbcc8489-a89b-43e9-8cef-36d8a2aa25de" recordSessionId="f0ffa9e6-8200-4605-8005-badc2a025939" recordTaskId="d8131983-8ea8-484e-b306-f4086861154b" segmentIndex="1" startedAt="2026-04-15T11:58:04.5702834+00:00">
</i>
@@ -1,3 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<i platform="Douyin" roomId="386835971310" liveRoomId="082677d1-f76c-4983-aeeb-3deb2cb91bca" recordSessionId="bd9b2b8f-739e-4be6-b6f7-358388925065" recordTaskId="8b3eb0b0-598a-415d-a2c0-f7d390ed805c" segmentIndex="1" startedAt="2026-04-16T03:00:05.1545652+00:00">
</i>