feat: improve recording automation and task workflows
This commit is contained in:
@@ -1,7 +0,0 @@
|
||||
// index.js
|
||||
const sign = require('./xbogus');
|
||||
|
||||
module.exports = function (url, userAgent) {
|
||||
return sign(url.split("?")[1], userAgent);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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("客户端断开");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
-1
Submodule dycast_repo deleted from dc3ab9ad46
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
npm-debug.log
|
||||
@@ -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;"]
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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_EVENT、LIVE_RECORDER_PLATFORM、LIVE_RECORDER_ROOM_ID、LIVE_RECORDER_ANCHOR、LIVE_RECORDER_RECORD_SESSION_ID、LIVE_RECORDER_RECORD_TASK_ID、LIVE_RECORDER_SEGMENT_INDEX、LIVE_RECORDER_SEGMENT_FILE_PATH、LIVE_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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+395
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+267
-26
@@ -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
|
||||
|
||||
+82
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
-3
@@ -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>
|
||||
-3
@@ -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>
|
||||
-3
@@ -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>
|
||||
-3
@@ -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>
|
||||
-3
@@ -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>
|
||||
-3
@@ -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>
|
||||
-3
@@ -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>
|
||||
-3
@@ -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>
|
||||
-3
@@ -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>
|
||||
-3
@@ -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>
|
||||
-3
@@ -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>
|
||||
-3
@@ -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>
|
||||
Reference in New Issue
Block a user