Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a062bf84bf | ||
|
|
df70b64956 | ||
|
|
5196ffa0f0 | ||
|
|
6da0690fd3 | ||
|
|
e640d5b5cc | ||
|
|
0831b33ea0 | ||
|
|
f5ad1dec00 | ||
|
|
a819322559 | ||
|
|
2b0345722e | ||
|
|
9e5bbbb948 | ||
|
|
f9016931e8 | ||
|
|
46da2d78f0 | ||
|
|
524a053263 | ||
|
|
24e5cf2a06 | ||
|
|
cbee29bef9 | ||
|
|
a5c2cc3202 | ||
|
|
b2aaef093d | ||
|
|
f7fa02d13c | ||
|
|
b82110461a | ||
|
|
fd5be2cc8e | ||
|
|
78f02978fd | ||
|
|
850c55f5da | ||
|
|
14773248d9 | ||
|
|
5a6c374320 | ||
|
|
9c2767f78c | ||
|
|
db458a9a14 | ||
|
|
4b5077e3da | ||
|
|
7a286fd619 | ||
|
|
b81ead700d |
@@ -11,6 +11,8 @@ webapi-build.log
|
||||
webapi-build-no-restore.log
|
||||
artifacts/
|
||||
data/
|
||||
!mobile/lib/features/live_recorder/data/
|
||||
!mobile/lib/features/live_recorder/data/**
|
||||
records/
|
||||
docker-data/
|
||||
src/LiveRecorder.WebApi/data/
|
||||
|
||||
Vendored
+86
-65
@@ -1,5 +1,5 @@
|
||||
pipeline {
|
||||
agent { label '构建机1' }
|
||||
agent { label '\u6784\u5efa\u673a1' }
|
||||
|
||||
options {
|
||||
timestamps()
|
||||
@@ -8,15 +8,17 @@ pipeline {
|
||||
}
|
||||
|
||||
environment {
|
||||
REGISTRY_URL = 'reg.nxsir.cn'
|
||||
API_IMAGE_NAME = 'liverecorder/app-api'
|
||||
WEB_IMAGE_NAME = 'liverecorder/app-web'
|
||||
IMAGE_TAG = "${env.BUILD_ID}"
|
||||
DOCKER_CREDS = 'harbor_key'
|
||||
TARGET_PLATFORMS = 'linux/amd64,linux/arm64'
|
||||
BUILDER_NAME = 'liverecorder-buildx'
|
||||
WEB_NODE_IMAGE = 'docker.m.daocloud.io/library/node:22-alpine'
|
||||
WEB_NGINX_IMAGE = 'docker.m.daocloud.io/library/nginx:1.27-alpine'
|
||||
REGISTRY_URL = 'reg.nxsir.cn'
|
||||
API_IMAGE_NAME = 'liverecorder/app-api'
|
||||
WEB_IMAGE_NAME = 'liverecorder/app-web'
|
||||
IMAGE_TAG = "${env.BUILD_ID}"
|
||||
DOCKER_CREDS = 'harbor_key'
|
||||
TARGET_PLATFORMS = 'linux/amd64,linux/arm64'
|
||||
BUILDER_NAME = 'liverecorder-buildx'
|
||||
WEB_NODE_IMAGE = 'docker.m.daocloud.io/library/node:22-alpine'
|
||||
WEB_NGINX_IMAGE = 'docker.m.daocloud.io/library/nginx:1.27-alpine'
|
||||
HTTP_PROXY_URL = 'http://192.168.5.200:7890'
|
||||
NO_PROXY_HOSTS = '127.0.0.1,localhost,reg.nxsir.cn,gitea.nxsir.cn'
|
||||
|
||||
GIT_REPO_URL = 'https://gitea.nxsir.cn/nanxun/live_recorder.git'
|
||||
GIT_BRANCH = 'main'
|
||||
@@ -26,6 +28,8 @@ pipeline {
|
||||
API_IMAGE_LATEST = "${REGISTRY_URL}/${API_IMAGE_NAME}:latest"
|
||||
WEB_IMAGE_TAGGED = "${REGISTRY_URL}/${WEB_IMAGE_NAME}:${IMAGE_TAG}"
|
||||
WEB_IMAGE_LATEST = "${REGISTRY_URL}/${WEB_IMAGE_NAME}:latest"
|
||||
API_CACHE_IMAGE = "${REGISTRY_URL}/${API_IMAGE_NAME}:buildcache"
|
||||
WEB_CACHE_IMAGE = "${REGISTRY_URL}/${WEB_IMAGE_NAME}:buildcache"
|
||||
}
|
||||
|
||||
stages {
|
||||
@@ -52,9 +56,20 @@ pipeline {
|
||||
set -e
|
||||
sudo docker buildx version
|
||||
sudo docker run --privileged --rm tonistiigi/binfmt --install arm64
|
||||
|
||||
# 强制清理旧的 builder 实例,防止僵尸状态
|
||||
sudo docker buildx rm ${BUILDER_NAME} || true
|
||||
if ! sudo docker buildx inspect --builder ${BUILDER_NAME} >/dev/null 2>&1; then
|
||||
sudo docker buildx create \
|
||||
--name ${BUILDER_NAME} \
|
||||
--driver docker-container \
|
||||
--driver-opt network=host \
|
||||
--driver-opt 'env.HTTP_PROXY=${HTTP_PROXY_URL}' \
|
||||
--driver-opt 'env.HTTPS_PROXY=${HTTP_PROXY_URL}' \
|
||||
--driver-opt 'env.NO_PROXY=reg.nxsir.cn' \
|
||||
--driver-opt 'env.http_proxy=${HTTP_PROXY_URL}' \
|
||||
--driver-opt 'env.https_proxy=${HTTP_PROXY_URL}' \
|
||||
--driver-opt 'env.no_proxy=reg.nxsir.cn' \
|
||||
--use
|
||||
fi
|
||||
sudo docker buildx inspect --builder ${BUILDER_NAME} --bootstrap >/dev/null
|
||||
"""
|
||||
}
|
||||
}
|
||||
@@ -82,53 +97,56 @@ pipeline {
|
||||
set -e
|
||||
run_with_heartbeat() {
|
||||
log_file="\$1"
|
||||
shift
|
||||
build_label="\$2"
|
||||
shift 2
|
||||
rm -f "\$log_file"
|
||||
: > "\$log_file"
|
||||
"\$@" >"\$log_file" 2>&1 &
|
||||
cmd_pid=\$!
|
||||
cmd_status=0
|
||||
tail --pid="\$cmd_pid" -n +1 -f "\$log_file" &
|
||||
tail_pid=\$!
|
||||
last_size=0
|
||||
while kill -0 "\$cmd_pid" >/dev/null 2>&1; do
|
||||
echo "[heartbeat] API multi-arch build still running at \$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo "[heartbeat] \${build_label} still running at \$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
if [ -f "\$log_file" ]; then
|
||||
current_size=\$(stat -c%s "\$log_file" 2>/dev/null || echo 0)
|
||||
if [ "\$current_size" -gt "\$last_size" ]; then
|
||||
start_byte=\$((last_size + 1))
|
||||
tail -c +"\$start_byte" "\$log_file" || true
|
||||
last_size=\$current_size
|
||||
fi
|
||||
fi
|
||||
sleep 20
|
||||
done
|
||||
wait "\$cmd_pid" || cmd_status=\$?
|
||||
wait "\$tail_pid" >/dev/null 2>&1 || true
|
||||
if [ -f "\$log_file" ]; then
|
||||
current_size=\$(stat -c%s "\$log_file" 2>/dev/null || echo 0)
|
||||
if [ "\$current_size" -gt "\$last_size" ]; then
|
||||
start_byte=\$((last_size + 1))
|
||||
tail -c +"\$start_byte" "\$log_file" || true
|
||||
fi
|
||||
fi
|
||||
if [ "\$cmd_status" -ne 0 ]; then
|
||||
echo "[heartbeat] API multi-arch build failed with exit code \$cmd_status"
|
||||
echo "[heartbeat] \${build_label} failed with exit code \$cmd_status"
|
||||
fi
|
||||
return "\$cmd_status"
|
||||
}
|
||||
if ! sudo docker buildx inspect --builder ${BUILDER_NAME} >/dev/null 2>&1; then
|
||||
sudo docker buildx create \
|
||||
--name ${BUILDER_NAME} \
|
||||
--driver docker-container \
|
||||
--driver-opt network=host \
|
||||
--driver-opt 'env.HTTP_PROXY=http://192.168.5.200:7890' \
|
||||
--driver-opt 'env.HTTPS_PROXY=http://192.168.5.200:7890' \
|
||||
--driver-opt 'env.NO_PROXY=reg.nxsir.cn' \
|
||||
--driver-opt 'env.http_proxy=http://192.168.5.200:7890' \
|
||||
--driver-opt 'env.https_proxy=http://192.168.5.200:7890' \
|
||||
--driver-opt 'env.no_proxy=reg.nxsir.cn' \
|
||||
--use
|
||||
fi
|
||||
sudo docker buildx inspect --builder ${BUILDER_NAME} --bootstrap >/dev/null
|
||||
echo 'Building and pushing multi-arch API image: ${API_IMAGE_TAGGED}'
|
||||
run_with_heartbeat /tmp/live-recorder-api-buildx-${IMAGE_TAG}.log \
|
||||
run_with_heartbeat /tmp/live-recorder-api-buildx-${IMAGE_TAG}.log "API multi-arch build" \
|
||||
sudo docker buildx build \
|
||||
--builder ${BUILDER_NAME} \
|
||||
--platform ${TARGET_PLATFORMS} \
|
||||
--network host \
|
||||
--progress=plain \
|
||||
--provenance=false \
|
||||
--build-arg HTTP_PROXY=http://192.168.5.200:7890 \
|
||||
--build-arg HTTPS_PROXY=http://192.168.5.200:7890 \
|
||||
--build-arg NO_PROXY=127.0.0.1,localhost,reg.nxsir.cn,gitea.nxsir.cn \
|
||||
--build-arg http_proxy=http://192.168.5.200:7890 \
|
||||
--build-arg https_proxy=http://192.168.5.200:7890 \
|
||||
--build-arg no_proxy=127.0.0.1,localhost,reg.nxsir.cn,gitea.nxsir.cn \
|
||||
--cache-from type=registry,ref=${API_CACHE_IMAGE} \
|
||||
--cache-to type=registry,ref=${API_CACHE_IMAGE},mode=max \
|
||||
--build-arg HTTP_PROXY=${HTTP_PROXY_URL} \
|
||||
--build-arg HTTPS_PROXY=${HTTP_PROXY_URL} \
|
||||
--build-arg NO_PROXY=${NO_PROXY_HOSTS} \
|
||||
--build-arg http_proxy=${HTTP_PROXY_URL} \
|
||||
--build-arg https_proxy=${HTTP_PROXY_URL} \
|
||||
--build-arg no_proxy=${NO_PROXY_HOSTS} \
|
||||
-f src/LiveRecorder.WebApi/Dockerfile \
|
||||
-t ${API_IMAGE_TAGGED} \
|
||||
-t ${API_IMAGE_LATEST} \
|
||||
@@ -144,55 +162,58 @@ pipeline {
|
||||
set -e
|
||||
run_with_heartbeat() {
|
||||
log_file="\$1"
|
||||
shift
|
||||
build_label="\$2"
|
||||
shift 2
|
||||
rm -f "\$log_file"
|
||||
: > "\$log_file"
|
||||
"\$@" >"\$log_file" 2>&1 &
|
||||
cmd_pid=\$!
|
||||
cmd_status=0
|
||||
tail --pid="\$cmd_pid" -n +1 -f "\$log_file" &
|
||||
tail_pid=\$!
|
||||
last_size=0
|
||||
while kill -0 "\$cmd_pid" >/dev/null 2>&1; do
|
||||
echo "[heartbeat] Web multi-arch build still running at \$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo "[heartbeat] \${build_label} still running at \$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
if [ -f "\$log_file" ]; then
|
||||
current_size=\$(stat -c%s "\$log_file" 2>/dev/null || echo 0)
|
||||
if [ "\$current_size" -gt "\$last_size" ]; then
|
||||
start_byte=\$((last_size + 1))
|
||||
tail -c +"\$start_byte" "\$log_file" || true
|
||||
last_size=\$current_size
|
||||
fi
|
||||
fi
|
||||
sleep 20
|
||||
done
|
||||
wait "\$cmd_pid" || cmd_status=\$?
|
||||
wait "\$tail_pid" >/dev/null 2>&1 || true
|
||||
if [ -f "\$log_file" ]; then
|
||||
current_size=\$(stat -c%s "\$log_file" 2>/dev/null || echo 0)
|
||||
if [ "\$current_size" -gt "\$last_size" ]; then
|
||||
start_byte=\$((last_size + 1))
|
||||
tail -c +"\$start_byte" "\$log_file" || true
|
||||
fi
|
||||
fi
|
||||
if [ "\$cmd_status" -ne 0 ]; then
|
||||
echo "[heartbeat] Web multi-arch build failed with exit code \$cmd_status"
|
||||
echo "[heartbeat] \${build_label} failed with exit code \$cmd_status"
|
||||
fi
|
||||
return "\$cmd_status"
|
||||
}
|
||||
if ! sudo docker buildx inspect --builder ${BUILDER_NAME} >/dev/null 2>&1; then
|
||||
sudo docker buildx create \
|
||||
--name ${BUILDER_NAME} \
|
||||
--driver docker-container \
|
||||
--driver-opt network=host \
|
||||
--driver-opt 'env.HTTP_PROXY=http://192.168.5.200:7890' \
|
||||
--driver-opt 'env.HTTPS_PROXY=http://192.168.5.200:7890' \
|
||||
--driver-opt 'env.NO_PROXY=reg.nxsir.cn' \
|
||||
--driver-opt 'env.http_proxy=http://192.168.5.200:7890' \
|
||||
--driver-opt 'env.https_proxy=http://192.168.5.200:7890' \
|
||||
--driver-opt 'env.no_proxy=reg.nxsir.cn' \
|
||||
--use
|
||||
fi
|
||||
sudo docker buildx inspect --builder ${BUILDER_NAME} --bootstrap >/dev/null
|
||||
echo 'Building and pushing multi-arch Web image: ${WEB_IMAGE_TAGGED}'
|
||||
run_with_heartbeat /tmp/live-recorder-web-buildx-${IMAGE_TAG}.log \
|
||||
run_with_heartbeat /tmp/live-recorder-web-buildx-${IMAGE_TAG}.log "Web multi-arch build" \
|
||||
sudo docker buildx build \
|
||||
--builder ${BUILDER_NAME} \
|
||||
--platform ${TARGET_PLATFORMS} \
|
||||
--network host \
|
||||
--progress=plain \
|
||||
--provenance=false \
|
||||
--cache-from type=registry,ref=${WEB_CACHE_IMAGE} \
|
||||
--cache-to type=registry,ref=${WEB_CACHE_IMAGE},mode=max \
|
||||
--build-arg NODE_IMAGE=${WEB_NODE_IMAGE} \
|
||||
--build-arg NGINX_IMAGE=${WEB_NGINX_IMAGE} \
|
||||
--build-arg HTTP_PROXY=http://192.168.5.200:7890 \
|
||||
--build-arg HTTPS_PROXY=http://192.168.5.200:7890 \
|
||||
--build-arg NO_PROXY=127.0.0.1,localhost,reg.nxsir.cn,gitea.nxsir.cn \
|
||||
--build-arg http_proxy=http://192.168.5.200:7890 \
|
||||
--build-arg https_proxy=http://192.168.5.200:7890 \
|
||||
--build-arg no_proxy=127.0.0.1,localhost,reg.nxsir.cn,gitea.nxsir.cn \
|
||||
--build-arg HTTP_PROXY=${HTTP_PROXY_URL} \
|
||||
--build-arg HTTPS_PROXY=${HTTP_PROXY_URL} \
|
||||
--build-arg NO_PROXY=${NO_PROXY_HOSTS} \
|
||||
--build-arg http_proxy=${HTTP_PROXY_URL} \
|
||||
--build-arg https_proxy=${HTTP_PROXY_URL} \
|
||||
--build-arg no_proxy=${NO_PROXY_HOSTS} \
|
||||
-f frontend/Dockerfile \
|
||||
--build-arg VITE_API_BASE_URL=/api \
|
||||
-t ${WEB_IMAGE_TAGGED} \
|
||||
|
||||
@@ -13,6 +13,10 @@ 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("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveRecorder.Tests", "tests\LiveRecorder.Tests\LiveRecorder.Tests.csproj", "{51C97AC8-CDF9-4C3C-AAEC-7C9D2F3A1273}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -71,6 +75,18 @@ 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
|
||||
{51C97AC8-CDF9-4C3C-AAEC-7C9D2F3A1273}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{51C97AC8-CDF9-4C3C-AAEC-7C9D2F3A1273}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{51C97AC8-CDF9-4C3C-AAEC-7C9D2F3A1273}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{51C97AC8-CDF9-4C3C-AAEC-7C9D2F3A1273}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{51C97AC8-CDF9-4C3C-AAEC-7C9D2F3A1273}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{51C97AC8-CDF9-4C3C-AAEC-7C9D2F3A1273}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{51C97AC8-CDF9-4C3C-AAEC-7C9D2F3A1273}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{51C97AC8-CDF9-4C3C-AAEC-7C9D2F3A1273}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{51C97AC8-CDF9-4C3C-AAEC-7C9D2F3A1273}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{51C97AC8-CDF9-4C3C-AAEC-7C9D2F3A1273}.Release|x64.Build.0 = Release|Any CPU
|
||||
{51C97AC8-CDF9-4C3C-AAEC-7C9D2F3A1273}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{51C97AC8-CDF9-4C3C-AAEC-7C9D2F3A1273}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -80,5 +96,6 @@ 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}
|
||||
{51C97AC8-CDF9-4C3C-AAEC-7C9D2F3A1273} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
Generated
+1151
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.2.0",
|
||||
"vite-plugin-vue-devtools": "^8.1.2",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import axios from "axios";
|
||||
import { ElNotification } from "element-plus";
|
||||
import { markBackendAvailable, markBackendUnavailable } from "@/composables/useBackendStatus";
|
||||
import { isNoBackendPreviewMode } from "@/utils/devPreview";
|
||||
|
||||
export const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
||||
const apiBaseUrl = "/api";
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: apiBaseUrl,
|
||||
@@ -134,6 +135,10 @@ export function getApiErrorMessage(error: unknown, fallback = "请求失败,
|
||||
}
|
||||
|
||||
function notifyBackendUnavailable(message: string) {
|
||||
if (isNoBackendPreviewMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastBackendUnavailableNotificationAt < backendUnavailableNotificationIntervalMs) {
|
||||
return;
|
||||
@@ -149,6 +154,7 @@ function notifyBackendUnavailable(message: string) {
|
||||
}
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
console.log("[API DEBUG]", config.method?.toUpperCase(), config.baseURL || "", config.url || "", "→", (config.baseURL || "") + (config.url || ""));
|
||||
const token = localStorage.getItem("live-recorder-token");
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,284 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onUnmounted, nextTick } from "vue";
|
||||
import { createDanmakuEngine } from "./danmakuEngine";
|
||||
import type { DanmakuEngine } from "./danmakuEngine";
|
||||
import type { DanmakuEvent } from "@/types";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
videoSrc: string;
|
||||
danmakuEvents: DanmakuEvent[];
|
||||
autoplay?: boolean;
|
||||
}>(),
|
||||
{
|
||||
autoplay: false,
|
||||
}
|
||||
);
|
||||
|
||||
const videoRef = ref<HTMLVideoElement | null>(null);
|
||||
const overlayRef = ref<HTMLDivElement | null>(null);
|
||||
|
||||
let engine: DanmakuEngine | null = null;
|
||||
const isPlaying = ref(false);
|
||||
const hasError = ref(false);
|
||||
const errorMessage = ref("");
|
||||
|
||||
function onPlay(): void {
|
||||
isPlaying.value = true;
|
||||
engine?.start();
|
||||
}
|
||||
|
||||
function onPause(): void {
|
||||
isPlaying.value = false;
|
||||
engine?.stop();
|
||||
}
|
||||
|
||||
function onSeeking(): void {
|
||||
engine?.stop();
|
||||
}
|
||||
|
||||
function onSeeked(): void {
|
||||
if (!videoRef.value) return;
|
||||
lastKnownTime = videoRef.value.currentTime;
|
||||
if (isPlaying.value) {
|
||||
engine?.start();
|
||||
}
|
||||
}
|
||||
|
||||
function onEnded(): void {
|
||||
isPlaying.value = false;
|
||||
engine?.stop();
|
||||
}
|
||||
|
||||
function onVideoError(): void {
|
||||
hasError.value = true;
|
||||
errorMessage.value = "视频加载失败,请刷新预览票据后重试。";
|
||||
}
|
||||
|
||||
function onLoadedMetadata(): void {
|
||||
hasError.value = false;
|
||||
errorMessage.value = "";
|
||||
}
|
||||
|
||||
let lastKnownTime = 0;
|
||||
|
||||
function initEngine(): void {
|
||||
if (!videoRef.value || !overlayRef.value) return;
|
||||
|
||||
// Clean up previous engine
|
||||
engine?.destroy();
|
||||
|
||||
engine = createDanmakuEngine(
|
||||
videoRef.value,
|
||||
props.danmakuEvents,
|
||||
overlayRef.value
|
||||
);
|
||||
|
||||
if (isPlaying.value) {
|
||||
engine.start();
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.danmakuEvents,
|
||||
() => {
|
||||
nextTick(() => {
|
||||
if (videoRef.value && overlayRef.value) {
|
||||
initEngine();
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.videoSrc,
|
||||
() => {
|
||||
hasError.value = false;
|
||||
errorMessage.value = "";
|
||||
isPlaying.value = false;
|
||||
engine?.destroy();
|
||||
engine = null;
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
if (videoRef.value && overlayRef.value) {
|
||||
initEngine();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
engine?.destroy();
|
||||
engine = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="danmaku-player" :class="{ 'has-error': hasError }">
|
||||
<!-- Error overlay -->
|
||||
<div v-if="hasError" class="danmaku-player__error">
|
||||
<p>{{ errorMessage }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Video element -->
|
||||
<video
|
||||
ref="videoRef"
|
||||
:src="videoSrc"
|
||||
:autoplay="autoplay"
|
||||
:key="videoSrc"
|
||||
class="danmaku-player__video"
|
||||
controls
|
||||
preload="auto"
|
||||
crossorigin="anonymous"
|
||||
@play="onPlay"
|
||||
@pause="onPause"
|
||||
@seeking="onSeeking"
|
||||
@seeked="onSeeked"
|
||||
@ended="onEnded"
|
||||
@error="onVideoError"
|
||||
@loadedmetadata="onLoadedMetadata"
|
||||
/>
|
||||
|
||||
<!-- Danmaku overlay -->
|
||||
<div
|
||||
ref="overlayRef"
|
||||
class="danmaku-player__overlay"
|
||||
:class="{ 'is-paused': !isPlaying && !hasError }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.danmaku-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: #09121d;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-soft, 0 4px 16px rgba(0, 0, 0, 0.12));
|
||||
}
|
||||
|
||||
.danmaku-player__video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 72vh;
|
||||
background: #09121d;
|
||||
}
|
||||
|
||||
.danmaku-player__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.danmaku-player__overlay.is-paused :deep(.danmaku-chat) {
|
||||
animation-play-state: paused !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.danmaku-player__error {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(9, 18, 29, 0.92);
|
||||
color: #f0aa6b;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* Danmaku chat messages (global styles since DOM elements are created by engine) */
|
||||
:deep(.danmaku-chat) {
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
text-shadow:
|
||||
1px 1px 2px rgba(0, 0, 0, 0.9),
|
||||
-1px -1px 2px rgba(0, 0, 0, 0.7);
|
||||
pointer-events: auto;
|
||||
font-weight: 700;
|
||||
will-change: transform;
|
||||
line-height: 1.2;
|
||||
z-index: 11;
|
||||
}
|
||||
|
||||
/* Danmaku event notifications (global styles since DOM elements are created by engine) */
|
||||
:deep(.danmaku-event-notification) {
|
||||
position: absolute;
|
||||
bottom: 64px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 8px 18px;
|
||||
border-radius: 24px;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
backdrop-filter: blur(8px);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
z-index: 12;
|
||||
animation: danmaku-event-in 0.3s ease-out, danmaku-event-out 0.4s ease-in 3.1s forwards;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
|
||||
max-width: 90%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
:deep(.danmaku-event-notification--gift) {
|
||||
border-left: 3px solid #f0aa6b;
|
||||
}
|
||||
|
||||
:deep(.danmaku-event-notification--like) {
|
||||
border-left: 3px solid #f25d8e;
|
||||
}
|
||||
|
||||
:deep(.danmaku-event-notification--member) {
|
||||
border-left: 3px solid #ffc53d;
|
||||
}
|
||||
|
||||
:deep(.danmaku-event-notification--superchat) {
|
||||
border-left: 3px solid #5dade2;
|
||||
}
|
||||
|
||||
@keyframes danmaku-event-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes danmaku-event-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.danmaku-player__video {
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
:deep(.danmaku-chat) {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
:deep(.danmaku-event-notification) {
|
||||
bottom: 56px;
|
||||
font-size: 12px;
|
||||
padding: 6px 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,326 @@
|
||||
import type { DanmakuEvent } from "@/types";
|
||||
|
||||
const CHAT_TRACK_COUNT = 12;
|
||||
const TRACK_HEIGHT_PX = 32;
|
||||
const CHAT_SCROLL_DURATION_SECONDS = 8;
|
||||
const MAX_ACTIVE_CHATS = 80;
|
||||
const MAX_ACTIVE_EVENTS = 3;
|
||||
const EVENT_DISPLAY_DURATION_MS = 3500;
|
||||
const LOOKBACK_SECONDS = 0.15;
|
||||
const LOOKAHEAD_SECONDS = 0.1;
|
||||
|
||||
interface ActiveChat {
|
||||
id: string;
|
||||
element: HTMLSpanElement;
|
||||
trackIndex: number;
|
||||
spawnTime: number;
|
||||
}
|
||||
|
||||
interface ActiveEventNotification {
|
||||
id: string;
|
||||
element: HTMLDivElement;
|
||||
spawnTime: number;
|
||||
}
|
||||
|
||||
export interface DanmakuEngine {
|
||||
start(): void;
|
||||
stop(): void;
|
||||
reset(): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export function createDanmakuEngine(
|
||||
video: HTMLVideoElement,
|
||||
events: DanmakuEvent[],
|
||||
overlay: HTMLElement
|
||||
): DanmakuEngine {
|
||||
let animationId = 0;
|
||||
let running = false;
|
||||
|
||||
// Pre-sort events by offsetSeconds
|
||||
const sortedEvents = [...events].sort(
|
||||
(a, b) => a.offsetSeconds - b.offsetSeconds
|
||||
);
|
||||
|
||||
// Separate chats and non-chat events
|
||||
const chatEvents = sortedEvents.filter((e) => e.type === "chat");
|
||||
const nonChatEvents = sortedEvents.filter((e) => e.type !== "chat");
|
||||
|
||||
let nextChatIndex = 0;
|
||||
let nextEventIndex = 0;
|
||||
|
||||
// Track occupancy
|
||||
const activeChats: ActiveChat[] = [];
|
||||
const activeEventNotifications: ActiveEventNotification[] = [];
|
||||
|
||||
let lastTime = 0;
|
||||
|
||||
function spawnChat(event: DanmakuEvent, currentTime: number): void {
|
||||
// Garbage collect finished chats first
|
||||
while (
|
||||
activeChats.length > 0 &&
|
||||
currentTime - activeChats[0].spawnTime > CHAT_SCROLL_DURATION_SECONDS
|
||||
) {
|
||||
const finished = activeChats.shift()!;
|
||||
if (finished.element.parentNode) {
|
||||
finished.element.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Cap active chats
|
||||
if (activeChats.length >= MAX_ACTIVE_CHATS) {
|
||||
const oldest = activeChats.shift()!;
|
||||
if (oldest.element.parentNode) {
|
||||
oldest.element.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Pick the least-occupied track
|
||||
const trackUsage = new Array<number>(CHAT_TRACK_COUNT).fill(0);
|
||||
for (const chat of activeChats) {
|
||||
if (chat.trackIndex < CHAT_TRACK_COUNT) {
|
||||
trackUsage[chat.trackIndex]++;
|
||||
}
|
||||
}
|
||||
let bestTrack = 0;
|
||||
let minUsage = Infinity;
|
||||
// Add some randomness to avoid all chats on the same "best" track
|
||||
const startTrack = Math.floor(Math.random() * CHAT_TRACK_COUNT);
|
||||
for (let offset = 0; offset < CHAT_TRACK_COUNT; offset++) {
|
||||
const trackIndex = (startTrack + offset) % CHAT_TRACK_COUNT;
|
||||
if (trackUsage[trackIndex] < minUsage) {
|
||||
minUsage = trackUsage[trackIndex];
|
||||
bestTrack = trackIndex;
|
||||
}
|
||||
}
|
||||
|
||||
const element = document.createElement("span");
|
||||
element.className = "danmaku-chat";
|
||||
element.textContent = event.content || "";
|
||||
|
||||
const color = event.color || "FFFFFF";
|
||||
const fontSize = event.fontSize || 25;
|
||||
const topPx = bestTrack * TRACK_HEIGHT_PX;
|
||||
|
||||
element.style.cssText = [
|
||||
`color: #${color}`,
|
||||
`font-size: ${fontSize}px`,
|
||||
`top: ${topPx}px`,
|
||||
"position: absolute",
|
||||
"white-space: nowrap",
|
||||
"text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8)",
|
||||
"pointer-events: auto",
|
||||
"font-weight: 700",
|
||||
"will-change: transform",
|
||||
"left: 100%",
|
||||
`transform: translateX(0)`,
|
||||
].join("; ");
|
||||
|
||||
overlay.appendChild(element);
|
||||
|
||||
activeChats.push({
|
||||
id: `chat-${event.offsetSeconds}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
element,
|
||||
trackIndex: bestTrack,
|
||||
spawnTime: currentTime,
|
||||
});
|
||||
}
|
||||
|
||||
function spawnEventNotification(event: DanmakuEvent): void {
|
||||
// Garbage collect finished notifications
|
||||
const now = performance.now();
|
||||
while (
|
||||
activeEventNotifications.length > 0 &&
|
||||
now - activeEventNotifications[0].spawnTime > EVENT_DISPLAY_DURATION_MS
|
||||
) {
|
||||
const finished = activeEventNotifications.shift()!;
|
||||
if (finished.element.parentNode) {
|
||||
finished.element.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Cap active notifications
|
||||
if (activeEventNotifications.length >= MAX_ACTIVE_EVENTS) {
|
||||
const oldest = activeEventNotifications.shift()!;
|
||||
if (oldest.element.parentNode) {
|
||||
oldest.element.remove();
|
||||
}
|
||||
}
|
||||
|
||||
const element = document.createElement("div");
|
||||
element.className = `danmaku-event-notification danmaku-event-notification--${event.type}`;
|
||||
|
||||
const userLabel = event.user ? `<strong>${escapeHtml(event.user)}</strong>` : "";
|
||||
const contentLabel = escapeHtml(event.content || "");
|
||||
|
||||
let typeIcon = "";
|
||||
switch (event.type) {
|
||||
case "gift":
|
||||
typeIcon = "🎁 ";
|
||||
break;
|
||||
case "like":
|
||||
typeIcon = "❤️ ";
|
||||
break;
|
||||
case "member":
|
||||
typeIcon = "⭐ ";
|
||||
break;
|
||||
case "enter":
|
||||
typeIcon = "👤 ";
|
||||
break;
|
||||
case "superchat":
|
||||
typeIcon = "💬 ";
|
||||
break;
|
||||
default:
|
||||
typeIcon = "📌 ";
|
||||
break;
|
||||
}
|
||||
|
||||
element.innerHTML = `${typeIcon}${userLabel} ${contentLabel}`;
|
||||
|
||||
overlay.appendChild(element);
|
||||
|
||||
activeEventNotifications.push({
|
||||
id: `event-${event.offsetSeconds}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
element,
|
||||
spawnTime: performance.now(),
|
||||
});
|
||||
}
|
||||
|
||||
function binarySearchFirst(
|
||||
arr: DanmakuEvent[],
|
||||
startIndex: number,
|
||||
target: number
|
||||
): number {
|
||||
let low = startIndex;
|
||||
let high = arr.length - 1;
|
||||
while (low <= high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
if (arr[mid].offsetSeconds < target) {
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
function tick(): void {
|
||||
if (!running) return;
|
||||
|
||||
const currentTime = video.currentTime;
|
||||
|
||||
// If seeking backwards, reset
|
||||
if (currentTime < lastTime - 0.5) {
|
||||
resetState();
|
||||
}
|
||||
lastTime = currentTime;
|
||||
|
||||
const lookback = currentTime - LOOKBACK_SECONDS;
|
||||
const lookahead = currentTime + LOOKAHEAD_SECONDS;
|
||||
|
||||
// Binary search to find the start of new chats
|
||||
nextChatIndex = binarySearchFirst(chatEvents, nextChatIndex, lookback);
|
||||
// Spawn chats within the window
|
||||
while (nextChatIndex < chatEvents.length && chatEvents[nextChatIndex].offsetSeconds <= lookahead) {
|
||||
spawnChat(chatEvents[nextChatIndex], currentTime);
|
||||
nextChatIndex++;
|
||||
}
|
||||
|
||||
// Same for non-chat events
|
||||
nextEventIndex = binarySearchFirst(nonChatEvents, nextEventIndex, lookback);
|
||||
while (nextEventIndex < nonChatEvents.length && nonChatEvents[nextEventIndex].offsetSeconds <= lookahead) {
|
||||
spawnEventNotification(nonChatEvents[nextEventIndex]);
|
||||
nextEventIndex++;
|
||||
}
|
||||
|
||||
// Update chat positions based on elapsed time since spawn
|
||||
const overlayWidth = overlay.clientWidth || video.clientWidth || 640;
|
||||
for (let i = activeChats.length - 1; i >= 0; i--) {
|
||||
const chat = activeChats[i];
|
||||
const elapsed = currentTime - chat.spawnTime;
|
||||
const progress = Math.max(0, Math.min(1, elapsed / CHAT_SCROLL_DURATION_SECONDS));
|
||||
const translateX = -overlayWidth * progress;
|
||||
chat.element.style.transform = `translateX(${translateX}px)`;
|
||||
|
||||
// Remove finished chats
|
||||
if (elapsed > CHAT_SCROLL_DURATION_SECONDS + 0.5) {
|
||||
if (chat.element.parentNode) {
|
||||
chat.element.remove();
|
||||
}
|
||||
activeChats.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Garbage collect finished event notifications
|
||||
const now = performance.now();
|
||||
for (let i = activeEventNotifications.length - 1; i >= 0; i--) {
|
||||
const notif = activeEventNotifications[i];
|
||||
if (now - notif.spawnTime > EVENT_DISPLAY_DURATION_MS) {
|
||||
if (notif.element.parentNode) {
|
||||
notif.element.remove();
|
||||
}
|
||||
activeEventNotifications.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
animationId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function resetState(): void {
|
||||
// Clear all active elements
|
||||
for (const chat of activeChats) {
|
||||
if (chat.element.parentNode) {
|
||||
chat.element.remove();
|
||||
}
|
||||
}
|
||||
activeChats.length = 0;
|
||||
|
||||
for (const notif of activeEventNotifications) {
|
||||
if (notif.element.parentNode) {
|
||||
notif.element.remove();
|
||||
}
|
||||
}
|
||||
activeEventNotifications.length = 0;
|
||||
|
||||
// Reset indices to the beginning
|
||||
nextChatIndex = 0;
|
||||
nextEventIndex = 0;
|
||||
// Reset lastTime so a seek to 0 doesn't trigger another reset
|
||||
lastTime = 0;
|
||||
}
|
||||
|
||||
function start(): void {
|
||||
if (running) return;
|
||||
running = true;
|
||||
resetState();
|
||||
lastTime = video.currentTime;
|
||||
animationId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
running = false;
|
||||
if (animationId) {
|
||||
cancelAnimationFrame(animationId);
|
||||
animationId = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
stop();
|
||||
resetState();
|
||||
start();
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
stop();
|
||||
resetState();
|
||||
}
|
||||
|
||||
return { start, stop, reset, destroy };
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { Box } from "@element-plus/icons-vue";
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title?: string;
|
||||
description?: string;
|
||||
actionText?: string;
|
||||
}>(),
|
||||
{
|
||||
title: "暂无数据",
|
||||
description: "当前筛选条件下没有可展示内容",
|
||||
actionText: ""
|
||||
}
|
||||
);
|
||||
|
||||
defineEmits<{
|
||||
(event: "action"): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="empty-state">
|
||||
<div class="empty-state__icon">
|
||||
<el-icon><Box /></el-icon>
|
||||
</div>
|
||||
<div class="empty-state__title">{{ title }}</div>
|
||||
<div class="empty-state__description">{{ description }}</div>
|
||||
<el-button v-if="actionText" type="primary" plain @click="$emit('action')">
|
||||
{{ actionText }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.empty-state {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 14px;
|
||||
padding: 42px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 18px;
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
color: var(--accent);
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.empty-state__title {
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.empty-state__description {
|
||||
max-width: 36ch;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from "vue";
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
label: string;
|
||||
value: string | number;
|
||||
description?: string;
|
||||
icon?: Component | null;
|
||||
}>(),
|
||||
{
|
||||
description: "",
|
||||
icon: null
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="metric-card">
|
||||
<div class="metric-card__header">
|
||||
<span class="metric-card__label">{{ label }}</span>
|
||||
<span v-if="icon" class="metric-card__icon">
|
||||
<el-icon><component :is="icon" /></el-icon>
|
||||
</span>
|
||||
</div>
|
||||
<div class="metric-card__value">{{ value }}</div>
|
||||
<div v-if="description" class="metric-card__description">{{ description }}</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.metric-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-height: 164px;
|
||||
padding: 20px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.98)),
|
||||
var(--surface);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.metric-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metric-card__label {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.metric-card__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
color: var(--accent);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.metric-card__value {
|
||||
color: var(--text-primary);
|
||||
font-size: clamp(28px, 2vw, 40px);
|
||||
font-weight: 760;
|
||||
letter-spacing: -0.06em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.metric-card__description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { Close } from "@element-plus/icons-vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
size?: string | number;
|
||||
}>(),
|
||||
{
|
||||
subtitle: "",
|
||||
size: "520px"
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update:modelValue", value: boolean): void;
|
||||
}>();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit("update:modelValue", value)
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-drawer v-model="visible" class="right-drawer" direction="rtl" :size="size" :with-header="false">
|
||||
<div class="right-drawer__shell">
|
||||
<header class="right-drawer__header">
|
||||
<div class="right-drawer__copy">
|
||||
<div class="right-drawer__eyebrow">详情面板</div>
|
||||
<h3 class="right-drawer__title">{{ title }}</h3>
|
||||
<p v-if="subtitle" class="right-drawer__subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<el-button class="right-drawer__close" text circle @click="visible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
</el-button>
|
||||
</header>
|
||||
|
||||
<div class="right-drawer__body">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<footer v-if="$slots.footer" class="right-drawer__footer">
|
||||
<slot name="footer" />
|
||||
</footer>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.right-drawer .el-drawer__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.right-drawer__shell {
|
||||
display: flex;
|
||||
min-height: 100%;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(180deg, var(--surface-raised), var(--surface));
|
||||
}
|
||||
|
||||
.right-drawer__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 24px 24px 18px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.right-drawer__eyebrow {
|
||||
margin-bottom: 8px;
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.right-drawer__title {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 22px;
|
||||
font-weight: 760;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.right-drawer__subtitle {
|
||||
margin: 8px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.right-drawer__close {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.right-drawer__body {
|
||||
flex: 1;
|
||||
padding: 20px 24px 24px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.right-drawer__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 24px 24px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,299 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
type BadgeContext =
|
||||
| "generic"
|
||||
| "availability"
|
||||
| "recording"
|
||||
| "task"
|
||||
| "session"
|
||||
| "cleanup"
|
||||
| "upload"
|
||||
| "boolean";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label?: string;
|
||||
status?: string | number | boolean | null;
|
||||
context?: BadgeContext;
|
||||
size?: "sm" | "md";
|
||||
}>(),
|
||||
{
|
||||
label: "",
|
||||
status: null,
|
||||
context: "generic",
|
||||
size: "md"
|
||||
}
|
||||
);
|
||||
|
||||
type Tone = "gray" | "green" | "blue" | "yellow" | "red" | "orange" | "indigo";
|
||||
|
||||
function normalizeValue(value: string | number | boolean | null) {
|
||||
if (typeof value === "string") {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveToneByNumber(value: number, context: BadgeContext): Tone {
|
||||
if (context === "availability") {
|
||||
if (value === 2) {
|
||||
return "green";
|
||||
}
|
||||
|
||||
return "gray";
|
||||
}
|
||||
|
||||
if (context === "recording") {
|
||||
if (value === 2) {
|
||||
return "blue";
|
||||
}
|
||||
|
||||
if (value === 1) {
|
||||
return "green";
|
||||
}
|
||||
|
||||
return "gray";
|
||||
}
|
||||
|
||||
if (context === "task" || context === "session") {
|
||||
if (value === 2) {
|
||||
return "blue";
|
||||
}
|
||||
|
||||
if (value === 1 || value === 7) {
|
||||
return "indigo";
|
||||
}
|
||||
|
||||
if (value === 0) {
|
||||
return "yellow";
|
||||
}
|
||||
|
||||
if (value === 3) {
|
||||
return "orange";
|
||||
}
|
||||
|
||||
if (value === 4) {
|
||||
return "green";
|
||||
}
|
||||
|
||||
if (value === 5) {
|
||||
return "red";
|
||||
}
|
||||
|
||||
return "gray";
|
||||
}
|
||||
|
||||
if (context === "upload") {
|
||||
if (value === 1) {
|
||||
return "green";
|
||||
}
|
||||
|
||||
if (value === 2) {
|
||||
return "red";
|
||||
}
|
||||
|
||||
return "gray";
|
||||
}
|
||||
|
||||
if (context === "boolean") {
|
||||
return value ? "green" : "gray";
|
||||
}
|
||||
|
||||
return "gray";
|
||||
}
|
||||
|
||||
function resolveToneByKeyword(value: string): Tone {
|
||||
if (
|
||||
value.includes("live") ||
|
||||
value.includes("online") ||
|
||||
value.includes("living") ||
|
||||
value.includes("开播") ||
|
||||
value.includes("直播中") ||
|
||||
value === "started" ||
|
||||
value === "completed" ||
|
||||
value.includes("归档") ||
|
||||
value.includes("healthy")
|
||||
) {
|
||||
return "green";
|
||||
}
|
||||
|
||||
if (value.includes("recording") || value.includes("录制中") || value.includes("uploading")) {
|
||||
return "blue";
|
||||
}
|
||||
|
||||
if (
|
||||
value.includes("pending") ||
|
||||
value.includes("queued") ||
|
||||
value.includes("待") ||
|
||||
value.includes("waiting") ||
|
||||
value.includes("storage")
|
||||
) {
|
||||
return "yellow";
|
||||
}
|
||||
|
||||
if (value.includes("retry") || value.includes("stopping") || value.includes("停止中")) {
|
||||
return "orange";
|
||||
}
|
||||
|
||||
if (value.includes("process") || value.includes("transcod")) {
|
||||
return "indigo";
|
||||
}
|
||||
|
||||
if (
|
||||
value.includes("error") ||
|
||||
value.includes("fail") ||
|
||||
value.includes("异常") ||
|
||||
value.includes("错误") ||
|
||||
value.includes("离线") ||
|
||||
value === "poll_failed"
|
||||
) {
|
||||
return "red";
|
||||
}
|
||||
|
||||
return "gray";
|
||||
}
|
||||
|
||||
const tone = computed<Tone>(() => {
|
||||
const normalized = normalizeValue(props.status);
|
||||
|
||||
if (typeof normalized === "number") {
|
||||
return resolveToneByNumber(normalized, props.context);
|
||||
}
|
||||
|
||||
if (typeof normalized === "boolean") {
|
||||
return normalized ? "green" : "gray";
|
||||
}
|
||||
|
||||
if (typeof normalized === "string" && normalized.length > 0) {
|
||||
return resolveToneByKeyword(normalized);
|
||||
}
|
||||
|
||||
return "gray";
|
||||
});
|
||||
|
||||
const displayLabel = computed(() => {
|
||||
if (props.label) {
|
||||
return props.label;
|
||||
}
|
||||
|
||||
if (props.status === null || props.status === undefined || props.status === "") {
|
||||
return "--";
|
||||
}
|
||||
|
||||
return String(props.status);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="status-badge" :class="[`status-badge--${tone}`, `status-badge--${size}`]">
|
||||
<span class="status-badge__dot"></span>
|
||||
<span>{{ displayLabel }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
max-width: 100%;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-badge--sm {
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-badge--md {
|
||||
min-height: 30px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-badge__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.status-badge--gray {
|
||||
background: #f8fafc;
|
||||
border-color: #e2e8f0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.status-badge--green {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
border-color: rgba(34, 197, 94, 0.18);
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.status-badge--blue {
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
border-color: rgba(37, 99, 235, 0.16);
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.status-badge--yellow {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
border-color: rgba(245, 158, 11, 0.18);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.status-badge--red {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.16);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.status-badge--orange {
|
||||
background: rgba(249, 115, 22, 0.12);
|
||||
border-color: rgba(249, 115, 22, 0.18);
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.status-badge--indigo {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
border-color: rgba(99, 102, 241, 0.18);
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
:global(html[data-theme="dark"]) .status-badge--gray {
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
border-color: rgba(148, 163, 184, 0.2);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
:global(html[data-theme="dark"]) .status-badge--green {
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
:global(html[data-theme="dark"]) .status-badge--blue {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
:global(html[data-theme="dark"]) .status-badge--yellow {
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
:global(html[data-theme="dark"]) .status-badge--red {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
:global(html[data-theme="dark"]) .status-badge--orange {
|
||||
color: #fdba74;
|
||||
}
|
||||
|
||||
:global(html[data-theme="dark"]) .status-badge--indigo {
|
||||
color: #a5b4fc;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ref } from "vue";
|
||||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||||
import type { DanmakuEvent, DanmakuResponse, SessionDanmakuResponse } from "@/types";
|
||||
|
||||
export function useDanmakuPlayer() {
|
||||
const danmakuEvents = ref<DanmakuEvent[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
async function loadTaskDanmaku(taskId: string): Promise<boolean> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.get<DanmakuResponse>(`/record-tasks/${taskId}/danmaku`);
|
||||
danmakuEvents.value = data.events;
|
||||
return data.events.length > 0;
|
||||
} catch (err) {
|
||||
if ((err as { response?: { status?: number } })?.response?.status === 404) {
|
||||
error.value = "该分片没有弹幕数据。";
|
||||
} else {
|
||||
error.value = getApiErrorMessage(err, "弹幕数据加载失败。");
|
||||
}
|
||||
danmakuEvents.value = [];
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSessionDanmaku(sessionId: string): Promise<boolean> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.get<SessionDanmakuResponse>(`/record-sessions/${sessionId}/danmaku`);
|
||||
// Flatten all task events into a single list with session-level offsets
|
||||
const allEvents: DanmakuEvent[] = [];
|
||||
for (const task of data.tasks) {
|
||||
allEvents.push(...task.events);
|
||||
}
|
||||
// Sort by offset for proper playback order
|
||||
allEvents.sort((a, b) => a.offsetSeconds - b.offsetSeconds);
|
||||
danmakuEvents.value = allEvents;
|
||||
return allEvents.length > 0;
|
||||
} catch (err) {
|
||||
if ((err as { response?: { status?: number } })?.response?.status === 404) {
|
||||
error.value = "该场次没有弹幕数据。";
|
||||
} else {
|
||||
error.value = getApiErrorMessage(err, "弹幕数据加载失败。");
|
||||
}
|
||||
danmakuEvents.value = [];
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
danmakuEvents.value = [];
|
||||
error.value = "";
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
return { danmakuEvents, loading, error, loadTaskDanmaku, loadSessionDanmaku, clear };
|
||||
}
|
||||
@@ -5,5 +5,8 @@ import "element-plus/dist/index.css";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import "./styles/main.css";
|
||||
import { ensurePreviewSession } from "./utils/devPreview";
|
||||
|
||||
ensurePreviewSession();
|
||||
|
||||
createApp(App).use(createPinia()).use(router).use(ElementPlus).mount("#app");
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const LoginView = () => import("@/views/LoginView.vue");
|
||||
const MainLayout = () => import("@/components/layout/MainLayout.vue");
|
||||
const DashboardView = () => import("@/views/DashboardView.vue");
|
||||
const LiveRoomsView = () => import("@/views/LiveRoomsView.vue");
|
||||
const RecordTasksView = () => import("@/views/RecordTasksView.vue");
|
||||
const TranscodeTasksView = () => import("@/views/TranscodeTasksView.vue");
|
||||
@@ -29,7 +30,8 @@ const router = createRouter({
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
redirect: "/live-rooms"
|
||||
name: "dashboard",
|
||||
component: DashboardView
|
||||
},
|
||||
{
|
||||
path: "live-rooms",
|
||||
|
||||
+178
-87
@@ -1,54 +1,55 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: "Inter", "SF Pro Display", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
font-family: "Segoe UI Variable", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
|
||||
--bg-base: #f3f7fb;
|
||||
--bg-subtle: #eef3f8;
|
||||
--bg-emphasis: #e6eef7;
|
||||
--bg-base: #f6f8fb;
|
||||
--bg-subtle: #f1f5f9;
|
||||
--bg-emphasis: #e8eef7;
|
||||
--surface: #ffffff;
|
||||
--surface-raised: #fbfdff;
|
||||
--surface-muted: #f4f8fc;
|
||||
--surface-strong: #edf4fb;
|
||||
--border-subtle: #d7e1eb;
|
||||
--border-base: #c7d3e1;
|
||||
--border-strong: #a7b7cb;
|
||||
--text-primary: #0d1726;
|
||||
--text-secondary: #425167;
|
||||
--text-muted: #6b7c91;
|
||||
--text-soft: #8f9cb0;
|
||||
--text-inverse: #eff5fb;
|
||||
--accent: #2f6fb4;
|
||||
--accent-strong: #245a92;
|
||||
--accent-soft: rgba(47, 111, 180, 0.12);
|
||||
--info: #2f6fb4;
|
||||
--success: #1f8a63;
|
||||
--warning: #ba7b1f;
|
||||
--danger: #c75151;
|
||||
--focus-ring: rgba(47, 111, 180, 0.2);
|
||||
--shadow-soft: 0 10px 30px rgba(15, 23, 38, 0.06);
|
||||
--shadow-card: 0 18px 42px rgba(15, 23, 38, 0.08);
|
||||
--shadow-float: 0 24px 60px rgba(15, 23, 38, 0.12);
|
||||
--surface-raised: #ffffff;
|
||||
--surface-muted: #f8fafc;
|
||||
--surface-strong: #eff6ff;
|
||||
--border-subtle: #e2e8f0;
|
||||
--border-base: #cbd5e1;
|
||||
--border-strong: #94a3b8;
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #334155;
|
||||
--text-muted: #64748b;
|
||||
--text-soft: #94a3b8;
|
||||
--text-inverse: #eff6ff;
|
||||
--accent: #2563eb;
|
||||
--accent-strong: #1d4ed8;
|
||||
--accent-soft: rgba(37, 99, 235, 0.1);
|
||||
--info: #2563eb;
|
||||
--success: #16a34a;
|
||||
--warning: #d97706;
|
||||
--danger: #dc2626;
|
||||
--focus-ring: rgba(37, 99, 235, 0.18);
|
||||
--shadow-soft: 0 12px 30px rgba(15, 23, 42, 0.06);
|
||||
--shadow-card: 0 18px 44px rgba(15, 23, 42, 0.08);
|
||||
--shadow-float: 0 24px 60px rgba(15, 23, 42, 0.14);
|
||||
--sidebar-shell-bg:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.42), rgba(255, 255, 255, 0.18)),
|
||||
var(--bg-emphasis);
|
||||
--topbar-shell-bg: rgba(255, 255, 255, 0.56);
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 10px;
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(248, 250, 252, 0.92)),
|
||||
var(--surface);
|
||||
--topbar-shell-bg: rgba(255, 255, 255, 0.9);
|
||||
--radius-sm: 10px;
|
||||
--radius-md: 16px;
|
||||
--radius-lg: 18px;
|
||||
--page-gap: 24px;
|
||||
--content-padding: 28px;
|
||||
--page-max-width: 1280px;
|
||||
--content-padding: 24px;
|
||||
--content-padding-mobile: 16px;
|
||||
--control-height: 40px;
|
||||
--control-height-sm: 34px;
|
||||
--table-row-padding: 16px;
|
||||
--header-row-height: 68px;
|
||||
--header-row-height: 64px;
|
||||
|
||||
--el-color-primary: var(--accent);
|
||||
--el-color-primary-light-3: #5d90ca;
|
||||
--el-color-primary-light-5: #84adde;
|
||||
--el-color-primary-light-7: #bad2ec;
|
||||
--el-color-primary-light-8: #d4e4f3;
|
||||
--el-color-primary-light-9: #ebf3fa;
|
||||
--el-color-primary-light-3: #5b8cf0;
|
||||
--el-color-primary-light-5: #7ba4f5;
|
||||
--el-color-primary-light-7: #a8c3fa;
|
||||
--el-color-primary-light-8: #c7dbfd;
|
||||
--el-color-primary-light-9: #e8f0ff;
|
||||
--el-color-primary-dark-2: var(--accent-strong);
|
||||
--el-color-success: var(--success);
|
||||
--el-color-warning: var(--warning);
|
||||
@@ -58,17 +59,25 @@
|
||||
--el-text-color-secondary: var(--text-muted);
|
||||
--el-border-color: var(--border-base);
|
||||
--el-border-color-light: var(--border-subtle);
|
||||
--el-border-radius-base: var(--radius-md);
|
||||
--el-border-radius-base: var(--radius-sm);
|
||||
--el-bg-color: transparent;
|
||||
--el-bg-color-page: var(--bg-base);
|
||||
--el-bg-color-overlay: var(--surface);
|
||||
--el-fill-color: var(--surface-muted);
|
||||
--el-fill-color-blank: var(--surface);
|
||||
--el-fill-color-light: var(--surface-muted);
|
||||
--el-fill-color-lighter: var(--surface-strong);
|
||||
--el-mask-color: rgba(7, 13, 22, 0.58);
|
||||
--el-fill-color-dark: var(--surface-raised);
|
||||
--el-fill-color-darker: var(--surface-strong);
|
||||
--el-disabled-bg-color: var(--surface-muted);
|
||||
--el-disabled-text-color: var(--text-soft);
|
||||
--el-text-color-placeholder: var(--text-soft);
|
||||
--el-mask-color: rgba(15, 23, 42, 0.54);
|
||||
}
|
||||
|
||||
html[data-density="compact"] {
|
||||
--page-gap: 20px;
|
||||
--content-padding: 22px;
|
||||
--content-padding: 20px;
|
||||
--content-padding-mobile: 14px;
|
||||
--control-height: 36px;
|
||||
--control-height-sm: 30px;
|
||||
@@ -79,52 +88,60 @@ html[data-density="compact"] {
|
||||
html[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
|
||||
--bg-base: #07111b;
|
||||
--bg-subtle: #0d1825;
|
||||
--bg-emphasis: #102131;
|
||||
--surface: #0f1b29;
|
||||
--surface-raised: #132233;
|
||||
--surface-muted: #17283a;
|
||||
--surface-strong: #1b2f45;
|
||||
--border-subtle: #22364d;
|
||||
--border-base: #314862;
|
||||
--border-strong: #4a6686;
|
||||
--bg-base: #08111d;
|
||||
--bg-subtle: #0f172a;
|
||||
--bg-emphasis: #132033;
|
||||
--surface: #0f1b2d;
|
||||
--surface-raised: #132236;
|
||||
--surface-muted: #17293f;
|
||||
--surface-strong: #1d3653;
|
||||
--border-subtle: #22334d;
|
||||
--border-base: #334a68;
|
||||
--border-strong: #4b6486;
|
||||
--text-primary: #eaf2fb;
|
||||
--text-secondary: #bdd0e3;
|
||||
--text-muted: #8ca0b6;
|
||||
--text-soft: #6f8399;
|
||||
--text-secondary: #bfd0e6;
|
||||
--text-muted: #8ea3bc;
|
||||
--text-soft: #6f849d;
|
||||
--text-inverse: #08111a;
|
||||
--accent: #5ba8ff;
|
||||
--accent-strong: #3f8de0;
|
||||
--accent-soft: rgba(91, 168, 255, 0.16);
|
||||
--info: #5ba8ff;
|
||||
--accent: #60a5fa;
|
||||
--accent-strong: #3b82f6;
|
||||
--accent-soft: rgba(96, 165, 250, 0.18);
|
||||
--info: #60a5fa;
|
||||
--success: #31c48d;
|
||||
--warning: #f0a23c;
|
||||
--danger: #ef6666;
|
||||
--focus-ring: rgba(91, 168, 255, 0.24);
|
||||
--warning: #f59e0b;
|
||||
--danger: #f87171;
|
||||
--focus-ring: rgba(96, 165, 250, 0.24);
|
||||
--shadow-soft: 0 12px 30px rgba(2, 6, 12, 0.32);
|
||||
--shadow-card: 0 18px 40px rgba(2, 6, 12, 0.4);
|
||||
--shadow-float: 0 24px 60px rgba(2, 6, 12, 0.52);
|
||||
--sidebar-shell-bg:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0)),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0)),
|
||||
var(--bg-subtle);
|
||||
--topbar-shell-bg: rgba(15, 27, 41, 0.78);
|
||||
--topbar-shell-bg: rgba(15, 23, 42, 0.84);
|
||||
|
||||
--el-color-primary: var(--accent);
|
||||
--el-color-primary-light-3: #7db9ff;
|
||||
--el-color-primary-light-5: #9ac8ff;
|
||||
--el-color-primary-light-7: #bfdcff;
|
||||
--el-color-primary-light-8: #d8e9ff;
|
||||
--el-color-primary-light-9: #eef6ff;
|
||||
--el-color-primary-light-3: #93c5fd;
|
||||
--el-color-primary-light-5: #bfdbfe;
|
||||
--el-color-primary-light-7: #dbeafe;
|
||||
--el-color-primary-light-8: #e5f0ff;
|
||||
--el-color-primary-light-9: #eff6ff;
|
||||
--el-color-primary-dark-2: var(--accent-strong);
|
||||
--el-text-color-primary: var(--text-primary);
|
||||
--el-text-color-regular: var(--text-secondary);
|
||||
--el-text-color-secondary: var(--text-muted);
|
||||
--el-border-color: var(--border-base);
|
||||
--el-border-color-light: var(--border-subtle);
|
||||
--el-bg-color-page: var(--bg-base);
|
||||
--el-bg-color-overlay: var(--surface);
|
||||
--el-fill-color: var(--surface-muted);
|
||||
--el-fill-color-blank: var(--surface);
|
||||
--el-fill-color-light: var(--surface-muted);
|
||||
--el-fill-color-lighter: var(--surface-strong);
|
||||
--el-fill-color-dark: var(--surface-raised);
|
||||
--el-fill-color-darker: var(--surface-strong);
|
||||
--el-disabled-bg-color: var(--surface-muted);
|
||||
--el-disabled-text-color: var(--text-soft);
|
||||
--el-text-color-placeholder: var(--text-soft);
|
||||
--el-mask-color: rgba(3, 7, 14, 0.72);
|
||||
}
|
||||
|
||||
@@ -147,14 +164,36 @@ body,
|
||||
body {
|
||||
color: var(--text-primary);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(91, 168, 255, 0.06), transparent 24%),
|
||||
radial-gradient(circle at top right, rgba(79, 201, 168, 0.05), transparent 22%),
|
||||
radial-gradient(circle at top left, rgba(37, 99, 235, 0.08), transparent 24%),
|
||||
radial-gradient(circle at top right, rgba(14, 165, 233, 0.06), transparent 22%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.6), transparent 18%),
|
||||
linear-gradient(180deg, var(--bg-base) 0%, var(--bg-subtle) 100%);
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.6) transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
border: 2px solid transparent;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.52);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
body,
|
||||
button,
|
||||
input,
|
||||
@@ -178,13 +217,16 @@ select:focus-visible {
|
||||
.page-stack {
|
||||
display: grid;
|
||||
gap: var(--page-gap);
|
||||
width: min(100%, var(--page-max-width));
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
gap: 24px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.page-header > div:first-child {
|
||||
@@ -194,16 +236,16 @@ select:focus-visible {
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: clamp(28px, 2vw, 40px);
|
||||
font-weight: 750;
|
||||
font-size: clamp(30px, 2vw, 42px);
|
||||
font-weight: 780;
|
||||
letter-spacing: -0.045em;
|
||||
line-height: 1.02;
|
||||
line-height: 1;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
max-width: 76ch;
|
||||
margin: 12px 0 0;
|
||||
margin: 14px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
@@ -229,9 +271,12 @@ select:focus-visible {
|
||||
.surface-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
--el-card-bg-color: transparent;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: linear-gradient(180deg, var(--surface-raised) 0%, var(--surface) 100%);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.98)),
|
||||
var(--surface);
|
||||
box-shadow: var(--shadow-soft);
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
@@ -252,20 +297,20 @@ select:focus-visible {
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
grid-column: span 3;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 18px;
|
||||
gap: 12px;
|
||||
min-height: 154px;
|
||||
padding: 20px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-subtle);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.42)),
|
||||
var(--surface-muted);
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.98)),
|
||||
var(--surface);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
@@ -285,7 +330,7 @@ html[data-theme="dark"] .stat-card {
|
||||
|
||||
.stat-card__value {
|
||||
color: var(--text-primary);
|
||||
font-size: clamp(26px, 2vw, 36px);
|
||||
font-size: clamp(30px, 2vw, 40px);
|
||||
font-weight: 760;
|
||||
letter-spacing: -0.05em;
|
||||
line-height: 1;
|
||||
@@ -545,9 +590,33 @@ html[data-theme="dark"] .stat-card {
|
||||
|
||||
.el-card {
|
||||
--el-card-border-color: transparent;
|
||||
--el-card-bg-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.el-tabs--border-card {
|
||||
border-color: var(--border-subtle);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.el-tabs--border-card > .el-tabs__content {
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.el-tabs--border-card > .el-tabs__header {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.el-tabs--border-card > .el-tabs__header .el-tabs__item {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.el-tabs--border-card > .el-tabs__header .el-tabs__item.is-active {
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.el-button {
|
||||
min-height: var(--control-height);
|
||||
padding: 0 15px;
|
||||
@@ -768,12 +837,23 @@ html[data-theme="dark"] .stat-card {
|
||||
}
|
||||
|
||||
.el-dialog {
|
||||
border-radius: 12px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--border-base);
|
||||
background: linear-gradient(180deg, var(--surface-raised), var(--surface));
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
|
||||
.el-message-box,
|
||||
.el-popover.el-popper,
|
||||
.el-select__popper.el-popper,
|
||||
.el-picker__popper.el-popper,
|
||||
.el-dropdown__popper.el-popper .el-dropdown-menu {
|
||||
border-color: var(--border-subtle);
|
||||
background: linear-gradient(180deg, var(--surface-raised), var(--surface));
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
|
||||
.el-dialog__header {
|
||||
margin: 0;
|
||||
padding: 22px 24px 10px;
|
||||
@@ -800,9 +880,20 @@ html[data-theme="dark"] .stat-card {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.el-badge__content.is-fixed.is-dot {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
.el-dropdown-menu {
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.stat-card {
|
||||
grid-column: span 6;
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.highlight-grid {
|
||||
|
||||
+192
-7
@@ -421,6 +421,8 @@ export interface SystemSettings {
|
||||
enableStorageGuard: boolean;
|
||||
pauseRecordingWhenFreeSpaceBelowMegabytes: number;
|
||||
resumeRecordingWhenFreeSpaceAboveMegabytes: number;
|
||||
storageGreenThresholdPercent: number;
|
||||
storageRedThresholdPercent: number;
|
||||
enableRetentionCleanup: boolean;
|
||||
retentionDays: number;
|
||||
retentionDeleteFiles: boolean;
|
||||
@@ -441,9 +443,7 @@ export interface SystemSettings {
|
||||
enableAutoUpload: boolean;
|
||||
deleteLocalFilesAfterUpload: boolean;
|
||||
uploadTarget: number;
|
||||
douyinProxy: PlatformProxySettings;
|
||||
bilibiliProxy: PlatformProxySettings;
|
||||
huyaProxy: PlatformProxySettings;
|
||||
platformRequestSettings: Record<string, PlatformRequestSettings>;
|
||||
webDavUpload: WebDavUploadSettings;
|
||||
s3Upload: S3UploadSettings;
|
||||
enableEventScripts: boolean;
|
||||
@@ -460,6 +460,8 @@ export interface SystemSettings {
|
||||
segmentCompletedScriptPath: string;
|
||||
segmentCompletedScriptContent: string;
|
||||
eventScriptTimeoutSeconds: number;
|
||||
eventScriptRetryAttempts: number;
|
||||
eventScriptRetryDelaySeconds: number;
|
||||
enableEmailNotification: boolean;
|
||||
emailSmtpHost: string;
|
||||
emailSmtpPort: number;
|
||||
@@ -482,9 +484,6 @@ export interface SystemSettings {
|
||||
notifyWebhookOnLiveStarted: boolean;
|
||||
notifyWebhookOnException: boolean;
|
||||
webhookBodyTemplate: string;
|
||||
douyinUserAgent: string;
|
||||
douyinReferer: string;
|
||||
douyinCookie: string;
|
||||
}
|
||||
|
||||
export interface PlatformProxySettings {
|
||||
@@ -492,6 +491,63 @@ export interface PlatformProxySettings {
|
||||
proxyUrl: string;
|
||||
}
|
||||
|
||||
export interface PlatformRequestSettings {
|
||||
proxy: PlatformProxySettings;
|
||||
userAgent: string;
|
||||
referer: string;
|
||||
cookie: string;
|
||||
}
|
||||
|
||||
export interface PlatformOption {
|
||||
key: string;
|
||||
value: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const platformOptionList: PlatformOption[] = [
|
||||
{ key: "douyin", value: 1, label: "Douyin" },
|
||||
{ key: "bilibili", value: 2, label: "Bilibili" },
|
||||
{ key: "huya", value: 3, label: "Huya" },
|
||||
{ key: "douyu", value: 4, label: "Douyu" },
|
||||
{ key: "kuaishou", value: 5, label: "Kuaishou" },
|
||||
{ key: "tiktok", value: 6, label: "TikTok" },
|
||||
{ key: "xiaohongshu", value: 7, label: "Xiaohongshu" },
|
||||
{ key: "youtube", value: 8, label: "YouTube" },
|
||||
{ key: "twitch", value: 9, label: "Twitch" },
|
||||
{ key: "pandatv", value: 10, label: "PandaTV" },
|
||||
{ key: "migu", value: 11, label: "Migu" }
|
||||
];
|
||||
|
||||
export function createDefaultPlatformRequestSettingsMap(): Record<string, PlatformRequestSettings> {
|
||||
const 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";
|
||||
const referers: Record<string, string> = {
|
||||
douyin: "https://live.douyin.com/",
|
||||
bilibili: "https://live.bilibili.com/",
|
||||
huya: "https://www.huya.com/",
|
||||
douyu: "https://www.douyu.com/",
|
||||
kuaishou: "https://live.kuaishou.com/",
|
||||
tiktok: "https://www.tiktok.com/",
|
||||
xiaohongshu: "https://www.xiaohongshu.com/",
|
||||
youtube: "https://www.youtube.com/",
|
||||
twitch: "https://www.twitch.tv/",
|
||||
pandatv: "https://www.pandalive.co.kr/",
|
||||
migu: "https://www.miguvideo.com/"
|
||||
};
|
||||
|
||||
return platformOptionList.reduce<Record<string, PlatformRequestSettings>>((accumulator, platform) => {
|
||||
accumulator[platform.key] = {
|
||||
proxy: {
|
||||
enabled: false,
|
||||
proxyUrl: ""
|
||||
},
|
||||
userAgent,
|
||||
referer: referers[platform.key] ?? "",
|
||||
cookie: ""
|
||||
};
|
||||
return accumulator;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export interface WebDavUploadSettings {
|
||||
endpoint: string;
|
||||
basePath: string;
|
||||
@@ -701,7 +757,15 @@ export const platformLabelMap: Record<number, string> = {
|
||||
0: "未知",
|
||||
1: "Douyin",
|
||||
2: "Bilibili",
|
||||
3: "Huya"
|
||||
3: "Huya",
|
||||
4: "Douyu",
|
||||
5: "Kuaishou",
|
||||
6: "TikTok",
|
||||
7: "Xiaohongshu",
|
||||
8: "YouTube",
|
||||
9: "Twitch",
|
||||
10: "PandaTV",
|
||||
11: "Migu"
|
||||
};
|
||||
|
||||
export const uploadTargetLabelMap: Record<number, string> = {
|
||||
@@ -715,3 +779,124 @@ export const uploadStatusLabelMap: Record<number, string> = {
|
||||
1: "已上传",
|
||||
2: "上传失败"
|
||||
};
|
||||
|
||||
export interface DanmakuEvent {
|
||||
offsetSeconds: number;
|
||||
type: string;
|
||||
content: string;
|
||||
user?: string;
|
||||
userId?: string;
|
||||
color?: string;
|
||||
fontSize?: number;
|
||||
mode?: number;
|
||||
timestampMs?: number;
|
||||
giftName?: string;
|
||||
count?: number;
|
||||
raw?: string;
|
||||
}
|
||||
|
||||
export interface DanmakuResponse {
|
||||
recordTaskId: string;
|
||||
segmentIndex: number;
|
||||
platform?: string;
|
||||
roomId?: string;
|
||||
liveRoomId?: string;
|
||||
recordSessionId: string;
|
||||
startedAt?: string;
|
||||
events: DanmakuEvent[];
|
||||
}
|
||||
|
||||
export interface SessionDanmakuResponse {
|
||||
recordSessionId: string;
|
||||
tasks: DanmakuResponse[];
|
||||
}
|
||||
|
||||
// Dashboard types
|
||||
export interface DashboardData {
|
||||
activeRecordingCount: number;
|
||||
liveRoomCount: number;
|
||||
offlineRoomCount: number;
|
||||
totalRoomCount: number;
|
||||
todayRecordingSeconds: number;
|
||||
todayDataBytes: number;
|
||||
todayDanmakuCount: number;
|
||||
activeSessionCount: number;
|
||||
recentErrorCount: number;
|
||||
storageStatus: DashboardStorageStatus;
|
||||
recentSessions: DashboardRecentSession[];
|
||||
topRooms: DashboardTopRoom[];
|
||||
pendingTranscodeCount: number;
|
||||
pendingUploadCount: number;
|
||||
queuedDataBytes: number;
|
||||
}
|
||||
|
||||
export interface DashboardStorageStatus {
|
||||
hasEnoughSpace: boolean;
|
||||
message: string;
|
||||
availableBytes: number;
|
||||
tier: string;
|
||||
usagePercent: number;
|
||||
}
|
||||
|
||||
export interface DashboardRecentSession {
|
||||
id: string;
|
||||
liveRoomId: string;
|
||||
liveRoomTitle: string;
|
||||
platformName: string;
|
||||
segmentCount: number;
|
||||
status: number;
|
||||
startedAt?: string;
|
||||
durationSeconds?: number;
|
||||
}
|
||||
|
||||
export interface DashboardTopRoom {
|
||||
liveRoomId: string;
|
||||
title?: string;
|
||||
anchorName?: string;
|
||||
platformName: string;
|
||||
roomId: string;
|
||||
sessionCount: number;
|
||||
totalDurationSeconds: number;
|
||||
}
|
||||
|
||||
// Video metadata types
|
||||
export interface VideoMetadata {
|
||||
durationSeconds?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
videoCodec?: string;
|
||||
audioCodec?: string;
|
||||
frameRate?: number;
|
||||
bitRate?: number;
|
||||
}
|
||||
|
||||
// Session playlist types
|
||||
export interface SessionPlaylistSegment {
|
||||
recordTaskId: string;
|
||||
segmentIndex: number;
|
||||
previewTicketUrl: string;
|
||||
durationSeconds?: number;
|
||||
}
|
||||
|
||||
export interface SessionPlaylist {
|
||||
recordSessionId: string;
|
||||
liveRoomTitle: string;
|
||||
segments: SessionPlaylistSegment[];
|
||||
}
|
||||
|
||||
// Bandwidth types
|
||||
export interface BandwidthSummary {
|
||||
totalTrafficMB: number;
|
||||
averageBitrateKbps: number;
|
||||
peakBitrateKbps: number;
|
||||
}
|
||||
|
||||
export interface BandwidthTimeline {
|
||||
points: BandwidthPoint[];
|
||||
}
|
||||
|
||||
export interface BandwidthPoint {
|
||||
timestamp: string;
|
||||
bytesDownloaded: number;
|
||||
bitrateKbps?: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { AuthenticatedUser } from "@/types";
|
||||
|
||||
const TOKEN_STORAGE_KEY = "live-recorder-token";
|
||||
const USER_STORAGE_KEY = "live-recorder-user";
|
||||
const PREVIEW_TOKEN = "dev-preview-token";
|
||||
|
||||
const previewUser: AuthenticatedUser = {
|
||||
userId: "dev-preview",
|
||||
username: "preview",
|
||||
displayName: "UI Preview",
|
||||
token: PREVIEW_TOKEN,
|
||||
expiresAt: "2099-12-31T23:59:59.000Z"
|
||||
};
|
||||
|
||||
export const isNoBackendPreviewMode = import.meta.env.DEV && import.meta.env.VITE_PREVIEW_NO_BACKEND === "1";
|
||||
|
||||
function hasUsablePreviewUser(value: string | null) {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Partial<AuthenticatedUser>;
|
||||
return Boolean(parsed.userId && parsed.username && parsed.displayName);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function ensurePreviewSession() {
|
||||
if (!isNoBackendPreviewMode || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!localStorage.getItem(TOKEN_STORAGE_KEY)) {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, PREVIEW_TOKEN);
|
||||
}
|
||||
|
||||
if (!hasUsablePreviewUser(localStorage.getItem(USER_STORAGE_KEY))) {
|
||||
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(previewUser));
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,15 @@ import { useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import axios from "axios";
|
||||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||||
import EmptyState from "@/components/ui/EmptyState.vue";
|
||||
import MetricCard from "@/components/ui/MetricCard.vue";
|
||||
import { useViewport } from "@/composables/useViewport";
|
||||
import type {
|
||||
DailyReviewPushResult,
|
||||
DailyReviewReport,
|
||||
PushDailyReviewRequest
|
||||
} from "@/types";
|
||||
import { Bell, Calendar, Clock, DataAnalysis, VideoCamera } from "@element-plus/icons-vue";
|
||||
|
||||
const router = useRouter();
|
||||
const { isMobile } = useViewport();
|
||||
@@ -137,6 +140,7 @@ onMounted(loadReport);
|
||||
<div class="page-stack">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="page-kicker">回顾分析</div>
|
||||
<h1 class="page-title">回顾日报</h1>
|
||||
<p class="page-subtitle">
|
||||
按天聚合录制时长、异常和弹幕热度。支持手动推送到已配置的 Webhook 或邮件。
|
||||
@@ -163,37 +167,13 @@ onMounted(loadReport);
|
||||
<el-skeleton v-if="loading && !report" animated :rows="10" />
|
||||
|
||||
<template v-else-if="report">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__label">活跃直播间</div>
|
||||
<div class="stat-card__value">{{ report.summary.activeLiveRoomCount }}</div>
|
||||
<div class="stat-card__hint">{{ report.date }} 当天有录制重叠的房间数</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__label">录制会话</div>
|
||||
<div class="stat-card__value">{{ report.summary.sessionCount }}</div>
|
||||
<div class="stat-card__hint">按整场直播会话聚合统计</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__label">分片总数</div>
|
||||
<div class="stat-card__value">{{ report.summary.segmentCount }}</div>
|
||||
<div class="stat-card__hint">只统计与当天有时间重叠的分片</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__label">录制时长</div>
|
||||
<div class="stat-card__value">{{ formatDuration(report.summary.totalDurationSeconds) }}</div>
|
||||
<div class="stat-card__hint">跨天会话按日报窗口裁剪</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__label">警告 / 错误</div>
|
||||
<div class="stat-card__value">{{ report.summary.warningCount }} / {{ report.summary.errorCount }}</div>
|
||||
<div class="stat-card__hint">来自当天警告 / 错误系统日志</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__label">弹幕事件</div>
|
||||
<div class="stat-card__value">{{ report.summary.totalDanmakuCount }}</div>
|
||||
<div class="stat-card__hint">优先按弹幕 XML 分钟桶统计</div>
|
||||
</div>
|
||||
<div class="stats-grid daily-review-metrics">
|
||||
<MetricCard label="活跃直播间" :value="report.summary.activeLiveRoomCount" :description="`${report.date} 当天有录制重叠的房间数`" :icon="Calendar" />
|
||||
<MetricCard label="录制会话" :value="report.summary.sessionCount" description="按整场直播会话聚合统计" :icon="VideoCamera" />
|
||||
<MetricCard label="分片总数" :value="report.summary.segmentCount" description="只统计与当天有时间重叠的分片" :icon="DataAnalysis" />
|
||||
<MetricCard label="录制时长" :value="formatDuration(report.summary.totalDurationSeconds)" description="跨天会话按日报窗口裁剪" :icon="Clock" />
|
||||
<MetricCard label="警告 / 错误" :value="`${report.summary.warningCount} / ${report.summary.errorCount}`" description="来自当天警告和错误系统日志" :icon="Bell" />
|
||||
<MetricCard label="弹幕事件" :value="report.summary.totalDanmakuCount" description="优先按弹幕 XML 分钟桶统计" :icon="Bell" />
|
||||
</div>
|
||||
|
||||
<el-card class="surface-card" shadow="never">
|
||||
@@ -204,7 +184,7 @@ onMounted(loadReport);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="report.rooms.length === 0" description="当天没有录制数据" />
|
||||
<EmptyState v-if="report.rooms.length === 0" description="当前筛选条件下没有可展示内容" />
|
||||
|
||||
<div v-else class="table-scroll-shell">
|
||||
<el-table :data="report.rooms" :height="roomTableHeight" class="premium-table" table-layout="auto">
|
||||
@@ -240,7 +220,7 @@ onMounted(loadReport);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="report.highlights.length === 0" description="当天没有可展示的会话亮点" />
|
||||
<EmptyState v-if="report.highlights.length === 0" description="当前筛选条件下没有可展示内容" />
|
||||
|
||||
<div v-else class="highlight-grid">
|
||||
<section
|
||||
@@ -278,7 +258,7 @@ onMounted(loadReport);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="report.moments.length === 0" description="当天没有可用的弹幕热度数据" />
|
||||
<EmptyState v-if="report.moments.length === 0" description="当前筛选条件下没有可展示内容" />
|
||||
|
||||
<div v-else class="table-scroll-shell">
|
||||
<el-table :data="report.moments" :height="momentTableHeight" class="premium-table" table-layout="auto">
|
||||
@@ -342,6 +322,10 @@ onMounted(loadReport);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.daily-review-metrics :deep(.metric-card__value) {
|
||||
font-size: clamp(24px, 1.9vw, 34px);
|
||||
}
|
||||
|
||||
.highlight-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Bell, Clock, DataAnalysis, House, VideoCamera, Warning } from "@element-plus/icons-vue";
|
||||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||||
import MetricCard from "@/components/ui/MetricCard.vue";
|
||||
import type { DashboardData } from "@/types";
|
||||
import { sessionStatusLabelMap } from "@/types";
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const loadError = ref("");
|
||||
const data = ref<DashboardData | null>(null);
|
||||
|
||||
function formatDuration(seconds?: number) {
|
||||
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) return "-";
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
function formatDataSize(bytes?: number) {
|
||||
if (typeof bytes !== "number" || bytes <= 0) return "-";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function formatDate(value?: string) {
|
||||
return value ? new Date(value).toLocaleString() : "-";
|
||||
}
|
||||
|
||||
function sessionStatusTagType(status: number) {
|
||||
if (status === 2) return "success";
|
||||
if (status === 5) return "danger";
|
||||
if (status === 4 || status === 6) return "info";
|
||||
return "warning";
|
||||
}
|
||||
|
||||
function storageTierTagType(): "success" | "warning" | "danger" {
|
||||
const tier = data.value?.storageStatus.tier;
|
||||
if (tier === "Green") return "success";
|
||||
if (tier === "Yellow") return "warning";
|
||||
return "danger";
|
||||
}
|
||||
|
||||
function storageTierLabel(): string {
|
||||
const tier = data.value?.storageStatus.tier;
|
||||
if (tier === "Green") return "正常";
|
||||
if (tier === "Yellow") return "警告";
|
||||
return "紧急";
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
try {
|
||||
const { data: result } = await apiClient.get<DashboardData>("/dashboard");
|
||||
data.value = result;
|
||||
} catch (error) {
|
||||
loadError.value = getApiErrorMessage(error, "仪表盘数据加载失败。");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">仪表盘</h1>
|
||||
<p class="page-subtitle">系统运行状态一览,包含直播间、录制会话、弹幕和存储概况。</p>
|
||||
</div>
|
||||
<el-space class="header-actions">
|
||||
<el-button @click="loadData" :loading="loading">刷新</el-button>
|
||||
</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 && !data" animated :rows="6" />
|
||||
|
||||
<template v-else-if="data">
|
||||
<!-- KPI Cards -->
|
||||
<div class="stats-grid">
|
||||
<MetricCard label="正在录制" :value="data.activeRecordingCount">
|
||||
<template #icon><el-icon :size="18"><VideoCamera /></el-icon></template>
|
||||
</MetricCard>
|
||||
|
||||
<MetricCard label="直播间" :value="`${data.liveRoomCount} / ${data.offlineRoomCount}`" :description="`共 ${data.totalRoomCount} 个直播间`">
|
||||
<template #icon><el-icon :size="18"><House /></el-icon></template>
|
||||
</MetricCard>
|
||||
|
||||
<MetricCard label="今日录制时长" :value="formatDuration(data.todayRecordingSeconds)">
|
||||
<template #icon><el-icon :size="18"><Clock /></el-icon></template>
|
||||
</MetricCard>
|
||||
|
||||
<MetricCard label="今日数据量" :value="formatDataSize(data.todayDataBytes)">
|
||||
<template #icon><el-icon :size="18"><DataAnalysis /></el-icon></template>
|
||||
</MetricCard>
|
||||
|
||||
<MetricCard label="今日弹幕" :value="data.todayDanmakuCount.toLocaleString()">
|
||||
<template #icon><el-icon :size="18"><Bell /></el-icon></template>
|
||||
</MetricCard>
|
||||
|
||||
<MetricCard
|
||||
label="24h 异常"
|
||||
:value="data.recentErrorCount"
|
||||
:description="data.recentErrorCount > 0 ? '请前往系统日志页面排查' : '系统运行正常'"
|
||||
>
|
||||
<template #icon><el-icon :size="18"><Warning /></el-icon></template>
|
||||
</MetricCard>
|
||||
</div>
|
||||
|
||||
<!-- Storage Status -->
|
||||
<el-card class="surface-card" shadow="never">
|
||||
<h3 class="section-title">存储状态</h3>
|
||||
<p class="section-subtitle">录制输出路径的磁盘剩余空间和当前录制保护阈值。</p>
|
||||
<div class="storage-bar-row">
|
||||
<div class="storage-bar-card">
|
||||
<span class="storage-bar-card__label">水位线</span>
|
||||
<el-tag :type="storageTierTagType()" size="large">
|
||||
{{ storageTierLabel() }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="storage-bar-card">
|
||||
<span class="storage-bar-card__label">使用率</span>
|
||||
<span class="storage-bar-card__value">{{ data.storageStatus.usagePercent.toFixed(1) }}%</span>
|
||||
</div>
|
||||
<div class="storage-bar-card">
|
||||
<span class="storage-bar-card__label">可用空间</span>
|
||||
<span class="storage-bar-card__value">{{ formatDataSize(data.storageStatus.availableBytes) }}</span>
|
||||
</div>
|
||||
<div class="storage-bar-card">
|
||||
<span class="storage-bar-card__label">说明</span>
|
||||
<span class="storage-bar-card__value storage-bar-card__value--message">{{ data.storageStatus.message || "-" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- Queue Status -->
|
||||
<el-row :gutter="18">
|
||||
<el-col :lg="12" :sm="24">
|
||||
<el-card class="surface-card" shadow="never">
|
||||
<h3 class="section-title">处理队列</h3>
|
||||
<p class="section-subtitle">待转码和待上传的文件积压情况。</p>
|
||||
<div class="storage-bar-row">
|
||||
<div class="storage-bar-card">
|
||||
<span class="storage-bar-card__label">待转码</span>
|
||||
<span class="storage-bar-card__value" :style="{ color: data.pendingTranscodeCount > 0 ? 'var(--warning)' : 'var(--text-primary)' }">
|
||||
{{ data.pendingTranscodeCount }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="storage-bar-card">
|
||||
<span class="storage-bar-card__label">待上传</span>
|
||||
<span class="storage-bar-card__value" :style="{ color: data.pendingUploadCount > 0 ? 'var(--warning)' : 'var(--text-primary)' }">
|
||||
{{ data.pendingUploadCount }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="storage-bar-card">
|
||||
<span class="storage-bar-card__label">积压数据量</span>
|
||||
<span class="storage-bar-card__value" :style="{ color: data.queuedDataBytes > 0 ? 'var(--warning)' : 'var(--text-primary)' }">
|
||||
{{ formatDataSize(data.queuedDataBytes) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- Recent Sessions + Top Rooms -->
|
||||
<el-row :gutter="18">
|
||||
<el-col :lg="12" :sm="24">
|
||||
<el-card class="surface-card" shadow="never">
|
||||
<h3 class="section-title">最近会话</h3>
|
||||
<p class="section-subtitle">最近创建的录制会话,点击可跳转至详情。</p>
|
||||
<div class="table-scroll-shell">
|
||||
<el-table :data="data.recentSessions" class="premium-table" size="small">
|
||||
<el-table-column label="直播间" min-width="140" prop="liveRoomTitle" />
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="sessionStatusTagType(row.status)" size="small">
|
||||
{{ sessionStatusLabelMap[row.status] }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分片" width="60" prop="segmentCount" />
|
||||
<el-table-column label="开始时间" width="160">
|
||||
<template #default="{ row }">{{ formatDate(row.startedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="router.push({ name: 'record-session-detail', params: { id: row.id } })">查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :lg="12" :sm="24">
|
||||
<el-card class="surface-card" shadow="never">
|
||||
<h3 class="section-title">今日热门直播间</h3>
|
||||
<p class="section-subtitle">今日录制时长最长的直播间(Top 5)。</p>
|
||||
<div class="table-scroll-shell">
|
||||
<el-table :data="data.topRooms" class="premium-table" size="small">
|
||||
<el-table-column label="直播间" min-width="130">
|
||||
<template #default="{ row }">{{ row.title || row.anchorName || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="平台" width="90" prop="platformName" />
|
||||
<el-table-column label="会话数" width="70" prop="sessionCount" />
|
||||
<el-table-column label="录制时长" width="100">
|
||||
<template #default="{ row }">{{ formatDuration(row.totalDurationSeconds) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="router.push({ name: 'live-rooms' })">查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-stack {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
.header-actions {
|
||||
align-self: center;
|
||||
}
|
||||
.page-error-alert {
|
||||
border-radius: 14px;
|
||||
}
|
||||
.storage-bar-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px 40px;
|
||||
}
|
||||
.storage-bar-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.storage-bar-card__label {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.storage-bar-card__value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.storage-bar-card__value--message {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
justify-content: stretch;
|
||||
}
|
||||
.header-actions :deep(.el-button) {
|
||||
flex: 1 1 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,10 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||||
import EmptyState from "@/components/ui/EmptyState.vue";
|
||||
import MetricCard from "@/components/ui/MetricCard.vue";
|
||||
import RightDrawer from "@/components/ui/RightDrawer.vue";
|
||||
import StatusBadge from "@/components/ui/StatusBadge.vue";
|
||||
import { useViewport } from "@/composables/useViewport";
|
||||
import type { BatchLiveRoomsResult, ImportLiveRoomsRequest, ImportLiveRoomsResult, LiveRoom, RecordTask } from "@/types";
|
||||
import {
|
||||
@@ -10,10 +14,12 @@ import {
|
||||
availabilityLabelMap,
|
||||
currentRecordingStateLabelMap,
|
||||
outputFormatLabelMap,
|
||||
platformOptionList,
|
||||
qualityOptionList,
|
||||
recordingTemplateLabelMap,
|
||||
saveModeLabelMap
|
||||
} from "@/types";
|
||||
import { House, RefreshRight, SwitchButton, VideoCamera } from "@element-plus/icons-vue";
|
||||
|
||||
const inheritValue = "__inherit__";
|
||||
const AUTO_REFRESH_INTERVAL_MS = 15000;
|
||||
@@ -38,6 +44,8 @@ const settingsRoom = ref<LiveRoom | null>(null);
|
||||
const rooms = ref<LiveRoom[]>([]);
|
||||
const selectedRooms = ref<LiveRoom[]>([]);
|
||||
const loadError = ref("");
|
||||
const roomDetailVisible = ref(false);
|
||||
const activeRoom = ref<LiveRoom | null>(null);
|
||||
const { isMobile } = useViewport();
|
||||
const roomsTableShellRef = ref<HTMLElement | null>(null);
|
||||
const roomsTableProxyRef = ref<HTMLElement | null>(null);
|
||||
@@ -85,14 +93,15 @@ const settingsForm = reactive({
|
||||
});
|
||||
|
||||
const platformOptions = [
|
||||
{ label: "自动识别", value: null },
|
||||
{ label: "抖音", value: 1 },
|
||||
{ label: "Bilibili", value: 2 },
|
||||
{ label: "虎牙", value: 3 }
|
||||
{ label: "自动识别", value: null as number | null },
|
||||
...platformOptionList.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.value
|
||||
}))
|
||||
];
|
||||
|
||||
const qualityOptions = qualityOptionList;
|
||||
const qualitySupportHint = "抖音和 Bilibili 支持按画质选流;虎牙暂未实现。若目标档位不可用,平台会自动回退到最接近的可用画质。";
|
||||
const qualitySupportHint = "不同平台的可选画质不同;如果目标档位不可用,系统会自动回退到当前平台最接近的可用流。";
|
||||
const booleanOverrideOptions = [
|
||||
{ label: "跟随全局", value: inheritValue },
|
||||
{ label: "开启", value: "true" },
|
||||
@@ -108,6 +117,15 @@ const totalRooms = computed(() => rooms.value.length);
|
||||
const enabledRooms = computed(() => rooms.value.filter((item) => item.isEnabled).length);
|
||||
const disabledRooms = computed(() => rooms.value.filter((item) => !item.isEnabled).length);
|
||||
const liveRooms = computed(() => rooms.value.filter((item) => item.availabilityStatus === 2).length);
|
||||
const recordingRooms = computed(() => rooms.value.filter((item) => item.currentRecordingState === 2).length);
|
||||
const priorityRooms = computed(() => rooms.value.filter((item) => item.isPriority).length);
|
||||
const activeRoomSubtitle = computed(() => {
|
||||
if (!activeRoom.value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return `${activeRoom.value.platformName || "--"} · ${activeRoom.value.roomId || "--"}`;
|
||||
});
|
||||
const tableHeight = computed(() => (isMobile.value ? undefined : 700));
|
||||
const createDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 560px)" : "560px"));
|
||||
const importDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 720px)" : "720px"));
|
||||
@@ -501,6 +519,11 @@ function openRecordDialog(room: LiveRoom) {
|
||||
recordDialogVisible.value = true;
|
||||
}
|
||||
|
||||
function openRoomDetails(room: LiveRoom) {
|
||||
activeRoom.value = room;
|
||||
roomDetailVisible.value = true;
|
||||
}
|
||||
|
||||
async function copyRoomLink(room: LiveRoom) {
|
||||
const url = room.originalLiveRoomUrl || room.sourceUrl;
|
||||
|
||||
@@ -688,6 +711,14 @@ function getRoomAvatarText(room: LiveRoom) {
|
||||
return source.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
function roomAvailabilityLabel(room: LiveRoom) {
|
||||
return availabilityLabelMap[room.availabilityStatus] ?? "--";
|
||||
}
|
||||
|
||||
function latestEventLabel(room: LiveRoom) {
|
||||
return room.lastAutoStartDecisionSummary || "暂无事件";
|
||||
}
|
||||
|
||||
function getQualityLabel(quality?: string | null) {
|
||||
return formatQualityLabel(quality);
|
||||
}
|
||||
@@ -802,14 +833,14 @@ onBeforeUnmount(() => {
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="page-kicker">直播监控</div>
|
||||
<h1 class="page-title">直播间管理</h1>
|
||||
<h1 class="page-title">直播间监控录制控制台</h1>
|
||||
<p class="page-subtitle">
|
||||
直播间会长期保留在系统里。单房间配置优先于全局配置;未设置的项会自动回退到系统设置。
|
||||
聚合直播状态、自动开录决策和单房间录制配置,面向开播检测、异常巡检与手动介入场景。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="page-toolbar">
|
||||
<el-button @click="loadRooms">刷新列表</el-button>
|
||||
<el-button @click="loadRooms">刷新状态</el-button>
|
||||
<el-button @click="openImportDialog">批量导入</el-button>
|
||||
<el-button :loading="exportLoading" @click="exportRooms">导出列表</el-button>
|
||||
<el-button type="primary" @click="openCreateDialog">新增直播间</el-button>
|
||||
@@ -818,34 +849,44 @@ onBeforeUnmount(() => {
|
||||
|
||||
<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>
|
||||
<div class="stat-card__value">{{ totalRooms }}</div>
|
||||
<div class="stat-card__hint">当前已接入系统的直播间数量</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__label">启用中</div>
|
||||
<div class="stat-card__value">{{ enabledRooms }}</div>
|
||||
<div class="stat-card__hint">参与后台巡检与自动录制</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__label">已停用</div>
|
||||
<div class="stat-card__value">{{ disabledRooms }}</div>
|
||||
<div class="stat-card__hint">保留解析结果,但不再自动录制</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__label">当前开播</div>
|
||||
<div class="stat-card__value">{{ liveRooms }}</div>
|
||||
<div class="stat-card__hint">基于最近一次状态检测</div>
|
||||
<div class="live-console-grid">
|
||||
<div class="stats-grid">
|
||||
<MetricCard label="在线直播间" :value="liveRooms" description="基于最近一次巡检识别为开播中的房间" :icon="House" />
|
||||
<MetricCard label="录制中任务" :value="recordingRooms" description="当前处于录制状态的直播间数量" :icon="VideoCamera" />
|
||||
<MetricCard label="启用房间" :value="enabledRooms" description="参与自动检测与自动录制的直播间" :icon="SwitchButton" />
|
||||
<MetricCard label="重点监控" :value="priorityRooms" description="标记为重点巡检与优先关注的房间" :icon="RefreshRight" />
|
||||
</div>
|
||||
|
||||
<el-card class="surface-card focus-card" shadow="never">
|
||||
<div class="focus-card__header">
|
||||
<div>
|
||||
<div class="focus-card__eyebrow">监控概览</div>
|
||||
<h3 class="section-title">直播采集态势</h3>
|
||||
</div>
|
||||
<StatusBadge
|
||||
:label="recordingRooms > 0 ? '录制通道活跃' : '等待开播'"
|
||||
:status="recordingRooms > 0"
|
||||
context="boolean"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="focus-card__description">
|
||||
当前共接入 {{ totalRooms }} 个直播间,其中 {{ enabledRooms }} 个处于自动巡检范围,{{ disabledRooms }} 个处于保留但停用状态。
|
||||
</p>
|
||||
|
||||
<div class="focus-card__chips">
|
||||
<span class="info-pill">自动开录复用现有后端策略</span>
|
||||
<span class="info-pill">单房间配置优先于全局设置</span>
|
||||
<span class="info-pill">无实时吞吐字段时统一显示占位</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-card class="surface-card table-card" shadow="never">
|
||||
<el-card class="surface-card table-card" shadow="never" v-loading="loading">
|
||||
<div class="toolbar-row">
|
||||
<div>
|
||||
<h3 class="section-title">直播间列表</h3>
|
||||
<p class="section-subtitle">支持刷新状态、手动启动录制、单房间配置覆盖,以及按需删除直播间。</p>
|
||||
<p class="section-subtitle">继续使用真实接口数据,统一强化为监控控制台视图,支持查看详情、手动录制和单房间配置覆盖。</p>
|
||||
</div>
|
||||
<el-space v-if="!isMobile" wrap class="batch-actions">
|
||||
<span class="batch-actions__count">已选 {{ selectedRoomCount }} 个</span>
|
||||
@@ -854,10 +895,18 @@ onBeforeUnmount(() => {
|
||||
<el-button type="danger" plain :disabled="!hasSelectedRooms || batchActionLoading" @click="openBatchDeleteDialog">
|
||||
批量删除
|
||||
</el-button>
|
||||
</el-space>
|
||||
</el-space>
|
||||
</div>
|
||||
|
||||
<div v-if="isMobile" class="data-card-list room-card-list">
|
||||
<EmptyState
|
||||
v-if="!loading && rooms.length === 0"
|
||||
title="暂无数据"
|
||||
description="当前筛选条件下没有可展示内容"
|
||||
action-text="刷新列表"
|
||||
@action="loadRooms"
|
||||
/>
|
||||
|
||||
<div v-else-if="isMobile" class="data-card-list room-card-list">
|
||||
<article v-for="row in rooms" :key="row.id" class="data-card room-card">
|
||||
<div class="data-card__header">
|
||||
<el-avatar :src="row.avatarUrl || row.coverUrl" :size="52" class="room-avatar">
|
||||
@@ -874,30 +923,42 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div class="badge-row">
|
||||
<el-tag :type="currentRecordingStateTagType(row.currentRecordingState)">
|
||||
{{ currentRecordingStateLabelMap[row.currentRecordingState] }}
|
||||
</el-tag>
|
||||
<el-tag effect="plain">{{ row.platformName }}</el-tag>
|
||||
<el-tag v-if="row.isPinned" size="small" effect="plain">置顶</el-tag>
|
||||
<el-tag v-if="row.isPriority" size="small" effect="plain" type="danger">重点</el-tag>
|
||||
<el-tag size="small" effect="plain" :type="autoStartDecisionTagType(row.lastAutoStartDecisionCode)">
|
||||
{{ autoStartDecisionLabel(row.lastAutoStartDecisionCode) }}
|
||||
</el-tag>
|
||||
<StatusBadge :label="roomAvailabilityLabel(row)" :status="row.availabilityStatus" context="availability" />
|
||||
<StatusBadge
|
||||
:label="currentRecordingStateLabelMap[row.currentRecordingState]"
|
||||
:status="row.currentRecordingState"
|
||||
context="recording"
|
||||
/>
|
||||
<StatusBadge :label="row.platformName || '--'" :status="row.platformName || 'unknown'" />
|
||||
<StatusBadge v-if="row.isPinned" label="置顶" status="completed" size="sm" />
|
||||
<StatusBadge v-if="row.isPriority" label="重点" status="retrying" size="sm" />
|
||||
</div>
|
||||
|
||||
<div class="data-card__grid">
|
||||
<div>
|
||||
<dt>自动开录</dt>
|
||||
<dd>{{ row.lastAutoStartDecisionSummary || "暂无自动开录摘要" }}</dd>
|
||||
<dt>最近事件</dt>
|
||||
<dd>{{ latestEventLabel(row) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>最近巡检</dt>
|
||||
<dd>{{ formatDate(row.lastCheckedAt) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>在线人数</dt>
|
||||
<dd>--</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>码率</dt>
|
||||
<dd>--</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>检测间隔</dt>
|
||||
<dd>{{ formatPollingInterval(row.pollingIntervalSecondsOverride) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>采集账号</dt>
|
||||
<dd>--</dd>
|
||||
</div>
|
||||
<div style="grid-column: 1 / -1;">
|
||||
<dt>房间配置</dt>
|
||||
<dd>
|
||||
@@ -926,6 +987,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div class="data-card__actions">
|
||||
<el-button size="small" @click="openRoomDetails(row)">查看详情</el-button>
|
||||
<el-button size="small" @click="refreshRoom(row)">刷新</el-button>
|
||||
<el-button size="small" @click="openSettingsDialog(row)">配置</el-button>
|
||||
<el-button size="small" @click="copyRoomLink(row)">复制链接</el-button>
|
||||
@@ -950,7 +1012,6 @@ onBeforeUnmount(() => {
|
||||
|
||||
<el-table
|
||||
:data="rooms"
|
||||
v-loading="loading"
|
||||
:height="tableHeight"
|
||||
class="premium-table rooms-table"
|
||||
table-layout="fixed"
|
||||
@@ -959,53 +1020,59 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<el-table-column type="selection" width="48" />
|
||||
|
||||
<el-table-column label="头像" width="88">
|
||||
<el-table-column label="直播间" min-width="340">
|
||||
<template #default="{ row }">
|
||||
<el-avatar :src="row.avatarUrl || row.coverUrl" :size="56" class="room-avatar">
|
||||
{{ getRoomAvatarText(row) }}
|
||||
</el-avatar>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="直播间" min-width="320">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-title">{{ row.title || row.anchorName || row.roomId }}</div>
|
||||
<div class="cell-subtitle">{{ row.anchorName || "未知主播" }}</div>
|
||||
<div class="room-summary-cell">
|
||||
<el-avatar :src="row.avatarUrl || row.coverUrl" :size="52" class="room-avatar">
|
||||
{{ getRoomAvatarText(row) }}
|
||||
</el-avatar>
|
||||
<div class="room-summary-cell__copy">
|
||||
<div class="cell-title">{{ row.title || row.anchorName || row.roomId }}</div>
|
||||
<div class="cell-subtitle">{{ row.anchorName || "未知主播" }}</div>
|
||||
<div class="config-summary">
|
||||
<span>{{ row.platformName || "--" }}</span>
|
||||
<span>{{ row.roomId || "--" }}</span>
|
||||
<span>{{ formatPollingInterval(row.pollingIntervalSecondsOverride) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="row.alias && row.alias !== row.anchorName" class="cell-subtitle">别名:{{ row.alias }}</div>
|
||||
<div v-if="row.remark" class="cell-subtitle">{{ row.remark }}</div>
|
||||
<div class="config-summary">
|
||||
<span v-if="row.isPinned">置顶</span>
|
||||
<span v-if="row.isPriority">重点</span>
|
||||
<span>{{ formatPollingInterval(row.pollingIntervalSecondsOverride) }}</span>
|
||||
<div class="badge-row">
|
||||
<StatusBadge v-if="row.isPinned" label="置顶" status="completed" size="sm" />
|
||||
<StatusBadge v-if="row.isPriority" label="重点" status="retrying" size="sm" />
|
||||
</div>
|
||||
<div class="monospace cell-mono">{{ row.roomId }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="平台" width="110">
|
||||
<el-table-column label="直播状态" width="128">
|
||||
<template #default="{ row }">
|
||||
<el-tag effect="plain">{{ row.platformName }}</el-tag>
|
||||
<StatusBadge :label="roomAvailabilityLabel(row)" :status="row.availabilityStatus" context="availability" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="直播状态" width="120">
|
||||
<el-table-column label="录制状态" width="128">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="currentRecordingStateTagType(row.currentRecordingState)">
|
||||
{{ currentRecordingStateLabelMap[row.currentRecordingState] }}
|
||||
</el-tag>
|
||||
<StatusBadge
|
||||
:label="currentRecordingStateLabelMap[row.currentRecordingState]"
|
||||
:status="row.currentRecordingState"
|
||||
context="recording"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="自动开录" min-width="280">
|
||||
<el-table-column label="最近事件" min-width="300">
|
||||
<template #default="{ row }">
|
||||
<div class="auto-start-cell">
|
||||
<div class="auto-start-cell__head">
|
||||
<el-tag size="small" :type="autoStartDecisionTagType(row.lastAutoStartDecisionCode)" effect="plain">
|
||||
{{ autoStartDecisionLabel(row.lastAutoStartDecisionCode) }}
|
||||
</el-tag>
|
||||
<StatusBadge
|
||||
size="sm"
|
||||
:label="autoStartDecisionLabel(row.lastAutoStartDecisionCode)"
|
||||
:status="row.lastAutoStartDecisionCode || 'unknown'"
|
||||
/>
|
||||
<span class="table-date-text">{{ formatDate(row.lastAutoStartDecisionAt) }}</span>
|
||||
</div>
|
||||
<div class="cell-subtitle">{{ row.lastAutoStartDecisionSummary || "暂无自动开录摘要" }}</div>
|
||||
<div class="cell-subtitle">{{ latestEventLabel(row) }}</div>
|
||||
<el-popover
|
||||
v-if="row.lastAutoStartDecisionDetail"
|
||||
trigger="hover"
|
||||
@@ -1032,7 +1099,7 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="录制开关" width="128">
|
||||
<el-table-column label="录制开关" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
:model-value="row.isEnabled"
|
||||
@@ -1045,6 +1112,16 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="监控字段" width="168">
|
||||
<template #default="{ row }">
|
||||
<div class="table-placeholder-grid">
|
||||
<span>在线人数 --</span>
|
||||
<span>码率 --</span>
|
||||
<span>采集账号 --</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="最近检测" width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="table-date-text">{{ formatDate(row.lastCheckedAt) }}</div>
|
||||
@@ -1054,6 +1131,7 @@ onBeforeUnmount(() => {
|
||||
<el-table-column label="操作" width="380">
|
||||
<template #default="{ row }">
|
||||
<div class="room-actions-cell">
|
||||
<el-button size="small" @click="openRoomDetails(row)">查看</el-button>
|
||||
<el-button size="small" @click="refreshRoom(row)">刷新</el-button>
|
||||
<el-button size="small" @click="openSettingsDialog(row)">配置</el-button>
|
||||
<el-button size="small" @click="copyRoomLink(row)">复制链接</el-button>
|
||||
@@ -1069,6 +1147,76 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<RightDrawer
|
||||
v-model="roomDetailVisible"
|
||||
:title="activeRoom?.title || activeRoom?.anchorName || activeRoom?.roomId || '直播间详情'"
|
||||
:subtitle="activeRoomSubtitle"
|
||||
>
|
||||
<div v-if="activeRoom" class="detail-panel">
|
||||
<div class="detail-panel__hero">
|
||||
<el-avatar :src="activeRoom.avatarUrl || activeRoom.coverUrl" :size="64" class="room-avatar">
|
||||
{{ getRoomAvatarText(activeRoom) }}
|
||||
</el-avatar>
|
||||
<div>
|
||||
<div class="detail-panel__title">{{ activeRoom.anchorName || "未知主播" }}</div>
|
||||
<div class="detail-panel__meta">{{ activeRoom.originalLiveRoomUrl || activeRoom.sourceUrl || "--" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="badge-row">
|
||||
<StatusBadge :label="roomAvailabilityLabel(activeRoom)" :status="activeRoom.availabilityStatus" context="availability" />
|
||||
<StatusBadge
|
||||
:label="currentRecordingStateLabelMap[activeRoom.currentRecordingState]"
|
||||
:status="activeRoom.currentRecordingState"
|
||||
context="recording"
|
||||
/>
|
||||
<StatusBadge :label="activeRoom.platformName || '--'" :status="activeRoom.platformName || 'unknown'" />
|
||||
</div>
|
||||
|
||||
<el-descriptions :column="1" border class="detail-panel__descriptions">
|
||||
<el-descriptions-item label="直播间名称">
|
||||
{{ activeRoom.title || activeRoom.anchorName || activeRoom.roomId || "--" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="平台 + Room ID">
|
||||
{{ activeRoom.platformName || "--" }} · {{ activeRoom.roomId || "--" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="直播状态">
|
||||
{{ roomAvailabilityLabel(activeRoom) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="录制状态">
|
||||
{{ currentRecordingStateLabelMap[activeRoom.currentRecordingState] || "--" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="在线人数">--</el-descriptions-item>
|
||||
<el-descriptions-item label="码率">--</el-descriptions-item>
|
||||
<el-descriptions-item label="录制时长">--</el-descriptions-item>
|
||||
<el-descriptions-item label="采集账号">--</el-descriptions-item>
|
||||
<el-descriptions-item label="最近事件">
|
||||
{{ latestEventLabel(activeRoom) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最近巡检">
|
||||
{{ formatDate(activeRoom.lastCheckedAt) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="自动开录详情">
|
||||
{{ activeRoom.lastAutoStartDecisionDetail || "--" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="房间配置">
|
||||
{{ getQualityLabel(activeRoom.effectiveSettings.preferredQuality) }} ·
|
||||
{{ outputFormatLabelMap[activeRoom.effectiveSettings.outputFormat] }} ·
|
||||
{{ saveModeLabelMap[activeRoom.effectiveSettings.saveMode] }} ·
|
||||
{{ activeRoom.effectiveSettings.enableDanmakuRecording ? "弹幕开" : "弹幕关" }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="roomDetailVisible = false">关闭</el-button>
|
||||
<el-button @click="activeRoom && openSettingsDialog(activeRoom)">配置</el-button>
|
||||
<el-button type="primary" :disabled="!activeRoom?.isEnabled" @click="activeRoom && openRecordDialog(activeRoom)">
|
||||
开始录制
|
||||
</el-button>
|
||||
</template>
|
||||
</RightDrawer>
|
||||
|
||||
<el-dialog
|
||||
v-model="createDialogVisible"
|
||||
class="form-dialog"
|
||||
@@ -1083,7 +1231,7 @@ onBeforeUnmount(() => {
|
||||
v-model="createForm.url"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
placeholder="粘贴抖音 / Bilibili / 虎牙直播间链接,或直接输入 roomId"
|
||||
placeholder="粘贴 Douyin / Bilibili / Huya / Douyu / Kuaishou / TikTok / YouTube / Twitch 等直播间链接"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
@@ -1129,7 +1277,7 @@ onBeforeUnmount(() => {
|
||||
v-model="importForm.content"
|
||||
type="textarea"
|
||||
:rows="12"
|
||||
placeholder="https://live.douyin.com/845878323112,主播: 熊宇一 https://live.douyin.com/262011082654"
|
||||
placeholder="https://live.douyin.com/845878323112,主播: 熊宇一 https://www.twitch.tv/example_channel https://www.youtube.com/watch?v=example12345"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
@@ -1455,6 +1603,46 @@ onBeforeUnmount(() => {
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.live-console-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.7fr) minmax(320px, 0.9fr);
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.focus-card :deep(.el-card__body) {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.focus-card__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.focus-card__eyebrow {
|
||||
margin-bottom: 8px;
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.focus-card__description {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.focus-card__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
align-self: center;
|
||||
}
|
||||
@@ -1518,6 +1706,16 @@ onBeforeUnmount(() => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.room-summary-cell {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.room-summary-cell__copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.room-avatar {
|
||||
box-shadow: 0 14px 28px rgba(52, 84, 112, 0.14);
|
||||
}
|
||||
@@ -1617,10 +1815,46 @@ onBeforeUnmount(() => {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table-placeholder-grid {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.room-actions-cell :deep(.el-button) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.detail-panel {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.detail-panel__hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.detail-panel__title {
|
||||
color: var(--text-primary);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.detail-panel__meta {
|
||||
margin-top: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.field-help {
|
||||
margin-top: -8px;
|
||||
margin-bottom: 14px;
|
||||
@@ -1798,6 +2032,10 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.live-console-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.toolbar-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { computed, onMounted, ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { FolderOpened, VideoPlay, Document, RefreshRight } from "@element-plus/icons-vue";
|
||||
import apiClient, { buildApiUrl, getApiErrorMessage } from "@/api/client";
|
||||
import EmptyState from "@/components/ui/EmptyState.vue";
|
||||
import MetricCard from "@/components/ui/MetricCard.vue";
|
||||
import type { MediaBrowserItem, MediaBrowserResponse, TranscodeMediaFileResult } from "@/types";
|
||||
|
||||
const loading = ref(false);
|
||||
@@ -14,6 +16,9 @@ const previewTitle = ref("");
|
||||
const previewUrl = ref("");
|
||||
|
||||
const currentPathLabel = computed(() => browser.value?.currentPath || "平台目录");
|
||||
const directoryCount = computed(() => browser.value?.items.filter((item) => item.type === "directory").length ?? 0);
|
||||
const mediaFileCount = computed(() => browser.value?.items.filter((item) => item.type !== "directory").length ?? 0);
|
||||
const transcodeReadyCount = computed(() => browser.value?.items.filter((item) => item.canTranscode).length ?? 0);
|
||||
|
||||
async function loadDirectory(path = "") {
|
||||
loading.value = true;
|
||||
@@ -140,6 +145,13 @@ onMounted(() => {
|
||||
|
||||
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
|
||||
|
||||
<div class="stats-grid media-browser-metrics">
|
||||
<MetricCard label="当前目录" :value="currentPathLabel" description="录制归档目录仅在后端允许的路径范围内浏览" />
|
||||
<MetricCard label="子目录数" :value="directoryCount" description="当前目录下的可进入目录数量" :icon="FolderOpened" />
|
||||
<MetricCard label="媒体文件" :value="mediaFileCount" description="当前目录下可直接预览或下载的文件数" :icon="Document" />
|
||||
<MetricCard label="待转码" :value="transcodeReadyCount" description="当前目录下支持补转码为 MP4 的文件数量" :icon="VideoPlay" />
|
||||
</div>
|
||||
|
||||
<el-card class="surface-card" shadow="never">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
@@ -171,7 +183,13 @@ onMounted(() => {
|
||||
|
||||
<el-skeleton v-if="loading && !browser" animated :rows="8" />
|
||||
|
||||
<el-empty v-else-if="browser && browser.items.length === 0" description="当前目录为空" />
|
||||
<EmptyState
|
||||
v-else-if="browser && browser.items.length === 0"
|
||||
title="暂无数据"
|
||||
description="当前筛选条件下没有可展示内容"
|
||||
action-text="刷新目录"
|
||||
@action="refreshCurrentDirectory"
|
||||
/>
|
||||
|
||||
<div v-else-if="browser" class="table-scroll-shell">
|
||||
<el-table :data="browser.items" class="premium-table" table-layout="auto">
|
||||
@@ -252,6 +270,13 @@ onMounted(() => {
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.media-browser-metrics :deep(.metric-card__value) {
|
||||
overflow: hidden;
|
||||
font-size: clamp(22px, 1.8vw, 30px);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.breadcrumb-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -3,9 +3,12 @@ import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useViewport } from "@/composables/useViewport";
|
||||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||||
import apiClient, { getApiErrorMessage, buildApiUrl } from "@/api/client";
|
||||
import { useDanmakuPlayer } from "@/composables/useDanmakuPlayer";
|
||||
import DanmakuPlayer from "@/components/player/DanmakuPlayer.vue";
|
||||
import type {
|
||||
RecordArtifactUploadBatchResult,
|
||||
RecordPreviewTicket,
|
||||
RecordSessionDetail,
|
||||
RecordSessionTimelineEvent,
|
||||
RecordSessionHeatBucket,
|
||||
@@ -28,6 +31,47 @@ const props = defineProps<{
|
||||
const router = useRouter();
|
||||
const { isMobile } = useViewport();
|
||||
|
||||
// Danmaku replay dialog
|
||||
const { danmakuEvents: replayDanmakuEvents, loading: danmakuLoading, error: danmakuError, loadTaskDanmaku, clear: clearDanmaku } = useDanmakuPlayer();
|
||||
const danmakuDialogVisible = ref(false);
|
||||
const danmakuDialogTitle = ref("");
|
||||
const danmakuPreviewUrl = ref("");
|
||||
const danmakuPreviewLoading = ref(false);
|
||||
const danmakuPreviewMessage = ref("");
|
||||
|
||||
async function openDanmakuReplay(recordTaskId: string, segmentIndex: number) {
|
||||
danmakuDialogVisible.value = true;
|
||||
danmakuDialogTitle.value = `弹幕回放 — 分片 #${segmentIndex}`;
|
||||
danmakuPreviewUrl.value = "";
|
||||
danmakuPreviewMessage.value = "";
|
||||
clearDanmaku();
|
||||
|
||||
// Load preview ticket and danmaku in parallel
|
||||
danmakuPreviewLoading.value = true;
|
||||
try {
|
||||
const [ticketResult] = await Promise.allSettled([
|
||||
apiClient.post<RecordPreviewTicket>(`/record-tasks/${recordTaskId}/preview-ticket`),
|
||||
loadTaskDanmaku(recordTaskId)
|
||||
]);
|
||||
|
||||
if (ticketResult.status === "fulfilled") {
|
||||
danmakuPreviewUrl.value = ticketResult.value.data.url;
|
||||
} else {
|
||||
danmakuPreviewMessage.value = "无法获取视频预览票据,请稍后重试。";
|
||||
}
|
||||
} catch {
|
||||
danmakuPreviewMessage.value = "加载预览资源失败。";
|
||||
} finally {
|
||||
danmakuPreviewLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeDanmakuDialog() {
|
||||
danmakuDialogVisible.value = false;
|
||||
danmakuPreviewUrl.value = "";
|
||||
clearDanmaku();
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
const uploadLoading = ref(false);
|
||||
const loadError = ref("");
|
||||
@@ -502,9 +546,18 @@ onMounted(loadDetail);
|
||||
{{ formatDuration(row.durationSeconds) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<el-table-column label="操作" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openTaskDetail(row.recordTaskId)">查看分片</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
:disabled="row.status !== 4 && row.status !== 6"
|
||||
@click="openDanmakuReplay(row.recordTaskId, row.segmentIndex)"
|
||||
>
|
||||
弹幕回放
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -544,6 +597,24 @@ onMounted(loadDetail);
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<!-- Danmaku Replay Dialog -->
|
||||
<el-dialog
|
||||
v-model="danmakuDialogVisible"
|
||||
:title="danmakuDialogTitle"
|
||||
width="90%"
|
||||
:close-on-click-modal="false"
|
||||
@close="closeDanmakuDialog"
|
||||
>
|
||||
<div v-if="danmakuPreviewLoading" class="preview-empty">正在准备预览资源…</div>
|
||||
<div v-else-if="danmakuPreviewMessage" class="preview-empty">{{ danmakuPreviewMessage }}</div>
|
||||
<DanmakuPlayer
|
||||
v-else-if="danmakuPreviewUrl"
|
||||
:video-src="danmakuPreviewUrl"
|
||||
:danmaku-events="replayDanmakuEvents"
|
||||
/>
|
||||
<div v-else class="preview-empty">无法加载该分片的预览。</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -720,6 +791,17 @@ onMounted(loadDetail);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.preview-empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 260px;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
border: 1px dashed var(--border-base);
|
||||
color: var(--text-muted);
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
@@ -730,5 +812,10 @@ onMounted(loadDetail);
|
||||
flex: 1 1 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.preview-empty {
|
||||
min-height: 180px;
|
||||
padding: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||||
import { useViewport } from "@/composables/useViewport";
|
||||
import { useDanmakuPlayer } from "@/composables/useDanmakuPlayer";
|
||||
import DanmakuPlayer from "@/components/player/DanmakuPlayer.vue";
|
||||
import type {
|
||||
ManualSegmentCompletedTriggerResult,
|
||||
RecordArtifactUploadItemResult,
|
||||
@@ -35,6 +37,29 @@ const previewUrl = ref("");
|
||||
const previewExpiresAt = ref("");
|
||||
const previewMessage = ref("");
|
||||
const { isMobile } = useViewport();
|
||||
|
||||
// Danmaku replay state
|
||||
const { danmakuEvents, loading: danmakuLoading, error: danmakuError, loadTaskDanmaku, clear: clearDanmaku } = useDanmakuPlayer();
|
||||
const showDanmaku = ref(false);
|
||||
const danmakuLoaded = ref(false);
|
||||
|
||||
async function toggleDanmaku() {
|
||||
if (showDanmaku.value) {
|
||||
showDanmaku.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!danmakuLoaded.value) {
|
||||
const hasEvents = await loadTaskDanmaku(props.id);
|
||||
if (!hasEvents && danmakuError.value) {
|
||||
ElMessage.warning(danmakuError.value);
|
||||
return;
|
||||
}
|
||||
danmakuLoaded.value = true;
|
||||
}
|
||||
|
||||
showDanmaku.value = true;
|
||||
}
|
||||
const rowGutter = computed(() => (isMobile.value ? 14 : 18));
|
||||
const logTableHeight = computed(() => (isMobile.value ? undefined : 420));
|
||||
const activeTaskStatuses = new Set([1, 2, 3, 7]);
|
||||
@@ -234,7 +259,16 @@ function formatProgress(value?: number) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? `${value.toFixed(0)}%` : "-";
|
||||
}
|
||||
|
||||
watch(() => props.id, loadDetailAndPreview);
|
||||
function resetDanmakuState() {
|
||||
showDanmaku.value = false;
|
||||
danmakuLoaded.value = false;
|
||||
clearDanmaku();
|
||||
}
|
||||
|
||||
watch(() => props.id, () => {
|
||||
resetDanmakuState();
|
||||
loadDetailAndPreview();
|
||||
});
|
||||
onMounted(loadDetailAndPreview);
|
||||
</script>
|
||||
|
||||
@@ -388,6 +422,14 @@ onMounted(loadDetailAndPreview);
|
||||
<div class="preview-meta" v-if="previewExpiresAt">
|
||||
票据有效至 {{ formatDate(previewExpiresAt) }}
|
||||
</div>
|
||||
<el-button
|
||||
v-if="previewUrl && !previewLoading"
|
||||
:type="showDanmaku ? 'primary' : 'default'"
|
||||
:loading="danmakuLoading"
|
||||
@click="toggleDanmaku"
|
||||
>
|
||||
{{ showDanmaku ? "关闭弹幕" : "弹幕回放" }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canManualTranscode"
|
||||
type="primary"
|
||||
@@ -400,9 +442,14 @@ onMounted(loadDetailAndPreview);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="previewLoading" class="preview-empty">正在准备预览资源…</div>
|
||||
<div v-if="previewLoading || danmakuLoading" class="preview-empty">正在准备预览资源…</div>
|
||||
<DanmakuPlayer
|
||||
v-else-if="previewUrl && showDanmaku"
|
||||
:video-src="previewUrl"
|
||||
:danmaku-events="danmakuEvents"
|
||||
/>
|
||||
<video
|
||||
v-else-if="previewUrl"
|
||||
v-else-if="previewUrl && !showDanmaku"
|
||||
:key="previewUrl"
|
||||
class="preview-player"
|
||||
:src="previewUrl"
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage, ElNotification } from "element-plus";
|
||||
import { Bell, Connection, VideoCamera } from "@element-plus/icons-vue";
|
||||
import apiClient, {
|
||||
buildApiUrl,
|
||||
getApiErrorMessage,
|
||||
getBackendUnavailableMessage
|
||||
} from "@/api/client";
|
||||
import EmptyState from "@/components/ui/EmptyState.vue";
|
||||
import MetricCard from "@/components/ui/MetricCard.vue";
|
||||
import StatusBadge from "@/components/ui/StatusBadge.vue";
|
||||
import { markBackendAvailable, markBackendUnavailable } from "@/composables/useBackendStatus";
|
||||
import { useViewport } from "@/composables/useViewport";
|
||||
import type {
|
||||
@@ -38,7 +42,7 @@ const uploadingTaskId = ref<string | null>(null);
|
||||
const triggeringSegmentCompletedTaskId = ref<string | null>(null);
|
||||
const cleanupOperation = ref<CleanupOperation | null>(null);
|
||||
const deleteDialogVisible = ref(false);
|
||||
const deleteDialogMode = ref<"tasks" | "sessions" | "conditional-sessions" | "mixed" | "empty-sessions">("tasks");
|
||||
const deleteDialogMode = ref<"tasks" | "sessions" | "conditional-sessions" | "mixed" | "empty-sessions" | "missing-file-tasks">("tasks");
|
||||
const deleteDialogTaskIds = ref<string[]>([]);
|
||||
const deleteDialogSessionIds = ref<string[]>([]);
|
||||
|
||||
@@ -170,6 +174,10 @@ const deleteDialogEyebrow = computed(() => {
|
||||
return "空闲会话清理";
|
||||
}
|
||||
|
||||
if (deleteDialogMode.value === "missing-file-tasks") {
|
||||
return "无文件分片清理";
|
||||
}
|
||||
|
||||
return "删除确认";
|
||||
});
|
||||
const deleteDialogTitle = computed(() => {
|
||||
@@ -189,6 +197,10 @@ const deleteDialogTitle = computed(() => {
|
||||
return "清理无分片会话";
|
||||
}
|
||||
|
||||
if (deleteDialogMode.value === "missing-file-tasks") {
|
||||
return "清理无文件分片";
|
||||
}
|
||||
|
||||
return "删除分片任务";
|
||||
});
|
||||
const deleteDialogLead = computed(() => {
|
||||
@@ -208,6 +220,10 @@ const deleteDialogLead = computed(() => {
|
||||
return `将自动找出所有没有任何分片任务的录制会话并批量删除。你也可以选择同时删除可能残留的本地文件。`;
|
||||
}
|
||||
|
||||
if (deleteDialogMode.value === "missing-file-tasks") {
|
||||
return `将自动找出所有视频文件已丢失的分片任务并批量删除(不限会话,只删命中的分片本身)。删除后若某个会话下不再有任何分片,会话也会一并清理;你也可以选择同时清理残留的弹幕 XML 文件。`;
|
||||
}
|
||||
|
||||
return `将删除 ${deleteDialogTaskCount.value} 个已选择分片任务。你可以只移除数据库记录,也可以同时清理本地视频和弹幕 XML 文件。`;
|
||||
});
|
||||
const deleteDialogNote = computed(() => {
|
||||
@@ -223,6 +239,10 @@ const deleteDialogNote = computed(() => {
|
||||
return "仅清理没有任何关联分片的空会话,不影响有录制产物的会话。";
|
||||
}
|
||||
|
||||
if (deleteDialogMode.value === "missing-file-tasks") {
|
||||
return "以分片为单位判定:仅当分片的视频文件在磁盘上不存在时才会删除。正在录制或处理中的分片会自动跳过,有视频文件的分片不受影响。";
|
||||
}
|
||||
|
||||
return "记录加文件会尝试删除视频文件和对应弹幕 XML。文件不存在时不会阻断删除,但会返回警告信息。";
|
||||
});
|
||||
function isActiveStatus(status: number) {
|
||||
@@ -568,6 +588,13 @@ function openDeleteEmptySessionsDialog() {
|
||||
deleteDialogVisible.value = true;
|
||||
}
|
||||
|
||||
function openDeleteMissingFileTasksDialog() {
|
||||
deleteDialogMode.value = "missing-file-tasks";
|
||||
deleteDialogSessionIds.value = [];
|
||||
deleteDialogTaskIds.value = [];
|
||||
deleteDialogVisible.value = true;
|
||||
}
|
||||
|
||||
async function confirmConditionalDelete() {
|
||||
conditionalDialogVisible.value = false;
|
||||
deleteDialogMode.value = "conditional-sessions";
|
||||
@@ -638,6 +665,7 @@ async function confirmDelete(deleteFiles: boolean) {
|
||||
const deletingSessions = currentMode === "sessions";
|
||||
const deletingConditionalSessions = currentMode === "conditional-sessions";
|
||||
const deletingEmptySessions = currentMode === "empty-sessions";
|
||||
const deletingMissingFileTasks = currentMode === "missing-file-tasks";
|
||||
const deletingMixed = currentMode === "mixed";
|
||||
const hasSelection = deletingMixed
|
||||
? deleteDialogSessionIds.value.length > 0 || deleteDialogTaskIds.value.length > 0
|
||||
@@ -645,7 +673,7 @@ async function confirmDelete(deleteFiles: boolean) {
|
||||
? deleteDialogSessionIds.value.length > 0
|
||||
: deleteDialogTaskIds.value.length > 0;
|
||||
|
||||
if (!deletingConditionalSessions && !deletingEmptySessions && !hasSelection) {
|
||||
if (!deletingConditionalSessions && !deletingEmptySessions && !deletingMissingFileTasks && !hasSelection) {
|
||||
closeDeleteDialog();
|
||||
return;
|
||||
}
|
||||
@@ -681,6 +709,11 @@ async function confirmDelete(deleteFiles: boolean) {
|
||||
deleteFiles
|
||||
});
|
||||
createdCleanupOperation = data;
|
||||
} else if (deletingMissingFileTasks) {
|
||||
const { data } = await apiClient.post<DeleteCompletedRecordTasksResult>("/record-tasks/delete-missing-files", {
|
||||
deleteFiles
|
||||
});
|
||||
deletedTaskResult = data;
|
||||
} else if (deletingMixed) {
|
||||
if (deleteDialogTaskIds.value.length > 0) {
|
||||
const { data } = await apiClient.post<DeleteCompletedRecordTasksResult>("/record-tasks/delete", {
|
||||
@@ -719,7 +752,17 @@ async function confirmDelete(deleteFiles: boolean) {
|
||||
deleteDialogVisible.value = false;
|
||||
resetDeleteDialogState();
|
||||
|
||||
if (deleteDialogMode.value === "tasks") {
|
||||
if (currentMode === "missing-file-tasks") {
|
||||
ElMessage.success(
|
||||
deletedTaskResult
|
||||
? `已清理 ${deletedTaskResult.deletedTaskIds.length} 个无文件分片。`
|
||||
: "未发现可清理的无文件分片。"
|
||||
);
|
||||
await loadSessions({ resetPanels: false, resetSelection: false });
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentMode === "tasks") {
|
||||
ElMessage.success(
|
||||
deletedTaskResult ? `已删除 ${deletedTaskResult.deletedTaskIds.length} 个分片任务。` : "已删除分片任务。"
|
||||
);
|
||||
@@ -733,11 +776,7 @@ async function confirmDelete(deleteFiles: boolean) {
|
||||
return;
|
||||
}
|
||||
|
||||
ElMessage.success(
|
||||
currentMode === "tasks"
|
||||
? "已删除分片任务。"
|
||||
: "后台清理任务已创建,页面会自动轮询进度。"
|
||||
);
|
||||
ElMessage.success("后台清理任务已创建,页面会自动轮询进度。");
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
@@ -818,7 +857,7 @@ onBeforeUnmount(() => {
|
||||
<div class="page-kicker">录制工作台</div>
|
||||
<h1 class="page-title">录制任务</h1>
|
||||
<p class="page-subtitle">
|
||||
按直播会话聚合展示分片任务,列表会自动接收状态、转码进度和分片变更;保留手动刷新入口用于兜底。
|
||||
按直播会话聚合展示真实录制任务,自动接收状态、转码进度和分片变化,适合观察开播检测到归档上传的完整链路。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -856,7 +895,7 @@ onBeforeUnmount(() => {
|
||||
<div class="cleanup-status-card__title">当前任务状态:{{ cleanupOperationStatusLabel }}</div>
|
||||
</div>
|
||||
<div class="cleanup-status-card__actions">
|
||||
<el-tag :type="cleanupOperationTagType">{{ cleanupOperationStatusLabel }}</el-tag>
|
||||
<StatusBadge :label="cleanupOperationStatusLabel" :status="cleanupOperation?.status" context="cleanup" />
|
||||
<el-button v-if="cleanupOperationFinished" text @click="clearTrackedCleanupOperation">收起</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -875,26 +914,30 @@ onBeforeUnmount(() => {
|
||||
</ul>
|
||||
</el-card>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__label">录制会话</div>
|
||||
<div class="stat-card__value">{{ sessions.length }}</div>
|
||||
<div class="stat-card__hint">按整场直播聚合分片任务</div>
|
||||
<div class="record-ops-grid">
|
||||
<div class="stats-grid">
|
||||
<MetricCard label="录制会话" :value="sessions.length" description="按整场直播聚合的录制会话数" :icon="VideoCamera" />
|
||||
<MetricCard label="活跃会话" :value="activeSessionCount" description="启动中、录制中、停止中、处理中" :icon="Connection" />
|
||||
<MetricCard label="分片任务" :value="totalTaskCount" description="包含单文件模式下的唯一任务" :icon="Bell" />
|
||||
<MetricCard label="弹幕事件" :value="totalDanmakuCount" description="累计写入 XML 的事件总数" :icon="Bell" />
|
||||
</div>
|
||||
<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 / Processing</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__label">分片任务</div>
|
||||
<div class="stat-card__value">{{ totalTaskCount }}</div>
|
||||
<div class="stat-card__hint">包含单文件模式下的唯一任务</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__label">弹幕事件</div>
|
||||
<div class="stat-card__value">{{ totalDanmakuCount }}</div>
|
||||
<div class="stat-card__hint">累计写入 XML 的事件总数</div>
|
||||
|
||||
<div class="record-feature-strip">
|
||||
<article class="record-feature-card">
|
||||
<div class="record-feature-card__eyebrow">能力说明</div>
|
||||
<div class="record-feature-card__title">开播自动录制</div>
|
||||
<p class="record-feature-card__description">继续复用后端轮询、自动开录和活动会话保护逻辑,不新增任何前端假状态。</p>
|
||||
</article>
|
||||
<article class="record-feature-card">
|
||||
<div class="record-feature-card__eyebrow">能力说明</div>
|
||||
<div class="record-feature-card__title">分片后处理</div>
|
||||
<p class="record-feature-card__description">保留现有转码、分片完成事件和实时进度展示,聚焦运维可读性。</p>
|
||||
</article>
|
||||
<article class="record-feature-card">
|
||||
<div class="record-feature-card__eyebrow">能力说明</div>
|
||||
<div class="record-feature-card__title">上传归档</div>
|
||||
<p class="record-feature-card__description">继续调用真实上传接口,空数据时显示空状态而不是伪造归档数量。</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -923,10 +966,19 @@ onBeforeUnmount(() => {
|
||||
<el-button plain :loading="deleting" @click="openDeleteEmptySessionsDialog">
|
||||
清理无分片会话
|
||||
</el-button>
|
||||
<el-button plain :loading="deleting" @click="openDeleteMissingFileTasksDialog">
|
||||
清理无文件分片
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!loading && sessions.length === 0" description="暂无录制会话" />
|
||||
<EmptyState
|
||||
v-if="!loading && sessions.length === 0"
|
||||
title="暂无录制任务"
|
||||
description="当前筛选条件下没有可展示内容"
|
||||
action-text="刷新列表"
|
||||
@action="loadSessions()"
|
||||
/>
|
||||
|
||||
<div v-else-if="isMobile" class="data-card-list session-card-list">
|
||||
<article v-for="session in sessions" :key="session.id" class="data-card session-card">
|
||||
@@ -935,9 +987,7 @@ onBeforeUnmount(() => {
|
||||
<div class="data-card__title">{{ session.liveRoomTitle }}</div>
|
||||
<div class="data-card__subtitle monospace">{{ session.roomId }}</div>
|
||||
</div>
|
||||
<el-tag :type="sessionTagType(session.status)">
|
||||
{{ sessionStatusLabelMap[session.status] }}
|
||||
</el-tag>
|
||||
<StatusBadge :label="sessionStatusLabelMap[session.status]" :status="session.status" context="session" />
|
||||
</div>
|
||||
|
||||
<div class="badge-row">
|
||||
@@ -993,7 +1043,7 @@ onBeforeUnmount(() => {
|
||||
<strong class="monospace">#{{ task.segmentIndex }}</strong>
|
||||
<div class="cell-subtitle">{{ formatDate(task.startedAt || task.createdAt) }}</div>
|
||||
</div>
|
||||
<el-tag :type="taskTagType(task.status)">{{ taskStatusLabelMap[task.status] }}</el-tag>
|
||||
<StatusBadge :label="taskStatusLabelMap[task.status]" :status="task.status" context="task" />
|
||||
</div>
|
||||
|
||||
<div v-if="hasPostProcess(task)" class="session-task-card__progress">
|
||||
@@ -1081,9 +1131,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div class="session-title__stats">
|
||||
<el-tag :type="sessionTagType(session.status)">
|
||||
{{ sessionStatusLabelMap[session.status] }}
|
||||
</el-tag>
|
||||
<StatusBadge :label="sessionStatusLabelMap[session.status]" :status="session.status" context="session" />
|
||||
<span>{{ saveModeLabelMap[session.saveMode] }}</span>
|
||||
<span>{{ outputFormatLabelMap[session.outputFormat] }}</span>
|
||||
<span>分片 {{ session.segmentCount }}</span>
|
||||
@@ -1157,9 +1205,7 @@ onBeforeUnmount(() => {
|
||||
<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>
|
||||
<StatusBadge :label="taskStatusLabelMap[row.status]" :status="row.status" context="task" />
|
||||
<template v-if="hasPostProcess(row)">
|
||||
<div class="task-status-cell__stage">{{ row.postProcessStage }}</div>
|
||||
<el-progress
|
||||
@@ -1360,6 +1406,50 @@ onBeforeUnmount(() => {
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.record-ops-grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.record-feature-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.record-feature-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 18px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.98)),
|
||||
var(--surface);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.record-feature-card__eyebrow {
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.record-feature-card__title {
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.record-feature-card__description {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
align-self: center;
|
||||
}
|
||||
@@ -1722,6 +1812,10 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.record-feature-strip {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
align-self: stretch;
|
||||
justify-content: stretch;
|
||||
|
||||
@@ -3,6 +3,9 @@ import { computed, onMounted, ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import axios from "axios";
|
||||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||||
import EmptyState from "@/components/ui/EmptyState.vue";
|
||||
import MetricCard from "@/components/ui/MetricCard.vue";
|
||||
import StatusBadge from "@/components/ui/StatusBadge.vue";
|
||||
import { useViewport } from "@/composables/useViewport";
|
||||
import type {
|
||||
RecoverableFinalization,
|
||||
@@ -11,6 +14,7 @@ import type {
|
||||
RecoveryOverview
|
||||
} from "@/types";
|
||||
import { autoStartDecisionLabelMap, taskStatusLabelMap } from "@/types";
|
||||
import { RefreshRight, VideoCamera, WarningFilled } from "@element-plus/icons-vue";
|
||||
|
||||
const loading = ref(false);
|
||||
const retryAllLoading = ref(false);
|
||||
@@ -183,27 +187,20 @@ onMounted(loadOverview);
|
||||
|
||||
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
|
||||
|
||||
<div class="stats-grid" v-loading="loading">
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__label">存储保护</div>
|
||||
<div class="stat-card__value">{{ storage?.hasEnoughSpace ? "可恢复" : "受限" }}</div>
|
||||
<div class="stat-card__hint">{{ storage?.message || "暂无数据" }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__label">可重试开录</div>
|
||||
<div class="stat-card__value">{{ liveRooms.length }}</div>
|
||||
<div class="stat-card__hint">在线、启用中且当前没有活动会话的直播间。</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__label">可恢复转码</div>
|
||||
<div class="stat-card__value">{{ finalizations.length }}</div>
|
||||
<div class="stat-card__hint">等待继续或可手动补转码的 MP4 任务。</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__label">剩余空间</div>
|
||||
<div class="stat-card__value">{{ storage ? formatBytes(storage.availableBytes) : "-" }}</div>
|
||||
<div class="stat-card__hint">恢复阈值 {{ storage ? formatBytes(storage.requiredBytes) : "-" }}</div>
|
||||
</div>
|
||||
<div class="stats-grid recovery-metrics" v-loading="loading">
|
||||
<MetricCard
|
||||
label="存储保护"
|
||||
:value="storage?.hasEnoughSpace ? '可恢复' : '受限'"
|
||||
:description="storage?.message || '暂无存储数据'"
|
||||
:icon="WarningFilled"
|
||||
/>
|
||||
<MetricCard label="可重试开录" :value="liveRooms.length" description="在线、启用中且没有活动会话的直播间" :icon="RefreshRight" />
|
||||
<MetricCard label="可恢复转码" :value="finalizations.length" description="等待继续或可手动补转码的 MP4 任务" :icon="VideoCamera" />
|
||||
<MetricCard
|
||||
label="剩余空间"
|
||||
:value="storage ? formatBytes(storage.availableBytes) : '--'"
|
||||
:description="`恢复阈值 ${storage ? formatBytes(storage.requiredBytes) : '--'}`"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-card class="surface-card table-card" shadow="never">
|
||||
@@ -223,20 +220,30 @@ onMounted(loadOverview);
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="isMobile" class="data-card-list">
|
||||
<EmptyState
|
||||
v-if="!loading && liveRooms.length === 0"
|
||||
title="暂无数据"
|
||||
description="当前筛选条件下没有可展示内容"
|
||||
action-text="刷新总览"
|
||||
@action="loadOverview"
|
||||
/>
|
||||
|
||||
<div v-else-if="isMobile" class="data-card-list">
|
||||
<article v-for="row in liveRooms" :key="row.liveRoomId" class="data-card">
|
||||
<div class="data-card__header">
|
||||
<div>
|
||||
<div class="data-card__title">{{ liveRoomTitle(row) }}</div>
|
||||
<div class="data-card__subtitle">{{ row.anchorName || "未知主播" }}</div>
|
||||
</div>
|
||||
<el-tag effect="plain">{{ row.platformName }}</el-tag>
|
||||
<StatusBadge :label="row.platformName || '--'" :status="row.platformName || 'unknown'" />
|
||||
</div>
|
||||
|
||||
<div class="badge-row">
|
||||
<el-tag size="small" effect="plain" :type="autoStartDecisionTagType(row.lastAutoStartDecisionCode)">
|
||||
{{ autoStartDecisionLabel(row.lastAutoStartDecisionCode) }}
|
||||
</el-tag>
|
||||
<StatusBadge
|
||||
size="sm"
|
||||
:label="autoStartDecisionLabel(row.lastAutoStartDecisionCode)"
|
||||
:status="row.lastAutoStartDecisionCode || 'unknown'"
|
||||
/>
|
||||
<span class="info-pill">{{ formatDate(row.lastCheckedAt) }}</span>
|
||||
</div>
|
||||
|
||||
@@ -286,7 +293,7 @@ onMounted(loadOverview);
|
||||
|
||||
<el-table-column label="平台" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag effect="plain">{{ row.platformName }}</el-tag>
|
||||
<StatusBadge :label="row.platformName || '--'" :status="row.platformName || 'unknown'" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
@@ -294,9 +301,11 @@ onMounted(loadOverview);
|
||||
<template #default="{ row }">
|
||||
<div class="decision-cell">
|
||||
<div class="decision-cell__head">
|
||||
<el-tag size="small" effect="plain" :type="autoStartDecisionTagType(row.lastAutoStartDecisionCode)">
|
||||
{{ autoStartDecisionLabel(row.lastAutoStartDecisionCode) }}
|
||||
</el-tag>
|
||||
<StatusBadge
|
||||
size="sm"
|
||||
:label="autoStartDecisionLabel(row.lastAutoStartDecisionCode)"
|
||||
:status="row.lastAutoStartDecisionCode || 'unknown'"
|
||||
/>
|
||||
<span class="table-date-text">{{ formatDate(row.lastAutoStartDecisionAt) }}</span>
|
||||
</div>
|
||||
<div class="cell-subtitle">{{ row.lastAutoStartDecisionSummary || "暂无自动开录摘要" }}</div>
|
||||
@@ -344,14 +353,22 @@ onMounted(loadOverview);
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="isMobile" class="data-card-list">
|
||||
<EmptyState
|
||||
v-if="!loading && finalizations.length === 0"
|
||||
title="暂无数据"
|
||||
description="当前筛选条件下没有可展示内容"
|
||||
action-text="刷新总览"
|
||||
@action="loadOverview"
|
||||
/>
|
||||
|
||||
<div v-else-if="isMobile" class="data-card-list">
|
||||
<article v-for="row in finalizations" :key="row.recordTaskId" class="data-card">
|
||||
<div class="data-card__header">
|
||||
<div>
|
||||
<div class="data-card__title">{{ finalizationTitle(row) }}</div>
|
||||
<div class="data-card__subtitle">{{ row.platformName }} · Segment #{{ row.segmentIndex }}</div>
|
||||
</div>
|
||||
<el-tag effect="plain">{{ taskStatusLabelMap[row.status] }}</el-tag>
|
||||
<StatusBadge :label="taskStatusLabelMap[row.status]" :status="row.status" context="task" />
|
||||
</div>
|
||||
|
||||
<div class="data-card__grid">
|
||||
@@ -404,7 +421,7 @@ onMounted(loadOverview);
|
||||
|
||||
<el-table-column label="状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag effect="plain">{{ taskStatusLabelMap[row.status] }}</el-tag>
|
||||
<StatusBadge :label="taskStatusLabelMap[row.status]" :status="row.status" context="task" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
@@ -439,6 +456,10 @@ onMounted(loadOverview);
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.recovery-metrics :deep(.metric-card__value) {
|
||||
font-size: clamp(24px, 1.9vw, 34px);
|
||||
}
|
||||
|
||||
.decision-cell {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
|
||||
@@ -1,25 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||||
import type {
|
||||
CleanupOperation,
|
||||
CleanupVideoFileCondition,
|
||||
EventScriptTestResult,
|
||||
PlatformProxySettings,
|
||||
PlatformRequestSettings,
|
||||
SystemSettings,
|
||||
WebhookTestResult
|
||||
} from "@/types";
|
||||
import { outputFormatLabelMap, qualityOptionList, recordingTemplateLabelMap, saveModeLabelMap, taskStatusLabelMap } from "@/types";
|
||||
import {
|
||||
createDefaultPlatformRequestSettingsMap,
|
||||
outputFormatLabelMap,
|
||||
platformOptionList,
|
||||
qualityOptionList,
|
||||
recordingTemplateLabelMap,
|
||||
saveModeLabelMap,
|
||||
taskStatusLabelMap
|
||||
} from "@/types";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useViewport } from "@/composables/useViewport";
|
||||
import { useUiPreferences } from "@/composables/useUiPreferences";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
type ScriptEventType = "live_started" | "live_ended" | "segment_completed";
|
||||
|
||||
type SettingsFormModel = SystemSettings & {
|
||||
douyinProxy: PlatformProxySettings;
|
||||
bilibiliProxy: PlatformProxySettings;
|
||||
huyaProxy: PlatformProxySettings;
|
||||
douyinUserAgent: string;
|
||||
douyinReferer: string;
|
||||
douyinCookie: string;
|
||||
};
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const route = useRoute();
|
||||
const retentionCleanupStorageKey = "live-recorder-settings-retention-cleanup-operation-id";
|
||||
const { isMobile } = useViewport();
|
||||
const { sidebarCollapsed } = useUiPreferences();
|
||||
const { themeMode, density, sidebarCollapsed } = useUiPreferences();
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
@@ -44,8 +65,14 @@ const webhookTestResult = ref<WebhookTestResult | null>(null);
|
||||
const retentionCleanupOperation = ref<CleanupOperation | null>(null);
|
||||
const loadError = ref("");
|
||||
const activeSettingTab = ref("recording");
|
||||
const qualitySupportHint = "Douyin and Bilibili support quality-based stream selection. Huya does not yet. If a target quality is unavailable, the platform falls back to the closest available option.";
|
||||
const profileDisplayName = computed(() => authStore.user?.displayName || authStore.user?.username || "管理员");
|
||||
const profileUsername = computed(() => authStore.user?.username || "--");
|
||||
const profileUserId = computed(() => authStore.user?.userId || "--");
|
||||
const profileExpiresAt = computed(() => authStore.user?.expiresAt || "--");
|
||||
const profileInitial = computed(() => profileDisplayName.value.trim().slice(0, 1).toUpperCase() || "录");
|
||||
const qualitySupportHint = "Different platforms expose different quality ladders. If a target quality is unavailable, the recorder automatically falls back to the closest stream that platform offers.";
|
||||
const qualityOptions = qualityOptionList;
|
||||
const platformRequestPlatforms = platformOptionList;
|
||||
let retentionCleanupPollTimer: number | null = null;
|
||||
|
||||
const savebarStyle = computed(() => {
|
||||
@@ -64,7 +91,7 @@ const savebarStyle = computed(() => {
|
||||
};
|
||||
});
|
||||
|
||||
const form = reactive<SystemSettings>({
|
||||
const form = reactive<SettingsFormModel>({
|
||||
ffmpegPath: "ffmpeg",
|
||||
outputRoot: "records",
|
||||
outputDirectoryTemplate: "{platform}/{yyyy}/{MM}/{dd}/{anchor}",
|
||||
@@ -79,6 +106,8 @@ const form = reactive<SystemSettings>({
|
||||
enableStorageGuard: true,
|
||||
pauseRecordingWhenFreeSpaceBelowMegabytes: 1024,
|
||||
resumeRecordingWhenFreeSpaceAboveMegabytes: 4096,
|
||||
storageGreenThresholdPercent: 30,
|
||||
storageRedThresholdPercent: 10,
|
||||
enableRetentionCleanup: false,
|
||||
retentionDays: 30,
|
||||
retentionDeleteFiles: false,
|
||||
@@ -99,6 +128,7 @@ const form = reactive<SystemSettings>({
|
||||
enableAutoUpload: false,
|
||||
deleteLocalFilesAfterUpload: false,
|
||||
uploadTarget: 0,
|
||||
platformRequestSettings: createDefaultPlatformRequestSettingsMap(),
|
||||
douyinProxy: {
|
||||
enabled: false,
|
||||
proxyUrl: ""
|
||||
@@ -140,6 +170,8 @@ const form = reactive<SystemSettings>({
|
||||
segmentCompletedScriptPath: "",
|
||||
segmentCompletedScriptContent: "",
|
||||
eventScriptTimeoutSeconds: 60,
|
||||
eventScriptRetryAttempts: 3,
|
||||
eventScriptRetryDelaySeconds: 10,
|
||||
enableEmailNotification: false,
|
||||
emailSmtpHost: "",
|
||||
emailSmtpPort: 587,
|
||||
@@ -163,6 +195,10 @@ const form = reactive<SystemSettings>({
|
||||
<li><strong>Detected At (Beijing Time):</strong> {{detectedAtUtc}}</li>
|
||||
</ul>
|
||||
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
|
||||
<div style="margin-top: 16px;">
|
||||
<strong>Event Script Output:</strong>
|
||||
</div>
|
||||
<div style="margin-top: 8px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{eventScriptOutput}}</div>
|
||||
</div>`,
|
||||
emailExceptionSubjectTemplate: "[{{appName}}] Exception: {{source}}",
|
||||
emailExceptionBodyTemplateHtml: `<div style="font-family: 'Segoe UI', 'PingFang SC', sans-serif; color: #1f2937; line-height: 1.7;">
|
||||
@@ -190,6 +226,62 @@ const form = reactive<SystemSettings>({
|
||||
douyinCookie: ""
|
||||
});
|
||||
|
||||
function normalizePlatformRequestSettings(
|
||||
value?: Record<string, PlatformRequestSettings> | null
|
||||
): Record<string, PlatformRequestSettings> {
|
||||
const defaults = createDefaultPlatformRequestSettingsMap();
|
||||
|
||||
if (!value) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
for (const platform of platformRequestPlatforms) {
|
||||
const current = value[platform.key];
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
|
||||
defaults[platform.key] = {
|
||||
proxy: {
|
||||
enabled: current.proxy?.enabled ?? defaults[platform.key].proxy.enabled,
|
||||
proxyUrl: current.proxy?.proxyUrl ?? defaults[platform.key].proxy.proxyUrl
|
||||
},
|
||||
userAgent: current.userAgent ?? defaults[platform.key].userAgent,
|
||||
referer: current.referer ?? defaults[platform.key].referer,
|
||||
cookie: current.cookie ?? defaults[platform.key].cookie
|
||||
};
|
||||
}
|
||||
|
||||
return defaults;
|
||||
}
|
||||
|
||||
function syncLegacyPlatformAliasesFromMap() {
|
||||
form.douyinProxy = { ...form.platformRequestSettings.douyin.proxy };
|
||||
form.bilibiliProxy = { ...form.platformRequestSettings.bilibili.proxy };
|
||||
form.huyaProxy = { ...form.platformRequestSettings.huya.proxy };
|
||||
form.douyinUserAgent = form.platformRequestSettings.douyin.userAgent;
|
||||
form.douyinReferer = form.platformRequestSettings.douyin.referer;
|
||||
form.douyinCookie = form.platformRequestSettings.douyin.cookie;
|
||||
}
|
||||
|
||||
function syncPlatformRequestSettingsFromLegacyAliases() {
|
||||
form.platformRequestSettings.douyin = {
|
||||
...form.platformRequestSettings.douyin,
|
||||
proxy: { ...form.douyinProxy },
|
||||
userAgent: form.douyinUserAgent,
|
||||
referer: form.douyinReferer,
|
||||
cookie: form.douyinCookie
|
||||
};
|
||||
form.platformRequestSettings.bilibili = {
|
||||
...form.platformRequestSettings.bilibili,
|
||||
proxy: { ...form.bilibiliProxy }
|
||||
};
|
||||
form.platformRequestSettings.huya = {
|
||||
...form.platformRequestSettings.huya,
|
||||
proxy: { ...form.huyaProxy }
|
||||
};
|
||||
}
|
||||
|
||||
const outputTemplateTokens = [
|
||||
"{platform}",
|
||||
"{roomId}",
|
||||
@@ -220,7 +312,8 @@ const emailTemplateTokens = [
|
||||
"{{liveRoomId}}",
|
||||
"{{recordTaskId}}",
|
||||
"{{taskStatus}}",
|
||||
"{{occurredAtUtc}}"
|
||||
"{{occurredAtUtc}}",
|
||||
"{{eventScriptOutput}}"
|
||||
];
|
||||
|
||||
const webhookTemplateTokens = [
|
||||
@@ -241,6 +334,7 @@ const webhookTemplateTokens = [
|
||||
"{{recordTask.status}}",
|
||||
"{{recordTask.segmentIndex}}",
|
||||
"{{recordTask.outputFilePath}}",
|
||||
"{{eventScriptOutput}}",
|
||||
"{{report.date}}",
|
||||
"{{report.summary.activeLiveRoomCount}}",
|
||||
"{{report.summary.sessionCount}}",
|
||||
@@ -357,7 +451,7 @@ const retentionCleanupSummary = computed(() => {
|
||||
`logs ${retentionCleanupOperation.value.deletedLogCount}`,
|
||||
`files ${retentionCleanupOperation.value.deletedFileCount}`,
|
||||
`danmaku ${retentionCleanupOperation.value.deletedDanmakuFileCount}`
|
||||
].join(" · ");
|
||||
].join(" 路 ");
|
||||
});
|
||||
const retentionCleanupWarningsPreview = computed(() => retentionCleanupOperation.value?.warnings.slice(0, 6) ?? []);
|
||||
|
||||
@@ -369,12 +463,12 @@ const canSendTestWebhook = computed(() => Boolean(form.webhookUrl.trim()));
|
||||
|
||||
const segmentedExamplePath = computed(() => {
|
||||
const extension = form.defaultOutputFormat === 1 ? "ts" : "mp4";
|
||||
return `Douyin/2026/04/15/主播名/221530_origin_主播名_直播标题_123456789_00001.${extension}`;
|
||||
return `Douyin/2026/04/15/主播名_221530_origin_主播名_直播标题_123456789_00001.${extension}`;
|
||||
});
|
||||
|
||||
const nestedSegmentedExamplePath = computed(() => {
|
||||
const extension = form.defaultOutputFormat === 1 ? "ts" : "mp4";
|
||||
return `Douyin/origin/2026/04/15/主播名/221530_origin_主播名_直播标题_123456789/221530_origin_主播名_直播标题_123456789_00001.${extension}`;
|
||||
return `Douyin/origin/2026/04/15/主播名_221530_origin_主播名_直播标题_123456789/221530_origin_主播名_直播标题_123456789_00001.${extension}`;
|
||||
});
|
||||
|
||||
async function loadSettings() {
|
||||
@@ -384,6 +478,8 @@ async function loadSettings() {
|
||||
try {
|
||||
const { data } = await apiClient.get<SystemSettings>("/settings");
|
||||
Object.assign(form, data);
|
||||
form.platformRequestSettings = normalizePlatformRequestSettings(data.platformRequestSettings);
|
||||
syncLegacyPlatformAliasesFromMap();
|
||||
} catch (error) {
|
||||
loadError.value = getApiErrorMessage(error, "Failed to load system settings. Please try again later.");
|
||||
} finally {
|
||||
@@ -443,8 +539,15 @@ async function saveSettings() {
|
||||
saving.value = true;
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.put<SystemSettings>("/settings", form);
|
||||
syncPlatformRequestSettingsFromLegacyAliases();
|
||||
const payload: SystemSettings = {
|
||||
...form,
|
||||
platformRequestSettings: normalizePlatformRequestSettings(form.platformRequestSettings)
|
||||
};
|
||||
const { data } = await apiClient.put<SystemSettings>("/settings", payload);
|
||||
Object.assign(form, data);
|
||||
form.platformRequestSettings = normalizePlatformRequestSettings(data.platformRequestSettings);
|
||||
syncLegacyPlatformAliasesFromMap();
|
||||
ElMessage.success("Settings saved.");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
@@ -737,23 +840,45 @@ function parseDownloadFileName(contentDisposition?: string) {
|
||||
return plainMatch?.[1] ?? null;
|
||||
}
|
||||
|
||||
async function syncSettingsHash(hash = route.hash) {
|
||||
if (!hash) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hash === "#security") {
|
||||
activeSettingTab.value = "security";
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
document.querySelector(hash)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSettings();
|
||||
await restoreRetentionCleanupTracking();
|
||||
await syncSettingsHash();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopRetentionCleanupPolling();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.hash,
|
||||
(hash) => {
|
||||
void syncSettingsHash(hash);
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="page-kicker">系统设置</div>
|
||||
<h1 class="page-title">系统设置</h1>
|
||||
<p class="page-subtitle">
|
||||
录制、巡检、脚本、Webhook、邮件和保留清理都集中在这里。脚本测试、Webhook 测试和邮件测试都会直接使用当前表单值,不要求先保存。
|
||||
录制、轮询、通知、脚本和保留清理统一收口到这里。个人资料、安全与显示偏好也通过现有设置页完成管理。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -774,12 +899,75 @@ onBeforeUnmount(() => {
|
||||
|
||||
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
|
||||
|
||||
<div class="settings-overview-grid">
|
||||
<el-card id="profile" class="surface-card settings-overview-card" shadow="never">
|
||||
<div class="settings-overview-card__header">
|
||||
<div>
|
||||
<div class="settings-overview-card__eyebrow">个人资料</div>
|
||||
<h3 class="section-title">当前登录账户</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-profile">
|
||||
<div class="settings-profile__avatar">{{ profileInitial }}</div>
|
||||
<div>
|
||||
<div class="settings-profile__name">{{ profileDisplayName }}</div>
|
||||
<div class="settings-profile__meta">{{ profileUsername }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-overview-list">
|
||||
<div><span>用户名</span><strong>{{ profileUsername }}</strong></div>
|
||||
<div><span>用户 ID</span><strong>{{ profileUserId }}</strong></div>
|
||||
<div><span>邮箱</span><strong>--</strong></div>
|
||||
<div><span>角色</span><strong>--</strong></div>
|
||||
<div><span>当前空间</span><strong>--</strong></div>
|
||||
<div><span>在线状态</span><strong>在线</strong></div>
|
||||
<div><span>凭证到期</span><strong>{{ profileExpiresAt }}</strong></div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card id="preferences" class="surface-card settings-overview-card" shadow="never">
|
||||
<div class="settings-overview-card__header">
|
||||
<div>
|
||||
<div class="settings-overview-card__eyebrow">偏好设置</div>
|
||||
<h3 class="section-title">控制台显示偏好</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-preferences">
|
||||
<div class="settings-preferences__row">
|
||||
<span>主题模式</span>
|
||||
<el-select v-model="themeMode">
|
||||
<el-option label="跟随系统" value="system" />
|
||||
<el-option label="浅色" value="light" />
|
||||
<el-option label="深色" value="dark" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="settings-preferences__row">
|
||||
<span>显示密度</span>
|
||||
<el-select v-model="density">
|
||||
<el-option label="舒适密度" value="comfortable" />
|
||||
<el-option label="紧凑密度" value="compact" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="settings-preferences__row settings-preferences__row--switch">
|
||||
<div>
|
||||
<strong>侧栏折叠</strong>
|
||||
<p>继续复用当前前端偏好存储逻辑。</p>
|
||||
</div>
|
||||
<el-switch v-model="sidebarCollapsed" />
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div class="settings-grid" v-loading="loading">
|
||||
<el-tabs v-model="activeSettingTab" type="border-card" class="settings-tabs">
|
||||
<el-tab-pane label="录制" name="recording">
|
||||
<el-card class="surface-card settings-card" shadow="never">
|
||||
<h3 class="section-title">录制基础</h3>
|
||||
<p class="section-subtitle">Default quality, output format, segmentation, ffmpeg templates, and network tolerance settings are managed here.</p>
|
||||
<p class="section-subtitle">默认画质、输出格式、分段策略、ffmpeg 模板和网络容错等录制基础参数在此集中管理。</p>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
@@ -789,12 +977,12 @@ onBeforeUnmount(() => {
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Output root">
|
||||
<el-form-item label="输出根目录">
|
||||
<el-input v-model="form.outputRoot" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Default quality">
|
||||
<el-form-item label="默认画质">
|
||||
<el-select v-model="form.defaultQuality">
|
||||
<el-option
|
||||
v-for="option in qualityOptions"
|
||||
@@ -847,7 +1035,7 @@ onBeforeUnmount(() => {
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Max concurrent transcode tasks">
|
||||
<el-form-item label="最大并发转码任务数">
|
||||
<el-input-number v-model="form.maxConcurrentFfmpegTranscodeTasks" :min="1" :max="16" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -881,7 +1069,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<el-card class="surface-card settings-card" shadow="never">
|
||||
<h3 class="section-title">存储保护</h3>
|
||||
<p class="section-subtitle">Pause recording and MP4 finalization below the threshold, then resume once free space recovers.</p>
|
||||
<p class="section-subtitle">当磁盘空闲空间低于阈值时暂停录制和 MP4 转码,空间恢复后自动继续。</p>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
@@ -891,7 +1079,7 @@ onBeforeUnmount(() => {
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Pause below free space (MB)">
|
||||
<el-form-item label="暂停阈值:空闲空间低于 (MB)">
|
||||
<el-input-number
|
||||
v-model="form.pauseRecordingWhenFreeSpaceBelowMegabytes"
|
||||
:min="0"
|
||||
@@ -901,7 +1089,7 @@ onBeforeUnmount(() => {
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Resume above free space (MB)">
|
||||
<el-form-item label="恢复阈值:空闲空间高于 (MB)">
|
||||
<el-input-number
|
||||
v-model="form.resumeRecordingWhenFreeSpaceAboveMegabytes"
|
||||
:min="0"
|
||||
@@ -911,16 +1099,43 @@ onBeforeUnmount(() => {
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16" style="margin-top: 16px">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="绿色水位线:剩余空间高于 (%)">
|
||||
<el-input-number
|
||||
v-model="form.storageGreenThresholdPercent"
|
||||
:min="5"
|
||||
:max="90"
|
||||
:step="5"
|
||||
:disabled="!form.enableStorageGuard"
|
||||
/>
|
||||
<div class="field-hint">高于此比例时正常录制,低于时拒绝新录制</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="红色水位线:剩余空间低于 (%)">
|
||||
<el-input-number
|
||||
v-model="form.storageRedThresholdPercent"
|
||||
:min="1"
|
||||
:max="85"
|
||||
:step="5"
|
||||
:disabled="!form.enableStorageGuard"
|
||||
/>
|
||||
<div class="field-hint">低于此比例时暂停所有录制和转码,仅保留上传</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
|
||||
<div class="helper-panel">
|
||||
恢复阈值建议高于暂停阈值,避免磁盘空间在临界值附近反复抖动。MP4 转码会额外占用中间 TS 文件空间。
|
||||
恢复阈值建议高于暂停阈值,避免磁盘空间在临界值附近反复抖动。MP4 转码会额外占用中间 TS 文件空间。<br/>
|
||||
绿色/红色水位线控制三级存储保护:<b>绿色</b>(正常录制) → <b>黄色</b>(拒绝新录制,现有继续转码上传) → <b>红色</b>(暂停所有录制转码,仅上传清盘)。
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="surface-card settings-card" shadow="never">
|
||||
<h3 class="section-title">保留清理</h3>
|
||||
<p class="section-subtitle">Clean up inactive sessions, tasks, results, and logs by retention age, with optional file deletion.</p>
|
||||
<p class="section-subtitle">按保留天数清理不活跃的会话、任务、结果和日志,可选删除磁盘文件。</p>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
@@ -940,7 +1155,7 @@ onBeforeUnmount(() => {
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Video file condition">
|
||||
<el-form-item label="视频文件条件">
|
||||
<el-select v-model="form.retentionVideoFileCondition" style="width: 100%">
|
||||
<el-option
|
||||
v-for="option in retentionVideoFileOptions"
|
||||
@@ -952,8 +1167,8 @@ onBeforeUnmount(() => {
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Task status condition (all segments)">
|
||||
<el-select v-model="form.retentionTaskStatuses" multiple placeholder="Any status" style="width: 100%">
|
||||
<el-form-item label="任务状态条件(所有分段)">
|
||||
<el-select v-model="form.retentionTaskStatuses" multiple placeholder="任意状态" style="width: 100%">
|
||||
<el-option
|
||||
v-for="option in retentionTaskStatusOptions"
|
||||
:key="option.value"
|
||||
@@ -968,7 +1183,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div class="action-strip">
|
||||
<div class="helper-text">Run now and the daily retention cleanup use the same saved rules. Save this section first if you just changed the filters.</div>
|
||||
<el-button :loading="runningRetentionCleanup" @click="runRetentionCleanup">Run cleanup now</el-button>
|
||||
<el-button :loading="runningRetentionCleanup" @click="runRetentionCleanup">立即执行清理</el-button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -978,8 +1193,8 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<div class="test-result__title">Current cleanup task</div>
|
||||
<div class="test-result__meta">
|
||||
Status={{ retentionCleanupStatusLabel }} ·
|
||||
progress={{ retentionCleanupProgressText }} ·
|
||||
Status={{ retentionCleanupStatusLabel }} 路
|
||||
progress={{ retentionCleanupProgressText }} 路
|
||||
{{ retentionCleanupSummary }}
|
||||
</div>
|
||||
<div v-if="retentionCleanupOperation.errorMessage" class="test-result__detail">
|
||||
@@ -1023,7 +1238,7 @@ onBeforeUnmount(() => {
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="最大重试退避(秒)">
|
||||
<el-form-item label="鏈€澶ч噸璇曢€€閬匡紙绉掞級">
|
||||
<el-input-number
|
||||
v-model="form.danmakuRetryDelayMaxSeconds"
|
||||
:min="1"
|
||||
@@ -1084,11 +1299,11 @@ onBeforeUnmount(() => {
|
||||
</el-card>
|
||||
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="Polling and upload" name="polling">
|
||||
<el-tab-pane label="轮询与上传" name="polling">
|
||||
|
||||
<el-card class="surface-card settings-card" shadow="never">
|
||||
<h3 class="section-title">后台巡检</h3>
|
||||
<p class="section-subtitle">Control scheduled live-status checks and automatic recording starts when a room goes live.</p>
|
||||
<p class="section-subtitle">控制定时直播状态检查和直播间开播时自动开始录制。</p>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
@@ -1103,7 +1318,7 @@ onBeforeUnmount(() => {
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Polling interval (s)">
|
||||
<el-form-item label="轮询间隔(秒)">
|
||||
<el-input-number v-model="form.pollingIntervalSeconds" :min="10" :max="3600" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1111,7 +1326,7 @@ onBeforeUnmount(() => {
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
|
||||
<el-card v-if="false" class="surface-card settings-card settings-grid__full" shadow="never">
|
||||
<h3 class="section-title">Upload and archive</h3>
|
||||
<p class="section-subtitle">Upload video files and matching danmaku XML automatically or manually, then optionally delete local files after a successful upload.</p>
|
||||
|
||||
@@ -1123,12 +1338,12 @@ onBeforeUnmount(() => {
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="自动上传">
|
||||
<el-form-item label="鑷姩涓婁紶">
|
||||
<el-switch v-model="form.enableAutoUpload" :disabled="!form.enableFileUpload" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="上传后删本地">
|
||||
<el-form-item label="涓婁紶鍚庡垹鏈湴">
|
||||
<el-switch v-model="form.deleteLocalFilesAfterUpload" :disabled="!form.enableFileUpload" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1163,7 +1378,7 @@ onBeforeUnmount(() => {
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Endpoint">
|
||||
<el-form-item label="端点">
|
||||
<el-input v-model="form.webDavUpload.endpoint" placeholder="https://dav.example.com" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1173,7 +1388,7 @@ onBeforeUnmount(() => {
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Username">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="form.webDavUpload.username" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1197,37 +1412,37 @@ onBeforeUnmount(() => {
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Endpoint">
|
||||
<el-form-item label="端点">
|
||||
<el-input v-model="form.s3Upload.endpoint" placeholder="https://s3.example.com" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Bucket">
|
||||
<el-form-item label="存储桶">
|
||||
<el-input v-model="form.s3Upload.bucket" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Region">
|
||||
<el-form-item label="区域">
|
||||
<el-input v-model="form.s3Upload.region" placeholder="auto / us-east-1" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="前缀">
|
||||
<el-form-item label="鍓嶇紑">
|
||||
<el-input v-model="form.s3Upload.prefix" placeholder="live-recorder/" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Access Key">
|
||||
<el-form-item label="访问密钥">
|
||||
<el-input v-model="form.s3Upload.accessKey" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Secret Key">
|
||||
<el-form-item label="秘密密钥">
|
||||
<el-input v-model="form.s3Upload.secretKey" type="password" show-password />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Force Path Style">
|
||||
<el-form-item label="强制路径样式">
|
||||
<el-switch v-model="form.s3Upload.forcePathStyle" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1240,11 +1455,11 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
|
||||
<el-card v-if="false" class="surface-card settings-card settings-grid__full" shadow="never">
|
||||
<h3 class="section-title">平台代理</h3>
|
||||
<p class="section-subtitle">These proxies only affect platform-side status checks and stream requests, not email, webhook, or file uploads.</p>
|
||||
|
||||
<div class="event-script-grid">
|
||||
<div v-if="false" class="event-script-grid">
|
||||
<div class="event-script-section">
|
||||
<div class="event-script-section__header">
|
||||
<div>
|
||||
@@ -1297,16 +1512,26 @@ onBeforeUnmount(() => {
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8">
|
||||
<el-col :span="6">
|
||||
<el-form-item label="启用事件脚本">
|
||||
<el-switch v-model="form.enableEventScripts" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="Script timeout (s)">
|
||||
<el-col :span="6">
|
||||
<el-form-item label="脚本超时(秒)">
|
||||
<el-input-number v-model="form.eventScriptTimeoutSeconds" :min="1" :max="3600" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="5">
|
||||
<el-form-item label="重试次数">
|
||||
<el-input-number v-model="form.eventScriptRetryAttempts" :min="0" :max="20" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="重试间隔(秒)">
|
||||
<el-input-number v-model="form.eventScriptRetryDelaySeconds" :min="0" :max="3600" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
|
||||
@@ -1314,8 +1539,8 @@ onBeforeUnmount(() => {
|
||||
<div class="event-script-section">
|
||||
<div class="event-script-section__header">
|
||||
<div>
|
||||
<h4 class="event-script-section__title">Live started</h4>
|
||||
<p class="event-script-section__subtitle">Triggered when the room first enters the online state.</p>
|
||||
<h4 class="event-script-section__title">开播</h4>
|
||||
<p class="event-script-section__subtitle">当直播间首次进入开播状态时触发。</p>
|
||||
</div>
|
||||
<div class="event-script-section__actions">
|
||||
<el-switch v-model="form.enableLiveStartedScript" inline-prompt active-text="On" inactive-text="Off" />
|
||||
@@ -1369,7 +1594,7 @@ onBeforeUnmount(() => {
|
||||
<div class="event-script-section__header">
|
||||
<div>
|
||||
<h4 class="event-script-section__title">下播</h4>
|
||||
<p class="event-script-section__subtitle">Triggered when the room changes from online back to offline.</p>
|
||||
<p class="event-script-section__subtitle">当直播间从开播状态回到离线状态时触发。</p>
|
||||
</div>
|
||||
<div class="event-script-section__actions">
|
||||
<el-switch v-model="form.enableLiveEndedScript" inline-prompt active-text="On" inactive-text="Off" />
|
||||
@@ -1423,7 +1648,7 @@ onBeforeUnmount(() => {
|
||||
<div class="event-script-section__header">
|
||||
<div>
|
||||
<h4 class="event-script-section__title">分片完成</h4>
|
||||
<p class="event-script-section__subtitle">Triggered after MP4 finalization completes and the segment status becomes completed.</p>
|
||||
<p class="event-script-section__subtitle">当 MP4 转码最终化完成且分段状态变为已完成时触发。</p>
|
||||
</div>
|
||||
<div class="event-script-section__actions">
|
||||
<el-switch v-model="form.enableSegmentCompletedScript" inline-prompt active-text="On" inactive-text="Off" />
|
||||
@@ -1493,6 +1718,10 @@ onBeforeUnmount(() => {
|
||||
The official Docker image includes <code>curl</code> and <code>jq</code> by default. If you run on a host machine or a custom image, rely on the commands available in that environment.
|
||||
</div>
|
||||
|
||||
<div class="event-script-help__intro">
|
||||
Set <code>Retry attempts</code> to <code>0</code> to disable automatic retries. Retry exhaustion failures are sent through the existing exception notification channel.
|
||||
</div>
|
||||
|
||||
<div class="event-script-example">
|
||||
<div class="event-script-example__label">Environment variable examples</div>
|
||||
<div class="event-script-example__grid">
|
||||
@@ -1516,7 +1745,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
|
||||
<h3 class="section-title">Webhook 通知</h3>
|
||||
<p class="section-subtitle">Send fixed JSON POST payloads with custom headers. Currently only live-started and exception events are sent.</p>
|
||||
<p class="section-subtitle">Send fixed JSON POST payloads with custom headers. Exception notifications also cover low-storage stop events and event-script failures after retries are exhausted.</p>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
@@ -1536,7 +1765,7 @@ onBeforeUnmount(() => {
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="Timeout (s)">
|
||||
<el-form-item label="超时(秒)">
|
||||
<el-input-number v-model="form.webhookTimeoutSeconds" :min="1" :max="300" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1563,7 +1792,7 @@ onBeforeUnmount(() => {
|
||||
v-model="form.webhookBodyTemplate"
|
||||
type="textarea"
|
||||
:rows="8"
|
||||
placeholder="{ "event": "{{eventType}}", "summary": "{{summary}}", "roomId": "{{liveRoom.roomId}}" }"
|
||||
placeholder="{ "event": "{{eventType}}", "summary": "{{summary}}", "roomId": "{{liveRoom.roomId}}", "eventScriptOutput": "{{eventScriptOutput}}" }"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1577,7 +1806,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div class="action-strip">
|
||||
<div class="helper-text">The test sends a sample live_started payload using the current URL, headers, and timeout values from this form.</div>
|
||||
<div class="helper-text">The test sends a sample live_started payload, including sample event script output, using the current URL, headers, and timeout values from this form.</div>
|
||||
<el-button :loading="testingWebhook" :disabled="!canSendTestWebhook" @click="testWebhook">测试 Webhook</el-button>
|
||||
</div>
|
||||
|
||||
@@ -1589,7 +1818,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
|
||||
<h3 class="section-title">邮件通知</h3>
|
||||
<p class="section-subtitle">Configure SMTP plus HTML templates for live-started and exception alerts. Tests use the current unsaved form values directly.</p>
|
||||
<p class="section-subtitle">Configure SMTP plus HTML templates for live-started and exception alerts. Exception notifications also cover low-storage stop events and event-script failures after retries are exhausted.</p>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
@@ -1662,7 +1891,7 @@ onBeforeUnmount(() => {
|
||||
<div class="template-section__header">
|
||||
<div>
|
||||
<h4 class="template-section__title">Live started template</h4>
|
||||
<p class="template-section__subtitle">Subject templates render plain text, while the body template supports HTML.</p>
|
||||
<p class="template-section__subtitle">Subject templates render plain text, while the body template supports HTML and can include event script output placeholders.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1681,7 +1910,7 @@ onBeforeUnmount(() => {
|
||||
<div class="template-section__header">
|
||||
<div>
|
||||
<h4 class="template-section__title">异常提醒模板</h4>
|
||||
<p class="template-section__subtitle">Exception emails inject source, summary, detail, and task context values into the HTML body.</p>
|
||||
<p class="template-section__subtitle">Exception emails inject source, summary, detail, task context values, and optional event script output into the HTML body.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1705,7 +1934,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div class="helper-panel">
|
||||
邮件模板里的 <code v-pre>{{detectedAtUtc}}</code> 和 <code v-pre>{{occurredAtUtc}}</code> 字段名保持不变,但实际渲染值已经统一改成北京时间(UTC+8)。
|
||||
邮件模板里的 <code v-pre>{{detectedAtUtc}}</code> 和 <code v-pre>{{occurredAtUtc}}</code> 字段名保持不变,但实际渲染值已经统一改成北京时间(UTC+8)。<code v-pre>{{eventScriptOutput}}</code> 则对应脚本通过自定义日志文件输出的文本内容。
|
||||
</div>
|
||||
|
||||
<div class="action-strip">
|
||||
@@ -1717,9 +1946,9 @@ onBeforeUnmount(() => {
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="Security and platform" name="security">
|
||||
|
||||
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
|
||||
<h3 class="section-title">Administrator password</h3>
|
||||
<p class="section-subtitle">Change the password of the currently signed-in account. The new password takes effect immediately.</p>
|
||||
<el-card id="security" class="surface-card settings-card settings-grid__full" shadow="never">
|
||||
<h3 class="section-title">账号安全</h3>
|
||||
<p class="section-subtitle">复用现有修改密码逻辑,保存后立即对当前登录账户生效。</p>
|
||||
|
||||
<el-form
|
||||
ref="pwdFormRef"
|
||||
@@ -1731,10 +1960,10 @@ onBeforeUnmount(() => {
|
||||
<el-form-item label="当前密码" prop="currentPassword">
|
||||
<el-input v-model="pwdForm.currentPassword" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="New password" prop="newPassword">
|
||||
<el-form-item label="新密码" prop="newPassword">
|
||||
<el-input v-model="pwdForm.newPassword" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="Confirm new password" prop="confirmPassword">
|
||||
<el-form-item label="确认新密码" prop="confirmPassword">
|
||||
<el-input v-model="pwdForm.confirmPassword" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-button type="primary" :loading="changingPassword" @click="changePassword">修改密码</el-button>
|
||||
@@ -1742,6 +1971,59 @@ onBeforeUnmount(() => {
|
||||
</el-card>
|
||||
|
||||
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
|
||||
<h3 class="section-title">Platform request settings</h3>
|
||||
<p class="section-subtitle">Configure independent proxy, User-Agent, Referer, and Cookie values for each platform.</p>
|
||||
|
||||
<div class="event-script-grid">
|
||||
<div
|
||||
v-for="platform in platformRequestPlatforms"
|
||||
:key="platform.key"
|
||||
class="event-script-section event-script-section--full"
|
||||
>
|
||||
<div class="event-script-section__header">
|
||||
<div>
|
||||
<h4 class="event-script-section__title">{{ platform.label }}</h4>
|
||||
<p class="event-script-section__subtitle">
|
||||
These settings apply only to {{ platform.label }} status checks and stream requests.
|
||||
</p>
|
||||
</div>
|
||||
<el-switch v-model="form.platformRequestSettings[platform.key].proxy.enabled" />
|
||||
</div>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="代理 URL">
|
||||
<el-input
|
||||
v-model="form.platformRequestSettings[platform.key].proxy.proxyUrl"
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="User-Agent">
|
||||
<el-input
|
||||
v-model="form.platformRequestSettings[platform.key].userAgent"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Referer">
|
||||
<el-input v-model="form.platformRequestSettings[platform.key].referer" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Cookie">
|
||||
<el-input
|
||||
v-model="form.platformRequestSettings[platform.key].cookie"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="Optional cookies for this platform only"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="false" class="surface-card settings-card settings-grid__full" shadow="never">
|
||||
<h3 class="section-title">Douyin request headers</h3>
|
||||
<p class="section-subtitle">These disguise parameters are used for both Douyin API requests and ffmpeg stream input when troubleshooting stream access checks.</p>
|
||||
|
||||
@@ -1772,7 +2054,7 @@ onBeforeUnmount(() => {
|
||||
<div class="settings-savebar__content">
|
||||
<div class="settings-savebar__copy">
|
||||
<div class="settings-savebar__title">当前修改不会自动保存</div>
|
||||
<div class="settings-savebar__subtitle">You can save the current settings from anywhere on this page.</div>
|
||||
<div class="settings-savebar__subtitle">您可以在此页面任意位置保存当前设置。</div>
|
||||
</div>
|
||||
<el-button type="primary" :loading="saving" @click="saveSettings">保存设置</el-button>
|
||||
</div>
|
||||
@@ -1799,6 +2081,123 @@ onBeforeUnmount(() => {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.settings-overview-card :deep(.el-card__body) {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.settings-overview-card__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-overview-card__eyebrow {
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.settings-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.settings-profile__avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, #2563eb 0%, #4338ca 100%);
|
||||
color: #ffffff;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.settings-profile__name {
|
||||
color: var(--text-primary);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-profile__meta {
|
||||
margin-top: 4px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.settings-overview-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.settings-overview-list div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface-muted);
|
||||
border: 1px solid var(--border-subtle);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.settings-overview-list span {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.settings-overview-list strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.settings-preferences {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.settings-preferences__row {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-preferences__row > span,
|
||||
.settings-preferences__row strong {
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-preferences__row--switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.settings-preferences__row--switch p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
@@ -2178,6 +2577,10 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.settings-overview-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.event-script-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import VueDevTools from "vite-plugin-vue-devtools";
|
||||
import path from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
export default defineConfig(({ command }) => ({
|
||||
plugins: [
|
||||
...(command === "serve" ? [VueDevTools()] : []),
|
||||
vue()
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src")
|
||||
}
|
||||
},
|
||||
build: {
|
||||
target: "es2015",
|
||||
chunkSizeWarningLimit: 900,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
@@ -42,4 +47,4 @@ export default defineConfig({
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
import 'package:live_recorder_mobile/core/network/api_client.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
|
||||
class AuthRepository {
|
||||
const AuthRepository(this._apiClient);
|
||||
|
||||
final ApiClient _apiClient;
|
||||
|
||||
Future<LoginResponse> login({
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
final response = await _apiClient.postJson(
|
||||
'/api/auth/login',
|
||||
body: <String, dynamic>{
|
||||
'username': username,
|
||||
'password': password,
|
||||
},
|
||||
);
|
||||
return LoginResponse.fromJson(response as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<void> logout() {
|
||||
return _apiClient.postEmpty('/api/auth/logout');
|
||||
}
|
||||
|
||||
Future<void> changePassword({
|
||||
required String currentPassword,
|
||||
required String newPassword,
|
||||
}) {
|
||||
return _apiClient.postEmpty(
|
||||
'/api/auth/change-password',
|
||||
body: <String, dynamic>{
|
||||
'currentPassword': currentPassword,
|
||||
'newPassword': newPassword,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:live_recorder_mobile/core/network/api_client.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
|
||||
class LiveRoomsRepository {
|
||||
const LiveRoomsRepository(this._apiClient);
|
||||
|
||||
final ApiClient _apiClient;
|
||||
|
||||
Future<List<LiveRoom>> listRooms() async {
|
||||
final response = await _apiClient.getJson('/api/live-rooms') as List<dynamic>;
|
||||
return response
|
||||
.map((dynamic item) => LiveRoom.fromJson(item as Map<String, dynamic>))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<LiveRoom> getRoom(String roomId) async {
|
||||
final response = await _apiClient.getJson('/api/live-rooms/$roomId') as Map<String, dynamic>;
|
||||
return LiveRoom.fromJson(response);
|
||||
}
|
||||
|
||||
Future<LiveRoom> refreshRoom(String roomId) async {
|
||||
final response = await _apiClient.postJson('/api/live-rooms/$roomId/refresh') as Map<String, dynamic>;
|
||||
return LiveRoom.fromJson(response);
|
||||
}
|
||||
|
||||
Future<LiveRoom> setRoomEnabled({
|
||||
required String roomId,
|
||||
required bool isEnabled,
|
||||
}) async {
|
||||
final response = await _apiClient.putJson(
|
||||
'/api/live-rooms/$roomId/enabled',
|
||||
body: <String, dynamic>{'isEnabled': isEnabled},
|
||||
) as Map<String, dynamic>;
|
||||
return LiveRoom.fromJson(response);
|
||||
}
|
||||
|
||||
Future<LiveRoom> createRoom({
|
||||
required String url,
|
||||
int? platformOverride,
|
||||
}) async {
|
||||
final response = await _apiClient.postJson(
|
||||
'/api/live-rooms',
|
||||
body: <String, dynamic>{
|
||||
'url': url,
|
||||
if (platformOverride case final int value) 'platformOverride': value,
|
||||
},
|
||||
) as Map<String, dynamic>;
|
||||
return LiveRoom.fromJson(response);
|
||||
}
|
||||
|
||||
Future<LiveRoom> updateMetadata({
|
||||
required String roomId,
|
||||
required Map<String, dynamic> payload,
|
||||
}) async {
|
||||
final response = await _apiClient.putJson(
|
||||
'/api/live-rooms/$roomId/metadata',
|
||||
body: payload,
|
||||
) as Map<String, dynamic>;
|
||||
return LiveRoom.fromJson(response);
|
||||
}
|
||||
|
||||
Future<LiveRoom> updateSettings({
|
||||
required String roomId,
|
||||
required Map<String, dynamic> payload,
|
||||
}) async {
|
||||
final response = await _apiClient.putJson(
|
||||
'/api/live-rooms/$roomId/settings',
|
||||
body: payload,
|
||||
) as Map<String, dynamic>;
|
||||
return LiveRoom.fromJson(response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:live_recorder_mobile/core/network/api_client.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
|
||||
class LogsRepository {
|
||||
const LogsRepository(this._apiClient);
|
||||
|
||||
final ApiClient _apiClient;
|
||||
|
||||
Future<List<SystemLog>> listLogs({
|
||||
String? liveRoomId,
|
||||
String? recordSessionId,
|
||||
String? recordTaskId,
|
||||
int? level,
|
||||
String? content,
|
||||
int take = 200,
|
||||
}) async {
|
||||
final response = await _apiClient.getJson(
|
||||
'/api/logs',
|
||||
queryParameters: <String, String>{
|
||||
if (liveRoomId != null && liveRoomId.isNotEmpty) 'liveRoomId': liveRoomId,
|
||||
if (recordSessionId != null && recordSessionId.isNotEmpty) 'recordSessionId': recordSessionId,
|
||||
if (recordTaskId != null && recordTaskId.isNotEmpty) 'recordTaskId': recordTaskId,
|
||||
if (level != null) 'level': '$level',
|
||||
if (content != null && content.trim().isNotEmpty) 'content': content.trim(),
|
||||
'take': '$take',
|
||||
},
|
||||
) as List<dynamic>;
|
||||
|
||||
return response
|
||||
.map((dynamic item) => SystemLog.fromJson(item as Map<String, dynamic>))
|
||||
.toList(growable: false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:live_recorder_mobile/core/network/api_client.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
|
||||
class MediaRepository {
|
||||
const MediaRepository(this._apiClient);
|
||||
|
||||
final ApiClient _apiClient;
|
||||
|
||||
Future<MediaBrowserResponse> browse({String? path}) async {
|
||||
final response = await _apiClient.getJson(
|
||||
'/api/media/browser',
|
||||
queryParameters: <String, String>{
|
||||
if (path != null && path.isNotEmpty) 'path': path,
|
||||
},
|
||||
) as Map<String, dynamic>;
|
||||
return MediaBrowserResponse.fromJson(response);
|
||||
}
|
||||
|
||||
Uri buildFileUri({
|
||||
required String relativePath,
|
||||
bool download = false,
|
||||
}) {
|
||||
return _apiClient.buildUri(
|
||||
'/api/media/file',
|
||||
queryParameters: <String, String>{
|
||||
'path': relativePath,
|
||||
if (download) 'download': 'true',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> transcodeFile(String relativePath) async {
|
||||
final response = await _apiClient.postJson(
|
||||
'/api/media/transcode-file',
|
||||
body: <String, dynamic>{'relativePath': relativePath},
|
||||
) as Map<String, dynamic>;
|
||||
return response['message']?.toString() ?? '转码任务已提交';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:live_recorder_mobile/core/network/api_client.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
|
||||
class RecordingsRepository {
|
||||
const RecordingsRepository(this._apiClient);
|
||||
|
||||
final ApiClient _apiClient;
|
||||
|
||||
Future<List<RecordTask>> listTasks({String? liveRoomId}) async {
|
||||
final response = await _apiClient.getJson(
|
||||
'/api/record-tasks',
|
||||
queryParameters: <String, String>{
|
||||
if (liveRoomId != null && liveRoomId.isNotEmpty) 'liveRoomId': liveRoomId,
|
||||
},
|
||||
) as List<dynamic>;
|
||||
return response
|
||||
.map((dynamic item) => RecordTask.fromJson(item as Map<String, dynamic>))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<RecordTaskDetail> getTaskDetail(String taskId) async {
|
||||
final response = await _apiClient.getJson('/api/record-tasks/$taskId') as Map<String, dynamic>;
|
||||
return RecordTaskDetail.fromJson(response);
|
||||
}
|
||||
|
||||
Future<RecordTask> startRecording({
|
||||
required String liveRoomId,
|
||||
required String preferredQuality,
|
||||
required int outputFormat,
|
||||
}) async {
|
||||
final response = await _apiClient.postJson(
|
||||
'/api/record-tasks/start',
|
||||
body: <String, dynamic>{
|
||||
'liveRoomId': liveRoomId,
|
||||
'preferredQuality': preferredQuality,
|
||||
'outputFormat': outputFormat,
|
||||
},
|
||||
) as Map<String, dynamic>;
|
||||
return RecordTask.fromJson(response);
|
||||
}
|
||||
|
||||
Future<RecordTask> stopTask(String taskId) async {
|
||||
final response = await _apiClient.postJson('/api/record-tasks/$taskId/stop') as Map<String, dynamic>;
|
||||
return RecordTask.fromJson(response);
|
||||
}
|
||||
|
||||
Future<RecordPreviewTicket> createPreviewTicket(String taskId) async {
|
||||
final response = await _apiClient.postJson('/api/record-tasks/$taskId/preview-ticket') as Map<String, dynamic>;
|
||||
return RecordPreviewTicket.fromJson(response);
|
||||
}
|
||||
|
||||
Future<void> uploadTask(String taskId) {
|
||||
return _apiClient.postEmpty('/api/record-tasks/$taskId/upload');
|
||||
}
|
||||
|
||||
Future<List<RecordSession>> listSessions({String? liveRoomId}) async {
|
||||
final response = await _apiClient.getJson(
|
||||
'/api/record-sessions',
|
||||
queryParameters: <String, String>{
|
||||
if (liveRoomId != null && liveRoomId.isNotEmpty) 'liveRoomId': liveRoomId,
|
||||
},
|
||||
) as List<dynamic>;
|
||||
return response
|
||||
.map((dynamic item) => RecordSession.fromJson(item as Map<String, dynamic>))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<RecordSessionDetail> getSessionDetail(String sessionId) async {
|
||||
final response = await _apiClient.getJson('/api/record-sessions/$sessionId') as Map<String, dynamic>;
|
||||
return RecordSessionDetail.fromJson(response);
|
||||
}
|
||||
|
||||
Future<RecordSession> stopSession(String sessionId) async {
|
||||
final response = await _apiClient.postJson('/api/record-sessions/$sessionId/stop') as Map<String, dynamic>;
|
||||
return RecordSession.fromJson(response);
|
||||
}
|
||||
|
||||
Future<void> uploadSession(String sessionId) {
|
||||
return _apiClient.postEmpty('/api/record-sessions/$sessionId/upload');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:live_recorder_mobile/core/network/api_client.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
|
||||
class RecoveryRepository {
|
||||
const RecoveryRepository(this._apiClient);
|
||||
|
||||
final ApiClient _apiClient;
|
||||
|
||||
Future<RecoveryOverview> getOverview() async {
|
||||
final response = await _apiClient.getJson('/api/recovery') as Map<String, dynamic>;
|
||||
return RecoveryOverview.fromJson(response);
|
||||
}
|
||||
|
||||
Future<RecoveryActionResult> retryLiveRoom(String roomId) async {
|
||||
final response = await _apiClient.postJson('/api/recovery/live-rooms/$roomId/retry') as Map<String, dynamic>;
|
||||
return RecoveryActionResult.fromJson(response);
|
||||
}
|
||||
|
||||
Future<RecoveryActionResult> retryAllLiveRooms() async {
|
||||
final response = await _apiClient.postJson('/api/recovery/live-rooms/retry-all') as Map<String, dynamic>;
|
||||
return RecoveryActionResult.fromJson(response);
|
||||
}
|
||||
|
||||
Future<RecoveryActionResult> resumeFinalization(String taskId) async {
|
||||
final response = await _apiClient.postJson('/api/recovery/finalizations/$taskId/resume') as Map<String, dynamic>;
|
||||
return RecoveryActionResult.fromJson(response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:live_recorder_mobile/core/network/api_client.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
|
||||
class SettingsRepository {
|
||||
const SettingsRepository(this._apiClient);
|
||||
|
||||
final ApiClient _apiClient;
|
||||
|
||||
Future<SystemSettings> getSettings() async {
|
||||
final response = await _apiClient.getJson('/api/settings') as Map<String, dynamic>;
|
||||
return SystemSettings.fromJson(response);
|
||||
}
|
||||
|
||||
Future<SystemSettings> updateSettings(SystemSettings settings) async {
|
||||
final response = await _apiClient.putJson(
|
||||
'/api/settings',
|
||||
body: settings.toJson(),
|
||||
) as Map<String, dynamic>;
|
||||
return SystemSettings.fromJson(response);
|
||||
}
|
||||
|
||||
Future<CleanupOperation> runRetentionCleanup() async {
|
||||
final response = await _apiClient.postJson('/api/settings/retention/run-now') as Map<String, dynamic>;
|
||||
return CleanupOperation.fromJson(response);
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -6,7 +6,10 @@ namespace LiveRecorder.Application.Abstractions.Notifications;
|
||||
|
||||
public interface IEmailNotificationService
|
||||
{
|
||||
Task SendLiveStartedAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default);
|
||||
Task SendLiveStartedAsync(
|
||||
LiveRoom liveRoom,
|
||||
CancellationToken cancellationToken = default,
|
||||
string? eventScriptOutput = null);
|
||||
|
||||
Task SendExceptionAsync(
|
||||
string source,
|
||||
@@ -14,7 +17,8 @@ public interface IEmailNotificationService
|
||||
string? detail = null,
|
||||
LiveRoom? liveRoom = null,
|
||||
RecordTask? recordTask = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
CancellationToken cancellationToken = default,
|
||||
string? eventScriptOutput = null);
|
||||
|
||||
Task SendTestAsync(SendTestEmailRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
|
||||
+6
-2
@@ -6,7 +6,10 @@ namespace LiveRecorder.Application.Abstractions.Notifications;
|
||||
|
||||
public interface IWebhookNotificationService
|
||||
{
|
||||
Task SendLiveStartedAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default);
|
||||
Task SendLiveStartedAsync(
|
||||
LiveRoom liveRoom,
|
||||
CancellationToken cancellationToken = default,
|
||||
string? eventScriptOutput = null);
|
||||
|
||||
Task SendExceptionAsync(
|
||||
string source,
|
||||
@@ -14,7 +17,8 @@ public interface IWebhookNotificationService
|
||||
string? detail = null,
|
||||
LiveRoom? liveRoom = null,
|
||||
RecordTask? recordTask = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
CancellationToken cancellationToken = default,
|
||||
string? eventScriptOutput = null);
|
||||
|
||||
Task<WebhookTestResultDto> SendTestAsync(
|
||||
SendTestWebhookRequest request,
|
||||
|
||||
@@ -25,6 +25,10 @@ public interface ILiveRoomRepository
|
||||
|
||||
Task<IReadOnlyList<LiveRoom>> ListAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountByAvailabilityAsync(LiveRoomAvailabilityStatus status, CancellationToken cancellationToken = default);
|
||||
|
||||
Task AddAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default);
|
||||
|
||||
void Remove(LiveRoom liveRoom);
|
||||
@@ -42,6 +46,10 @@ public interface IRecordTaskRepository
|
||||
|
||||
Task<RecordTask?> GetRunningByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<double> SumDurationSecondsAsync(DateTimeOffset startedFrom, DateTimeOffset startedTo, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountByStatusAsync(RecordTaskStatus status, CancellationToken cancellationToken = default);
|
||||
|
||||
Task AddAsync(RecordTask recordTask, CancellationToken cancellationToken = default);
|
||||
|
||||
void Remove(RecordTask recordTask);
|
||||
@@ -59,6 +67,14 @@ public interface IRecordSessionRepository
|
||||
|
||||
Task<RecordSession?> GetActiveByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountByStatusAsync(RecordSessionStatus status, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountActiveAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<RecordSession>> ListRecentAsync(int take, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<RecordSession>> ListInDateRangeAsync(DateTimeOffset startedFrom, DateTimeOffset startedTo, CancellationToken cancellationToken = default);
|
||||
|
||||
Task AddAsync(RecordSession recordSession, CancellationToken cancellationToken = default);
|
||||
|
||||
void Remove(RecordSession recordSession);
|
||||
@@ -68,6 +84,12 @@ public interface IRecordResultRepository
|
||||
{
|
||||
Task<RecordResult?> GetByTaskIdAsync(Guid recordTaskId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<(long TotalBytes, int TotalDanmaku)> GetTodayAggregateAsync(DateTimeOffset createdFrom, DateTimeOffset createdTo, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountPendingUploadAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<long> SumPendingUploadBytesAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task AddAsync(RecordResult recordResult, CancellationToken cancellationToken = default);
|
||||
|
||||
void Update(RecordResult recordResult);
|
||||
@@ -100,6 +122,8 @@ public interface ISystemLogRepository
|
||||
int take = 200,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountRecentErrorsAsync(DateTimeOffset since, CancellationToken cancellationToken = default);
|
||||
|
||||
void RemoveRange(IEnumerable<SystemLogEntry> entries);
|
||||
|
||||
Task<IReadOnlyList<Guid>> ListSessionIdsWithoutTasksAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -14,6 +14,8 @@ public interface ILiveDanmakuAdapter
|
||||
public interface ILiveDanmakuAdapterFactory
|
||||
{
|
||||
ILiveDanmakuAdapter GetByPlatform(LivePlatformType platformType);
|
||||
|
||||
ILiveDanmakuAdapter? TryGetByPlatform(LivePlatformType platformType);
|
||||
}
|
||||
|
||||
public interface ILiveDanmakuConnection : IAsyncDisposable
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using LiveRecorder.Application.Models.RecordTasks;
|
||||
|
||||
namespace LiveRecorder.Application.Abstractions.Recording;
|
||||
|
||||
/// <summary>
|
||||
/// Service for reading and parsing danmaku XML files produced by the recording system.
|
||||
/// </summary>
|
||||
public interface IDanmakuService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns parsed danmaku events for a single recording task (one segment).
|
||||
/// Returns null if the task does not exist or has no danmaku file.
|
||||
/// </summary>
|
||||
Task<DanmakuResponseDto?> GetTaskDanmakuAsync(Guid recordTaskId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns aggregated danmaku events for all tasks in a recording session.
|
||||
/// Returns null if the session does not exist or has no tasks with danmaku.
|
||||
/// Offsets for segments beyond the first are adjusted so they are relative to the session start.
|
||||
/// </summary>
|
||||
Task<SessionDanmakuResponseDto?> GetSessionDanmakuAsync(Guid recordSessionId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace LiveRecorder.Application.Abstractions.Recording;
|
||||
|
||||
/// <summary>
|
||||
/// Service for extracting video metadata and generating thumbnails using ffmpeg/ffprobe.
|
||||
/// </summary>
|
||||
public interface IVideoMetadataService
|
||||
{
|
||||
Task<VideoMetadata?> ExtractMetadataAsync(string filePath, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<string?> GenerateThumbnailAsync(string filePath, string outputDir, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record VideoMetadata(
|
||||
double? DurationSeconds,
|
||||
int? Width,
|
||||
int? Height,
|
||||
string? VideoCodec,
|
||||
string? AudioCodec,
|
||||
double? FrameRate,
|
||||
long? BitRate);
|
||||
|
||||
public sealed record VideoMetadataDto(
|
||||
double? DurationSeconds,
|
||||
int? Width,
|
||||
int? Height,
|
||||
string? VideoCodec,
|
||||
string? AudioCodec,
|
||||
double? FrameRate,
|
||||
long? BitRate);
|
||||
@@ -5,11 +5,17 @@ namespace LiveRecorder.Application.Abstractions.Scripting;
|
||||
|
||||
public interface IEventScriptService
|
||||
{
|
||||
Task RunLiveStartedAsync(LiveRoom liveRoom, DateTimeOffset occurredAt, CancellationToken cancellationToken = default);
|
||||
Task<EventScriptExecutionResultDto?> RunLiveStartedAsync(
|
||||
LiveRoom liveRoom,
|
||||
DateTimeOffset occurredAt,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task RunLiveEndedAsync(LiveRoom liveRoom, DateTimeOffset occurredAt, CancellationToken cancellationToken = default);
|
||||
Task<EventScriptExecutionResultDto?> RunLiveEndedAsync(
|
||||
LiveRoom liveRoom,
|
||||
DateTimeOffset occurredAt,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task RunSegmentCompletedAsync(
|
||||
Task<EventScriptExecutionResultDto?> RunSegmentCompletedAsync(
|
||||
LiveRoom? liveRoom,
|
||||
RecordSession recordSession,
|
||||
RecordTask recordTask,
|
||||
|
||||
@@ -2,6 +2,18 @@ using LiveRecorder.Application.Models.Settings;
|
||||
|
||||
namespace LiveRecorder.Application.Abstractions.Storage;
|
||||
|
||||
public enum StorageTier
|
||||
{
|
||||
/// <summary>Disk has sufficient free space for normal operation.</summary>
|
||||
Green = 0,
|
||||
|
||||
/// <summary>Disk space is low. Deny new recordings but allow existing to finish.</summary>
|
||||
Yellow = 1,
|
||||
|
||||
/// <summary>Disk space is critically low. Deny new recordings and pause active ones.</summary>
|
||||
Red = 2
|
||||
}
|
||||
|
||||
public interface IStorageGuardService
|
||||
{
|
||||
StorageGuardResult CheckCanStartOrResume(SystemSettingsDto settings, long additionalRequiredBytes = 0);
|
||||
@@ -15,5 +27,27 @@ public sealed record StorageGuardResult(
|
||||
string CheckedPath,
|
||||
long AvailableBytes,
|
||||
long RequiredBytes,
|
||||
string Message);
|
||||
string Message)
|
||||
{
|
||||
/// <summary>
|
||||
/// Current storage tier (Green/Yellow/Red).
|
||||
/// </summary>
|
||||
public StorageTier Tier { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Disk usage percentage (0-100). Only populated when IsEnabled is true.
|
||||
/// </summary>
|
||||
public double UsagePercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// True if new recordings can be started. Only true in Green tier.
|
||||
/// This replaces the old binary HasEnoughSpace check — the tier system is the single source of truth.
|
||||
/// </summary>
|
||||
public bool CanStartNewRecording => Tier == StorageTier.Green;
|
||||
|
||||
/// <summary>
|
||||
/// True if active recordings should be paused. Only true in Red tier.
|
||||
/// This replaces the old MB-based CheckShouldPause — consolidated into the tier system.
|
||||
/// </summary>
|
||||
public bool ShouldPauseActive => Tier == StorageTier.Red;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using LiveRecorder.Domain.Enums;
|
||||
|
||||
namespace LiveRecorder.Application.Common;
|
||||
|
||||
public sealed record LivePlatformDefinition(
|
||||
LivePlatformType Type,
|
||||
string Key,
|
||||
string DisplayName,
|
||||
string DefaultReferer,
|
||||
string DefaultUserAgent);
|
||||
|
||||
public static class LivePlatformCatalog
|
||||
{
|
||||
public const string GenericDesktopUserAgent =
|
||||
"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";
|
||||
|
||||
private static readonly LivePlatformDefinition[] Definitions =
|
||||
[
|
||||
new(LivePlatformType.Douyin, "douyin", "Douyin", "https://live.douyin.com/", GenericDesktopUserAgent),
|
||||
new(LivePlatformType.Bilibili, "bilibili", "Bilibili", "https://live.bilibili.com/", GenericDesktopUserAgent),
|
||||
new(LivePlatformType.Huya, "huya", "Huya", "https://www.huya.com/", GenericDesktopUserAgent),
|
||||
new(LivePlatformType.Douyu, "douyu", "Douyu", "https://www.douyu.com/", GenericDesktopUserAgent),
|
||||
new(LivePlatformType.Kuaishou, "kuaishou", "Kuaishou", "https://live.kuaishou.com/", GenericDesktopUserAgent),
|
||||
new(LivePlatformType.TikTok, "tiktok", "TikTok", "https://www.tiktok.com/", GenericDesktopUserAgent),
|
||||
new(LivePlatformType.Xiaohongshu, "xiaohongshu", "Xiaohongshu", "https://www.xiaohongshu.com/", GenericDesktopUserAgent),
|
||||
new(LivePlatformType.YouTube, "youtube", "YouTube", "https://www.youtube.com/", GenericDesktopUserAgent),
|
||||
new(LivePlatformType.Twitch, "twitch", "Twitch", "https://www.twitch.tv/", GenericDesktopUserAgent),
|
||||
new(LivePlatformType.PandaTV, "pandatv", "PandaTV", "https://www.pandalive.co.kr/", GenericDesktopUserAgent),
|
||||
new(LivePlatformType.Migu, "migu", "Migu", "https://www.miguvideo.com/", GenericDesktopUserAgent)
|
||||
];
|
||||
|
||||
private static readonly IReadOnlyDictionary<LivePlatformType, LivePlatformDefinition> DefinitionByType =
|
||||
Definitions.ToDictionary(static item => item.Type);
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, LivePlatformDefinition> DefinitionByKey =
|
||||
Definitions.ToDictionary(static item => item.Key, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static IReadOnlyList<LivePlatformDefinition> All => Definitions;
|
||||
|
||||
public static LivePlatformDefinition Get(LivePlatformType platformType)
|
||||
{
|
||||
if (!TryGet(platformType, out var definition))
|
||||
{
|
||||
throw new NotSupportedException($"Unsupported live platform: {platformType}.");
|
||||
}
|
||||
|
||||
return definition;
|
||||
}
|
||||
|
||||
public static bool TryGet(LivePlatformType platformType, out LivePlatformDefinition definition) =>
|
||||
DefinitionByType.TryGetValue(platformType, out definition!);
|
||||
|
||||
public static bool TryGetByKey(string? key, out LivePlatformDefinition definition)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
definition = default!;
|
||||
return false;
|
||||
}
|
||||
|
||||
return DefinitionByKey.TryGetValue(key.Trim(), out definition!);
|
||||
}
|
||||
|
||||
public static string GetKey(LivePlatformType platformType) => Get(platformType).Key;
|
||||
|
||||
public static string GetDisplayName(LivePlatformType platformType) =>
|
||||
TryGet(platformType, out var definition)
|
||||
? definition.DisplayName
|
||||
: platformType.ToString();
|
||||
}
|
||||
@@ -22,6 +22,16 @@ public sealed class MediaBrowserItemDto
|
||||
public bool CanTranscode { get; init; }
|
||||
|
||||
public bool CanPreview { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Video metadata (only populated when includeMetadata is requested and item is a video file).
|
||||
/// </summary>
|
||||
public Abstractions.Recording.VideoMetadataDto? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Thumbnail URL relative path (only populated when includeMetadata is requested and item is a video file).
|
||||
/// </summary>
|
||||
public string? ThumbnailUrl { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MediaBrowserResponseDto
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace LiveRecorder.Application.Models.RecordTasks;
|
||||
|
||||
/// <summary>
|
||||
/// Bandwidth summary statistics.
|
||||
/// </summary>
|
||||
public sealed class BandwidthSummaryDto
|
||||
{
|
||||
public double TotalTrafficMB { get; init; }
|
||||
public double AverageBitrateKbps { get; init; }
|
||||
public double PeakBitrateKbps { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bandwidth timeline for a recording session.
|
||||
/// </summary>
|
||||
public sealed class BandwidthTimelineDto
|
||||
{
|
||||
public Guid RecordSessionId { get; init; }
|
||||
public required IReadOnlyList<BandwidthPointDto> Points { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single bandwidth sample point in time.
|
||||
/// </summary>
|
||||
public sealed class BandwidthPointDto
|
||||
{
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
public long BytesDownloaded { get; init; }
|
||||
public double? BitrateKbps { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
namespace LiveRecorder.Application.Models.RecordTasks;
|
||||
|
||||
/// <summary>
|
||||
/// A single parsed danmaku event (chat message or non-chat event like gift/like/member/enter).
|
||||
/// </summary>
|
||||
public sealed class DanmakuEventDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Offset in seconds from the segment's video start time.
|
||||
/// </summary>
|
||||
public double OffsetSeconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event type: "chat", "gift", "like", "member", "enter", "superchat", "live", "preparing", or platform-specific types.
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// For chat: the message text. For non-chat events: a descriptive label (e.g., "gift: rose x1").
|
||||
/// </summary>
|
||||
public required string Content { get; init; }
|
||||
|
||||
public string? User { get; init; }
|
||||
|
||||
public string? UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hex color string for chat messages (e.g., "FFFFFF"). Only meaningful for chat events.
|
||||
/// </summary>
|
||||
public string? Color { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Font size for chat messages (e.g., 25). Only meaningful for chat events.
|
||||
/// </summary>
|
||||
public int? FontSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Display mode for chat messages (1 = scroll right-to-left). Only meaningful for chat events.
|
||||
/// </summary>
|
||||
public int? Mode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unix timestamp in milliseconds when the event occurred (from the platform or recorded time).
|
||||
/// </summary>
|
||||
public long? TimestampMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// For gift events: the gift name (e.g., "rose").
|
||||
/// </summary>
|
||||
public string? GiftName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// For gift/like events: the repeat count.
|
||||
/// </summary>
|
||||
public int? Count { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Truncated raw payload from the platform (for debugging).
|
||||
/// </summary>
|
||||
public string? Raw { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Danmaku response for a single recording task (one segment).
|
||||
/// </summary>
|
||||
public sealed class DanmakuResponseDto
|
||||
{
|
||||
public Guid RecordTaskId { get; init; }
|
||||
|
||||
public int SegmentIndex { get; init; }
|
||||
|
||||
public string? Platform { get; init; }
|
||||
|
||||
public string? RoomId { get; init; }
|
||||
|
||||
public string? LiveRoomId { get; init; }
|
||||
|
||||
public Guid RecordSessionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The UTC time when this segment's recording started (anchor for offset calculation).
|
||||
/// </summary>
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
|
||||
public required IReadOnlyList<DanmakuEventDto> Events { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated danmaku response for an entire recording session (all segments).
|
||||
/// </summary>
|
||||
public sealed class SessionDanmakuResponseDto
|
||||
{
|
||||
public Guid RecordSessionId { get; init; }
|
||||
|
||||
public required IReadOnlyList<DanmakuResponseDto> Tasks { get; init; }
|
||||
}
|
||||
@@ -166,3 +166,23 @@ public sealed class RecordSessionDeletionBatchResult
|
||||
|
||||
public required IReadOnlyList<string> Warnings { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SessionPlaylistDto
|
||||
{
|
||||
public Guid RecordSessionId { get; init; }
|
||||
|
||||
public string LiveRoomTitle { get; init; } = string.Empty;
|
||||
|
||||
public required IReadOnlyList<SessionPlaylistSegmentDto> Segments { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SessionPlaylistSegmentDto
|
||||
{
|
||||
public Guid RecordTaskId { get; init; }
|
||||
|
||||
public int SegmentIndex { get; init; }
|
||||
|
||||
public string PreviewTicketUrl { get; init; } = string.Empty;
|
||||
|
||||
public double? DurationSeconds { get; init; }
|
||||
}
|
||||
|
||||
@@ -108,6 +108,11 @@ public sealed class DeleteCompletedRecordTasksRequest
|
||||
public bool DeleteFiles { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DeleteMissingFileRecordTasksRequest
|
||||
{
|
||||
public bool DeleteFiles { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DeleteCompletedRecordTasksResultDto
|
||||
{
|
||||
public required IReadOnlyList<Guid> DeletedTaskIds { get; init; }
|
||||
|
||||
@@ -24,6 +24,12 @@ public sealed class StorageGuardStatusDto
|
||||
public long RequiredBytes { get; init; }
|
||||
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>Storage tier: Green, Yellow, or Red.</summary>
|
||||
public string Tier { get; init; } = "Green";
|
||||
|
||||
/// <summary>Disk usage percentage (0-100).</summary>
|
||||
public double UsagePercent { get; init; }
|
||||
}
|
||||
|
||||
public sealed class RecoverableLiveRoomDto
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
namespace LiveRecorder.Application.Models.Reports;
|
||||
|
||||
/// <summary>
|
||||
/// Real-time system dashboard overview DTO.
|
||||
/// </summary>
|
||||
public sealed class DashboardDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of sessions currently recording (Running status).
|
||||
/// </summary>
|
||||
public int ActiveRecordingCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of live rooms currently live.
|
||||
/// </summary>
|
||||
public int LiveRoomCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of live rooms currently offline.
|
||||
/// </summary>
|
||||
public int OfflineRoomCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of live rooms in the system.
|
||||
/// </summary>
|
||||
public int TotalRoomCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total recording duration in seconds for sessions started today (Beijing time).
|
||||
/// </summary>
|
||||
public double TodayRecordingSeconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total data recorded today in bytes (sum of FileSizeBytes).
|
||||
/// </summary>
|
||||
public long TodayDataBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total danmaku events recorded today.
|
||||
/// </summary>
|
||||
public int TodayDanmakuCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of sessions with Starting or Running status.
|
||||
/// </summary>
|
||||
public int ActiveSessionCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of Error-level system logs in the last 24 hours.
|
||||
/// </summary>
|
||||
public int RecentErrorCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current storage guard status.
|
||||
/// </summary>
|
||||
public StorageStatusDto StorageStatus { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Most recent active/completed sessions (up to 5).
|
||||
/// </summary>
|
||||
public required IReadOnlyList<RecentSessionItemDto> RecentSessions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Top live rooms by recording duration today (up to 5).
|
||||
/// </summary>
|
||||
public required IReadOnlyList<TopRoomItemDto> TopRooms { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of recording tasks currently in Processing (transcoding) status.
|
||||
/// </summary>
|
||||
public int PendingTranscodeCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of recording results with NotUploaded status where local file still exists.
|
||||
/// </summary>
|
||||
public int PendingUploadCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total file size in bytes of files awaiting upload.
|
||||
/// </summary>
|
||||
public long QueuedDataBytes { get; init; }
|
||||
}
|
||||
|
||||
public sealed class StorageStatusDto
|
||||
{
|
||||
public bool HasEnoughSpace { get; init; }
|
||||
public string Message { get; init; } = string.Empty;
|
||||
public long AvailableBytes { get; init; }
|
||||
public string Tier { get; init; } = "Green";
|
||||
public double UsagePercent { get; init; }
|
||||
}
|
||||
|
||||
public sealed class RecentSessionItemDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid LiveRoomId { get; init; }
|
||||
public string LiveRoomTitle { get; init; } = string.Empty;
|
||||
public string PlatformName { get; init; } = string.Empty;
|
||||
public int SegmentCount { get; init; }
|
||||
public int Status { get; init; }
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
public double? DurationSeconds { get; init; }
|
||||
}
|
||||
|
||||
public sealed class TopRoomItemDto
|
||||
{
|
||||
public Guid LiveRoomId { get; init; }
|
||||
public string? Title { get; init; }
|
||||
public string? AnchorName { get; init; }
|
||||
public string PlatformName { get; init; } = string.Empty;
|
||||
public string RoomId { get; init; } = string.Empty;
|
||||
public int SessionCount { get; init; }
|
||||
public double TotalDurationSeconds { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace LiveRecorder.Application.Models.Reports;
|
||||
|
||||
public sealed class HealthReadyResponse
|
||||
{
|
||||
public string Status { get; set; } = "ready";
|
||||
public DateTimeOffset Timestamp { get; set; }
|
||||
public DatabaseHealthStatus Database { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class DatabaseHealthStatus
|
||||
{
|
||||
public bool Reachable { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
public int ConsecutiveFailures { get; set; }
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using System.Text.Json.Serialization;
|
||||
using LiveRecorder.Application.Common;
|
||||
using LiveRecorder.Application.Models.Cleanup;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
|
||||
namespace LiveRecorder.Application.Models.Settings;
|
||||
|
||||
@@ -17,6 +19,41 @@ public sealed class PlatformProxySettingsDto
|
||||
public string ProxyUrl { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class PlatformRequestSettingsDto
|
||||
{
|
||||
public PlatformProxySettingsDto Proxy { get; set; } = new();
|
||||
|
||||
public string UserAgent { get; set; } = string.Empty;
|
||||
|
||||
public string Referer { get; set; } = string.Empty;
|
||||
|
||||
public string Cookie { get; set; } = string.Empty;
|
||||
|
||||
public static PlatformRequestSettingsDto CreateDefault(LivePlatformType platformType)
|
||||
{
|
||||
var definition = LivePlatformCatalog.Get(platformType);
|
||||
return new PlatformRequestSettingsDto
|
||||
{
|
||||
Proxy = new PlatformProxySettingsDto(),
|
||||
UserAgent = definition.DefaultUserAgent,
|
||||
Referer = definition.DefaultReferer,
|
||||
Cookie = string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
public PlatformRequestSettingsDto Clone() => new()
|
||||
{
|
||||
Proxy = new PlatformProxySettingsDto
|
||||
{
|
||||
Enabled = Proxy.Enabled,
|
||||
ProxyUrl = Proxy.ProxyUrl
|
||||
},
|
||||
UserAgent = UserAgent,
|
||||
Referer = Referer,
|
||||
Cookie = Cookie
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class WebDavUploadSettingsDto
|
||||
{
|
||||
public string Endpoint { get; set; } = string.Empty;
|
||||
@@ -75,6 +112,12 @@ public sealed class SystemSettingsDto
|
||||
|
||||
public int ResumeRecordingWhenFreeSpaceAboveMegabytes { get; set; } = 4096;
|
||||
|
||||
/// <summary>Disk free percentage above which storage is considered healthy (Green tier). Default 30%.</summary>
|
||||
public double StorageGreenThresholdPercent { get; set; } = 30;
|
||||
|
||||
/// <summary>Disk free percentage below which storage is critical (Red tier). Default 10%.</summary>
|
||||
public double StorageRedThresholdPercent { get; set; } = 10;
|
||||
|
||||
public bool EnableAutoReconnect { get; set; } = true;
|
||||
|
||||
public int ReconnectDelayMaxSeconds { get; set; } = 5;
|
||||
@@ -105,11 +148,8 @@ public sealed class SystemSettingsDto
|
||||
|
||||
public UploadTargetType UploadTarget { get; set; } = UploadTargetType.None;
|
||||
|
||||
public PlatformProxySettingsDto DouyinProxy { get; set; } = new();
|
||||
|
||||
public PlatformProxySettingsDto BilibiliProxy { get; set; } = new();
|
||||
|
||||
public PlatformProxySettingsDto HuyaProxy { get; set; } = new();
|
||||
public IDictionary<string, PlatformRequestSettingsDto> PlatformRequestSettings { get; set; } =
|
||||
CreatePlatformRequestSettingsMap();
|
||||
|
||||
public WebDavUploadSettingsDto WebDavUpload { get; set; } = new();
|
||||
|
||||
@@ -143,6 +183,10 @@ public sealed class SystemSettingsDto
|
||||
|
||||
public int EventScriptTimeoutSeconds { get; set; } = 60;
|
||||
|
||||
public int EventScriptRetryAttempts { get; set; } = 3;
|
||||
|
||||
public int EventScriptRetryDelaySeconds { get; set; } = 10;
|
||||
|
||||
public bool EnableRetentionCleanup { get; set; } = false;
|
||||
|
||||
public int RetentionDays { get; set; } = 30;
|
||||
@@ -189,6 +233,10 @@ public sealed class SystemSettingsDto
|
||||
<li><strong>Detected At (Beijing Time):</strong> {{detectedAtUtc}}</li>
|
||||
</ul>
|
||||
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
|
||||
<div style="margin-top: 16px;">
|
||||
<strong>Event Script Output:</strong>
|
||||
</div>
|
||||
<div style="margin-top: 8px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{eventScriptOutput}}</div>
|
||||
</div>
|
||||
""";
|
||||
|
||||
@@ -224,12 +272,93 @@ public sealed class SystemSettingsDto
|
||||
|
||||
public bool NotifyWebhookOnException { get; set; } = true;
|
||||
|
||||
public string DouyinUserAgent { get; set; } =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36";
|
||||
[JsonIgnore]
|
||||
public PlatformProxySettingsDto DouyinProxy
|
||||
{
|
||||
get => GetPlatformRequestSettings(LivePlatformType.Douyin).Proxy;
|
||||
set => SetPlatformProxy(LivePlatformType.Douyin, value);
|
||||
}
|
||||
|
||||
public string DouyinReferer { get; set; } = "https://live.douyin.com/";
|
||||
[JsonIgnore]
|
||||
public PlatformProxySettingsDto BilibiliProxy
|
||||
{
|
||||
get => GetPlatformRequestSettings(LivePlatformType.Bilibili).Proxy;
|
||||
set => SetPlatformProxy(LivePlatformType.Bilibili, value);
|
||||
}
|
||||
|
||||
public string DouyinCookie { get; set; } = string.Empty;
|
||||
[JsonIgnore]
|
||||
public PlatformProxySettingsDto HuyaProxy
|
||||
{
|
||||
get => GetPlatformRequestSettings(LivePlatformType.Huya).Proxy;
|
||||
set => SetPlatformProxy(LivePlatformType.Huya, value);
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public string DouyinUserAgent
|
||||
{
|
||||
get => GetPlatformRequestSettings(LivePlatformType.Douyin).UserAgent;
|
||||
set => UpdatePlatformRequestSettings(LivePlatformType.Douyin, settings => settings.UserAgent = value?.Trim() ?? string.Empty);
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public string DouyinReferer
|
||||
{
|
||||
get => GetPlatformRequestSettings(LivePlatformType.Douyin).Referer;
|
||||
set => UpdatePlatformRequestSettings(LivePlatformType.Douyin, settings => settings.Referer = value?.Trim() ?? string.Empty);
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public string DouyinCookie
|
||||
{
|
||||
get => GetPlatformRequestSettings(LivePlatformType.Douyin).Cookie;
|
||||
set => UpdatePlatformRequestSettings(LivePlatformType.Douyin, settings => settings.Cookie = value?.Trim() ?? string.Empty);
|
||||
}
|
||||
|
||||
public PlatformRequestSettingsDto GetPlatformRequestSettings(LivePlatformType platformType)
|
||||
{
|
||||
var platformKey = LivePlatformCatalog.GetKey(platformType);
|
||||
if (!PlatformRequestSettings.TryGetValue(platformKey, out var settings) || settings is null)
|
||||
{
|
||||
settings = PlatformRequestSettingsDto.CreateDefault(platformType);
|
||||
PlatformRequestSettings[platformKey] = settings;
|
||||
}
|
||||
|
||||
settings.Proxy ??= new PlatformProxySettingsDto();
|
||||
settings.UserAgent = string.IsNullOrWhiteSpace(settings.UserAgent)
|
||||
? LivePlatformCatalog.Get(platformType).DefaultUserAgent
|
||||
: settings.UserAgent.Trim();
|
||||
settings.Referer = string.IsNullOrWhiteSpace(settings.Referer)
|
||||
? LivePlatformCatalog.Get(platformType).DefaultReferer
|
||||
: settings.Referer.Trim();
|
||||
settings.Cookie = settings.Cookie?.Trim() ?? string.Empty;
|
||||
settings.Proxy.ProxyUrl = settings.Proxy.ProxyUrl?.Trim() ?? string.Empty;
|
||||
return settings;
|
||||
}
|
||||
|
||||
public static Dictionary<string, PlatformRequestSettingsDto> CreatePlatformRequestSettingsMap()
|
||||
{
|
||||
return LivePlatformCatalog.All.ToDictionary(
|
||||
static item => item.Key,
|
||||
static item => PlatformRequestSettingsDto.CreateDefault(item.Type),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private void SetPlatformProxy(LivePlatformType platformType, PlatformProxySettingsDto? proxy)
|
||||
{
|
||||
UpdatePlatformRequestSettings(
|
||||
platformType,
|
||||
settings =>
|
||||
{
|
||||
settings.Proxy = proxy ?? new PlatformProxySettingsDto();
|
||||
settings.Proxy.ProxyUrl = settings.Proxy.ProxyUrl?.Trim() ?? string.Empty;
|
||||
});
|
||||
}
|
||||
|
||||
private void UpdatePlatformRequestSettings(LivePlatformType platformType, Action<PlatformRequestSettingsDto> update)
|
||||
{
|
||||
var settings = GetPlatformRequestSettings(platformType);
|
||||
update(settings);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UpdateSystemSettingsRequest
|
||||
@@ -262,6 +391,12 @@ public sealed class UpdateSystemSettingsRequest
|
||||
|
||||
public int ResumeRecordingWhenFreeSpaceAboveMegabytes { get; set; } = 4096;
|
||||
|
||||
/// <summary>Disk free percentage above which storage is considered healthy (Green tier). Default 30%.</summary>
|
||||
public double StorageGreenThresholdPercent { get; set; } = 30;
|
||||
|
||||
/// <summary>Disk free percentage below which storage is critical (Red tier). Default 10%.</summary>
|
||||
public double StorageRedThresholdPercent { get; set; } = 10;
|
||||
|
||||
public bool EnableAutoReconnect { get; set; } = true;
|
||||
|
||||
public int ReconnectDelayMaxSeconds { get; set; } = 5;
|
||||
@@ -292,11 +427,8 @@ public sealed class UpdateSystemSettingsRequest
|
||||
|
||||
public UploadTargetType UploadTarget { get; set; } = UploadTargetType.None;
|
||||
|
||||
public PlatformProxySettingsDto DouyinProxy { get; set; } = new();
|
||||
|
||||
public PlatformProxySettingsDto BilibiliProxy { get; set; } = new();
|
||||
|
||||
public PlatformProxySettingsDto HuyaProxy { get; set; } = new();
|
||||
public IDictionary<string, PlatformRequestSettingsDto> PlatformRequestSettings { get; set; } =
|
||||
SystemSettingsDto.CreatePlatformRequestSettingsMap();
|
||||
|
||||
public WebDavUploadSettingsDto WebDavUpload { get; set; } = new();
|
||||
|
||||
@@ -330,6 +462,10 @@ public sealed class UpdateSystemSettingsRequest
|
||||
|
||||
public int EventScriptTimeoutSeconds { get; set; } = 60;
|
||||
|
||||
public int EventScriptRetryAttempts { get; set; } = 3;
|
||||
|
||||
public int EventScriptRetryDelaySeconds { get; set; } = 10;
|
||||
|
||||
public bool EnableRetentionCleanup { get; set; } = false;
|
||||
|
||||
public int RetentionDays { get; set; } = 30;
|
||||
@@ -376,6 +512,10 @@ public sealed class UpdateSystemSettingsRequest
|
||||
<li><strong>Detected At (Beijing Time):</strong> {{detectedAtUtc}}</li>
|
||||
</ul>
|
||||
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
|
||||
<div style="margin-top: 16px;">
|
||||
<strong>Event Script Output:</strong>
|
||||
</div>
|
||||
<div style="margin-top: 8px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{eventScriptOutput}}</div>
|
||||
</div>
|
||||
""";
|
||||
|
||||
@@ -411,12 +551,85 @@ public sealed class UpdateSystemSettingsRequest
|
||||
|
||||
public bool NotifyWebhookOnException { get; set; } = true;
|
||||
|
||||
public string DouyinUserAgent { get; set; } =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36";
|
||||
[JsonIgnore]
|
||||
public PlatformProxySettingsDto DouyinProxy
|
||||
{
|
||||
get => GetPlatformRequestSettings(LivePlatformType.Douyin).Proxy;
|
||||
set => SetPlatformProxy(LivePlatformType.Douyin, value);
|
||||
}
|
||||
|
||||
public string DouyinReferer { get; set; } = "https://live.douyin.com/";
|
||||
[JsonIgnore]
|
||||
public PlatformProxySettingsDto BilibiliProxy
|
||||
{
|
||||
get => GetPlatformRequestSettings(LivePlatformType.Bilibili).Proxy;
|
||||
set => SetPlatformProxy(LivePlatformType.Bilibili, value);
|
||||
}
|
||||
|
||||
public string DouyinCookie { get; set; } = string.Empty;
|
||||
[JsonIgnore]
|
||||
public PlatformProxySettingsDto HuyaProxy
|
||||
{
|
||||
get => GetPlatformRequestSettings(LivePlatformType.Huya).Proxy;
|
||||
set => SetPlatformProxy(LivePlatformType.Huya, value);
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public string DouyinUserAgent
|
||||
{
|
||||
get => GetPlatformRequestSettings(LivePlatformType.Douyin).UserAgent;
|
||||
set => UpdatePlatformRequestSettings(LivePlatformType.Douyin, settings => settings.UserAgent = value?.Trim() ?? string.Empty);
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public string DouyinReferer
|
||||
{
|
||||
get => GetPlatformRequestSettings(LivePlatformType.Douyin).Referer;
|
||||
set => UpdatePlatformRequestSettings(LivePlatformType.Douyin, settings => settings.Referer = value?.Trim() ?? string.Empty);
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public string DouyinCookie
|
||||
{
|
||||
get => GetPlatformRequestSettings(LivePlatformType.Douyin).Cookie;
|
||||
set => UpdatePlatformRequestSettings(LivePlatformType.Douyin, settings => settings.Cookie = value?.Trim() ?? string.Empty);
|
||||
}
|
||||
|
||||
public PlatformRequestSettingsDto GetPlatformRequestSettings(LivePlatformType platformType)
|
||||
{
|
||||
var platformKey = LivePlatformCatalog.GetKey(platformType);
|
||||
if (!PlatformRequestSettings.TryGetValue(platformKey, out var settings) || settings is null)
|
||||
{
|
||||
settings = PlatformRequestSettingsDto.CreateDefault(platformType);
|
||||
PlatformRequestSettings[platformKey] = settings;
|
||||
}
|
||||
|
||||
settings.Proxy ??= new PlatformProxySettingsDto();
|
||||
settings.UserAgent = string.IsNullOrWhiteSpace(settings.UserAgent)
|
||||
? LivePlatformCatalog.Get(platformType).DefaultUserAgent
|
||||
: settings.UserAgent.Trim();
|
||||
settings.Referer = string.IsNullOrWhiteSpace(settings.Referer)
|
||||
? LivePlatformCatalog.Get(platformType).DefaultReferer
|
||||
: settings.Referer.Trim();
|
||||
settings.Cookie = settings.Cookie?.Trim() ?? string.Empty;
|
||||
settings.Proxy.ProxyUrl = settings.Proxy.ProxyUrl?.Trim() ?? string.Empty;
|
||||
return settings;
|
||||
}
|
||||
|
||||
private void SetPlatformProxy(LivePlatformType platformType, PlatformProxySettingsDto? proxy)
|
||||
{
|
||||
UpdatePlatformRequestSettings(
|
||||
platformType,
|
||||
settings =>
|
||||
{
|
||||
settings.Proxy = proxy ?? new PlatformProxySettingsDto();
|
||||
settings.Proxy.ProxyUrl = settings.Proxy.ProxyUrl?.Trim() ?? string.Empty;
|
||||
});
|
||||
}
|
||||
|
||||
private void UpdatePlatformRequestSettings(LivePlatformType platformType, Action<PlatformRequestSettingsDto> update)
|
||||
{
|
||||
var settings = GetPlatformRequestSettings(platformType);
|
||||
update(settings);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SendTestEmailRequest
|
||||
@@ -451,6 +664,10 @@ public sealed class SendTestEmailRequest
|
||||
<li><strong>Detected At (Beijing Time):</strong> {{detectedAtUtc}}</li>
|
||||
</ul>
|
||||
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
|
||||
<div style="margin-top: 16px;">
|
||||
<strong>Event Script Output:</strong>
|
||||
</div>
|
||||
<div style="margin-top: 8px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{eventScriptOutput}}</div>
|
||||
</div>
|
||||
""";
|
||||
|
||||
@@ -486,6 +703,17 @@ public sealed class TestEventScriptRequest
|
||||
public int TimeoutSeconds { get; set; } = 60;
|
||||
}
|
||||
|
||||
public sealed class EventScriptExecutionResultDto
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
|
||||
public required string Message { get; init; }
|
||||
|
||||
public string? Detail { get; init; }
|
||||
|
||||
public string? CustomLogOutput { get; init; }
|
||||
}
|
||||
|
||||
public sealed class EventScriptTestResultDto
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
using System.Text.Json;
|
||||
using LiveRecorder.Application.Abstractions.Persistence;
|
||||
using LiveRecorder.Application.Models.RecordTasks;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
|
||||
namespace LiveRecorder.Application.Services;
|
||||
|
||||
public sealed class BandwidthStatisticsService
|
||||
{
|
||||
private const string BandwidthCategory = "Bandwidth";
|
||||
private const string SampleMessage = "bandwidth_sample";
|
||||
|
||||
private readonly ISystemLogRepository _systemLogRepository;
|
||||
|
||||
public BandwidthStatisticsService(ISystemLogRepository systemLogRepository)
|
||||
{
|
||||
_systemLogRepository = systemLogRepository;
|
||||
}
|
||||
|
||||
public async Task<BandwidthTimelineDto?> GetSessionTimelineAsync(Guid recordSessionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Query all bandwidth sample log entries for this session
|
||||
// Since there's no dedicated method, we use ListAsync with category filter
|
||||
var allLogs = await _systemLogRepository.ListAllAsync(cancellationToken);
|
||||
var bandwidthLogs = allLogs
|
||||
.Where(item => item.Category == BandwidthCategory
|
||||
&& item.Message == SampleMessage
|
||||
&& item.RecordSessionId == recordSessionId)
|
||||
.OrderBy(item => item.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
if (bandwidthLogs.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var points = new List<BandwidthPointDto>(bandwidthLogs.Count);
|
||||
foreach (var entry in bandwidthLogs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry.Detail))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(entry.Detail);
|
||||
long bytesDownloaded = 0;
|
||||
double? bitrateKbps = null;
|
||||
|
||||
if (doc.RootElement.TryGetProperty("bytesDownloaded", out var bd) && bd.TryGetInt64(out var bv))
|
||||
bytesDownloaded = bv;
|
||||
|
||||
if (doc.RootElement.TryGetProperty("bitrateKbps", out var br) && br.TryGetDouble(out var bkv))
|
||||
bitrateKbps = bkv;
|
||||
|
||||
points.Add(new BandwidthPointDto
|
||||
{
|
||||
Timestamp = entry.CreatedAt,
|
||||
BytesDownloaded = bytesDownloaded,
|
||||
BitrateKbps = bitrateKbps
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip malformed entries
|
||||
}
|
||||
}
|
||||
|
||||
return new BandwidthTimelineDto
|
||||
{
|
||||
RecordSessionId = recordSessionId,
|
||||
Points = points
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<BandwidthSummaryDto?> GetDailySummaryAsync(DateOnly date, int utcOffsetMinutes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var windowStartUtc = new DateTimeOffset(date.ToDateTime(TimeOnly.MinValue), TimeSpan.FromMinutes(utcOffsetMinutes));
|
||||
var windowEndUtc = new DateTimeOffset(date.ToDateTime(TimeOnly.MaxValue), TimeSpan.FromMinutes(utcOffsetMinutes));
|
||||
|
||||
var allLogs = await _systemLogRepository.ListAllAsync(cancellationToken);
|
||||
var bandwidthLogs = allLogs
|
||||
.Where(item => item.Category == BandwidthCategory
|
||||
&& item.Message == SampleMessage
|
||||
&& item.CreatedAt >= windowStartUtc
|
||||
&& item.CreatedAt <= windowEndUtc)
|
||||
.OrderBy(item => item.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
if (bandwidthLogs.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var bitrates = new List<double>();
|
||||
long maxBytes = 0;
|
||||
long finalBytes = 0;
|
||||
|
||||
foreach (var entry in bandwidthLogs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry.Detail))
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(entry.Detail);
|
||||
if (doc.RootElement.TryGetProperty("bytesDownloaded", out var bd) && bd.TryGetInt64(out var bv))
|
||||
{
|
||||
if (bv > maxBytes) maxBytes = bv;
|
||||
finalBytes = bv;
|
||||
}
|
||||
|
||||
if (doc.RootElement.TryGetProperty("bitrateKbps", out var br) && br.TryGetDouble(out var bkv))
|
||||
{
|
||||
bitrates.Add(bkv);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip
|
||||
}
|
||||
}
|
||||
|
||||
var avgBitrate = bitrates.Count > 0 ? bitrates.Average() : 0;
|
||||
var peakBitrate = bitrates.Count > 0 ? bitrates.Max() : 0;
|
||||
|
||||
return new BandwidthSummaryDto
|
||||
{
|
||||
TotalTrafficMB = finalBytes / (1024.0 * 1024.0),
|
||||
AverageBitrateKbps = avgBitrate,
|
||||
PeakBitrateKbps = peakBitrate
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using LiveRecorder.Application.Abstractions.Persistence;
|
||||
using LiveRecorder.Application.Abstractions.Settings;
|
||||
using LiveRecorder.Application.Abstractions.Storage;
|
||||
using LiveRecorder.Application.Models.Reports;
|
||||
using LiveRecorder.Application.Common;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
|
||||
namespace LiveRecorder.Application.Services;
|
||||
|
||||
public sealed class DashboardService
|
||||
{
|
||||
private readonly ILiveRoomRepository _liveRoomRepository;
|
||||
private readonly IRecordSessionRepository _recordSessionRepository;
|
||||
private readonly IRecordTaskRepository _recordTaskRepository;
|
||||
private readonly IRecordResultRepository _recordResultRepository;
|
||||
private readonly ISystemLogRepository _systemLogRepository;
|
||||
private readonly ISystemSettingsService _systemSettingsService;
|
||||
private readonly IStorageGuardService _storageGuardService;
|
||||
|
||||
public DashboardService(
|
||||
ILiveRoomRepository liveRoomRepository,
|
||||
IRecordSessionRepository recordSessionRepository,
|
||||
IRecordTaskRepository recordTaskRepository,
|
||||
IRecordResultRepository recordResultRepository,
|
||||
ISystemLogRepository systemLogRepository,
|
||||
ISystemSettingsService systemSettingsService,
|
||||
IStorageGuardService storageGuardService)
|
||||
{
|
||||
_liveRoomRepository = liveRoomRepository;
|
||||
_recordSessionRepository = recordSessionRepository;
|
||||
_recordTaskRepository = recordTaskRepository;
|
||||
_recordResultRepository = recordResultRepository;
|
||||
_systemLogRepository = systemLogRepository;
|
||||
_systemSettingsService = systemSettingsService;
|
||||
_storageGuardService = storageGuardService;
|
||||
}
|
||||
|
||||
public async Task<DashboardDto> GetDashboardAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var beijingNow = ChinaTime.ToBeijingTime(now);
|
||||
var todayBeijingDate = DateOnly.FromDateTime(beijingNow.DateTime);
|
||||
var todayUtcStart = new DateTimeOffset(todayBeijingDate.ToDateTime(TimeOnly.MinValue), ChinaTime.Zone.GetUtcOffset(beijingNow.DateTime));
|
||||
var todayUtcEnd = new DateTimeOffset(todayBeijingDate.ToDateTime(TimeOnly.MaxValue), ChinaTime.Zone.GetUtcOffset(beijingNow.DateTime));
|
||||
var recentErrorSince = now.AddHours(-24);
|
||||
|
||||
// Run queries sequentially — DbContext is not thread-safe
|
||||
var activeRecordingCount = await _recordSessionRepository.CountByStatusAsync(RecordSessionStatus.Running, cancellationToken);
|
||||
var liveRoomCount = await _liveRoomRepository.CountByAvailabilityAsync(LiveRoomAvailabilityStatus.Live, cancellationToken);
|
||||
var offlineRoomCount = await _liveRoomRepository.CountByAvailabilityAsync(LiveRoomAvailabilityStatus.Offline, cancellationToken);
|
||||
var totalRoomCount = await _liveRoomRepository.CountAsync(cancellationToken);
|
||||
var activeSessionCount = await _recordSessionRepository.CountActiveAsync(cancellationToken);
|
||||
var recentErrorCount = await _systemLogRepository.CountRecentErrorsAsync(recentErrorSince, cancellationToken);
|
||||
var todayRecordingSeconds = await _recordTaskRepository.SumDurationSecondsAsync(todayUtcStart, todayUtcEnd, cancellationToken);
|
||||
var (todayTotalBytes, todayTotalDanmaku) = await _recordResultRepository.GetTodayAggregateAsync(todayUtcStart, todayUtcEnd, cancellationToken);
|
||||
var recentSessions = await _recordSessionRepository.ListRecentAsync(5, cancellationToken);
|
||||
var todaySessions = await _recordSessionRepository.ListInDateRangeAsync(todayUtcStart, todayUtcEnd, cancellationToken);
|
||||
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||
var storageCheck = _storageGuardService.CheckCanStartOrResume(settings);
|
||||
var pendingTranscodeCount = await _recordTaskRepository.CountByStatusAsync(RecordTaskStatus.Processing, cancellationToken);
|
||||
var pendingUploadCount = await _recordResultRepository.CountPendingUploadAsync(cancellationToken);
|
||||
var queuedDataBytes = await _recordResultRepository.SumPendingUploadBytesAsync(cancellationToken);
|
||||
|
||||
return new DashboardDto
|
||||
{
|
||||
ActiveRecordingCount = activeRecordingCount,
|
||||
LiveRoomCount = liveRoomCount,
|
||||
OfflineRoomCount = offlineRoomCount,
|
||||
TotalRoomCount = totalRoomCount,
|
||||
TodayRecordingSeconds = todayRecordingSeconds,
|
||||
TodayDataBytes = todayTotalBytes,
|
||||
TodayDanmakuCount = todayTotalDanmaku,
|
||||
ActiveSessionCount = activeSessionCount,
|
||||
RecentErrorCount = recentErrorCount,
|
||||
StorageStatus = new StorageStatusDto
|
||||
{
|
||||
HasEnoughSpace = storageCheck.HasEnoughSpace,
|
||||
Message = storageCheck.Message ?? string.Empty,
|
||||
AvailableBytes = storageCheck.AvailableBytes,
|
||||
Tier = storageCheck.Tier.ToString(),
|
||||
UsagePercent = storageCheck.UsagePercent
|
||||
},
|
||||
PendingTranscodeCount = pendingTranscodeCount,
|
||||
PendingUploadCount = pendingUploadCount,
|
||||
QueuedDataBytes = queuedDataBytes,
|
||||
RecentSessions = recentSessions
|
||||
.Select(MapRecentSession)
|
||||
.ToList(),
|
||||
TopRooms = ComputeTopRooms(todaySessions)
|
||||
};
|
||||
}
|
||||
|
||||
private static RecentSessionItemDto MapRecentSession(Domain.Entities.RecordSession session)
|
||||
{
|
||||
var duration = session.RecordTasks
|
||||
.Where(item => item.DurationSeconds.HasValue)
|
||||
.Sum(item => item.DurationSeconds ?? 0);
|
||||
|
||||
return new RecentSessionItemDto
|
||||
{
|
||||
Id = session.Id,
|
||||
LiveRoomId = session.LiveRoomId,
|
||||
LiveRoomTitle = session.LiveRoom?.Title ?? session.LiveRoom?.Alias ?? session.LiveRoom?.AnchorName ?? "-",
|
||||
PlatformName = session.LiveRoom?.Platform.ToString() ?? "-",
|
||||
SegmentCount = session.SegmentCount,
|
||||
Status = (int)session.Status,
|
||||
StartedAt = session.StartedAt ?? session.CreatedAt,
|
||||
DurationSeconds = duration > 0 ? duration : null
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<TopRoomItemDto> ComputeTopRooms(IReadOnlyCollection<Domain.Entities.RecordSession> sessions)
|
||||
{
|
||||
return sessions
|
||||
.GroupBy(item => item.LiveRoomId)
|
||||
.Select(group =>
|
||||
{
|
||||
var first = group.First();
|
||||
var totalDuration = group
|
||||
.SelectMany(item => item.RecordTasks)
|
||||
.Sum(item => item.DurationSeconds ?? 0);
|
||||
|
||||
return new TopRoomItemDto
|
||||
{
|
||||
LiveRoomId = group.Key,
|
||||
Title = first.LiveRoom?.Title ?? first.LiveRoom?.Alias ?? first.LiveRoom?.AnchorName,
|
||||
AnchorName = first.LiveRoom?.AnchorName,
|
||||
PlatformName = first.LiveRoom?.Platform.ToString() ?? "-",
|
||||
RoomId = first.LiveRoom?.RoomId ?? "-",
|
||||
SessionCount = group.Count(),
|
||||
TotalDurationSeconds = totalDuration
|
||||
};
|
||||
})
|
||||
.OrderByDescending(item => item.TotalDurationSeconds)
|
||||
.Take(5)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -21,4 +21,7 @@ public sealed class LiveDanmakuAdapterFactory : ILiveDanmakuAdapterFactory
|
||||
|
||||
return adapter;
|
||||
}
|
||||
|
||||
public ILiveDanmakuAdapter? TryGetByPlatform(LivePlatformType platformType) =>
|
||||
_adapterByPlatform.TryGetValue(platformType, out var adapter) ? adapter : null;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,14 @@ public sealed class LiveRoomService
|
||||
"""^(?:(?:https?:\/\/)?live\.bilibili\.com\/(?:blanc\/|h5\/)?)?(?<id>\d+)\/?(?:[#\?].*)?$""",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex YouTubeVideoIdRegex = new(
|
||||
@"[A-Za-z0-9_-]{11}",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex TikTokHandleRegex = new(
|
||||
@"@(?<handle>[A-Za-z0-9._-]{2,})",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private readonly ILiveRoomRepository _liveRoomRepository;
|
||||
private readonly IRecordSessionRepository _recordSessionRepository;
|
||||
private readonly ILivePlatformAdapterFactory _livePlatformAdapterFactory;
|
||||
@@ -507,6 +515,15 @@ public sealed class LiveRoomService
|
||||
{
|
||||
LivePlatformType.Douyin => ParseDouyinRoomLocally(input),
|
||||
LivePlatformType.Bilibili => ParseBilibiliRoomLocally(input),
|
||||
LivePlatformType.Huya => ParsePathRoomLocally(input, platform, "https://www.huya.com/"),
|
||||
LivePlatformType.Douyu => ParsePathRoomLocally(input, platform, "https://www.douyu.com/"),
|
||||
LivePlatformType.Kuaishou => ParsePathRoomLocally(input, platform, "https://live.kuaishou.com/"),
|
||||
LivePlatformType.TikTok => ParseTikTokRoomLocally(input),
|
||||
LivePlatformType.Xiaohongshu => ParsePathRoomLocally(input, platform, "https://www.xiaohongshu.com/"),
|
||||
LivePlatformType.YouTube => ParseYouTubeRoomLocally(input),
|
||||
LivePlatformType.Twitch => ParseTwitchRoomLocally(input),
|
||||
LivePlatformType.PandaTV => ParsePathRoomLocally(input, platform, "https://www.pandalive.co.kr/"),
|
||||
LivePlatformType.Migu => ParsePathRoomLocally(input, platform, "https://www.miguvideo.com/"),
|
||||
_ => throw new NotSupportedException(
|
||||
"This platform still requires live room detection during import. Please enable detection or choose a supported platform.")
|
||||
};
|
||||
@@ -530,6 +547,53 @@ public sealed class LiveRoomService
|
||||
return LivePlatformType.Bilibili;
|
||||
}
|
||||
|
||||
if (input.Contains("huya.com", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return LivePlatformType.Huya;
|
||||
}
|
||||
|
||||
if (input.Contains("douyu.com", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return LivePlatformType.Douyu;
|
||||
}
|
||||
|
||||
if (input.Contains("kuaishou.com", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return LivePlatformType.Kuaishou;
|
||||
}
|
||||
|
||||
if (input.Contains("tiktok.com", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return LivePlatformType.TikTok;
|
||||
}
|
||||
|
||||
if (input.Contains("xiaohongshu.com", StringComparison.OrdinalIgnoreCase) ||
|
||||
input.Contains("xhslink.com", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return LivePlatformType.Xiaohongshu;
|
||||
}
|
||||
|
||||
if (input.Contains("youtube.com", StringComparison.OrdinalIgnoreCase) ||
|
||||
input.Contains("youtu.be", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return LivePlatformType.YouTube;
|
||||
}
|
||||
|
||||
if (input.Contains("twitch.tv", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return LivePlatformType.Twitch;
|
||||
}
|
||||
|
||||
if (input.Contains("pandalive.co.kr", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return LivePlatformType.PandaTV;
|
||||
}
|
||||
|
||||
if (input.Contains("miguvideo.com", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return LivePlatformType.Migu;
|
||||
}
|
||||
|
||||
throw new NotSupportedException(
|
||||
"Unable to infer the platform without detection. Please specify a platform override or keep detection enabled.");
|
||||
}
|
||||
@@ -566,6 +630,74 @@ public sealed class LiveRoomService
|
||||
$"https://live.bilibili.com/{roomId}");
|
||||
}
|
||||
|
||||
private static ParsedLiveRoom ParsePathRoomLocally(string input, LivePlatformType platformType, string rootUrl)
|
||||
{
|
||||
if (!TryExtractPathBasedRoomId(input, out var roomId))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Unable to extract the {platformType} room id locally. Please keep detection enabled for this link.");
|
||||
}
|
||||
|
||||
return new ParsedLiveRoom(
|
||||
platformType,
|
||||
roomId,
|
||||
input.Trim(),
|
||||
$"{rootUrl.TrimEnd('/')}/{roomId}");
|
||||
}
|
||||
|
||||
private static ParsedLiveRoom ParseTikTokRoomLocally(string input)
|
||||
{
|
||||
var match = TikTokHandleRegex.Match(input.Trim());
|
||||
if (!match.Success)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Unable to extract the TikTok handle locally. Please keep detection enabled for this link.");
|
||||
}
|
||||
|
||||
var handle = match.Groups["handle"].Value;
|
||||
return new ParsedLiveRoom(
|
||||
LivePlatformType.TikTok,
|
||||
handle,
|
||||
input.Trim(),
|
||||
$"https://www.tiktok.com/@{handle}/live");
|
||||
}
|
||||
|
||||
private static ParsedLiveRoom ParseYouTubeRoomLocally(string input)
|
||||
{
|
||||
var roomId = ExtractYouTubeVideoId(input);
|
||||
if (string.IsNullOrWhiteSpace(roomId))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Unable to extract the YouTube video id locally. Please keep detection enabled for this link.");
|
||||
}
|
||||
|
||||
return new ParsedLiveRoom(
|
||||
LivePlatformType.YouTube,
|
||||
roomId,
|
||||
input.Trim(),
|
||||
$"https://www.youtube.com/watch?v={roomId}");
|
||||
}
|
||||
|
||||
private static ParsedLiveRoom ParseTwitchRoomLocally(string input)
|
||||
{
|
||||
if (!TryExtractPathBasedRoomId(input, out var roomId))
|
||||
{
|
||||
roomId = input.Trim().Trim('/');
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(roomId))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Unable to extract the Twitch channel login locally. Please keep detection enabled for this link.");
|
||||
}
|
||||
|
||||
return new ParsedLiveRoom(
|
||||
LivePlatformType.Twitch,
|
||||
roomId,
|
||||
input.Trim(),
|
||||
$"https://www.twitch.tv/{roomId}");
|
||||
}
|
||||
|
||||
private static string? ExtractDouyinRoomId(string input)
|
||||
{
|
||||
var trimmedInput = input.Trim();
|
||||
@@ -593,6 +725,56 @@ public sealed class LiveRoomService
|
||||
.FirstOrDefault(static item => !string.IsNullOrWhiteSpace(item) && item.All(char.IsDigit));
|
||||
}
|
||||
|
||||
private static string? ExtractYouTubeVideoId(string input)
|
||||
{
|
||||
var trimmedInput = input.Trim();
|
||||
var directMatch = YouTubeVideoIdRegex.Match(trimmedInput);
|
||||
if (directMatch.Success && directMatch.Index == 0 && directMatch.Length == trimmedInput.Length)
|
||||
{
|
||||
return directMatch.Value;
|
||||
}
|
||||
|
||||
if (Uri.TryCreate(trimmedInput, UriKind.Absolute, out var uri))
|
||||
{
|
||||
var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query);
|
||||
if (query.TryGetValue("v", out var videoId) &&
|
||||
!string.IsNullOrWhiteSpace(videoId.ToString()) &&
|
||||
YouTubeVideoIdRegex.IsMatch(videoId.ToString()))
|
||||
{
|
||||
return videoId.ToString();
|
||||
}
|
||||
|
||||
var segment = uri.Segments
|
||||
.Select(static item => item.Trim('/'))
|
||||
.FirstOrDefault(static item => !string.IsNullOrWhiteSpace(item) && YouTubeVideoIdRegex.IsMatch(item));
|
||||
if (!string.IsNullOrWhiteSpace(segment))
|
||||
{
|
||||
return segment;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryExtractPathBasedRoomId(string input, out string roomId)
|
||||
{
|
||||
roomId = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmedInput = input.Trim().Trim('/');
|
||||
if (!Uri.TryCreate(trimmedInput, UriKind.Absolute, out var uri))
|
||||
{
|
||||
roomId = trimmedInput;
|
||||
return !string.IsNullOrWhiteSpace(roomId);
|
||||
}
|
||||
|
||||
roomId = uri.AbsolutePath.Trim('/');
|
||||
return !string.IsNullOrWhiteSpace(roomId);
|
||||
}
|
||||
|
||||
private async Task<Dictionary<Guid, RecordingExecutionSettings>> BuildEffectiveSettingsLookupAsync(
|
||||
IReadOnlyCollection<LiveRoom> rooms,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
@@ -60,9 +60,15 @@ public sealed class LiveRoomStatusService
|
||||
return;
|
||||
}
|
||||
|
||||
await _emailNotificationService.SendLiveStartedAsync(liveRoom, cancellationToken);
|
||||
await _webhookNotificationService.SendLiveStartedAsync(liveRoom, cancellationToken);
|
||||
await _eventScriptService.RunLiveStartedAsync(liveRoom, observedAt, cancellationToken);
|
||||
var eventScriptResult = await _eventScriptService.RunLiveStartedAsync(liveRoom, observedAt, cancellationToken);
|
||||
await _emailNotificationService.SendLiveStartedAsync(
|
||||
liveRoom,
|
||||
cancellationToken,
|
||||
eventScriptOutput: eventScriptResult?.CustomLogOutput);
|
||||
await _webhookNotificationService.SendLiveStartedAsync(
|
||||
liveRoom,
|
||||
cancellationToken,
|
||||
eventScriptOutput: eventScriptResult?.CustomLogOutput);
|
||||
liveRoom.MarkLiveNotificationSent(observedAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,16 +11,19 @@ public sealed class MediaBrowserService
|
||||
|
||||
private readonly ISystemSettingsService _systemSettingsService;
|
||||
private readonly IFfmpegService _ffmpegService;
|
||||
private readonly IVideoMetadataService _videoMetadataService;
|
||||
|
||||
public MediaBrowserService(
|
||||
ISystemSettingsService systemSettingsService,
|
||||
IFfmpegService ffmpegService)
|
||||
IFfmpegService ffmpegService,
|
||||
IVideoMetadataService videoMetadataService)
|
||||
{
|
||||
_systemSettingsService = systemSettingsService;
|
||||
_ffmpegService = ffmpegService;
|
||||
_videoMetadataService = videoMetadataService;
|
||||
}
|
||||
|
||||
public async Task<MediaBrowserResponseDto> BrowseAsync(string? relativePath, CancellationToken cancellationToken = default)
|
||||
public async Task<MediaBrowserResponseDto> BrowseAsync(string? relativePath, bool includeMetadata = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||
var rootPath = ResolveOutputRoot(settings.OutputRoot);
|
||||
@@ -50,28 +53,56 @@ public sealed class MediaBrowserService
|
||||
};
|
||||
});
|
||||
|
||||
var files = Directory
|
||||
.EnumerateFiles(targetPath)
|
||||
.Select(filePath =>
|
||||
{
|
||||
var info = new FileInfo(filePath);
|
||||
var extension = info.Extension.ToLowerInvariant();
|
||||
return new MediaBrowserItemDto
|
||||
{
|
||||
Name = info.Name,
|
||||
RelativePath = Path.GetRelativePath(rootPath, info.FullName).Replace('\\', '/'),
|
||||
Type = ResolveItemType(extension),
|
||||
SizeBytes = info.Length,
|
||||
ModifiedAt = info.LastWriteTimeUtc == DateTime.MinValue
|
||||
? null
|
||||
: new DateTimeOffset(info.LastWriteTimeUtc, TimeSpan.Zero),
|
||||
CanPreview = PreviewableExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase),
|
||||
CanTranscode = TranscodableExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)
|
||||
};
|
||||
});
|
||||
var items = new List<MediaBrowserItemDto>();
|
||||
items.AddRange(directories);
|
||||
|
||||
var items = directories
|
||||
.Concat(files)
|
||||
foreach (var filePath in Directory.EnumerateFiles(targetPath))
|
||||
{
|
||||
var info = new FileInfo(filePath);
|
||||
var extension = info.Extension.ToLowerInvariant();
|
||||
var absolutePath = info.FullName;
|
||||
VideoMetadataDto? metadata = null;
|
||||
string? thumbnailUrl = null;
|
||||
|
||||
if (includeMetadata && PreviewableExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var extracted = await _videoMetadataService.ExtractMetadataAsync(absolutePath, cancellationToken);
|
||||
if (extracted is not null)
|
||||
{
|
||||
metadata = new VideoMetadataDto(
|
||||
extracted.DurationSeconds,
|
||||
extracted.Width,
|
||||
extracted.Height,
|
||||
extracted.VideoCodec,
|
||||
extracted.AudioCodec,
|
||||
extracted.FrameRate,
|
||||
extracted.BitRate);
|
||||
}
|
||||
|
||||
var thumb = await _videoMetadataService.GenerateThumbnailAsync(absolutePath, rootPath, cancellationToken);
|
||||
if (thumb is not null)
|
||||
{
|
||||
thumbnailUrl = Path.GetRelativePath(rootPath, thumb).Replace('\\', '/');
|
||||
}
|
||||
}
|
||||
|
||||
items.Add(new MediaBrowserItemDto
|
||||
{
|
||||
Name = info.Name,
|
||||
RelativePath = Path.GetRelativePath(rootPath, absolutePath).Replace('\\', '/'),
|
||||
Type = ResolveItemType(extension),
|
||||
SizeBytes = info.Length,
|
||||
ModifiedAt = info.LastWriteTimeUtc == DateTime.MinValue
|
||||
? null
|
||||
: new DateTimeOffset(info.LastWriteTimeUtc, TimeSpan.Zero),
|
||||
CanPreview = PreviewableExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase),
|
||||
CanTranscode = TranscodableExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase),
|
||||
Metadata = metadata,
|
||||
ThumbnailUrl = thumbnailUrl
|
||||
});
|
||||
}
|
||||
|
||||
var sortedItems = items
|
||||
.OrderBy(static item => item.Type != "directory")
|
||||
.ThenBy(static item => item.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
@@ -207,8 +207,17 @@ public sealed class RecordService
|
||||
|
||||
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||
var storageCheck = _storageGuardService.CheckCanStartOrResume(settings);
|
||||
if (!storageCheck.HasEnoughSpace)
|
||||
if (!storageCheck.CanStartNewRecording)
|
||||
{
|
||||
var storageMessage = storageCheck.Tier switch
|
||||
{
|
||||
Application.Abstractions.Storage.StorageTier.Yellow =>
|
||||
"Storage is in warning state. New recordings are paused but existing recordings continue. Transcoding and uploading will free up space.",
|
||||
Application.Abstractions.Storage.StorageTier.Red =>
|
||||
"Storage is critically low. All recordings are paused until enough disk space is freed.",
|
||||
_ => storageCheck.Message
|
||||
};
|
||||
|
||||
await _systemLogService.WriteAsync(
|
||||
SystemLogLevel.Warning,
|
||||
"Storage",
|
||||
@@ -222,12 +231,12 @@ public sealed class RecordService
|
||||
await UpdateAutoStartDecisionAsync(
|
||||
liveRoom,
|
||||
AutoStartDecisionCodes.SkippedStorage,
|
||||
"Auto-start skipped because storage is below threshold.",
|
||||
$"Auto-start skipped because storage tier is {storageCheck.Tier}.",
|
||||
storageCheck.Message,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(storageCheck.Message);
|
||||
throw new InvalidOperationException(storageMessage);
|
||||
}
|
||||
|
||||
var effectiveSettings = _liveRoomRecordingSettingsResolver.Resolve(liveRoom, settings);
|
||||
@@ -495,6 +504,32 @@ public sealed class RecordService
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<DeleteCompletedRecordTasksResultDto> DeleteMissingFileTasksAsync(
|
||||
DeleteMissingFileRecordTasksRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var tasks = await _recordTaskRepository.ListAsync(null, cancellationToken);
|
||||
var missingFileTaskIds = tasks
|
||||
.Where(static task => !IsActiveTaskStatus(task.Status) && !HasExistingVideoFile(task))
|
||||
.Select(static task => task.Id)
|
||||
.ToArray();
|
||||
|
||||
if (missingFileTaskIds.Length == 0)
|
||||
{
|
||||
return CreateEmptyDeleteResult();
|
||||
}
|
||||
|
||||
return await DeleteTasksAsync(
|
||||
new DeleteCompletedRecordTasksRequest
|
||||
{
|
||||
TaskIds = missingFileTaskIds,
|
||||
DeleteFiles = request.DeleteFiles
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<RecordPreviewTicketDto> CreatePreviewTicketAsync(Guid id, string mediaBaseUrl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var previewTicket = await _recordMediaService.CreatePreviewTicketAsync(id, cancellationToken);
|
||||
@@ -661,14 +696,14 @@ public sealed class RecordService
|
||||
}
|
||||
|
||||
var storageCheck = _storageGuardService.CheckCanStartOrResume(settings);
|
||||
if (!storageCheck.HasEnoughSpace)
|
||||
if (!storageCheck.CanStartNewRecording)
|
||||
{
|
||||
foreach (var liveRoomId in liveRoomIds)
|
||||
{
|
||||
await TryUpdateAutoStartDecisionAsync(
|
||||
liveRoomId,
|
||||
AutoStartDecisionCodes.SkippedStorage,
|
||||
"Auto-start skipped because storage is below threshold.",
|
||||
$"Auto-start skipped because storage tier is {storageCheck.Tier}.",
|
||||
storageCheck.Message,
|
||||
cancellationToken);
|
||||
await _systemLogService.WriteAsync(
|
||||
@@ -814,6 +849,24 @@ public sealed class RecordService
|
||||
or RecordTaskStatus.Stopping
|
||||
or RecordTaskStatus.Processing;
|
||||
|
||||
private static bool HasExistingVideoFile(RecordTask task)
|
||||
{
|
||||
var candidatePath = !string.IsNullOrWhiteSpace(task.Result?.FilePath)
|
||||
? task.Result!.FilePath
|
||||
: task.OutputFilePath;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(candidatePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var resolvedPath = Path.IsPathRooted(candidatePath)
|
||||
? candidatePath
|
||||
: Path.GetFullPath(candidatePath, AppContext.BaseDirectory);
|
||||
|
||||
return File.Exists(resolvedPath);
|
||||
}
|
||||
|
||||
private static bool IsActiveSessionStatus(RecordSessionStatus status) =>
|
||||
status is RecordSessionStatus.Starting or RecordSessionStatus.Running or RecordSessionStatus.Stopping;
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using LiveRecorder.Application.Abstractions.Persistence;
|
||||
using LiveRecorder.Application.Abstractions.Settings;
|
||||
using LiveRecorder.Application.Common;
|
||||
using LiveRecorder.Application.Models.Cleanup;
|
||||
using LiveRecorder.Application.Models.Settings;
|
||||
using LiveRecorder.Domain.Entities;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LiveRecorder.Application.Services;
|
||||
@@ -24,6 +26,8 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
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 StorageGreenThresholdPercentKey = "storage.guard.green_threshold_percent";
|
||||
private const string StorageRedThresholdPercentKey = "storage.guard.red_threshold_percent";
|
||||
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";
|
||||
@@ -70,6 +74,8 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
private const string SegmentCompletedScriptPathKey = "event_scripts.segment_completed.path";
|
||||
private const string SegmentCompletedScriptContentKey = "event_scripts.segment_completed.content";
|
||||
private const string EventScriptTimeoutSecondsKey = "event_scripts.timeout_seconds";
|
||||
private const string EventScriptRetryAttemptsKey = "event_scripts.retry_attempts";
|
||||
private const string EventScriptRetryDelaySecondsKey = "event_scripts.retry_delay_seconds";
|
||||
private const string EnableRetentionCleanupKey = "retention.cleanup.enabled";
|
||||
private const string RetentionDaysKey = "retention.cleanup.days";
|
||||
private const string RetentionDeleteFilesKey = "retention.cleanup.delete_files";
|
||||
@@ -137,6 +143,8 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
EnableStorageGuard = bool.TryParse(GetValue(lookup, EnableStorageGuardKey, "true"), out var enableStorageGuard) && enableStorageGuard,
|
||||
PauseRecordingWhenFreeSpaceBelowMegabytes = GetIntValue(lookup, PauseRecordingWhenFreeSpaceBelowMegabytesKey, 1024, 0, 1048576),
|
||||
ResumeRecordingWhenFreeSpaceAboveMegabytes = GetIntValue(lookup, ResumeRecordingWhenFreeSpaceAboveMegabytesKey, 4096, 0, 1048576),
|
||||
StorageGreenThresholdPercent = GetDoubleValue(lookup, StorageGreenThresholdPercentKey, 30, 5, 90),
|
||||
StorageRedThresholdPercent = GetDoubleValue(lookup, StorageRedThresholdPercentKey, 10, 1, 85),
|
||||
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),
|
||||
@@ -154,21 +162,7 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
UploadTarget = Enum.TryParse(GetValue(lookup, UploadTargetKey, "None"), true, out UploadTargetType uploadTarget)
|
||||
? uploadTarget
|
||||
: UploadTargetType.None,
|
||||
DouyinProxy = new PlatformProxySettingsDto
|
||||
{
|
||||
Enabled = bool.TryParse(GetValue(lookup, DouyinProxyEnabledKey, "false"), out var douyinProxyEnabled) && douyinProxyEnabled,
|
||||
ProxyUrl = GetValue(lookup, DouyinProxyUrlKey, string.Empty)
|
||||
},
|
||||
BilibiliProxy = new PlatformProxySettingsDto
|
||||
{
|
||||
Enabled = bool.TryParse(GetValue(lookup, BilibiliProxyEnabledKey, "false"), out var bilibiliProxyEnabled) && bilibiliProxyEnabled,
|
||||
ProxyUrl = GetValue(lookup, BilibiliProxyUrlKey, string.Empty)
|
||||
},
|
||||
HuyaProxy = new PlatformProxySettingsDto
|
||||
{
|
||||
Enabled = bool.TryParse(GetValue(lookup, HuyaProxyEnabledKey, "false"), out var huyaProxyEnabled) && huyaProxyEnabled,
|
||||
ProxyUrl = GetValue(lookup, HuyaProxyUrlKey, string.Empty)
|
||||
},
|
||||
PlatformRequestSettings = BuildPlatformRequestSettingsMap(lookup),
|
||||
WebDavUpload = new WebDavUploadSettingsDto
|
||||
{
|
||||
Endpoint = GetValue(lookup, WebDavEndpointKey, string.Empty),
|
||||
@@ -215,6 +209,8 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
SegmentCompletedScriptPath = GetValue(lookup, SegmentCompletedScriptPathKey, string.Empty),
|
||||
SegmentCompletedScriptContent = GetValue(lookup, SegmentCompletedScriptContentKey, string.Empty),
|
||||
EventScriptTimeoutSeconds = GetIntValue(lookup, EventScriptTimeoutSecondsKey, 60, 1, 3600),
|
||||
EventScriptRetryAttempts = GetIntValue(lookup, EventScriptRetryAttemptsKey, 3, 0, 20),
|
||||
EventScriptRetryDelaySeconds = GetIntValue(lookup, EventScriptRetryDelaySecondsKey, 10, 0, 3600),
|
||||
EnableRetentionCleanup = bool.TryParse(GetValue(lookup, EnableRetentionCleanupKey, "false"), out var enableRetentionCleanup) && enableRetentionCleanup,
|
||||
RetentionDays = GetIntValue(lookup, RetentionDaysKey, 30, 1, 3650),
|
||||
RetentionDeleteFiles = bool.TryParse(GetValue(lookup, RetentionDeleteFilesKey, "false"), out var retentionDeleteFiles) && retentionDeleteFiles,
|
||||
@@ -274,13 +270,7 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
WebhookBodyTemplate = GetValue(lookup, WebhookBodyTemplateKey, string.Empty),
|
||||
WebhookTimeoutSeconds = GetIntValue(lookup, WebhookTimeoutSecondsKey, 15, 1, 300),
|
||||
NotifyWebhookOnLiveStarted = bool.TryParse(GetValue(lookup, NotifyWebhookOnLiveStartedKey, "true"), out var notifyWebhookOnLiveStarted) && notifyWebhookOnLiveStarted,
|
||||
NotifyWebhookOnException = bool.TryParse(GetValue(lookup, NotifyWebhookOnExceptionKey, "true"), out var notifyWebhookOnException) && notifyWebhookOnException,
|
||||
DouyinUserAgent = GetValue(
|
||||
lookup,
|
||||
DouyinUserAgentKey,
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"),
|
||||
DouyinReferer = GetValue(lookup, DouyinRefererKey, "https://live.douyin.com/"),
|
||||
DouyinCookie = GetValue(lookup, DouyinCookieKey, string.Empty)
|
||||
NotifyWebhookOnException = bool.TryParse(GetValue(lookup, NotifyWebhookOnExceptionKey, "true"), out var notifyWebhookOnException) && notifyWebhookOnException
|
||||
};
|
||||
}
|
||||
|
||||
@@ -289,9 +279,6 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var douyinProxy = request.DouyinProxy ?? new PlatformProxySettingsDto();
|
||||
var bilibiliProxy = request.BilibiliProxy ?? new PlatformProxySettingsDto();
|
||||
var huyaProxy = request.HuyaProxy ?? new PlatformProxySettingsDto();
|
||||
var webDavUpload = request.WebDavUpload ?? new WebDavUploadSettingsDto();
|
||||
var s3Upload = request.S3Upload ?? new S3UploadSettingsDto();
|
||||
|
||||
@@ -325,6 +312,16 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
Math.Clamp(request.ResumeRecordingWhenFreeSpaceAboveMegabytes, 0, 1048576).ToString(),
|
||||
now,
|
||||
cancellationToken);
|
||||
await UpsertAsync(
|
||||
StorageGreenThresholdPercentKey,
|
||||
Math.Clamp(request.StorageGreenThresholdPercent, 5, 90).ToString("F1", CultureInfo.InvariantCulture),
|
||||
now,
|
||||
cancellationToken);
|
||||
await UpsertAsync(
|
||||
StorageRedThresholdPercentKey,
|
||||
Math.Clamp(request.StorageRedThresholdPercent, 1, 85).ToString("F1", CultureInfo.InvariantCulture),
|
||||
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);
|
||||
@@ -351,12 +348,15 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
await UpsertAsync(S3SecretKeyKey, s3Upload.SecretKey, now, cancellationToken);
|
||||
await UpsertAsync(S3PrefixKey, s3Upload.Prefix.Trim(), now, cancellationToken);
|
||||
await UpsertAsync(S3ForcePathStyleKey, s3Upload.ForcePathStyle.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(DouyinProxyEnabledKey, douyinProxy.Enabled.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(DouyinProxyUrlKey, douyinProxy.ProxyUrl.Trim(), now, cancellationToken);
|
||||
await UpsertAsync(BilibiliProxyEnabledKey, bilibiliProxy.Enabled.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(BilibiliProxyUrlKey, bilibiliProxy.ProxyUrl.Trim(), now, cancellationToken);
|
||||
await UpsertAsync(HuyaProxyEnabledKey, huyaProxy.Enabled.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(HuyaProxyUrlKey, huyaProxy.ProxyUrl.Trim(), now, cancellationToken);
|
||||
foreach (var platformDefinition in LivePlatformCatalog.All)
|
||||
{
|
||||
var platformRequestSettings = request.GetPlatformRequestSettings(platformDefinition.Type);
|
||||
await UpsertAsync(GetPlatformProxyEnabledKey(platformDefinition.Key), platformRequestSettings.Proxy.Enabled.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(GetPlatformProxyUrlKey(platformDefinition.Key), platformRequestSettings.Proxy.ProxyUrl.Trim(), now, cancellationToken);
|
||||
await UpsertAsync(GetPlatformUserAgentKey(platformDefinition.Key), platformRequestSettings.UserAgent.Trim(), now, cancellationToken);
|
||||
await UpsertAsync(GetPlatformRefererKey(platformDefinition.Key), platformRequestSettings.Referer.Trim(), now, cancellationToken);
|
||||
await UpsertAsync(GetPlatformCookieKey(platformDefinition.Key), platformRequestSettings.Cookie.Trim(), now, cancellationToken);
|
||||
}
|
||||
await UpsertAsync(EnableEventScriptsKey, request.EnableEventScripts.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(EnableLiveStartedScriptKey, request.EnableLiveStartedScript.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(LiveStartedScriptModeKey, NormalizeEventScriptMode(request.LiveStartedScriptMode), now, cancellationToken);
|
||||
@@ -371,6 +371,8 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
await UpsertAsync(SegmentCompletedScriptPathKey, request.SegmentCompletedScriptPath.Trim(), now, cancellationToken);
|
||||
await UpsertAsync(SegmentCompletedScriptContentKey, request.SegmentCompletedScriptContent, now, cancellationToken);
|
||||
await UpsertAsync(EventScriptTimeoutSecondsKey, Math.Clamp(request.EventScriptTimeoutSeconds, 1, 3600).ToString(), now, cancellationToken);
|
||||
await UpsertAsync(EventScriptRetryAttemptsKey, Math.Clamp(request.EventScriptRetryAttempts, 0, 20).ToString(), now, cancellationToken);
|
||||
await UpsertAsync(EventScriptRetryDelaySecondsKey, Math.Clamp(request.EventScriptRetryDelaySeconds, 0, 3600).ToString(), now, cancellationToken);
|
||||
await UpsertAsync(EnableRetentionCleanupKey, request.EnableRetentionCleanup.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(RetentionDaysKey, Math.Clamp(request.RetentionDays, 1, 3650).ToString(), now, cancellationToken);
|
||||
await UpsertAsync(RetentionDeleteFilesKey, request.RetentionDeleteFiles.ToString(), now, cancellationToken);
|
||||
@@ -398,14 +400,50 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
await UpsertAsync(WebhookTimeoutSecondsKey, Math.Clamp(request.WebhookTimeoutSeconds, 1, 300).ToString(), now, cancellationToken);
|
||||
await UpsertAsync(NotifyWebhookOnLiveStartedKey, request.NotifyWebhookOnLiveStarted.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(NotifyWebhookOnExceptionKey, request.NotifyWebhookOnException.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(DouyinUserAgentKey, request.DouyinUserAgent.Trim(), now, cancellationToken);
|
||||
await UpsertAsync(DouyinRefererKey, request.DouyinReferer.Trim(), now, cancellationToken);
|
||||
await UpsertAsync(DouyinCookieKey, request.DouyinCookie.Trim(), now, cancellationToken);
|
||||
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
return await GetAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static Dictionary<string, PlatformRequestSettingsDto> BuildPlatformRequestSettingsMap(
|
||||
IReadOnlyDictionary<string, string> lookup)
|
||||
{
|
||||
var result = SystemSettingsDto.CreatePlatformRequestSettingsMap();
|
||||
|
||||
foreach (var platformDefinition in LivePlatformCatalog.All)
|
||||
{
|
||||
var settings = result[platformDefinition.Key];
|
||||
settings.Proxy.Enabled = bool.TryParse(
|
||||
GetPlatformValue(
|
||||
lookup,
|
||||
GetPlatformProxyEnabledKey(platformDefinition.Key),
|
||||
GetLegacyProxyEnabledKey(platformDefinition.Type),
|
||||
"false"),
|
||||
out var proxyEnabled) && proxyEnabled;
|
||||
settings.Proxy.ProxyUrl = GetPlatformValue(
|
||||
lookup,
|
||||
GetPlatformProxyUrlKey(platformDefinition.Key),
|
||||
GetLegacyProxyUrlKey(platformDefinition.Type),
|
||||
string.Empty);
|
||||
settings.UserAgent = GetPlatformValue(
|
||||
lookup,
|
||||
GetPlatformUserAgentKey(platformDefinition.Key),
|
||||
GetLegacyUserAgentKey(platformDefinition.Type),
|
||||
platformDefinition.DefaultUserAgent);
|
||||
settings.Referer = GetPlatformValue(
|
||||
lookup,
|
||||
GetPlatformRefererKey(platformDefinition.Key),
|
||||
GetLegacyRefererKey(platformDefinition.Type),
|
||||
platformDefinition.DefaultReferer);
|
||||
settings.Cookie = GetPlatformValue(
|
||||
lookup,
|
||||
GetPlatformCookieKey(platformDefinition.Key),
|
||||
GetLegacyCookieKey(platformDefinition.Type),
|
||||
string.Empty);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string GetValue(IReadOnlyDictionary<string, string> lookup, string key, string fallback) =>
|
||||
lookup.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value) ? value : fallback;
|
||||
|
||||
@@ -443,6 +481,22 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
return Math.Clamp(parsedValue, minimum, maximum);
|
||||
}
|
||||
|
||||
private static double GetDoubleValue(
|
||||
IReadOnlyDictionary<string, string> lookup,
|
||||
string key,
|
||||
double fallback,
|
||||
double minimum,
|
||||
double maximum)
|
||||
{
|
||||
var raw = GetValue(lookup, key, fallback.ToString(CultureInfo.InvariantCulture));
|
||||
if (!double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedValue))
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.Clamp(parsedValue, minimum, maximum);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<int> GetIntListValue(IReadOnlyDictionary<string, string> lookup, string key)
|
||||
{
|
||||
if (!lookup.TryGetValue(key, out var raw) || string.IsNullOrWhiteSpace(raw))
|
||||
@@ -498,6 +552,69 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
.OrderBy(static item => item)
|
||||
.ToArray());
|
||||
|
||||
private static string GetPlatformProxyEnabledKey(string platformKey) =>
|
||||
$"platform_request.{platformKey}.proxy.enabled";
|
||||
|
||||
private static string GetPlatformProxyUrlKey(string platformKey) =>
|
||||
$"platform_request.{platformKey}.proxy.url";
|
||||
|
||||
private static string GetPlatformUserAgentKey(string platformKey) =>
|
||||
$"platform_request.{platformKey}.user_agent";
|
||||
|
||||
private static string GetPlatformRefererKey(string platformKey) =>
|
||||
$"platform_request.{platformKey}.referer";
|
||||
|
||||
private static string GetPlatformCookieKey(string platformKey) =>
|
||||
$"platform_request.{platformKey}.cookie";
|
||||
|
||||
private static string? GetLegacyProxyEnabledKey(LivePlatformType platformType) =>
|
||||
platformType switch
|
||||
{
|
||||
LivePlatformType.Douyin => DouyinProxyEnabledKey,
|
||||
LivePlatformType.Bilibili => BilibiliProxyEnabledKey,
|
||||
LivePlatformType.Huya => HuyaProxyEnabledKey,
|
||||
_ => null
|
||||
};
|
||||
|
||||
private static string? GetLegacyProxyUrlKey(LivePlatformType platformType) =>
|
||||
platformType switch
|
||||
{
|
||||
LivePlatformType.Douyin => DouyinProxyUrlKey,
|
||||
LivePlatformType.Bilibili => BilibiliProxyUrlKey,
|
||||
LivePlatformType.Huya => HuyaProxyUrlKey,
|
||||
_ => null
|
||||
};
|
||||
|
||||
private static string? GetLegacyUserAgentKey(LivePlatformType platformType) =>
|
||||
platformType == LivePlatformType.Douyin ? DouyinUserAgentKey : null;
|
||||
|
||||
private static string? GetLegacyRefererKey(LivePlatformType platformType) =>
|
||||
platformType == LivePlatformType.Douyin ? DouyinRefererKey : null;
|
||||
|
||||
private static string? GetLegacyCookieKey(LivePlatformType platformType) =>
|
||||
platformType == LivePlatformType.Douyin ? DouyinCookieKey : null;
|
||||
|
||||
private static string GetPlatformValue(
|
||||
IReadOnlyDictionary<string, string> lookup,
|
||||
string key,
|
||||
string? legacyKey,
|
||||
string fallback)
|
||||
{
|
||||
if (lookup.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(legacyKey) &&
|
||||
lookup.TryGetValue(legacyKey, out var legacyValue) &&
|
||||
!string.IsNullOrWhiteSpace(legacyValue))
|
||||
{
|
||||
return legacyValue.Trim();
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private async Task UpsertAsync(string key, string value, DateTimeOffset updatedAt, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await _appSettingRepository.GetByKeyAsync(key, cancellationToken);
|
||||
|
||||
@@ -7,5 +7,11 @@ public enum LivePlatformType
|
||||
Bilibili = 2,
|
||||
Huya = 3,
|
||||
Douyu = 4,
|
||||
Kuaishou = 5
|
||||
Kuaishou = 5,
|
||||
TikTok = 6,
|
||||
Xiaohongshu = 7,
|
||||
YouTube = 8,
|
||||
Twitch = 9,
|
||||
PandaTV = 10,
|
||||
Migu = 11
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL;
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Custom EF Core execution strategy that integrates with <see cref="DatabaseCircuitBreaker"/>.
|
||||
///
|
||||
/// When the circuit is open, operations fail immediately without retrying.
|
||||
/// When a non-transient error occurs (e.g. disk_full), the operation does not retry.
|
||||
/// After each failure, the circuit is recorded, and after each success, the circuit is reset.
|
||||
/// </summary>
|
||||
public sealed class CircuitAwareExecutionStrategy : NpgsqlRetryingExecutionStrategy
|
||||
{
|
||||
private static readonly TimeSpan DefaultMaxRetryDelay = TimeSpan.FromSeconds(15);
|
||||
|
||||
public CircuitAwareExecutionStrategy(
|
||||
ExecutionStrategyDependencies dependencies,
|
||||
int maxRetryCount,
|
||||
TimeSpan maxRetryDelay)
|
||||
: base(dependencies, maxRetryCount, maxRetryDelay, errorCodesToAdd: null)
|
||||
{
|
||||
}
|
||||
|
||||
public CircuitAwareExecutionStrategy(
|
||||
ExecutionStrategyDependencies dependencies,
|
||||
int maxRetryCount,
|
||||
TimeSpan maxRetryDelay,
|
||||
ICollection<string>? errorCodesToAdd)
|
||||
: base(dependencies, maxRetryCount, maxRetryDelay, errorCodesToAdd)
|
||||
{
|
||||
}
|
||||
|
||||
protected override bool ShouldRetryOn(Exception? exception)
|
||||
{
|
||||
// Fast-fail: circuit is open
|
||||
if (DatabaseCircuitBreaker.IsOpen)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fast-fail: non-transient errors (disk_full, out_of_memory, etc.)
|
||||
if (DatabaseCircuitBreaker.IsNonTransient(exception))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delegate to default Npgsql retry logic for transient errors
|
||||
return base.ShouldRetryOn(exception);
|
||||
}
|
||||
|
||||
protected override void OnFirstExecution()
|
||||
{
|
||||
// If circuit is open, throw immediately before even attempting
|
||||
if (DatabaseCircuitBreaker.IsOpen)
|
||||
{
|
||||
throw new DatabaseCircuitOpenException(
|
||||
$"Database circuit breaker is open. Consecutive failures: {DatabaseCircuitBreaker.ConsecutiveFailures}. " +
|
||||
$"Circuit opened at: {DatabaseCircuitBreaker.OpenedAt:O}.");
|
||||
}
|
||||
|
||||
base.OnFirstExecution();
|
||||
}
|
||||
|
||||
public override TResult Execute<TState, TResult>(
|
||||
TState state,
|
||||
Func<DbContext, TState, TResult> operation,
|
||||
Func<DbContext, TState, ExecutionResult<TResult>>? verifySucceeded)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = base.Execute(state, operation, verifySucceeded);
|
||||
DatabaseCircuitBreaker.RecordSuccess();
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
DatabaseCircuitBreaker.RecordFailure();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task<TResult> ExecuteAsync<TState, TResult>(
|
||||
TState state,
|
||||
Func<DbContext, TState, CancellationToken, Task<TResult>> operation,
|
||||
Func<DbContext, TState, CancellationToken, Task<ExecutionResult<TResult>>>? verifySucceeded,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await base.ExecuteAsync(state, operation, verifySucceeded, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
DatabaseCircuitBreaker.RecordSuccess();
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
DatabaseCircuitBreaker.RecordFailure();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe static circuit breaker for database operations.
|
||||
/// When the database becomes unavailable (e.g. disk full), this prevents
|
||||
/// every API request from wasting 45 seconds on doomed retries.
|
||||
/// </summary>
|
||||
public static class DatabaseCircuitBreaker
|
||||
{
|
||||
private static readonly object Lock = new();
|
||||
|
||||
/// <summary>Consecutive failures before the circuit opens.</summary>
|
||||
private const int FailureThreshold = 5;
|
||||
|
||||
/// <summary>How long the circuit stays open before allowing a probe.</summary>
|
||||
private static readonly TimeSpan BreakDuration = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>Npgsql error codes that are NOT transient — retrying is futile.</summary>
|
||||
private static readonly HashSet<string> NonTransientCodes = new(StringComparer.Ordinal)
|
||||
{
|
||||
"53100", // disk_full
|
||||
"53200", // out_of_memory
|
||||
"53300", // too_many_connections
|
||||
"08006", // connection_failure (persistent)
|
||||
"57P03", // cannot_connect_now
|
||||
"42601", // syntax_error (bug, not transient)
|
||||
"42501", // insufficient_privilege
|
||||
"3D000", // invalid_catalog_name
|
||||
"28P01", // invalid_password
|
||||
};
|
||||
|
||||
private static int _consecutiveFailures;
|
||||
private static DateTimeOffset _openedAt = DateTimeOffset.MinValue;
|
||||
|
||||
/// <summary>Whether the circuit is currently open (failing fast).</summary>
|
||||
public static bool IsOpen
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_consecutiveFailures < FailureThreshold)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (DateTimeOffset.UtcNow - _openedAt > BreakDuration)
|
||||
{
|
||||
// Transition to half-open: allow one probe
|
||||
lock (Lock)
|
||||
{
|
||||
if (_consecutiveFailures >= FailureThreshold &&
|
||||
DateTimeOffset.UtcNow - _openedAt > BreakDuration)
|
||||
{
|
||||
// Reset to just below threshold so the next call probes
|
||||
_consecutiveFailures = FailureThreshold - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static int ConsecutiveFailures => Volatile.Read(ref _consecutiveFailures);
|
||||
|
||||
public static DateTimeOffset OpenedAt => _openedAt;
|
||||
|
||||
/// <summary>Record a successful database operation.</summary>
|
||||
public static void RecordSuccess()
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
_consecutiveFailures = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Record a failed database operation.</summary>
|
||||
public static void RecordFailure()
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
_consecutiveFailures++;
|
||||
if (_consecutiveFailures >= FailureThreshold)
|
||||
{
|
||||
_openedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check whether a given exception is a non-transient database error
|
||||
/// that should NOT be retried. Returns true if retrying would be futile.
|
||||
/// </summary>
|
||||
public static bool IsNonTransient(Exception? ex)
|
||||
{
|
||||
while (ex is not null)
|
||||
{
|
||||
if (ex is Npgsql.NpgsqlException npgEx && npgEx.SqlState is { Length: 5 } state)
|
||||
{
|
||||
return NonTransientCodes.Contains(state);
|
||||
}
|
||||
|
||||
ex = ex.InnerException;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Log the current circuit state.</summary>
|
||||
public static void LogState(ILogger logger)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"DatabaseCircuitBreaker state: Open={IsOpen}, ConsecutiveFailures={Failures}, OpenedAt={OpenedAt}",
|
||||
IsOpen,
|
||||
ConsecutiveFailures,
|
||||
OpenedAt == DateTimeOffset.MinValue ? "never" : OpenedAt.ToString("O"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace LiveRecorder.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when a database operation is rejected because the circuit breaker is open.
|
||||
/// This is a fast-fail — the request will not be retried.
|
||||
/// </summary>
|
||||
public sealed class DatabaseCircuitOpenException : InvalidOperationException
|
||||
{
|
||||
public DatabaseCircuitOpenException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public DatabaseCircuitOpenException(string message, Exception innerException) : base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,12 @@ public sealed class LiveRoomRepository : ILiveRoomRepository
|
||||
.OrderByDescending(static item => item.UpdatedAt)
|
||||
.ToList();
|
||||
|
||||
public Task<int> CountAsync(CancellationToken cancellationToken = default) =>
|
||||
_dbContext.LiveRooms.CountAsync(cancellationToken);
|
||||
|
||||
public Task<int> CountByAvailabilityAsync(LiveRoomAvailabilityStatus status, CancellationToken cancellationToken = default) =>
|
||||
_dbContext.LiveRooms.CountAsync(item => item.AvailabilityStatus == status, cancellationToken);
|
||||
|
||||
public Task AddAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default) =>
|
||||
_dbContext.LiveRooms.AddAsync(liveRoom, cancellationToken).AsTask();
|
||||
|
||||
@@ -130,6 +136,15 @@ public sealed class RecordTaskRepository : IRecordTaskRepository
|
||||
(item.Status == RecordTaskStatus.Starting || item.Status == RecordTaskStatus.Running),
|
||||
cancellationToken);
|
||||
|
||||
public Task<double> SumDurationSecondsAsync(DateTimeOffset startedFrom, DateTimeOffset startedTo, CancellationToken cancellationToken = default) =>
|
||||
_dbContext.RecordTasks
|
||||
.Include(item => item.RecordSession)
|
||||
.Where(item => item.RecordSession != null && item.RecordSession.StartedAt >= startedFrom && item.RecordSession.StartedAt <= startedTo)
|
||||
.SumAsync(item => item.DurationSeconds ?? 0, cancellationToken);
|
||||
|
||||
public Task<int> CountByStatusAsync(RecordTaskStatus status, CancellationToken cancellationToken = default) =>
|
||||
_dbContext.RecordTasks.CountAsync(item => item.Status == status, cancellationToken);
|
||||
|
||||
public Task AddAsync(RecordTask recordTask, CancellationToken cancellationToken = default) =>
|
||||
_dbContext.RecordTasks.AddAsync(recordTask, cancellationToken).AsTask();
|
||||
|
||||
@@ -195,6 +210,32 @@ public sealed class RecordSessionRepository : IRecordSessionRepository
|
||||
item.Status == RecordSessionStatus.Stopping),
|
||||
cancellationToken);
|
||||
|
||||
public Task<int> CountByStatusAsync(RecordSessionStatus status, CancellationToken cancellationToken = default) =>
|
||||
_dbContext.RecordSessions.CountAsync(item => item.Status == status, cancellationToken);
|
||||
|
||||
public Task<int> CountActiveAsync(CancellationToken cancellationToken = default) =>
|
||||
_dbContext.RecordSessions.CountAsync(item =>
|
||||
item.Status == RecordSessionStatus.Starting || item.Status == RecordSessionStatus.Running, cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<RecordSession>> ListRecentAsync(int take, CancellationToken cancellationToken = default) =>
|
||||
await _dbContext.RecordSessions
|
||||
.Include(item => item.LiveRoom)
|
||||
.Include(item => item.RecordTasks.OrderBy(task => task.SegmentIndex))
|
||||
.ThenInclude(item => item.Result)
|
||||
.AsNoTracking()
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(take, 1, 50))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<RecordSession>> ListInDateRangeAsync(DateTimeOffset startedFrom, DateTimeOffset startedTo, CancellationToken cancellationToken = default) =>
|
||||
await _dbContext.RecordSessions
|
||||
.Include(item => item.LiveRoom)
|
||||
.Include(item => item.RecordTasks.OrderBy(task => task.SegmentIndex))
|
||||
.ThenInclude(item => item.Result)
|
||||
.AsNoTracking()
|
||||
.Where(item => item.StartedAt >= startedFrom && item.StartedAt <= startedTo)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
public Task AddAsync(RecordSession recordSession, CancellationToken cancellationToken = default) =>
|
||||
_dbContext.RecordSessions.AddAsync(recordSession, cancellationToken).AsTask();
|
||||
|
||||
@@ -213,6 +254,29 @@ public sealed class RecordResultRepository : IRecordResultRepository
|
||||
public Task<RecordResult?> GetByTaskIdAsync(Guid recordTaskId, CancellationToken cancellationToken = default) =>
|
||||
_dbContext.RecordResults.FirstOrDefaultAsync(item => item.RecordTaskId == recordTaskId, cancellationToken);
|
||||
|
||||
public async Task<(long TotalBytes, int TotalDanmaku)> GetTodayAggregateAsync(DateTimeOffset createdFrom, DateTimeOffset createdTo, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await _dbContext.RecordResults
|
||||
.Where(item => item.CreatedAt >= createdFrom && item.CreatedAt <= createdTo)
|
||||
.GroupBy(_ => 1)
|
||||
.Select(g => new
|
||||
{
|
||||
TotalBytes = g.Sum(item => item.FileSizeBytes ?? 0L),
|
||||
TotalDanmaku = g.Sum(item => item.DanmakuMessageCount)
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return (result?.TotalBytes ?? 0L, result?.TotalDanmaku ?? 0);
|
||||
}
|
||||
|
||||
public Task<int> CountPendingUploadAsync(CancellationToken cancellationToken = default) =>
|
||||
_dbContext.RecordResults.CountAsync(item => item.UploadStatus == RecordArtifactUploadStatus.NotUploaded, cancellationToken);
|
||||
|
||||
public Task<long> SumPendingUploadBytesAsync(CancellationToken cancellationToken = default) =>
|
||||
_dbContext.RecordResults
|
||||
.Where(item => item.UploadStatus == RecordArtifactUploadStatus.NotUploaded)
|
||||
.SumAsync(item => item.FileSizeBytes ?? 0L, cancellationToken);
|
||||
|
||||
public Task AddAsync(RecordResult recordResult, CancellationToken cancellationToken = default) =>
|
||||
_dbContext.RecordResults.AddAsync(recordResult, cancellationToken).AsTask();
|
||||
|
||||
@@ -310,6 +374,9 @@ public sealed class SystemLogRepository : ISystemLogRepository
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task<int> CountRecentErrorsAsync(DateTimeOffset since, CancellationToken cancellationToken = default) =>
|
||||
_dbContext.SystemLogEntries.CountAsync(item => item.Level == SystemLogLevel.Error && item.CreatedAt >= since, cancellationToken);
|
||||
|
||||
public void RemoveRange(IEnumerable<SystemLogEntry> entries) => _dbContext.SystemLogEntries.RemoveRange(entries);
|
||||
|
||||
public async Task<IReadOnlyList<Guid>> ListSessionIdsWithoutTasksAsync(CancellationToken cancellationToken = default)
|
||||
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
using LiveRecorder.Application.Abstractions.Platforms;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Platforms.Common;
|
||||
|
||||
public abstract class PathBasedPageLivePlatformAdapterBase : ILivePlatformAdapter
|
||||
{
|
||||
private readonly PlatformHttpRequestService _requestService;
|
||||
|
||||
protected PathBasedPageLivePlatformAdapterBase(PlatformHttpRequestService requestService)
|
||||
{
|
||||
_requestService = requestService;
|
||||
}
|
||||
|
||||
public abstract LivePlatformType PlatformType { get; }
|
||||
|
||||
protected abstract IReadOnlyList<string> SupportedHosts { get; }
|
||||
|
||||
protected abstract string PlatformRootUrl { get; }
|
||||
|
||||
public bool CanHandle(string input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TryExtractRoomId(input, out _))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return SupportedHosts.Any(host => input.Contains(host, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public virtual async Task<ParsedLiveRoom> ParseRoomAsync(string input, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (TryExtractRoomId(input, out var roomId))
|
||||
{
|
||||
return new ParsedLiveRoom(PlatformType, roomId, input.Trim(), BuildPageUrl(roomId));
|
||||
}
|
||||
|
||||
var response = await _requestService.GetStringAsync(
|
||||
PlatformType,
|
||||
input.Trim(),
|
||||
referer: PlatformRootUrl,
|
||||
cancellationToken: cancellationToken);
|
||||
roomId = response.FinalUri is not null && TryExtractRoomId(response.FinalUri.AbsoluteUri, out var resolvedRoomId)
|
||||
? resolvedRoomId
|
||||
: TryExtractRoomId(response.Body, out var bodyRoomId)
|
||||
? bodyRoomId
|
||||
: null;
|
||||
if (string.IsNullOrWhiteSpace(roomId))
|
||||
{
|
||||
throw new InvalidOperationException($"Unable to extract the {PlatformType} room id from the input.");
|
||||
}
|
||||
|
||||
return new ParsedLiveRoom(
|
||||
PlatformType,
|
||||
roomId,
|
||||
input.Trim(),
|
||||
response.FinalUri?.AbsoluteUri ?? BuildPageUrl(roomId));
|
||||
}
|
||||
|
||||
public virtual async Task<LiveStatusSnapshot> GetLiveStatusAsync(string roomId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var pageUrl = BuildPageUrl(roomId);
|
||||
var response = await _requestService.GetStringAsync(
|
||||
PlatformType,
|
||||
pageUrl,
|
||||
referer: PlatformRootUrl,
|
||||
cancellationToken: cancellationToken);
|
||||
var options = ExtractStreamOptions(response.Body);
|
||||
var isLive = options.Count > 0;
|
||||
return new LiveStatusSnapshot(
|
||||
isLive,
|
||||
ExtractTitle(response.Body),
|
||||
ExtractAnchorName(response.Body),
|
||||
roomId,
|
||||
ExtractAvatarUrl(response.Body),
|
||||
ExtractCoverUrl(response.Body),
|
||||
isLive ? 1 : 0,
|
||||
isLive ? "live" : "offline");
|
||||
}
|
||||
|
||||
public virtual async Task<StreamUrlResult> GetStreamUrlAsync(
|
||||
string roomId,
|
||||
string? preferredQuality = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var pageUrl = BuildPageUrl(roomId);
|
||||
var response = await _requestService.GetStringAsync(
|
||||
PlatformType,
|
||||
pageUrl,
|
||||
referer: PlatformRootUrl,
|
||||
cancellationToken: cancellationToken);
|
||||
var options = ExtractStreamOptions(response.Body);
|
||||
if (options.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"{PlatformType} did not return any playable stream URL.");
|
||||
}
|
||||
|
||||
var selected = PlatformAdapterUtilities.SelectQuality(options, preferredQuality);
|
||||
var inputHeaders = await _requestService.BuildStreamInputHeadersAsync(
|
||||
PlatformType,
|
||||
pageUrl,
|
||||
cancellationToken: cancellationToken);
|
||||
return new StreamUrlResult(
|
||||
selected.QualityKey,
|
||||
selected.Protocol,
|
||||
selected.Url,
|
||||
inputHeaders,
|
||||
options);
|
||||
}
|
||||
|
||||
protected virtual string BuildPageUrl(string roomId) => $"{PlatformRootUrl.TrimEnd('/')}/{roomId.TrimStart('/')}";
|
||||
|
||||
protected virtual string? ExtractTitle(string html) =>
|
||||
PlatformAdapterUtilities.ExtractMetaContent(html, "og:title", "twitter:title", "title");
|
||||
|
||||
protected virtual string? ExtractAnchorName(string html) =>
|
||||
PlatformAdapterUtilities.ExtractMetaContent(html, "author", "profile:username");
|
||||
|
||||
protected virtual string? ExtractAvatarUrl(string html) => null;
|
||||
|
||||
protected virtual string? ExtractCoverUrl(string html) =>
|
||||
PlatformAdapterUtilities.ExtractMetaContent(html, "og:image", "twitter:image");
|
||||
|
||||
protected virtual IReadOnlyList<StreamQualityOption> ExtractStreamOptions(string html)
|
||||
{
|
||||
var options = new List<StreamQualityOption>();
|
||||
foreach (var url in PlatformAdapterUtilities.ExtractPlayableUrls(html, ".m3u8"))
|
||||
{
|
||||
options.Add(new StreamQualityOption("origin", "Origin", url, "hls", 100));
|
||||
}
|
||||
|
||||
foreach (var url in PlatformAdapterUtilities.ExtractPlayableUrls(html, ".flv"))
|
||||
{
|
||||
options.Add(new StreamQualityOption("origin", "Origin", url, "flv", 100));
|
||||
}
|
||||
|
||||
return options
|
||||
.DistinctBy(static item => item.Url, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
protected virtual bool TryExtractRoomId(string input, out string roomId)
|
||||
{
|
||||
roomId = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(input.Trim(), UriKind.Absolute, out var uri))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!SupportedHosts.Any(host => uri.Host.Contains(host, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
roomId = uri.AbsolutePath.Trim('/');
|
||||
return !string.IsNullOrWhiteSpace(roomId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using LiveRecorder.Application.Abstractions.Platforms;
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Platforms.Common;
|
||||
|
||||
internal static class PlatformAdapterUtilities
|
||||
{
|
||||
private static readonly Regex MetaTagRegex = new(
|
||||
"<meta[^>]+(?:property|name)=[\"'](?<name>[^\"']+)[\"'][^>]+content=[\"'](?<content>[^\"']+)[\"'][^>]*>",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex UrlRegex = new(
|
||||
@"https?:\\?/\\?/[^""'<>\\\s]+",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
|
||||
public static string? ExtractMetaContent(string html, params string[] names)
|
||||
{
|
||||
foreach (Match match in MetaTagRegex.Matches(html))
|
||||
{
|
||||
var name = WebUtility.HtmlDecode(match.Groups["name"].Value);
|
||||
if (names.Any(candidate => string.Equals(candidate, name, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return WebUtility.HtmlDecode(match.Groups["content"].Value);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string DecodeEscapedPayload(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var normalized = value
|
||||
.Replace("\\u002F", "/", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("\\/", "/", StringComparison.Ordinal)
|
||||
.Replace("&", "&", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
try
|
||||
{
|
||||
return Regex.Unescape(normalized);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
public static List<string> ExtractPlayableUrls(string html, params string[] requiredFragments)
|
||||
{
|
||||
var urls = UrlRegex.Matches(html)
|
||||
.Select(static match => DecodeEscapedPayload(match.Value))
|
||||
.Where(url => requiredFragments.Length == 0 ||
|
||||
requiredFragments.Any(fragment => url.Contains(fragment, StringComparison.OrdinalIgnoreCase)))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
return urls;
|
||||
}
|
||||
|
||||
public static string? ExtractJsonObjectAfterMarker(string html, string marker)
|
||||
{
|
||||
var markerIndex = html.IndexOf(marker, StringComparison.Ordinal);
|
||||
if (markerIndex < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var objectStart = html.IndexOf('{', markerIndex + marker.Length);
|
||||
if (objectStart < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return ExtractBalancedBlock(html, objectStart, '{', '}');
|
||||
}
|
||||
|
||||
public static string? ExtractJsonArrayAfterMarker(string html, string marker)
|
||||
{
|
||||
var markerIndex = html.IndexOf(marker, StringComparison.Ordinal);
|
||||
if (markerIndex < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var arrayStart = html.IndexOf('[', markerIndex + marker.Length);
|
||||
if (arrayStart < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return ExtractBalancedBlock(html, arrayStart, '[', ']');
|
||||
}
|
||||
|
||||
public static string? ExtractScriptTagJsonById(string html, string id)
|
||||
{
|
||||
var pattern = $"""<script[^>]+id=["']{Regex.Escape(id)}["'][^>]*>(?<json>[\s\S]*?)</script>""";
|
||||
var match = Regex.Match(html, pattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
return match.Success ? match.Groups["json"].Value.Trim() : null;
|
||||
}
|
||||
|
||||
public static JsonDocument? TryParseJson(string? payload)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(payload))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonDocument.Parse(payload);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static string? FindFirstString(JsonElement element, params string[] propertyNames)
|
||||
{
|
||||
foreach (var propertyName in propertyNames)
|
||||
{
|
||||
var result = FindFirstString(element, propertyName);
|
||||
if (!string.IsNullOrWhiteSpace(result))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string? FindFirstString(JsonElement element, string propertyName)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var scalar = ReadScalarString(property.Value);
|
||||
if (!string.IsNullOrWhiteSpace(scalar))
|
||||
{
|
||||
return scalar;
|
||||
}
|
||||
}
|
||||
|
||||
var nested = FindFirstString(property.Value, propertyName);
|
||||
if (!string.IsNullOrWhiteSpace(nested))
|
||||
{
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
var nested = FindFirstString(item, propertyName);
|
||||
if (!string.IsNullOrWhiteSpace(nested))
|
||||
{
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static int? FindFirstInt(JsonElement element, params string[] propertyNames)
|
||||
{
|
||||
foreach (var propertyName in propertyNames)
|
||||
{
|
||||
var result = FindFirstInt(element, propertyName);
|
||||
if (result.HasValue)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static int? FindFirstInt(JsonElement element, string propertyName)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (property.Value.ValueKind == JsonValueKind.Number && property.Value.TryGetInt32(out var intValue))
|
||||
{
|
||||
return intValue;
|
||||
}
|
||||
|
||||
if (property.Value.ValueKind == JsonValueKind.String &&
|
||||
int.TryParse(property.Value.GetString(), out intValue))
|
||||
{
|
||||
return intValue;
|
||||
}
|
||||
}
|
||||
|
||||
var nested = FindFirstInt(property.Value, propertyName);
|
||||
if (nested.HasValue)
|
||||
{
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
var nested = FindFirstInt(item, propertyName);
|
||||
if (nested.HasValue)
|
||||
{
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static JsonElement? FindFirstObject(JsonElement element, string propertyName)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return property.Value;
|
||||
}
|
||||
|
||||
var nested = FindFirstObject(property.Value, propertyName);
|
||||
if (nested.HasValue)
|
||||
{
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
var nested = FindFirstObject(item, propertyName);
|
||||
if (nested.HasValue)
|
||||
{
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static StreamQualityOption SelectQuality(IReadOnlyList<StreamQualityOption> options, string? preferredQuality)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(preferredQuality))
|
||||
{
|
||||
var normalized = preferredQuality.Trim();
|
||||
var exact = options
|
||||
.Where(item =>
|
||||
item.QualityKey.Equals(normalized, StringComparison.OrdinalIgnoreCase) ||
|
||||
item.QualityName.Equals(normalized, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(static item => item.Rank)
|
||||
.FirstOrDefault();
|
||||
if (exact is not null)
|
||||
{
|
||||
return exact;
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
.OrderByDescending(static item => item.Rank)
|
||||
.First();
|
||||
}
|
||||
|
||||
public static int GetQualityRank(string? qualityLabel)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(qualityLabel))
|
||||
{
|
||||
return 50;
|
||||
}
|
||||
|
||||
var normalized = qualityLabel.Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"origin" or "source" or "raw" or "uhd" or "4k" => 100,
|
||||
"full_hd" or "fullhd" or "fhd" or "1080p" => 90,
|
||||
"hd" or "720p" => 80,
|
||||
"sd" or "540p" or "480p" => 70,
|
||||
"ld" or "360p" => 60,
|
||||
_ when normalized.Contains("origin", StringComparison.Ordinal) => 100,
|
||||
_ when normalized.Contains("1080", StringComparison.Ordinal) => 90,
|
||||
_ when normalized.Contains("720", StringComparison.Ordinal) => 80,
|
||||
_ when normalized.Contains("540", StringComparison.Ordinal) => 70,
|
||||
_ when normalized.Contains("480", StringComparison.Ordinal) => 70,
|
||||
_ when normalized.Contains("360", StringComparison.Ordinal) => 60,
|
||||
_ => 50
|
||||
};
|
||||
}
|
||||
|
||||
public static string InferProtocolFromUrl(string url)
|
||||
{
|
||||
if (url.Contains(".m3u8", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "hls";
|
||||
}
|
||||
|
||||
if (url.Contains(".mpd", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "dash";
|
||||
}
|
||||
|
||||
if (url.Contains(".flv", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "flv";
|
||||
}
|
||||
|
||||
return "http";
|
||||
}
|
||||
|
||||
public static string? ExtractFirstPathSegment(Uri uri, int index = 0)
|
||||
{
|
||||
return uri.Segments
|
||||
.Select(static item => item.Trim('/'))
|
||||
.Where(static item => !string.IsNullOrWhiteSpace(item))
|
||||
.Skip(index)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static string? ExtractBalancedBlock(string html, int startIndex, char openChar, char closeChar)
|
||||
{
|
||||
var depth = 0;
|
||||
var inString = false;
|
||||
var escaped = false;
|
||||
|
||||
for (var index = startIndex; index < html.Length; index++)
|
||||
{
|
||||
var character = html[index];
|
||||
|
||||
if (inString)
|
||||
{
|
||||
if (escaped)
|
||||
{
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character == '\\')
|
||||
{
|
||||
escaped = true;
|
||||
}
|
||||
else if (character == '"')
|
||||
{
|
||||
inString = false;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character == '"')
|
||||
{
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character == openChar)
|
||||
{
|
||||
depth++;
|
||||
}
|
||||
else if (character == closeChar)
|
||||
{
|
||||
depth--;
|
||||
if (depth == 0)
|
||||
{
|
||||
return html[startIndex..(index + 1)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ReadScalarString(JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => element.GetString(),
|
||||
JsonValueKind.Number => element.GetRawText(),
|
||||
JsonValueKind.True => bool.TrueString.ToLowerInvariant(),
|
||||
JsonValueKind.False => bool.FalseString.ToLowerInvariant(),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LiveRecorder.Application.Abstractions.Platforms;
|
||||
using LiveRecorder.Application.Abstractions.Settings;
|
||||
using LiveRecorder.Application.Models.Settings;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using LiveRecorder.Infrastructure.Services;
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Platforms.Common;
|
||||
|
||||
public sealed class PlatformHttpRequestService
|
||||
{
|
||||
private readonly PlatformHttpClientFactory _platformHttpClientFactory;
|
||||
private readonly ISystemSettingsService _systemSettingsService;
|
||||
|
||||
public PlatformHttpRequestService(
|
||||
PlatformHttpClientFactory platformHttpClientFactory,
|
||||
ISystemSettingsService systemSettingsService)
|
||||
{
|
||||
_platformHttpClientFactory = platformHttpClientFactory;
|
||||
_systemSettingsService = systemSettingsService;
|
||||
}
|
||||
|
||||
public async Task<PlatformHttpResponse> GetStringAsync(
|
||||
LivePlatformType platform,
|
||||
string requestUri,
|
||||
string? referer = null,
|
||||
IReadOnlyDictionary<string, string>? additionalHeaders = null,
|
||||
bool forceDirectConnection = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var profile = await GetProfileAsync(platform, referer, cancellationToken);
|
||||
using var client = await _platformHttpClientFactory.CreateAsync(platform, forceDirectConnection, cancellationToken);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
ApplyHeaders(request, profile, additionalHeaders);
|
||||
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken);
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return new PlatformHttpResponse(response.RequestMessage?.RequestUri, response.StatusCode, body);
|
||||
}
|
||||
|
||||
public async Task<JsonDocument> GetJsonAsync(
|
||||
LivePlatformType platform,
|
||||
string requestUri,
|
||||
string? referer = null,
|
||||
IReadOnlyDictionary<string, string>? additionalHeaders = null,
|
||||
bool forceDirectConnection = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = await GetStringAsync(
|
||||
platform,
|
||||
requestUri,
|
||||
referer,
|
||||
additionalHeaders,
|
||||
forceDirectConnection,
|
||||
cancellationToken);
|
||||
return JsonDocument.Parse(response.Body);
|
||||
}
|
||||
|
||||
public async Task<PlatformHttpResponse> SendJsonAsync(
|
||||
LivePlatformType platform,
|
||||
string requestUri,
|
||||
object payload,
|
||||
string? referer = null,
|
||||
IReadOnlyDictionary<string, string>? additionalHeaders = null,
|
||||
bool forceDirectConnection = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var profile = await GetProfileAsync(platform, referer, cancellationToken);
|
||||
using var client = await _platformHttpClientFactory.CreateAsync(platform, forceDirectConnection, cancellationToken);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, requestUri)
|
||||
{
|
||||
Content = new StringContent(
|
||||
JsonSerializer.Serialize(payload),
|
||||
Encoding.UTF8,
|
||||
"application/json")
|
||||
};
|
||||
ApplyHeaders(request, profile, additionalHeaders);
|
||||
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken);
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return new PlatformHttpResponse(response.RequestMessage?.RequestUri, response.StatusCode, body);
|
||||
}
|
||||
|
||||
public async Task<PlatformRequestProfile> GetProfileAsync(
|
||||
LivePlatformType platform,
|
||||
string? referer = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||
var platformSettings = settings.GetPlatformRequestSettings(platform);
|
||||
return new PlatformRequestProfile(
|
||||
platformSettings.UserAgent,
|
||||
string.IsNullOrWhiteSpace(referer) ? platformSettings.Referer : referer.Trim(),
|
||||
platformSettings.Cookie);
|
||||
}
|
||||
|
||||
public async Task<StreamInputHeaders> BuildStreamInputHeadersAsync(
|
||||
LivePlatformType platform,
|
||||
string? referer = null,
|
||||
IReadOnlyDictionary<string, string>? additionalHeaders = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var profile = await GetProfileAsync(platform, referer, cancellationToken);
|
||||
return new StreamInputHeaders(
|
||||
profile.UserAgent,
|
||||
profile.Referer,
|
||||
string.IsNullOrWhiteSpace(profile.Cookie) ? null : profile.Cookie,
|
||||
additionalHeaders);
|
||||
}
|
||||
|
||||
public static void ApplyHeaders(
|
||||
HttpRequestMessage request,
|
||||
PlatformRequestProfile profile,
|
||||
IReadOnlyDictionary<string, string>? additionalHeaders = null)
|
||||
{
|
||||
request.Headers.Accept.Clear();
|
||||
request.Headers.Accept.ParseAdd("*/*");
|
||||
request.Headers.AcceptLanguage.Clear();
|
||||
request.Headers.AcceptLanguage.ParseAdd("zh-CN,zh;q=0.9,en;q=0.8");
|
||||
request.Headers.TryAddWithoutValidation("User-Agent", profile.UserAgent);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(profile.Referer))
|
||||
{
|
||||
request.Headers.Referrer = new Uri(profile.Referer);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(profile.Cookie))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("Cookie", profile.Cookie);
|
||||
}
|
||||
|
||||
if (additionalHeaders is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var (key, value) in additionalHeaders)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!request.Headers.TryAddWithoutValidation(key, value) && request.Content is not null)
|
||||
{
|
||||
request.Content.Headers.TryAddWithoutValidation(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record PlatformRequestProfile(
|
||||
string UserAgent,
|
||||
string Referer,
|
||||
string Cookie);
|
||||
|
||||
public sealed record PlatformHttpResponse(
|
||||
Uri? FinalUri,
|
||||
HttpStatusCode StatusCode,
|
||||
string Body);
|
||||
@@ -0,0 +1,192 @@
|
||||
using System.Text.Json;
|
||||
using LiveRecorder.Application.Abstractions.Platforms;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using LiveRecorder.Infrastructure.Platforms.Common;
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Platforms.Douyu;
|
||||
|
||||
public sealed class DouyuLivePlatformAdapter : ILivePlatformAdapter
|
||||
{
|
||||
private readonly PlatformHttpRequestService _requestService;
|
||||
|
||||
public DouyuLivePlatformAdapter(PlatformHttpRequestService requestService)
|
||||
{
|
||||
_requestService = requestService;
|
||||
}
|
||||
|
||||
public LivePlatformType PlatformType => LivePlatformType.Douyu;
|
||||
|
||||
public bool CanHandle(string input) =>
|
||||
!string.IsNullOrWhiteSpace(input) &&
|
||||
(input.Contains("douyu.com", StringComparison.OrdinalIgnoreCase) || !Uri.IsWellFormedUriString(input, UriKind.Absolute));
|
||||
|
||||
public Task<ParsedLiveRoom> ParseRoomAsync(string input, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var roomId = ExtractRoomId(input);
|
||||
if (string.IsNullOrWhiteSpace(roomId))
|
||||
{
|
||||
throw new InvalidOperationException("Unable to extract the Douyu room id from the input.");
|
||||
}
|
||||
|
||||
return Task.FromResult(new ParsedLiveRoom(
|
||||
PlatformType,
|
||||
roomId,
|
||||
input.Trim(),
|
||||
BuildPageUrl(roomId)));
|
||||
}
|
||||
|
||||
public async Task<LiveStatusSnapshot> GetLiveStatusAsync(string roomId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var document = await GetRoomDocumentAsync(roomId, cancellationToken);
|
||||
var data = document.RootElement.TryGetProperty("room", out var roomElement) ? roomElement :
|
||||
document.RootElement.TryGetProperty("data", out roomElement) ? roomElement : default;
|
||||
var status = PlatformAdapterUtilities.FindFirstInt(data, "show_status", "room_status", "videoLoop");
|
||||
var options = ParseStreamOptions(data);
|
||||
var isLive = options.Count > 0 || status == 1;
|
||||
|
||||
return new LiveStatusSnapshot(
|
||||
isLive,
|
||||
PlatformAdapterUtilities.FindFirstString(data, "room_name", "roomName"),
|
||||
PlatformAdapterUtilities.FindFirstString(data, "nickname", "owner_name"),
|
||||
PlatformAdapterUtilities.FindFirstString(data, "owner_uid", "up_id", "rid"),
|
||||
PlatformAdapterUtilities.FindFirstString(data, "avatar"),
|
||||
PlatformAdapterUtilities.FindFirstString(data, "room_thumb", "room_pic"),
|
||||
status ?? (isLive ? 1 : 0),
|
||||
status?.ToString() ?? (isLive ? "live" : "offline"));
|
||||
}
|
||||
|
||||
public async Task<StreamUrlResult> GetStreamUrlAsync(
|
||||
string roomId,
|
||||
string? preferredQuality = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var document = await GetRoomDocumentAsync(roomId, cancellationToken);
|
||||
var data = document.RootElement.TryGetProperty("room", out var roomElement) ? roomElement :
|
||||
document.RootElement.TryGetProperty("data", out roomElement) ? roomElement : default;
|
||||
var options = ParseStreamOptions(data);
|
||||
if (options.Count == 0)
|
||||
{
|
||||
var page = await _requestService.GetStringAsync(
|
||||
PlatformType,
|
||||
BuildPageUrl(roomId),
|
||||
referer: "https://www.douyu.com/",
|
||||
cancellationToken: cancellationToken);
|
||||
options = PlatformAdapterUtilities.ExtractPlayableUrls(page.Body, ".m3u8")
|
||||
.Select(static url => new StreamQualityOption("origin", "Origin", url, "hls", 90))
|
||||
.Concat(PlatformAdapterUtilities.ExtractPlayableUrls(page.Body, ".flv")
|
||||
.Select(static url => new StreamQualityOption("origin", "Origin", url, "flv", 100)))
|
||||
.DistinctBy(static item => item.Url, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
if (options.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Douyu did not return any playable stream URL.");
|
||||
}
|
||||
|
||||
var selected = PlatformAdapterUtilities.SelectQuality(options, preferredQuality);
|
||||
var inputHeaders = await _requestService.BuildStreamInputHeadersAsync(
|
||||
PlatformType,
|
||||
BuildPageUrl(roomId),
|
||||
cancellationToken: cancellationToken);
|
||||
return new StreamUrlResult(
|
||||
selected.QualityKey,
|
||||
selected.Protocol,
|
||||
selected.Url,
|
||||
inputHeaders,
|
||||
options);
|
||||
}
|
||||
|
||||
private Task<JsonDocument> GetRoomDocumentAsync(string roomId, CancellationToken cancellationToken)
|
||||
{
|
||||
return _requestService.GetJsonAsync(
|
||||
PlatformType,
|
||||
$"https://www.douyu.com/betard/{Uri.EscapeDataString(roomId)}",
|
||||
referer: BuildPageUrl(roomId),
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
private static List<StreamQualityOption> ParseStreamOptions(JsonElement data)
|
||||
{
|
||||
var options = new List<StreamQualityOption>();
|
||||
|
||||
AddStream(options, "origin", "Origin", "flv", JoinDouyuUrl(
|
||||
PlatformAdapterUtilities.FindFirstString(data, "flv_url"),
|
||||
PlatformAdapterUtilities.FindFirstString(data, "flv_live")));
|
||||
AddStream(options, "origin", "Origin", "hls", JoinDouyuUrl(
|
||||
PlatformAdapterUtilities.FindFirstString(data, "hls_url"),
|
||||
PlatformAdapterUtilities.FindFirstString(data, "hls_live")));
|
||||
AddStream(options, "origin", "Origin", "rtmp", JoinDouyuUrl(
|
||||
PlatformAdapterUtilities.FindFirstString(data, "rtmp_url"),
|
||||
PlatformAdapterUtilities.FindFirstString(data, "rtmp_live")));
|
||||
|
||||
AddStream(options, "origin", "Origin", PlatformAdapterUtilities.InferProtocolFromUrl(
|
||||
PlatformAdapterUtilities.FindFirstString(data, "stream_url") ?? string.Empty),
|
||||
PlatformAdapterUtilities.FindFirstString(data, "stream_url"));
|
||||
|
||||
return options
|
||||
.Where(static item => !string.IsNullOrWhiteSpace(item.Url))
|
||||
.DistinctBy(static item => item.Url, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static void AddStream(
|
||||
ICollection<StreamQualityOption> options,
|
||||
string qualityKey,
|
||||
string qualityName,
|
||||
string protocol,
|
||||
string? url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
options.Add(new StreamQualityOption(qualityKey, qualityName, url, protocol, 100));
|
||||
}
|
||||
|
||||
private static string? JoinDouyuUrl(string? baseUrl, string? liveName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(liveName))
|
||||
{
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
if (liveName.Contains("://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return liveName;
|
||||
}
|
||||
|
||||
return $"{baseUrl.TrimEnd('/')}/{liveName.TrimStart('/')}";
|
||||
}
|
||||
|
||||
private static string BuildPageUrl(string roomId) => $"https://www.douyu.com/{roomId}";
|
||||
|
||||
private static string? ExtractRoomId(string? input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmedInput = input.Trim().Trim('/');
|
||||
if (!Uri.TryCreate(trimmedInput, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return trimmedInput;
|
||||
}
|
||||
|
||||
if (!uri.Host.Contains("douyu.com", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return uri.Segments
|
||||
.Select(static item => item.Trim('/'))
|
||||
.FirstOrDefault(static item => !string.IsNullOrWhiteSpace(item));
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,230 @@
|
||||
using System.Text.Json;
|
||||
using LiveRecorder.Application.Abstractions.Platforms;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using LiveRecorder.Infrastructure.Platforms.Common;
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Platforms.Huya;
|
||||
|
||||
public sealed class HuyaLivePlatformAdapter : ILivePlatformAdapter
|
||||
{
|
||||
private readonly PlatformHttpRequestService _requestService;
|
||||
|
||||
public HuyaLivePlatformAdapter(PlatformHttpRequestService requestService)
|
||||
{
|
||||
_requestService = requestService;
|
||||
}
|
||||
|
||||
public LivePlatformType PlatformType => LivePlatformType.Huya;
|
||||
|
||||
public bool CanHandle(string input) =>
|
||||
!string.IsNullOrWhiteSpace(input) &&
|
||||
input.Contains("huya.com", StringComparison.OrdinalIgnoreCase);
|
||||
(input.Contains("huya.com", StringComparison.OrdinalIgnoreCase) || !Uri.IsWellFormedUriString(input, UriKind.Absolute));
|
||||
|
||||
public Task<ParsedLiveRoom> ParseRoomAsync(string input, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException("Huya 适配器尚未实现。");
|
||||
public Task<ParsedLiveRoom> ParseRoomAsync(string input, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var roomId = ExtractRoomId(input);
|
||||
if (string.IsNullOrWhiteSpace(roomId))
|
||||
{
|
||||
throw new InvalidOperationException("Unable to extract the Huya room id from the input.");
|
||||
}
|
||||
|
||||
public Task<LiveStatusSnapshot> GetLiveStatusAsync(string roomId, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException("Huya 适配器尚未实现。");
|
||||
return Task.FromResult(new ParsedLiveRoom(
|
||||
PlatformType,
|
||||
roomId,
|
||||
input.Trim(),
|
||||
BuildPageUrl(roomId)));
|
||||
}
|
||||
|
||||
public Task<StreamUrlResult> GetStreamUrlAsync(
|
||||
public async Task<LiveStatusSnapshot> GetLiveStatusAsync(string roomId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var document = await GetProfileDocumentAsync(roomId, cancellationToken);
|
||||
var root = document.RootElement;
|
||||
var title = PlatformAdapterUtilities.FindFirstString(root, "introduction", "roomName");
|
||||
var anchorName = PlatformAdapterUtilities.FindFirstString(root, "nick", "screenName");
|
||||
var anchorId = PlatformAdapterUtilities.FindFirstString(root, "profileRoom", "uid") ??
|
||||
PlatformAdapterUtilities.FindFirstString(root, "uid");
|
||||
var avatar = PlatformAdapterUtilities.FindFirstString(root, "avatar180", "avatar");
|
||||
var cover = PlatformAdapterUtilities.FindFirstString(root, "screenshot", "gameLiveScreenshot");
|
||||
var liveStatus = PlatformAdapterUtilities.FindFirstInt(root, "liveStatus", "status");
|
||||
var options = ParseStreamOptions(root);
|
||||
var isLive = options.Count > 0 || liveStatus is 1 or 2;
|
||||
|
||||
return new LiveStatusSnapshot(
|
||||
isLive,
|
||||
title,
|
||||
anchorName,
|
||||
anchorId,
|
||||
avatar,
|
||||
cover,
|
||||
liveStatus ?? (isLive ? 1 : 0),
|
||||
liveStatus?.ToString() ?? (isLive ? "live" : "offline"));
|
||||
}
|
||||
|
||||
public async Task<StreamUrlResult> GetStreamUrlAsync(
|
||||
string roomId,
|
||||
string? preferredQuality = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException("Huya 适配器尚未实现。");
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var document = await GetProfileDocumentAsync(roomId, cancellationToken);
|
||||
var options = ParseStreamOptions(document.RootElement);
|
||||
if (options.Count == 0)
|
||||
{
|
||||
var page = await _requestService.GetStringAsync(
|
||||
PlatformType,
|
||||
BuildPageUrl(roomId),
|
||||
referer: "https://www.huya.com/",
|
||||
cancellationToken: cancellationToken);
|
||||
options = PlatformAdapterUtilities.ExtractPlayableUrls(page.Body, ".m3u8")
|
||||
.Select(static url => new StreamQualityOption("origin", "Origin", url, "hls", 90))
|
||||
.Concat(PlatformAdapterUtilities.ExtractPlayableUrls(page.Body, ".flv")
|
||||
.Select(static url => new StreamQualityOption("origin", "Origin", url, "flv", 100)))
|
||||
.DistinctBy(static item => item.Url, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
if (options.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Huya did not return any playable stream URL.");
|
||||
}
|
||||
|
||||
var selected = PlatformAdapterUtilities.SelectQuality(options, preferredQuality);
|
||||
var inputHeaders = await _requestService.BuildStreamInputHeadersAsync(
|
||||
PlatformType,
|
||||
BuildPageUrl(roomId),
|
||||
cancellationToken: cancellationToken);
|
||||
return new StreamUrlResult(
|
||||
selected.QualityKey,
|
||||
selected.Protocol,
|
||||
selected.Url,
|
||||
inputHeaders,
|
||||
options);
|
||||
}
|
||||
|
||||
private Task<JsonDocument> GetProfileDocumentAsync(string roomId, CancellationToken cancellationToken)
|
||||
{
|
||||
return _requestService.GetJsonAsync(
|
||||
PlatformType,
|
||||
$"https://mp.huya.com/cache.php?m=Live&do=profileRoom&roomid={Uri.EscapeDataString(roomId)}",
|
||||
referer: BuildPageUrl(roomId),
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
private static List<StreamQualityOption> ParseStreamOptions(JsonElement root)
|
||||
{
|
||||
var streamInfoList = PlatformAdapterUtilities.FindFirstObject(root, "gameStreamInfoList") ??
|
||||
PlatformAdapterUtilities.FindFirstObject(root, "streamInfoList");
|
||||
if (!streamInfoList.HasValue || streamInfoList.Value.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var options = new List<StreamQualityOption>();
|
||||
foreach (var stream in streamInfoList.Value.EnumerateArray())
|
||||
{
|
||||
var streamName = PlatformAdapterUtilities.FindFirstString(stream, "sStreamName");
|
||||
if (string.IsNullOrWhiteSpace(streamName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var qualityName = PlatformAdapterUtilities.FindFirstString(stream, "sDisplayName", "iBitRate") ?? "origin";
|
||||
var qualityKey = MapQualityKey(qualityName);
|
||||
|
||||
var flvUrl = BuildUrl(
|
||||
PlatformAdapterUtilities.FindFirstString(stream, "sFlvUrl"),
|
||||
streamName,
|
||||
PlatformAdapterUtilities.FindFirstString(stream, "sFlvUrlSuffix"),
|
||||
PlatformAdapterUtilities.FindFirstString(stream, "sFlvAntiCode"));
|
||||
if (!string.IsNullOrWhiteSpace(flvUrl))
|
||||
{
|
||||
options.Add(new StreamQualityOption(
|
||||
qualityKey,
|
||||
qualityName,
|
||||
flvUrl,
|
||||
"flv",
|
||||
PlatformAdapterUtilities.GetQualityRank(qualityKey)));
|
||||
}
|
||||
|
||||
var hlsUrl = BuildUrl(
|
||||
PlatformAdapterUtilities.FindFirstString(stream, "sHlsUrl"),
|
||||
streamName,
|
||||
PlatformAdapterUtilities.FindFirstString(stream, "sHlsUrlSuffix"),
|
||||
PlatformAdapterUtilities.FindFirstString(stream, "sHlsAntiCode"));
|
||||
if (!string.IsNullOrWhiteSpace(hlsUrl))
|
||||
{
|
||||
options.Add(new StreamQualityOption(
|
||||
qualityKey,
|
||||
qualityName,
|
||||
hlsUrl,
|
||||
"hls",
|
||||
PlatformAdapterUtilities.GetQualityRank(qualityKey) - 5));
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
.DistinctBy(static item => item.Url, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderByDescending(static item => item.Rank)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string? BuildUrl(string? baseUrl, string? streamName, string? suffix, string? query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(streamName) || string.IsNullOrWhiteSpace(suffix))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var url = $"{baseUrl.TrimEnd('/')}/{streamName}.{suffix.TrimStart('.')}";
|
||||
return string.IsNullOrWhiteSpace(query) ? url : $"{url}?{query.TrimStart('?')}";
|
||||
}
|
||||
|
||||
private static string MapQualityKey(string qualityName)
|
||||
{
|
||||
var normalized = qualityName.Trim();
|
||||
if (normalized.Contains("蓝光", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.Contains("1080", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "FULL_HD";
|
||||
}
|
||||
|
||||
if (normalized.Contains("超清", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.Contains("高清", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.Contains("720", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "HD";
|
||||
}
|
||||
|
||||
if (normalized.Contains("标清", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.Contains("流畅", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "SD";
|
||||
}
|
||||
|
||||
return "origin";
|
||||
}
|
||||
|
||||
private static string BuildPageUrl(string roomId) => $"https://www.huya.com/{roomId}";
|
||||
|
||||
private static string? ExtractRoomId(string? input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmedInput = input.Trim().Trim('/');
|
||||
if (!Uri.TryCreate(trimmedInput, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return trimmedInput;
|
||||
}
|
||||
|
||||
if (!uri.Host.Contains("huya.com", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return uri.Segments
|
||||
.Select(static item => item.Trim('/'))
|
||||
.FirstOrDefault(static item => !string.IsNullOrWhiteSpace(item));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using LiveRecorder.Infrastructure.Platforms.Common;
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Platforms.Kuaishou;
|
||||
|
||||
public sealed class KuaishouLivePlatformAdapter : PathBasedPageLivePlatformAdapterBase
|
||||
{
|
||||
public KuaishouLivePlatformAdapter(PlatformHttpRequestService requestService)
|
||||
: base(requestService)
|
||||
{
|
||||
}
|
||||
|
||||
public override LivePlatformType PlatformType => LivePlatformType.Kuaishou;
|
||||
|
||||
protected override IReadOnlyList<string> SupportedHosts => ["live.kuaishou.com", "v.kuaishou.com"];
|
||||
|
||||
protected override string PlatformRootUrl => "https://live.kuaishou.com/";
|
||||
|
||||
protected override string? ExtractAnchorName(string html)
|
||||
{
|
||||
var title = ExtractTitle(html);
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
return base.ExtractAnchorName(html);
|
||||
}
|
||||
|
||||
var match = Regex.Match(title, @"^(?<name>.+?)(?:的直播|直播中| - 快手直播)", RegexOptions.CultureInvariant);
|
||||
return match.Success ? match.Groups["name"].Value.Trim() : base.ExtractAnchorName(html);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using LiveRecorder.Infrastructure.Platforms.Common;
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Platforms.Migu;
|
||||
|
||||
public sealed class MiguLivePlatformAdapter : PathBasedPageLivePlatformAdapterBase
|
||||
{
|
||||
public MiguLivePlatformAdapter(PlatformHttpRequestService requestService)
|
||||
: base(requestService)
|
||||
{
|
||||
}
|
||||
|
||||
public override LivePlatformType PlatformType => LivePlatformType.Migu;
|
||||
|
||||
protected override IReadOnlyList<string> SupportedHosts => ["miguvideo.com"];
|
||||
|
||||
protected override string PlatformRootUrl => "https://www.miguvideo.com/";
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using LiveRecorder.Infrastructure.Platforms.Common;
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Platforms.PandaTV;
|
||||
|
||||
public sealed class PandaTvLivePlatformAdapter : PathBasedPageLivePlatformAdapterBase
|
||||
{
|
||||
public PandaTvLivePlatformAdapter(PlatformHttpRequestService requestService)
|
||||
: base(requestService)
|
||||
{
|
||||
}
|
||||
|
||||
public override LivePlatformType PlatformType => LivePlatformType.PandaTV;
|
||||
|
||||
protected override IReadOnlyList<string> SupportedHosts => ["pandalive.co.kr"];
|
||||
|
||||
protected override string PlatformRootUrl => "https://www.pandalive.co.kr/";
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using LiveRecorder.Application.Abstractions.Platforms;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using LiveRecorder.Infrastructure.Platforms.Common;
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Platforms.TikTok;
|
||||
|
||||
public sealed class TikTokLivePlatformAdapter : ILivePlatformAdapter
|
||||
{
|
||||
private static readonly Regex HandleRegex = new(
|
||||
@"@(?<handle>[A-Za-z0-9._-]{2,})",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private readonly PlatformHttpRequestService _requestService;
|
||||
|
||||
public TikTokLivePlatformAdapter(PlatformHttpRequestService requestService)
|
||||
{
|
||||
_requestService = requestService;
|
||||
}
|
||||
|
||||
public LivePlatformType PlatformType => LivePlatformType.TikTok;
|
||||
|
||||
public bool CanHandle(string input) =>
|
||||
!string.IsNullOrWhiteSpace(input) &&
|
||||
(input.Contains("tiktok.com", StringComparison.OrdinalIgnoreCase) ||
|
||||
input.Contains("vt.tiktok.com", StringComparison.OrdinalIgnoreCase) ||
|
||||
HandleRegex.IsMatch(input));
|
||||
|
||||
public async Task<ParsedLiveRoom> ParseRoomAsync(string input, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var handle = ExtractHandle(input);
|
||||
if (string.IsNullOrWhiteSpace(handle))
|
||||
{
|
||||
var response = await _requestService.GetStringAsync(
|
||||
PlatformType,
|
||||
input.Trim(),
|
||||
referer: "https://www.tiktok.com/",
|
||||
cancellationToken: cancellationToken);
|
||||
handle = ExtractHandle(response.FinalUri?.AbsoluteUri) ?? ExtractHandle(response.Body);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(handle))
|
||||
{
|
||||
throw new InvalidOperationException("Unable to extract the TikTok live handle from the input.");
|
||||
}
|
||||
|
||||
return new ParsedLiveRoom(
|
||||
PlatformType,
|
||||
handle,
|
||||
input.Trim(),
|
||||
BuildPageUrl(handle));
|
||||
}
|
||||
|
||||
public async Task<LiveStatusSnapshot> GetLiveStatusAsync(string roomId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var page = await _requestService.GetStringAsync(
|
||||
PlatformType,
|
||||
BuildPageUrl(roomId),
|
||||
referer: "https://www.tiktok.com/",
|
||||
cancellationToken: cancellationToken);
|
||||
var options = ExtractStreamOptions(page.Body);
|
||||
var title = PlatformAdapterUtilities.ExtractMetaContent(page.Body, "og:title", "twitter:title");
|
||||
var anchorName = PlatformAdapterUtilities.ExtractMetaContent(page.Body, "og:description") ??
|
||||
$"@{roomId}";
|
||||
var cover = PlatformAdapterUtilities.ExtractMetaContent(page.Body, "og:image", "twitter:image");
|
||||
var isLive = options.Count > 0 || page.Body.Contains("\"isLive\":true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return new LiveStatusSnapshot(
|
||||
isLive,
|
||||
title,
|
||||
anchorName,
|
||||
$"@{roomId}",
|
||||
AvatarUrl: null,
|
||||
CoverUrl: cover,
|
||||
isLive ? 1 : 0,
|
||||
isLive ? "live" : "offline");
|
||||
}
|
||||
|
||||
public async Task<StreamUrlResult> GetStreamUrlAsync(
|
||||
string roomId,
|
||||
string? preferredQuality = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var page = await _requestService.GetStringAsync(
|
||||
PlatformType,
|
||||
BuildPageUrl(roomId),
|
||||
referer: "https://www.tiktok.com/",
|
||||
cancellationToken: cancellationToken);
|
||||
var options = ExtractStreamOptions(page.Body);
|
||||
if (options.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("TikTok did not return any playable stream URL.");
|
||||
}
|
||||
|
||||
var selected = PlatformAdapterUtilities.SelectQuality(options, preferredQuality);
|
||||
var inputHeaders = await _requestService.BuildStreamInputHeadersAsync(
|
||||
PlatformType,
|
||||
BuildPageUrl(roomId),
|
||||
cancellationToken: cancellationToken);
|
||||
return new StreamUrlResult(
|
||||
selected.QualityKey,
|
||||
selected.Protocol,
|
||||
selected.Url,
|
||||
inputHeaders,
|
||||
options);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<StreamQualityOption> ExtractStreamOptions(string html)
|
||||
{
|
||||
var options = new List<StreamQualityOption>();
|
||||
var scriptPayload = PlatformAdapterUtilities.ExtractScriptTagJsonById(html, "SIGI_STATE") ??
|
||||
PlatformAdapterUtilities.ExtractScriptTagJsonById(html, "__UNIVERSAL_DATA_FOR_REHYDRATION__");
|
||||
using var scriptDocument = PlatformAdapterUtilities.TryParseJson(scriptPayload);
|
||||
if (scriptDocument is not null)
|
||||
{
|
||||
var hlsUrl = PlatformAdapterUtilities.FindFirstString(scriptDocument.RootElement, "hls_pull_url");
|
||||
var flvUrl = PlatformAdapterUtilities.FindFirstString(scriptDocument.RootElement, "flv_pull_url");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(flvUrl))
|
||||
{
|
||||
options.Add(new StreamQualityOption("origin", "Origin", flvUrl, "flv", 100));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(hlsUrl))
|
||||
{
|
||||
options.Add(new StreamQualityOption("origin", "Origin", hlsUrl, "hls", 90));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var url in PlatformAdapterUtilities.ExtractPlayableUrls(html, ".m3u8"))
|
||||
{
|
||||
options.Add(new StreamQualityOption("origin", "Origin", url, "hls", 90));
|
||||
}
|
||||
|
||||
foreach (var url in PlatformAdapterUtilities.ExtractPlayableUrls(html, ".flv"))
|
||||
{
|
||||
options.Add(new StreamQualityOption("origin", "Origin", url, "flv", 100));
|
||||
}
|
||||
|
||||
return options
|
||||
.DistinctBy(static item => item.Url, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderByDescending(static item => item.Rank)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string BuildPageUrl(string handle) => $"https://www.tiktok.com/@{handle}/live";
|
||||
|
||||
private static string? ExtractHandle(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var match = HandleRegex.Match(value);
|
||||
return match.Success ? match.Groups["handle"].Value : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using LiveRecorder.Application.Abstractions.Platforms;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using LiveRecorder.Infrastructure.Platforms.Common;
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Platforms.Twitch;
|
||||
|
||||
public sealed class TwitchLivePlatformAdapter : ILivePlatformAdapter
|
||||
{
|
||||
private const string ClientId = "kimne78kx3ncx6brgo4mv6wki5h1ko";
|
||||
|
||||
private static readonly Regex LoginRegex = new(
|
||||
@"^(?<login>[A-Za-z0-9_]{3,25})$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private readonly PlatformHttpRequestService _requestService;
|
||||
|
||||
public TwitchLivePlatformAdapter(PlatformHttpRequestService requestService)
|
||||
{
|
||||
_requestService = requestService;
|
||||
}
|
||||
|
||||
public LivePlatformType PlatformType => LivePlatformType.Twitch;
|
||||
|
||||
public bool CanHandle(string input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return input.Contains("twitch.tv", StringComparison.OrdinalIgnoreCase) ||
|
||||
LoginRegex.IsMatch(input.Trim());
|
||||
}
|
||||
|
||||
public async Task<ParsedLiveRoom> ParseRoomAsync(string input, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var login = ExtractLogin(input);
|
||||
if (string.IsNullOrWhiteSpace(login))
|
||||
{
|
||||
var response = await _requestService.GetStringAsync(
|
||||
PlatformType,
|
||||
input.Trim(),
|
||||
referer: "https://www.twitch.tv/",
|
||||
cancellationToken: cancellationToken);
|
||||
login = ExtractLogin(response.FinalUri?.AbsoluteUri);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(login))
|
||||
{
|
||||
throw new InvalidOperationException("Unable to extract the Twitch channel login from the input.");
|
||||
}
|
||||
|
||||
return new ParsedLiveRoom(
|
||||
PlatformType,
|
||||
login,
|
||||
input.Trim(),
|
||||
BuildChannelUrl(login));
|
||||
}
|
||||
|
||||
public async Task<LiveStatusSnapshot> GetLiveStatusAsync(string roomId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = await ExecuteGraphQlAsync(
|
||||
operationName: "StreamMetadata",
|
||||
query: """
|
||||
query StreamMetadata($login: String!) {
|
||||
user(login: $login) {
|
||||
id
|
||||
login
|
||||
displayName
|
||||
profileImageURL(width: 300)
|
||||
stream {
|
||||
id
|
||||
type
|
||||
title
|
||||
previewImageURL(width: 1920, height: 1080)
|
||||
}
|
||||
}
|
||||
}
|
||||
""",
|
||||
variables: new { login = roomId },
|
||||
cancellationToken);
|
||||
|
||||
var user = payload.RootElement.TryGetProperty("data", out var dataElement) &&
|
||||
dataElement.TryGetProperty("user", out var userElement)
|
||||
? userElement
|
||||
: default;
|
||||
if (user.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new InvalidOperationException($"Twitch channel '{roomId}' was not found.");
|
||||
}
|
||||
|
||||
var stream = user.TryGetProperty("stream", out var streamElement) ? streamElement : default;
|
||||
var isLive = stream.ValueKind == JsonValueKind.Object &&
|
||||
stream.TryGetProperty("type", out var typeElement) &&
|
||||
string.Equals(typeElement.GetString(), "live", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return new LiveStatusSnapshot(
|
||||
isLive,
|
||||
isLive ? PlatformAdapterUtilities.FindFirstString(stream, "title") : null,
|
||||
PlatformAdapterUtilities.FindFirstString(user, "displayName"),
|
||||
PlatformAdapterUtilities.FindFirstString(user, "id"),
|
||||
PlatformAdapterUtilities.FindFirstString(user, "profileImageURL"),
|
||||
isLive ? PlatformAdapterUtilities.FindFirstString(stream, "previewImageURL") : null,
|
||||
isLive ? 1 : 0,
|
||||
isLive ? "live" : "offline");
|
||||
}
|
||||
|
||||
public async Task<StreamUrlResult> GetStreamUrlAsync(
|
||||
string roomId,
|
||||
string? preferredQuality = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = await ExecuteGraphQlAsync(
|
||||
operationName: "PlaybackAccessToken",
|
||||
query: """
|
||||
query PlaybackAccessToken($login: String!) {
|
||||
streamPlaybackAccessToken(
|
||||
channelName: $login
|
||||
params: {
|
||||
platform: "web"
|
||||
playerBackend: "mediaplayer"
|
||||
playerType: "site"
|
||||
}
|
||||
) {
|
||||
value
|
||||
signature
|
||||
}
|
||||
}
|
||||
""",
|
||||
variables: new { login = roomId },
|
||||
cancellationToken);
|
||||
|
||||
if (!payload.RootElement.TryGetProperty("data", out var dataElement) ||
|
||||
!dataElement.TryGetProperty("streamPlaybackAccessToken", out var tokenElement))
|
||||
{
|
||||
throw new InvalidOperationException("Twitch did not return a playback access token.");
|
||||
}
|
||||
|
||||
var token = PlatformAdapterUtilities.FindFirstString(tokenElement, "value");
|
||||
var signature = PlatformAdapterUtilities.FindFirstString(tokenElement, "signature");
|
||||
if (string.IsNullOrWhiteSpace(token) || string.IsNullOrWhiteSpace(signature))
|
||||
{
|
||||
throw new InvalidOperationException("Twitch returned an empty playback access token.");
|
||||
}
|
||||
|
||||
var url = $"https://usher.ttvnw.net/api/channel/hls/{roomId}.m3u8" +
|
||||
$"?allow_source=true&allow_audio_only=true&fast_bread=true&player_backend=mediaplayer" +
|
||||
$"&playlist_include_framerate=true&reassignments_supported=true" +
|
||||
$"&sig={Uri.EscapeDataString(signature)}&token={Uri.EscapeDataString(token)}";
|
||||
|
||||
var selected = new StreamQualityOption("origin", "Origin", url, "hls", 100);
|
||||
var inputHeaders = await _requestService.BuildStreamInputHeadersAsync(
|
||||
PlatformType,
|
||||
BuildChannelUrl(roomId),
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Client-Id"] = ClientId,
|
||||
["Origin"] = "https://www.twitch.tv"
|
||||
},
|
||||
cancellationToken);
|
||||
return new StreamUrlResult(
|
||||
selected.QualityKey,
|
||||
selected.Protocol,
|
||||
selected.Url,
|
||||
inputHeaders,
|
||||
[selected]);
|
||||
}
|
||||
|
||||
private async Task<JsonDocument> ExecuteGraphQlAsync(
|
||||
string operationName,
|
||||
string query,
|
||||
object variables,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await _requestService.SendJsonAsync(
|
||||
PlatformType,
|
||||
"https://gql.twitch.tv/gql",
|
||||
new
|
||||
{
|
||||
operationName,
|
||||
query,
|
||||
variables
|
||||
},
|
||||
referer: "https://www.twitch.tv/",
|
||||
additionalHeaders: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Client-Id"] = ClientId
|
||||
},
|
||||
cancellationToken: cancellationToken);
|
||||
return JsonDocument.Parse(response.Body);
|
||||
}
|
||||
|
||||
private static string BuildChannelUrl(string login) => $"https://www.twitch.tv/{login}";
|
||||
|
||||
private static string? ExtractLogin(string? input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmedInput = input.Trim().Trim('/');
|
||||
var directMatch = LoginRegex.Match(trimmedInput);
|
||||
if (directMatch.Success)
|
||||
{
|
||||
return directMatch.Groups["login"].Value.ToLowerInvariant();
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(trimmedInput, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!uri.Host.Contains("twitch.tv", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var login = PlatformAdapterUtilities.ExtractFirstPathSegment(uri);
|
||||
if (string.IsNullOrWhiteSpace(login))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.Equals(login, "directory", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(login, "videos", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return login.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using LiveRecorder.Infrastructure.Platforms.Common;
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Platforms.Xiaohongshu;
|
||||
|
||||
public sealed class XiaohongshuLivePlatformAdapter : PathBasedPageLivePlatformAdapterBase
|
||||
{
|
||||
public XiaohongshuLivePlatformAdapter(PlatformHttpRequestService requestService)
|
||||
: base(requestService)
|
||||
{
|
||||
}
|
||||
|
||||
public override LivePlatformType PlatformType => LivePlatformType.Xiaohongshu;
|
||||
|
||||
protected override IReadOnlyList<string> SupportedHosts => ["xiaohongshu.com", "xhslink.com"];
|
||||
|
||||
protected override string PlatformRootUrl => "https://www.xiaohongshu.com/";
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using LiveRecorder.Application.Abstractions.Platforms;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using LiveRecorder.Infrastructure.Platforms.Common;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Platforms.YouTube;
|
||||
|
||||
public sealed class YouTubeLivePlatformAdapter : ILivePlatformAdapter
|
||||
{
|
||||
private static readonly Regex VideoIdRegex = new(
|
||||
@"^[A-Za-z0-9_-]{11}$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private readonly PlatformHttpRequestService _requestService;
|
||||
|
||||
public YouTubeLivePlatformAdapter(PlatformHttpRequestService requestService)
|
||||
{
|
||||
_requestService = requestService;
|
||||
}
|
||||
|
||||
public LivePlatformType PlatformType => LivePlatformType.YouTube;
|
||||
|
||||
public bool CanHandle(string input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return input.Contains("youtube.com", StringComparison.OrdinalIgnoreCase) ||
|
||||
input.Contains("youtu.be", StringComparison.OrdinalIgnoreCase) ||
|
||||
VideoIdRegex.IsMatch(input.Trim());
|
||||
}
|
||||
|
||||
public async Task<ParsedLiveRoom> ParseRoomAsync(string input, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var trimmedInput = input.Trim();
|
||||
var videoId = ExtractVideoId(trimmedInput);
|
||||
if (!string.IsNullOrWhiteSpace(videoId))
|
||||
{
|
||||
return new ParsedLiveRoom(
|
||||
PlatformType,
|
||||
videoId,
|
||||
trimmedInput,
|
||||
BuildWatchUrl(videoId));
|
||||
}
|
||||
|
||||
var response = await _requestService.GetStringAsync(
|
||||
PlatformType,
|
||||
trimmedInput,
|
||||
referer: "https://www.youtube.com/",
|
||||
cancellationToken: cancellationToken);
|
||||
videoId = ExtractVideoId(response.FinalUri?.AbsoluteUri) ?? ExtractVideoId(response.Body);
|
||||
if (string.IsNullOrWhiteSpace(videoId))
|
||||
{
|
||||
throw new InvalidOperationException("Unable to extract the YouTube video id from the input.");
|
||||
}
|
||||
|
||||
return new ParsedLiveRoom(
|
||||
PlatformType,
|
||||
videoId,
|
||||
trimmedInput,
|
||||
BuildWatchUrl(videoId));
|
||||
}
|
||||
|
||||
public async Task<LiveStatusSnapshot> GetLiveStatusAsync(string roomId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var page = await GetWatchPageAsync(roomId, cancellationToken);
|
||||
var playerResponse = GetPlayerResponse(page.Body);
|
||||
|
||||
var videoDetails = playerResponse.RootElement.TryGetProperty("videoDetails", out var detailsElement)
|
||||
? detailsElement
|
||||
: default;
|
||||
var microformat = TryGetNested(playerResponse.RootElement, out var microformatElement, "microformat", "playerMicroformatRenderer")
|
||||
? microformatElement
|
||||
: default;
|
||||
var thumbnails = PlatformAdapterUtilities.FindFirstObject(videoDetails, "thumbnail");
|
||||
|
||||
var isLive = GetBoolean(videoDetails, "isLive") ||
|
||||
GetBoolean(videoDetails, "isLiveContent") ||
|
||||
GetBoolean(microformat, "isLiveNow") ||
|
||||
page.Body.Contains("\"isLiveNow\":true", StringComparison.OrdinalIgnoreCase);
|
||||
var title = PlatformAdapterUtilities.FindFirstString(videoDetails, "title") ??
|
||||
PlatformAdapterUtilities.ExtractMetaContent(page.Body, "og:title", "twitter:title");
|
||||
var anchorName = PlatformAdapterUtilities.FindFirstString(videoDetails, "author") ??
|
||||
PlatformAdapterUtilities.FindFirstString(microformat, "ownerChannelName");
|
||||
var coverUrl = SelectBestThumbnail(thumbnails) ??
|
||||
PlatformAdapterUtilities.ExtractMetaContent(page.Body, "og:image", "twitter:image");
|
||||
var anchorId = PlatformAdapterUtilities.FindFirstString(videoDetails, "channelId");
|
||||
|
||||
return new LiveStatusSnapshot(
|
||||
isLive,
|
||||
title,
|
||||
anchorName,
|
||||
anchorId,
|
||||
AvatarUrl: null,
|
||||
CoverUrl: coverUrl,
|
||||
isLive ? 1 : 0,
|
||||
isLive ? "live" : "offline");
|
||||
}
|
||||
|
||||
public async Task<StreamUrlResult> GetStreamUrlAsync(
|
||||
string roomId,
|
||||
string? preferredQuality = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var page = await GetWatchPageAsync(roomId, cancellationToken);
|
||||
var playerResponse = GetPlayerResponse(page.Body);
|
||||
var streamingData = playerResponse.RootElement.TryGetProperty("streamingData", out var element)
|
||||
? element
|
||||
: throw new InvalidOperationException("YouTube page did not expose a streamingData payload.");
|
||||
|
||||
var hlsManifestUrl = PlatformAdapterUtilities.FindFirstString(streamingData, "hlsManifestUrl");
|
||||
var dashManifestUrl = PlatformAdapterUtilities.FindFirstString(streamingData, "dashManifestUrl");
|
||||
var selectedUrl = !string.IsNullOrWhiteSpace(hlsManifestUrl) ? hlsManifestUrl : dashManifestUrl;
|
||||
if (string.IsNullOrWhiteSpace(selectedUrl))
|
||||
{
|
||||
throw new InvalidOperationException("YouTube did not return a playable live stream URL.");
|
||||
}
|
||||
|
||||
var selectedProtocol = PlatformAdapterUtilities.InferProtocolFromUrl(selectedUrl);
|
||||
var selectedQuality = new StreamQualityOption("origin", "Origin", selectedUrl, selectedProtocol, 100);
|
||||
var inputHeaders = await _requestService.BuildStreamInputHeadersAsync(
|
||||
PlatformType,
|
||||
BuildWatchUrl(roomId),
|
||||
cancellationToken: cancellationToken);
|
||||
return new StreamUrlResult(
|
||||
selectedQuality.QualityKey,
|
||||
selectedQuality.Protocol,
|
||||
selectedQuality.Url,
|
||||
inputHeaders,
|
||||
[selectedQuality]);
|
||||
}
|
||||
|
||||
private Task<PlatformHttpResponse> GetWatchPageAsync(string roomId, CancellationToken cancellationToken)
|
||||
{
|
||||
return _requestService.GetStringAsync(
|
||||
PlatformType,
|
||||
BuildWatchUrl(roomId),
|
||||
referer: "https://www.youtube.com/",
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
private static JsonDocument GetPlayerResponse(string html)
|
||||
{
|
||||
var payload = PlatformAdapterUtilities.ExtractJsonObjectAfterMarker(html, "var ytInitialPlayerResponse = ") ??
|
||||
PlatformAdapterUtilities.ExtractJsonObjectAfterMarker(html, "ytInitialPlayerResponse = ") ??
|
||||
PlatformAdapterUtilities.ExtractJsonObjectAfterMarker(html, "\"playerResponse\":");
|
||||
var document = PlatformAdapterUtilities.TryParseJson(payload);
|
||||
if (document is null)
|
||||
{
|
||||
throw new InvalidOperationException("Unable to parse the YouTube player response.");
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
private static string BuildWatchUrl(string videoId) => $"https://www.youtube.com/watch?v={videoId}";
|
||||
|
||||
private static string? ExtractVideoId(string? input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmedInput = input.Trim();
|
||||
if (VideoIdRegex.IsMatch(trimmedInput))
|
||||
{
|
||||
return trimmedInput;
|
||||
}
|
||||
|
||||
if (Uri.TryCreate(trimmedInput, UriKind.Absolute, out var uri))
|
||||
{
|
||||
if (uri.Host.Contains("youtu.be", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var pathSegment = PlatformAdapterUtilities.ExtractFirstPathSegment(uri);
|
||||
if (!string.IsNullOrWhiteSpace(pathSegment) && VideoIdRegex.IsMatch(pathSegment))
|
||||
{
|
||||
return pathSegment;
|
||||
}
|
||||
}
|
||||
|
||||
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||
var fromQuery = query.TryGetValue("v", out var videoIdValue)
|
||||
? videoIdValue.ToString()
|
||||
: null;
|
||||
if (!string.IsNullOrWhiteSpace(fromQuery) && VideoIdRegex.IsMatch(fromQuery))
|
||||
{
|
||||
return fromQuery;
|
||||
}
|
||||
}
|
||||
|
||||
var match = Regex.Match(trimmedInput, @"[?&]v=(?<id>[A-Za-z0-9_-]{11})", RegexOptions.CultureInvariant);
|
||||
if (match.Success)
|
||||
{
|
||||
return match.Groups["id"].Value;
|
||||
}
|
||||
|
||||
match = Regex.Match(trimmedInput, @"/(?<id>[A-Za-z0-9_-]{11})(?:[/?#]|$)", RegexOptions.CultureInvariant);
|
||||
return match.Success ? match.Groups["id"].Value : null;
|
||||
}
|
||||
|
||||
private static string? SelectBestThumbnail(JsonElement? thumbnailElement)
|
||||
{
|
||||
if (!thumbnailElement.HasValue || thumbnailElement.Value.ValueKind == JsonValueKind.Undefined)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (thumbnailElement.Value.ValueKind == JsonValueKind.Object &&
|
||||
thumbnailElement.Value.TryGetProperty("thumbnails", out var thumbnails) &&
|
||||
thumbnails.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return thumbnails.EnumerateArray()
|
||||
.Select(static item => PlatformAdapterUtilities.FindFirstString(item, "url"))
|
||||
.Where(static item => !string.IsNullOrWhiteSpace(item))
|
||||
.LastOrDefault();
|
||||
}
|
||||
|
||||
return PlatformAdapterUtilities.FindFirstString(thumbnailElement.Value, "url");
|
||||
}
|
||||
|
||||
private static bool TryGetNested(JsonElement element, out JsonElement value, params string[] path)
|
||||
{
|
||||
value = element;
|
||||
foreach (var segment in path)
|
||||
{
|
||||
if (value.ValueKind != JsonValueKind.Object || !value.TryGetProperty(segment, out value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool GetBoolean(JsonElement element, string propertyName)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(propertyName, out var property))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return property.ValueKind switch
|
||||
{
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.String => bool.TryParse(property.GetString(), out var value) && value,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using LiveRecorder.Infrastructure.Persistence;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -7,6 +8,8 @@ namespace LiveRecorder.Infrastructure.Services;
|
||||
public sealed class CleanupOperationBackgroundService : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan IdleDelay = TimeSpan.FromSeconds(2);
|
||||
private static readonly TimeSpan ErrorBaseDelay = TimeSpan.FromSeconds(5);
|
||||
private static readonly TimeSpan ErrorMaxDelay = TimeSpan.FromMinutes(5);
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
private readonly ILogger<CleanupOperationBackgroundService> _logger;
|
||||
|
||||
@@ -20,6 +23,7 @@ public sealed class CleanupOperationBackgroundService : BackgroundService
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// Startup: requeue interrupted operations
|
||||
try
|
||||
{
|
||||
using var startupScope = _serviceScopeFactory.CreateScope();
|
||||
@@ -31,17 +35,36 @@ public sealed class CleanupOperationBackgroundService : BackgroundService
|
||||
_logger.LogWarning(ex, "Failed to requeue interrupted cleanup operations at startup");
|
||||
}
|
||||
|
||||
var consecutiveErrors = 0;
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Skip processing if the circuit is open — don't waste resources
|
||||
if (DatabaseCircuitBreaker.IsOpen)
|
||||
{
|
||||
consecutiveErrors = await DelayWithBackoff(ErrorBaseDelay, ErrorMaxDelay, consecutiveErrors, stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var coordinator = scope.ServiceProvider.GetRequiredService<CleanupOperationCoordinator>();
|
||||
var processed = await coordinator.ProcessNextQueuedOperationAsync(stoppingToken);
|
||||
|
||||
if (processed)
|
||||
{
|
||||
consecutiveErrors = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
// No queued operations — idle delay
|
||||
consecutiveErrors = 0;
|
||||
await Task.Delay(IdleDelay, stoppingToken);
|
||||
}
|
||||
catch (DatabaseCircuitOpenException)
|
||||
{
|
||||
consecutiveErrors = await DelayWithBackoff(ErrorBaseDelay, ErrorMaxDelay, consecutiveErrors, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
@@ -49,17 +72,47 @@ public sealed class CleanupOperationBackgroundService : BackgroundService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Cleanup operation background worker failed");
|
||||
}
|
||||
var isDatabaseError = DatabaseCircuitBreaker.IsNonTransient(ex) ||
|
||||
ex is Npgsql.NpgsqlException ||
|
||||
ex is Microsoft.EntityFrameworkCore.DbUpdateException;
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(IdleDelay, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
if (isDatabaseError)
|
||||
{
|
||||
DatabaseCircuitBreaker.RecordFailure();
|
||||
_logger.LogWarning(ex,
|
||||
"Cleanup background worker: database error (#{ErrorCount}). Circuit state: Open={IsOpen}, Failures={Failures}",
|
||||
consecutiveErrors + 1,
|
||||
DatabaseCircuitBreaker.IsOpen,
|
||||
DatabaseCircuitBreaker.ConsecutiveFailures);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError(ex, "Cleanup operation background worker failed");
|
||||
}
|
||||
|
||||
consecutiveErrors = await DelayWithBackoff(ErrorBaseDelay, ErrorMaxDelay, consecutiveErrors, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<int> DelayWithBackoff(
|
||||
TimeSpan baseDelay, TimeSpan maxDelay, int errorCount, CancellationToken cancellationToken)
|
||||
{
|
||||
errorCount++;
|
||||
// Exponential backoff: 5s, 10s, 20s, 40s, 80s, 160s, capping at 5min
|
||||
var factor = Math.Pow(2, Math.Min(errorCount - 1, 6));
|
||||
var delay = TimeSpan.FromMilliseconds(
|
||||
Math.Min(baseDelay.TotalMilliseconds * factor, maxDelay.TotalMilliseconds));
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Swallow — loop will exit on next iteration
|
||||
}
|
||||
|
||||
return errorCount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
using System.Globalization;
|
||||
using System.Xml;
|
||||
using LiveRecorder.Application.Abstractions.Persistence;
|
||||
using LiveRecorder.Application.Abstractions.Recording;
|
||||
using LiveRecorder.Application.Models.RecordTasks;
|
||||
using LiveRecorder.Domain.Entities;
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Services;
|
||||
|
||||
public sealed class DanmakuService : IDanmakuService
|
||||
{
|
||||
private readonly IRecordTaskRepository _recordTaskRepository;
|
||||
private readonly IRecordSessionRepository _recordSessionRepository;
|
||||
|
||||
public DanmakuService(
|
||||
IRecordTaskRepository recordTaskRepository,
|
||||
IRecordSessionRepository recordSessionRepository)
|
||||
{
|
||||
_recordTaskRepository = recordTaskRepository;
|
||||
_recordSessionRepository = recordSessionRepository;
|
||||
}
|
||||
|
||||
public async Task<DanmakuResponseDto?> GetTaskDanmakuAsync(
|
||||
Guid recordTaskId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var task = await _recordTaskRepository.GetByIdAsync(recordTaskId, cancellationToken);
|
||||
if (task is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var danmakuPath = ResolveDanmakuPath(task);
|
||||
if (string.IsNullOrWhiteSpace(danmakuPath) || !File.Exists(danmakuPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var taskStartedAt = task.StartedAt ?? task.CreatedAt;
|
||||
var events = ParseDanmakuXml(danmakuPath);
|
||||
|
||||
return new DanmakuResponseDto
|
||||
{
|
||||
RecordTaskId = task.Id,
|
||||
SegmentIndex = task.SegmentIndex,
|
||||
Platform = task.LiveRoom?.Platform.ToString(),
|
||||
RoomId = task.LiveRoom?.RoomId,
|
||||
LiveRoomId = task.LiveRoomId.ToString(),
|
||||
RecordSessionId = task.RecordSessionId,
|
||||
StartedAt = taskStartedAt,
|
||||
Events = events
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<SessionDanmakuResponseDto?> GetSessionDanmakuAsync(
|
||||
Guid recordSessionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var session = await _recordSessionRepository.GetByIdAsync(recordSessionId, cancellationToken);
|
||||
if (session is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var tasks = await _recordTaskRepository.ListBySessionIdAsync(recordSessionId, cancellationToken);
|
||||
if (tasks.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the session anchor: the earliest task StartedAt (or CreatedAt)
|
||||
var sessionAnchor = tasks
|
||||
.Select(static task => task.StartedAt ?? task.CreatedAt)
|
||||
.Min();
|
||||
|
||||
var taskResponses = new List<DanmakuResponseDto>(tasks.Count);
|
||||
foreach (var task in tasks.OrderBy(static item => item.SegmentIndex).ThenBy(static item => item.CreatedAt))
|
||||
{
|
||||
var danmakuPath = ResolveDanmakuPath(task);
|
||||
if (string.IsNullOrWhiteSpace(danmakuPath) || !File.Exists(danmakuPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var taskStartedAt = task.StartedAt ?? task.CreatedAt;
|
||||
var events = ParseDanmakuXml(danmakuPath);
|
||||
|
||||
// Adjust offsets so they are relative to the session anchor (not the individual segment)
|
||||
var segmentOffsetFromSessionAnchor = (taskStartedAt - sessionAnchor).TotalSeconds;
|
||||
if (Math.Abs(segmentOffsetFromSessionAnchor) > 0.01)
|
||||
{
|
||||
events = events
|
||||
.Select(item => new DanmakuEventDto
|
||||
{
|
||||
OffsetSeconds = item.OffsetSeconds + segmentOffsetFromSessionAnchor,
|
||||
Type = item.Type,
|
||||
Content = item.Content,
|
||||
User = item.User,
|
||||
UserId = item.UserId,
|
||||
Color = item.Color,
|
||||
FontSize = item.FontSize,
|
||||
Mode = item.Mode,
|
||||
TimestampMs = item.TimestampMs,
|
||||
GiftName = item.GiftName,
|
||||
Count = item.Count,
|
||||
Raw = item.Raw
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
taskResponses.Add(new DanmakuResponseDto
|
||||
{
|
||||
RecordTaskId = task.Id,
|
||||
SegmentIndex = task.SegmentIndex,
|
||||
Platform = task.LiveRoom?.Platform.ToString(),
|
||||
RoomId = task.LiveRoom?.RoomId,
|
||||
LiveRoomId = task.LiveRoomId.ToString(),
|
||||
RecordSessionId = task.RecordSessionId,
|
||||
StartedAt = taskStartedAt,
|
||||
Events = events
|
||||
});
|
||||
}
|
||||
|
||||
if (taskResponses.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SessionDanmakuResponseDto
|
||||
{
|
||||
RecordSessionId = recordSessionId,
|
||||
Tasks = taskResponses
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ResolveDanmakuPath(RecordTask task)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(task.Result?.DanmakuFilePath))
|
||||
{
|
||||
return task.Result.DanmakuFilePath;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(task.OutputFilePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Path.ChangeExtension(task.OutputFilePath, ".xml");
|
||||
}
|
||||
|
||||
private static IReadOnlyList<DanmakuEventDto> ParseDanmakuXml(string danmakuPath)
|
||||
{
|
||||
var events = new List<DanmakuEventDto>();
|
||||
|
||||
try
|
||||
{
|
||||
var settings = new XmlReaderSettings
|
||||
{
|
||||
IgnoreComments = true,
|
||||
IgnoreWhitespace = true,
|
||||
DtdProcessing = DtdProcessing.Ignore
|
||||
};
|
||||
|
||||
using var reader = XmlReader.Create(danmakuPath, settings);
|
||||
while (reader.Read())
|
||||
{
|
||||
if (reader.NodeType != XmlNodeType.Element)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(reader.Name, "d", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var chatEvent = ParseChatElement(reader);
|
||||
if (chatEvent is not null)
|
||||
{
|
||||
events.Add(chatEvent);
|
||||
}
|
||||
}
|
||||
else if (string.Equals(reader.Name, "event", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var nonChatEvent = ParseEventElement(reader);
|
||||
if (nonChatEvent is not null)
|
||||
{
|
||||
events.Add(nonChatEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
private static DanmakuEventDto? ParseChatElement(XmlReader reader)
|
||||
{
|
||||
// <d p="offsetSeconds,mode,fontSize,color,timestampMs,?,userId,?" user="..." type="chat" raw="...">content</d>
|
||||
var payload = reader.GetAttribute("p");
|
||||
var user = reader.GetAttribute("user");
|
||||
var raw = reader.GetAttribute("raw");
|
||||
|
||||
double? offsetSeconds = null;
|
||||
int? fontSize = null;
|
||||
int? mode = null;
|
||||
string? color = null;
|
||||
long? timestampMs = null;
|
||||
string? userId = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(payload))
|
||||
{
|
||||
var parts = payload.Split(',');
|
||||
if (parts.Length >= 1 && double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedOffset))
|
||||
{
|
||||
offsetSeconds = parsedOffset;
|
||||
}
|
||||
|
||||
if (parts.Length >= 2 && int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedMode))
|
||||
{
|
||||
mode = parsedMode;
|
||||
}
|
||||
|
||||
if (parts.Length >= 3 && int.TryParse(parts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedFontSize))
|
||||
{
|
||||
fontSize = parsedFontSize;
|
||||
}
|
||||
|
||||
if (parts.Length >= 4)
|
||||
{
|
||||
color = string.IsNullOrWhiteSpace(parts[3]) ? null : parts[3].Trim();
|
||||
}
|
||||
|
||||
if (parts.Length >= 5 && long.TryParse(parts[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedTs))
|
||||
{
|
||||
timestampMs = parsedTs;
|
||||
}
|
||||
|
||||
if (parts.Length >= 7)
|
||||
{
|
||||
userId = string.IsNullOrWhiteSpace(parts[6]) ? null : parts[6].Trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (!offsetSeconds.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var content = reader.ReadInnerXml().Trim();
|
||||
|
||||
return new DanmakuEventDto
|
||||
{
|
||||
OffsetSeconds = Math.Max(0, offsetSeconds.Value),
|
||||
Type = "chat",
|
||||
Content = string.IsNullOrWhiteSpace(content) ? string.Empty : content,
|
||||
User = NormalizeNullable(user),
|
||||
UserId = NormalizeNullable(userId),
|
||||
Color = NormalizeNullable(color) ?? "FFFFFF",
|
||||
FontSize = fontSize ?? 25,
|
||||
Mode = mode ?? 1,
|
||||
TimestampMs = timestampMs,
|
||||
Raw = NormalizeNullable(raw)
|
||||
};
|
||||
}
|
||||
|
||||
private static DanmakuEventDto? ParseEventElement(XmlReader reader)
|
||||
{
|
||||
// <event type="gift" ts="123" offset="12.3" user="name" userId="uid" content="desc" raw="..." [extraKey="extraValue"] ... />
|
||||
var type = reader.GetAttribute("type");
|
||||
var offset = reader.GetAttribute("offset");
|
||||
var ts = reader.GetAttribute("ts");
|
||||
var user = reader.GetAttribute("user");
|
||||
var userId = reader.GetAttribute("userId");
|
||||
var content = reader.GetAttribute("content");
|
||||
var raw = reader.GetAttribute("raw");
|
||||
|
||||
if (!double.TryParse(offset, NumberStyles.Float, CultureInfo.InvariantCulture, out var offsetSeconds))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
long? timestampMs = null;
|
||||
if (long.TryParse(ts, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedTs))
|
||||
{
|
||||
timestampMs = parsedTs;
|
||||
}
|
||||
|
||||
var giftName = reader.GetAttribute("giftName");
|
||||
var count = reader.GetAttribute("count");
|
||||
|
||||
int? countValue = null;
|
||||
if (int.TryParse(count, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedCount))
|
||||
{
|
||||
countValue = parsedCount;
|
||||
}
|
||||
|
||||
return new DanmakuEventDto
|
||||
{
|
||||
OffsetSeconds = Math.Max(0, offsetSeconds),
|
||||
Type = string.IsNullOrWhiteSpace(type) ? "other" : type.Trim(),
|
||||
Content = NormalizeNullable(content) ?? string.Empty,
|
||||
User = NormalizeNullable(user),
|
||||
UserId = NormalizeNullable(userId),
|
||||
Color = null,
|
||||
FontSize = null,
|
||||
Mode = null,
|
||||
TimestampMs = timestampMs,
|
||||
GiftName = NormalizeNullable(giftName),
|
||||
Count = countValue,
|
||||
Raw = NormalizeNullable(raw)
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizeNullable(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
@@ -28,7 +28,10 @@ public sealed class EmailNotificationService : IEmailNotificationService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task SendLiveStartedAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default)
|
||||
public async Task SendLiveStartedAsync(
|
||||
LiveRoom liveRoom,
|
||||
CancellationToken cancellationToken = default,
|
||||
string? eventScriptOutput = null)
|
||||
{
|
||||
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||
if (!settings.EnableEmailNotification || !settings.NotifyOnLiveStarted)
|
||||
@@ -43,7 +46,8 @@ public sealed class EmailNotificationService : IEmailNotificationService
|
||||
["title"] = liveRoom.Title,
|
||||
["anchor"] = liveRoom.AnchorName,
|
||||
["sourceUrl"] = liveRoom.SourceUrl,
|
||||
["detectedAtUtc"] = ChinaTime.ToBeijingTime(DateTimeOffset.UtcNow).ToString("O")
|
||||
["detectedAtUtc"] = ChinaTime.ToBeijingTime(DateTimeOffset.UtcNow).ToString("O"),
|
||||
["eventScriptOutput"] = NormalizeNotificationText(eventScriptOutput)
|
||||
});
|
||||
|
||||
var subject = RenderSubject(settings.EmailLiveStartedSubjectTemplate, tokens);
|
||||
@@ -58,7 +62,8 @@ public sealed class EmailNotificationService : IEmailNotificationService
|
||||
string? detail = null,
|
||||
LiveRoom? liveRoom = null,
|
||||
RecordTask? recordTask = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default,
|
||||
string? eventScriptOutput = null)
|
||||
{
|
||||
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||
if (!settings.EnableEmailNotification || !settings.NotifyOnException)
|
||||
@@ -75,7 +80,8 @@ public sealed class EmailNotificationService : IEmailNotificationService
|
||||
["roomId"] = liveRoom?.RoomId,
|
||||
["recordTaskId"] = recordTask?.Id.ToString(),
|
||||
["taskStatus"] = recordTask?.Status.ToString(),
|
||||
["occurredAtUtc"] = ChinaTime.ToBeijingTime(DateTimeOffset.UtcNow).ToString("O")
|
||||
["occurredAtUtc"] = ChinaTime.ToBeijingTime(DateTimeOffset.UtcNow).ToString("O"),
|
||||
["eventScriptOutput"] = NormalizeNotificationText(eventScriptOutput)
|
||||
});
|
||||
|
||||
var subject = RenderSubject(settings.EmailExceptionSubjectTemplate, tokens);
|
||||
@@ -114,7 +120,8 @@ public sealed class EmailNotificationService : IEmailNotificationService
|
||||
["title"] = "Sample Live Title",
|
||||
["anchor"] = "Sample Anchor",
|
||||
["sourceUrl"] = "https://live.douyin.com/123456789",
|
||||
["detectedAtUtc"] = ChinaTime.ToBeijingTime(DateTimeOffset.UtcNow).ToString("O")
|
||||
["detectedAtUtc"] = ChinaTime.ToBeijingTime(DateTimeOffset.UtcNow).ToString("O"),
|
||||
["eventScriptOutput"] = "line one from script\nline two from script"
|
||||
});
|
||||
var sampleExceptionTokens = CreateTokenMap(new Dictionary<string, string?>
|
||||
{
|
||||
@@ -125,7 +132,8 @@ public sealed class EmailNotificationService : IEmailNotificationService
|
||||
["roomId"] = "123456789",
|
||||
["recordTaskId"] = Guid.NewGuid().ToString(),
|
||||
["taskStatus"] = "Running",
|
||||
["occurredAtUtc"] = ChinaTime.ToBeijingTime(DateTimeOffset.UtcNow).ToString("O")
|
||||
["occurredAtUtc"] = ChinaTime.ToBeijingTime(DateTimeOffset.UtcNow).ToString("O"),
|
||||
["eventScriptOutput"] = "line one from script\nline two from script"
|
||||
});
|
||||
|
||||
var body = $$"""
|
||||
@@ -275,6 +283,24 @@ public sealed class EmailNotificationService : IEmailNotificationService
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private static string NormalizeNotificationText(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
const int maxLength = 8000;
|
||||
const string suffix = "... [truncated]";
|
||||
var trimmed = value.Trim();
|
||||
if (trimmed.Length <= maxLength)
|
||||
{
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return string.Concat(trimmed[..(maxLength - suffix.Length)], suffix);
|
||||
}
|
||||
|
||||
private static string RenderSubject(string template, IReadOnlyDictionary<string, string> tokens)
|
||||
{
|
||||
var rendered = RenderTemplate(template, tokens, htmlEncodeValues: false);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using LiveRecorder.Application.Abstractions.Logging;
|
||||
using LiveRecorder.Application.Abstractions.Notifications;
|
||||
using LiveRecorder.Application.Abstractions.Scripting;
|
||||
using LiveRecorder.Application.Abstractions.Settings;
|
||||
using LiveRecorder.Application.Common;
|
||||
@@ -18,31 +20,44 @@ public sealed class EventScriptService : IEventScriptService
|
||||
|
||||
private readonly ISystemSettingsService _settingsService;
|
||||
private readonly ISystemLogService _systemLogService;
|
||||
private readonly IEmailNotificationService _emailNotificationService;
|
||||
private readonly IWebhookNotificationService _webhookNotificationService;
|
||||
private readonly ILogger<EventScriptService> _logger;
|
||||
|
||||
public EventScriptService(
|
||||
ISystemSettingsService settingsService,
|
||||
ISystemLogService systemLogService,
|
||||
IEmailNotificationService emailNotificationService,
|
||||
IWebhookNotificationService webhookNotificationService,
|
||||
ILogger<EventScriptService> logger)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
_systemLogService = systemLogService;
|
||||
_emailNotificationService = emailNotificationService;
|
||||
_webhookNotificationService = webhookNotificationService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task RunLiveStartedAsync(LiveRoom liveRoom, DateTimeOffset occurredAt, CancellationToken cancellationToken = default)
|
||||
public async Task<EventScriptExecutionResultDto?> 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(
|
||||
return await RunAsync(
|
||||
settings.EnableEventScripts && settings.EnableLiveStartedScript,
|
||||
settings.LiveStartedScriptMode,
|
||||
settings.LiveStartedScriptPath,
|
||||
settings.LiveStartedScriptContent,
|
||||
settings.EventScriptTimeoutSeconds,
|
||||
settings.EventScriptRetryAttempts,
|
||||
settings.EventScriptRetryDelaySeconds,
|
||||
"live_started",
|
||||
environment,
|
||||
liveRoom,
|
||||
recordTask: null,
|
||||
"Script",
|
||||
liveRoom.Id,
|
||||
recordSessionId: null,
|
||||
@@ -50,19 +65,26 @@ public sealed class EventScriptService : IEventScriptService
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task RunLiveEndedAsync(LiveRoom liveRoom, DateTimeOffset occurredAt, CancellationToken cancellationToken = default)
|
||||
public async Task<EventScriptExecutionResultDto?> 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(
|
||||
return await RunAsync(
|
||||
settings.EnableEventScripts && settings.EnableLiveEndedScript,
|
||||
settings.LiveEndedScriptMode,
|
||||
settings.LiveEndedScriptPath,
|
||||
settings.LiveEndedScriptContent,
|
||||
settings.EventScriptTimeoutSeconds,
|
||||
settings.EventScriptRetryAttempts,
|
||||
settings.EventScriptRetryDelaySeconds,
|
||||
"live_ended",
|
||||
environment,
|
||||
liveRoom,
|
||||
recordTask: null,
|
||||
"Script",
|
||||
liveRoom.Id,
|
||||
recordSessionId: null,
|
||||
@@ -70,7 +92,7 @@ public sealed class EventScriptService : IEventScriptService
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task RunSegmentCompletedAsync(
|
||||
public async Task<EventScriptExecutionResultDto?> RunSegmentCompletedAsync(
|
||||
LiveRoom? liveRoom,
|
||||
RecordSession recordSession,
|
||||
RecordTask recordTask,
|
||||
@@ -93,14 +115,18 @@ public sealed class EventScriptService : IEventScriptService
|
||||
environment["LIVE_RECORDER_TASK_STATUS"] = recordTask.Status.ToString();
|
||||
environment["LIVE_RECORDER_SESSION_STATUS"] = recordSession.Status.ToString();
|
||||
|
||||
await RunAsync(
|
||||
return await RunAsync(
|
||||
forceRun || (settings.EnableEventScripts && settings.EnableSegmentCompletedScript),
|
||||
settings.SegmentCompletedScriptMode,
|
||||
settings.SegmentCompletedScriptPath,
|
||||
settings.SegmentCompletedScriptContent,
|
||||
settings.EventScriptTimeoutSeconds,
|
||||
settings.EventScriptRetryAttempts,
|
||||
settings.EventScriptRetryDelaySeconds,
|
||||
"segment_completed",
|
||||
environment,
|
||||
liveRoom,
|
||||
recordTask,
|
||||
"Script",
|
||||
liveRoom?.Id ?? recordSession.LiveRoomId,
|
||||
recordSession.Id,
|
||||
@@ -156,14 +182,26 @@ public sealed class EventScriptService : IEventScriptService
|
||||
};
|
||||
}
|
||||
|
||||
private async Task RunAsync(
|
||||
private static EventScriptExecutionResultDto MapOutcome(ScriptExecutionOutcome outcome) => new()
|
||||
{
|
||||
Success = outcome.Success,
|
||||
Message = outcome.Message,
|
||||
Detail = outcome.Detail,
|
||||
CustomLogOutput = outcome.CustomLogOutput
|
||||
};
|
||||
|
||||
private async Task<EventScriptExecutionResultDto?> RunAsync(
|
||||
bool enabled,
|
||||
string scriptMode,
|
||||
string scriptPath,
|
||||
string scriptContent,
|
||||
int timeoutSeconds,
|
||||
int retryAttempts,
|
||||
int retryDelaySeconds,
|
||||
string eventName,
|
||||
IReadOnlyDictionary<string, string> environment,
|
||||
LiveRoom? liveRoom,
|
||||
RecordTask? recordTask,
|
||||
string logCategory,
|
||||
Guid? liveRoomId,
|
||||
Guid? recordSessionId,
|
||||
@@ -172,10 +210,47 @@ public sealed class EventScriptService : IEventScriptService
|
||||
{
|
||||
if (!enabled)
|
||||
{
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
await ExecuteAsync(
|
||||
var outcome = await ExecuteWithRetryAsync(
|
||||
scriptMode,
|
||||
scriptPath,
|
||||
scriptContent,
|
||||
timeoutSeconds,
|
||||
retryAttempts,
|
||||
retryDelaySeconds,
|
||||
eventName,
|
||||
environment,
|
||||
liveRoom,
|
||||
recordTask,
|
||||
logCategory,
|
||||
liveRoomId,
|
||||
recordSessionId,
|
||||
recordTaskId,
|
||||
cancellationToken);
|
||||
|
||||
return MapOutcome(outcome);
|
||||
}
|
||||
|
||||
private async Task<ScriptExecutionOutcome> ExecuteWithRetryAsync(
|
||||
string scriptMode,
|
||||
string scriptPath,
|
||||
string scriptContent,
|
||||
int timeoutSeconds,
|
||||
int retryAttempts,
|
||||
int retryDelaySeconds,
|
||||
string eventName,
|
||||
IReadOnlyDictionary<string, string> environment,
|
||||
LiveRoom? liveRoom,
|
||||
RecordTask? recordTask,
|
||||
string logCategory,
|
||||
Guid? liveRoomId,
|
||||
Guid? recordSessionId,
|
||||
Guid? recordTaskId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var outcome = await ExecuteAsync(
|
||||
scriptMode,
|
||||
scriptPath,
|
||||
scriptContent,
|
||||
@@ -187,6 +262,54 @@ public sealed class EventScriptService : IEventScriptService
|
||||
recordSessionId,
|
||||
recordTaskId,
|
||||
cancellationToken);
|
||||
|
||||
if (outcome.Success || !IsRetryableFailure(outcome))
|
||||
{
|
||||
return outcome;
|
||||
}
|
||||
|
||||
var maxRetryAttempts = Math.Clamp(retryAttempts, 0, 20);
|
||||
var totalAttempts = 1;
|
||||
|
||||
for (var retryIndex = 1; retryIndex <= maxRetryAttempts; retryIndex++)
|
||||
{
|
||||
await _systemLogService.WriteAsync(
|
||||
SystemLogLevel.Warning,
|
||||
logCategory,
|
||||
$"Event script retry {retryIndex} of {maxRetryAttempts} scheduled for {eventName}.",
|
||||
BuildRetryAttemptDetail(eventName, retryIndex + 1, maxRetryAttempts + 1, retryDelaySeconds, outcome),
|
||||
liveRoomId,
|
||||
recordSessionId,
|
||||
recordTaskId,
|
||||
cancellationToken);
|
||||
|
||||
if (retryDelaySeconds > 0)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(Math.Clamp(retryDelaySeconds, 0, 3600)), cancellationToken);
|
||||
}
|
||||
|
||||
outcome = await ExecuteAsync(
|
||||
scriptMode,
|
||||
scriptPath,
|
||||
scriptContent,
|
||||
timeoutSeconds,
|
||||
eventName,
|
||||
environment,
|
||||
logCategory,
|
||||
liveRoomId,
|
||||
recordSessionId,
|
||||
recordTaskId,
|
||||
cancellationToken);
|
||||
totalAttempts++;
|
||||
|
||||
if (outcome.Success || !IsRetryableFailure(outcome))
|
||||
{
|
||||
return outcome;
|
||||
}
|
||||
}
|
||||
|
||||
await NotifyRetryExhaustedAsync(eventName, totalAttempts, outcome, liveRoom, recordTask, cancellationToken);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
private async Task<ScriptExecutionOutcome> ExecuteAsync(
|
||||
@@ -209,7 +332,9 @@ public sealed class EventScriptService : IEventScriptService
|
||||
false,
|
||||
$"Event script was not configured for {eventName}.",
|
||||
null,
|
||||
null);
|
||||
null,
|
||||
null,
|
||||
ScriptFailureKind.MissingConfiguration);
|
||||
|
||||
await WriteOutcomeLogAsync(
|
||||
missingConfiguration,
|
||||
@@ -228,7 +353,9 @@ public sealed class EventScriptService : IEventScriptService
|
||||
false,
|
||||
$"Event script was not found for {eventName}.",
|
||||
execution.Detail,
|
||||
null);
|
||||
null,
|
||||
execution.Detail,
|
||||
ScriptFailureKind.MissingScript);
|
||||
|
||||
await WriteOutcomeLogAsync(
|
||||
missingScript,
|
||||
@@ -279,7 +406,9 @@ public sealed class EventScriptService : IEventScriptService
|
||||
? $"Event script completed for {eventName}."
|
||||
: $"Event script exited with code {process.ExitCode} for {eventName}.",
|
||||
execution.Detail,
|
||||
null);
|
||||
null,
|
||||
execution.Detail,
|
||||
process.ExitCode == 0 ? ScriptFailureKind.None : ScriptFailureKind.ExitCode);
|
||||
outcomeLevel = process.ExitCode == 0 ? SystemLogLevel.Info : SystemLogLevel.Warning;
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
@@ -289,7 +418,9 @@ public sealed class EventScriptService : IEventScriptService
|
||||
false,
|
||||
$"Event script timed out for {eventName}.",
|
||||
execution.Detail,
|
||||
null);
|
||||
null,
|
||||
execution.Detail,
|
||||
ScriptFailureKind.Timeout);
|
||||
outcomeLevel = SystemLogLevel.Warning;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -299,7 +430,9 @@ public sealed class EventScriptService : IEventScriptService
|
||||
false,
|
||||
$"Event script failed for {eventName}.",
|
||||
ex.ToString(),
|
||||
null);
|
||||
null,
|
||||
execution.Detail,
|
||||
ScriptFailureKind.Exception);
|
||||
outcomeLevel = SystemLogLevel.Warning;
|
||||
}
|
||||
finally
|
||||
@@ -328,6 +461,31 @@ public sealed class EventScriptService : IEventScriptService
|
||||
return outcome;
|
||||
}
|
||||
|
||||
private async Task NotifyRetryExhaustedAsync(
|
||||
string eventName,
|
||||
int totalAttempts,
|
||||
ScriptExecutionOutcome outcome,
|
||||
LiveRoom? liveRoom,
|
||||
RecordTask? recordTask,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var detail = BuildRetryFailureNotificationDetail(eventName, totalAttempts, outcome);
|
||||
await _emailNotificationService.SendExceptionAsync(
|
||||
"EventScript",
|
||||
"Event script failed after retries.",
|
||||
detail,
|
||||
liveRoom,
|
||||
recordTask,
|
||||
cancellationToken);
|
||||
await _webhookNotificationService.SendExceptionAsync(
|
||||
"EventScript",
|
||||
"Event script failed after retries.",
|
||||
detail,
|
||||
liveRoom,
|
||||
recordTask,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static EventScriptExecution? CreateExecution(string scriptMode, string scriptPath, string scriptContent)
|
||||
{
|
||||
if (string.Equals(scriptMode, EventScriptSourceModes.Inline, StringComparison.OrdinalIgnoreCase))
|
||||
@@ -566,6 +724,71 @@ public sealed class EventScriptService : IEventScriptService
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static string BuildRetryAttemptDetail(
|
||||
string eventName,
|
||||
int nextAttempt,
|
||||
int totalAttempts,
|
||||
int retryDelaySeconds,
|
||||
ScriptExecutionOutcome outcome)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine($"Event: {eventName}");
|
||||
builder.AppendLine($"Next attempt: {nextAttempt}/{totalAttempts}");
|
||||
builder.AppendLine($"Retry delay: {Math.Clamp(retryDelaySeconds, 0, 3600)} second(s)");
|
||||
builder.AppendLine($"Last result: {outcome.Message}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(outcome.ExecutionTarget))
|
||||
{
|
||||
builder.AppendLine($"Script: {outcome.ExecutionTarget}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(outcome.Detail) &&
|
||||
!string.Equals(outcome.Detail, outcome.ExecutionTarget, StringComparison.Ordinal))
|
||||
{
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("Detail:");
|
||||
builder.AppendLine(outcome.Detail);
|
||||
}
|
||||
|
||||
return TruncateSystemLogDetail(builder.ToString().Trim());
|
||||
}
|
||||
|
||||
private static string BuildRetryFailureNotificationDetail(
|
||||
string eventName,
|
||||
int totalAttempts,
|
||||
ScriptExecutionOutcome outcome)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine($"Event: {eventName}");
|
||||
builder.AppendLine($"Attempts: {totalAttempts}");
|
||||
builder.AppendLine($"Last result: {outcome.Message}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(outcome.ExecutionTarget))
|
||||
{
|
||||
builder.AppendLine($"Script: {outcome.ExecutionTarget}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(outcome.Detail) &&
|
||||
!string.Equals(outcome.Detail, outcome.ExecutionTarget, StringComparison.Ordinal))
|
||||
{
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("Detail:");
|
||||
builder.AppendLine(outcome.Detail);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(outcome.CustomLogOutput))
|
||||
{
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("Custom log output:");
|
||||
builder.AppendLine(outcome.CustomLogOutput);
|
||||
}
|
||||
|
||||
return TruncateSystemLogDetail(builder.ToString().Trim());
|
||||
}
|
||||
|
||||
private static bool IsRetryableFailure(ScriptExecutionOutcome outcome) =>
|
||||
outcome.FailureKind is ScriptFailureKind.ExitCode or ScriptFailureKind.Timeout or ScriptFailureKind.Exception;
|
||||
|
||||
private static string CreateScriptLogPath()
|
||||
{
|
||||
return Path.Combine(
|
||||
@@ -612,11 +835,23 @@ public sealed class EventScriptService : IEventScriptService
|
||||
}
|
||||
}
|
||||
|
||||
private enum ScriptFailureKind
|
||||
{
|
||||
None,
|
||||
MissingConfiguration,
|
||||
MissingScript,
|
||||
ExitCode,
|
||||
Timeout,
|
||||
Exception
|
||||
}
|
||||
|
||||
private sealed record EventScriptExecution(ProcessStartInfo? StartInfo, string Detail, bool IsMissing = false);
|
||||
|
||||
private sealed record ScriptExecutionOutcome(
|
||||
bool Success,
|
||||
string Message,
|
||||
string? Detail,
|
||||
string? CustomLogOutput);
|
||||
string? CustomLogOutput,
|
||||
string? ExecutionTarget,
|
||||
ScriptFailureKind FailureKind);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using LiveRecorder.Application.Abstractions.Logging;
|
||||
using LiveRecorder.Application.Abstractions.Notifications;
|
||||
using LiveRecorder.Application.Abstractions.Platforms;
|
||||
@@ -56,6 +57,70 @@ public sealed partial class FfmpegService
|
||||
{
|
||||
_ = ValidateRuntimeSourceFailureAsync(runtime, line);
|
||||
}
|
||||
|
||||
TryUpdateBandwidthFromProgressLine(runtime, line);
|
||||
|
||||
// Flush bandwidth sample periodically
|
||||
_ = runtime.FlushBandwidthSampleIfNeededAsync(WriteBandwidthSampleAsync, CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task WriteBandwidthSampleAsync(
|
||||
Guid liveRoomId,
|
||||
Guid recordSessionId,
|
||||
Guid recordTaskId,
|
||||
string detail,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var systemLogService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
|
||||
await systemLogService.WriteAsync(
|
||||
SystemLogLevel.Info,
|
||||
"Bandwidth",
|
||||
"bandwidth_sample",
|
||||
detail,
|
||||
liveRoomId: liveRoomId,
|
||||
recordSessionId: recordSessionId,
|
||||
recordTaskId: recordTaskId,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Silently ignore bandwidth logging failures
|
||||
}
|
||||
}
|
||||
|
||||
private void TryUpdateBandwidthFromProgressLine(SessionProcessRuntime runtime, string line)
|
||||
{
|
||||
if (line.StartsWith("total_size=", StringComparison.Ordinal))
|
||||
{
|
||||
if (long.TryParse(line.AsSpan("total_size=".Length), out var totalSize))
|
||||
{
|
||||
runtime.UpdateBandwidthTotalSize(totalSize);
|
||||
}
|
||||
}
|
||||
else if (line.StartsWith("bitrate=", StringComparison.Ordinal))
|
||||
{
|
||||
// bitrate format: "1234.5kbits/s"
|
||||
var bitrateStr = line.AsSpan("bitrate=".Length).Trim();
|
||||
if (bitrateStr.EndsWith("kbits/s", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
bitrateStr = bitrateStr.Slice(0, bitrateStr.Length - "kbits/s".Length).Trim();
|
||||
}
|
||||
if (double.TryParse(bitrateStr, NumberStyles.Float, CultureInfo.InvariantCulture, out var bitrate))
|
||||
{
|
||||
runtime.UpdateBandwidthBitrate(bitrate);
|
||||
}
|
||||
}
|
||||
else if (line.StartsWith("speed=", StringComparison.Ordinal))
|
||||
{
|
||||
var speedStr = line.AsSpan("speed=".Length).TrimEnd('x').Trim();
|
||||
if (double.TryParse(speedStr, NumberStyles.Float, CultureInfo.InvariantCulture, out var speed))
|
||||
{
|
||||
runtime.UpdateBandwidthSpeed(speed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryClassifyPersistedFfmpegLine(string line, bool isError, out SystemLogLevel level)
|
||||
@@ -1401,7 +1466,20 @@ public sealed partial class FfmpegService
|
||||
}
|
||||
|
||||
includeNonChatEvents = runtime.RecordingSettings.DanmakuIncludeNonChatEvents;
|
||||
var adapter = adapterFactory.GetByPlatform(liveRoom.Platform);
|
||||
var adapter = adapterFactory.TryGetByPlatform(liveRoom.Platform);
|
||||
if (adapter is null)
|
||||
{
|
||||
await WriteDanmakuSystemLogAsync(
|
||||
SystemLogLevel.Info,
|
||||
$"No danmaku adapter is registered for {liveRoom.Platform}. Recording will continue without live comments.",
|
||||
null,
|
||||
runtime.LiveRoomId,
|
||||
runtime.RecordSessionId,
|
||||
initialTask.Id,
|
||||
cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
runtime.DanmakuConnection = await adapter.ConnectAsync(
|
||||
new DanmakuConnectionContext(
|
||||
liveRoom.Id,
|
||||
@@ -1826,6 +1904,54 @@ public sealed partial class FfmpegService
|
||||
public ILiveDanmakuConnection? DanmakuConnection { get; set; }
|
||||
public Task? DanmakuPumpTask { get; set; }
|
||||
private List<string> CurrentRecorderSegmentPaths { get; } = [];
|
||||
// Bandwidth tracking fields
|
||||
private long _lastBandwidthTotalSize;
|
||||
private double? _lastBandwidthBitrate;
|
||||
private double _lastBandwidthSpeed;
|
||||
private DateTimeOffset _lastBandwidthFlushAt = DateTimeOffset.MinValue;
|
||||
private static readonly TimeSpan BandwidthFlushInterval = TimeSpan.FromSeconds(30);
|
||||
|
||||
public void UpdateBandwidthTotalSize(long totalSize)
|
||||
{
|
||||
_lastBandwidthTotalSize = Math.Max(0, totalSize);
|
||||
}
|
||||
|
||||
public void UpdateBandwidthBitrate(double bitrateKbps)
|
||||
{
|
||||
_lastBandwidthBitrate = Math.Max(0, bitrateKbps);
|
||||
}
|
||||
|
||||
public void UpdateBandwidthSpeed(double speed)
|
||||
{
|
||||
_lastBandwidthSpeed = speed;
|
||||
}
|
||||
|
||||
public async Task FlushBandwidthSampleIfNeededAsync(
|
||||
Func<Guid, Guid, Guid, string, System.Threading.CancellationToken, Task> writeLogAsync,
|
||||
System.Threading.CancellationToken cancellationToken)
|
||||
{
|
||||
var nowUtc = DateTimeOffset.UtcNow;
|
||||
if (nowUtc - _lastBandwidthFlushAt < BandwidthFlushInterval)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lastBandwidthFlushAt = nowUtc;
|
||||
|
||||
if (_lastBandwidthTotalSize <= 0 && !_lastBandwidthBitrate.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var detail = $$"""{"bytesDownloaded":{{_lastBandwidthTotalSize}},"bitrateKbps":{{(_lastBandwidthBitrate?.ToString("F1", CultureInfo.InvariantCulture) ?? "null")}},"speed":{{_lastBandwidthSpeed.ToString("F2", CultureInfo.InvariantCulture)}}}""";
|
||||
|
||||
await writeLogAsync(
|
||||
LiveRoomId,
|
||||
RecordSessionId,
|
||||
CurrentTaskId,
|
||||
detail,
|
||||
cancellationToken);
|
||||
}
|
||||
private object RuntimeSourceFailureSync { get; } = new();
|
||||
private object RecentOutputSync { get; } = new();
|
||||
private Queue<string> RecentOutputLines { get; } = new();
|
||||
|
||||
@@ -656,7 +656,7 @@ public sealed partial class FfmpegService
|
||||
string? selectedVideoCodec,
|
||||
FfmpegInputOptionProfile inputOptionProfile)
|
||||
{
|
||||
var arguments = new List<string> { "-hide_banner", "-y" };
|
||||
var arguments = new List<string> { "-hide_banner", "-y", "-progress", "pipe:1" };
|
||||
var useIntermediateTransportStream = ShouldUseIntermediateTransportStream(outputFilePath, outputFormat, saveMode);
|
||||
|
||||
if (IsHttpInput(streamUrl))
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using LiveRecorder.Application.Abstractions.Recording;
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Services;
|
||||
|
||||
public sealed class FfmpegVideoMetadataService : IVideoMetadataService
|
||||
{
|
||||
private const string ThumbnailsSubDir = ".thumbnails";
|
||||
|
||||
public async Task<VideoMetadata?> ExtractMetadataAsync(string filePath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = GetFfprobePath(),
|
||||
Arguments = $"-v quiet -print_format json -show_format -show_streams \"{filePath}\"",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
var output = await process.StandardOutput.ReadToEndAsync(cancellationToken);
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
|
||||
if (process.ExitCode != 0 || string.IsNullOrWhiteSpace(output))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return ParseFfprobeOutput(output);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> GenerateThumbnailAsync(string filePath, string outputDir, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var relativePath = Path.GetRelativePath(Path.GetFullPath(outputDir, AppContext.BaseDirectory), filePath);
|
||||
// Sanitize: replace directory separators with safe characters
|
||||
var safeRelativePath = relativePath
|
||||
.Replace('\\', '/')
|
||||
.TrimStart('/')
|
||||
.Replace('/', '_');
|
||||
var thumbDir = Path.Combine(outputDir, ThumbnailsSubDir);
|
||||
var thumbPath = Path.Combine(thumbDir, $"{safeRelativePath}.jpg");
|
||||
|
||||
// Return cached thumbnail if it exists
|
||||
if (File.Exists(thumbPath) && new FileInfo(thumbPath).Length > 0)
|
||||
{
|
||||
return thumbPath;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Calculate snapshot time: 10% of duration or 30 seconds default
|
||||
var metadata = await ExtractMetadataAsync(filePath, cancellationToken);
|
||||
var seekSeconds = metadata?.DurationSeconds.HasValue == true && metadata.DurationSeconds.Value > 60
|
||||
? (int)(metadata.DurationSeconds.Value * 0.1)
|
||||
: 30;
|
||||
|
||||
Directory.CreateDirectory(thumbDir);
|
||||
|
||||
var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = GetFfmpegPath(),
|
||||
Arguments = $"-ss {seekSeconds} -i \"{filePath}\" -vframes 1 -q:v 2 -y \"{thumbPath}\"",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
|
||||
if (process.ExitCode == 0 && File.Exists(thumbPath) && new FileInfo(thumbPath).Length > 0)
|
||||
{
|
||||
return thumbPath;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Thumbnail generation failed silently
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static VideoMetadata? ParseFfprobeOutput(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
var format = doc.RootElement.TryGetProperty("format", out var fmt) ? fmt : (JsonElement?)null;
|
||||
var streams = doc.RootElement.TryGetProperty("streams", out var str) ? str : (JsonElement?)null;
|
||||
|
||||
double? duration = null;
|
||||
long? bitRate = null;
|
||||
if (format.HasValue)
|
||||
{
|
||||
if (format.Value.TryGetProperty("duration", out var dur) && dur.TryGetDouble(out var d))
|
||||
duration = d;
|
||||
if (format.Value.TryGetProperty("bit_rate", out var br) && br.TryGetInt64(out var b))
|
||||
bitRate = b;
|
||||
}
|
||||
|
||||
int? width = null;
|
||||
int? height = null;
|
||||
string? videoCodec = null;
|
||||
string? audioCodec = null;
|
||||
double? frameRate = null;
|
||||
|
||||
if (streams.HasValue && streams.Value.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var stream in streams.Value.EnumerateArray())
|
||||
{
|
||||
var codecType = stream.TryGetProperty("codec_type", out var ct) ? ct.GetString() : null;
|
||||
var codecName = stream.TryGetProperty("codec_name", out var cn) ? cn.GetString() : null;
|
||||
|
||||
if (codecType == "video")
|
||||
{
|
||||
if (stream.TryGetProperty("width", out var w) && w.TryGetInt32(out var wv))
|
||||
width = wv;
|
||||
if (stream.TryGetProperty("height", out var h) && h.TryGetInt32(out var hv))
|
||||
height = hv;
|
||||
videoCodec = codecName;
|
||||
if (stream.TryGetProperty("r_frame_rate", out var fr) && fr.GetString() is { } frStr)
|
||||
frameRate = ParseFrameRate(frStr);
|
||||
}
|
||||
else if (codecType == "audio")
|
||||
{
|
||||
audioCodec = codecName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new VideoMetadata(duration, width, height, videoCodec, audioCodec, frameRate, bitRate);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static double? ParseFrameRate(string fraction)
|
||||
{
|
||||
var parts = fraction.Split('/');
|
||||
if (parts.Length == 2 &&
|
||||
double.TryParse(parts[0], out var num) &&
|
||||
double.TryParse(parts[1], out var den) &&
|
||||
den > 0)
|
||||
{
|
||||
return num / den;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string GetFfmpegPath() => "ffmpeg";
|
||||
private static string GetFfprobePath() => "ffprobe";
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text;
|
||||
using LiveRecorder.Application.Common;
|
||||
using LiveRecorder.Application.Abstractions.Logging;
|
||||
using LiveRecorder.Application.Abstractions.Notifications;
|
||||
@@ -74,7 +75,8 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
|
||||
var settings = await settingsService.GetAsync(stoppingToken);
|
||||
var ffmpegService = scope.ServiceProvider.GetRequiredService<IFfmpegService>();
|
||||
var storageGuardService = scope.ServiceProvider.GetRequiredService<IStorageGuardService>();
|
||||
if (storageGuardService.CheckCanStartOrResume(settings).HasEnoughSpace)
|
||||
var storageCheck = storageGuardService.CheckCanStartOrResume(settings);
|
||||
if (storageCheck.Tier != StorageTier.Red)
|
||||
{
|
||||
await ffmpegService.ResumePausedFinalizationsAsync(stoppingToken);
|
||||
}
|
||||
@@ -144,8 +146,20 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (DatabaseCircuitOpenException)
|
||||
{
|
||||
// Circuit is open — skip this iteration and wait
|
||||
_logger.LogDebug("Polling loop skipped: database circuit breaker is open");
|
||||
delay = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Record database failures to the circuit breaker
|
||||
if (IsTransientDatabaseException(ex) || DatabaseCircuitBreaker.IsNonTransient(ex))
|
||||
{
|
||||
DatabaseCircuitBreaker.RecordFailure();
|
||||
}
|
||||
|
||||
_logger.LogError(ex, "Background live room polling failed");
|
||||
|
||||
try
|
||||
@@ -353,15 +367,18 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
|
||||
return;
|
||||
}
|
||||
|
||||
var pauseCheck = storageGuardService.CheckShouldPause(settings);
|
||||
if (!pauseCheck.HasEnoughSpace)
|
||||
var guardCheck = storageGuardService.CheckCanStartOrResume(settings);
|
||||
if (guardCheck.ShouldPauseActive)
|
||||
{
|
||||
await PauseActiveSessionsForLowStorageAsync(
|
||||
dbContext,
|
||||
ffmpegService,
|
||||
logService,
|
||||
emailNotificationService,
|
||||
webhookNotificationService,
|
||||
liveRoom,
|
||||
liveRoom.Id,
|
||||
pauseCheck.Message,
|
||||
guardCheck.Message,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
@@ -377,25 +394,6 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
|
||||
return;
|
||||
}
|
||||
|
||||
var startCheck = storageGuardService.CheckCanStartOrResume(settings);
|
||||
if (!startCheck.HasEnoughSpace)
|
||||
{
|
||||
await UpdateAutoStartDecisionAsync(
|
||||
dbContext,
|
||||
liveRoom,
|
||||
AutoStartDecisionCodes.SkippedStorage,
|
||||
"Auto-start skipped because storage is below threshold.",
|
||||
startCheck.Message,
|
||||
cancellationToken);
|
||||
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 reconciledStaleSessionIds = await ReconcileStaleActiveSessionsAsync(
|
||||
dbContext,
|
||||
@@ -413,6 +411,25 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
if (!guardCheck.CanStartNewRecording)
|
||||
{
|
||||
await UpdateAutoStartDecisionAsync(
|
||||
dbContext,
|
||||
liveRoom,
|
||||
AutoStartDecisionCodes.SkippedStorage,
|
||||
$"Auto-start skipped because storage tier is {guardCheck.Tier}.",
|
||||
guardCheck.Message,
|
||||
cancellationToken);
|
||||
await logService.WriteAsync(
|
||||
SystemLogLevel.Warning,
|
||||
"Storage",
|
||||
"Auto-start recording skipped because storage tier is not Green.",
|
||||
guardCheck.Message,
|
||||
liveRoomId: liveRoom.Id,
|
||||
cancellationToken: cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var hasRunningSession = await dbContext.RecordSessions.AnyAsync(
|
||||
item => item.LiveRoomId == liveRoom.Id &&
|
||||
(item.Status == RecordSessionStatus.Starting ||
|
||||
@@ -589,6 +606,9 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
|
||||
LiveRecorderDbContext dbContext,
|
||||
IFfmpegService ffmpegService,
|
||||
ISystemLogService logService,
|
||||
IEmailNotificationService emailNotificationService,
|
||||
IWebhookNotificationService webhookNotificationService,
|
||||
Domain.Entities.LiveRoom liveRoom,
|
||||
Guid liveRoomId,
|
||||
string detail,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -600,8 +620,13 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
|
||||
item.Status == RecordSessionStatus.Stopping))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var affectedSessionCount = 0;
|
||||
var forcedStopCount = 0;
|
||||
|
||||
foreach (var activeSession in activeSessions.OrderBy(static item => item.CreatedAt))
|
||||
{
|
||||
affectedSessionCount++;
|
||||
|
||||
await logService.WriteAsync(
|
||||
SystemLogLevel.Warning,
|
||||
"Storage",
|
||||
@@ -628,12 +653,32 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
|
||||
liveRoomId,
|
||||
activeSession.Id,
|
||||
cancellationToken: cancellationToken);
|
||||
forcedStopCount++;
|
||||
await ffmpegService.KillAndWaitAsync(activeSession.Id, OfflineForcedStopTimeout, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
await ffmpegService.TryReconcileInactiveSessionAsync(activeSession.Id, cancellationToken);
|
||||
}
|
||||
|
||||
if (affectedSessionCount <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var notificationDetail = BuildLowStorageNotificationDetail(liveRoom, affectedSessionCount, forcedStopCount, detail);
|
||||
await emailNotificationService.SendExceptionAsync(
|
||||
"StorageGuard",
|
||||
"Low storage paused active recording sessions.",
|
||||
notificationDetail,
|
||||
liveRoom,
|
||||
cancellationToken: cancellationToken);
|
||||
await webhookNotificationService.SendExceptionAsync(
|
||||
"StorageGuard",
|
||||
"Low storage paused active recording sessions.",
|
||||
notificationDetail,
|
||||
liveRoom,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<Guid>> ReconcileStaleActiveSessionsAsync(
|
||||
@@ -699,6 +744,39 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
|
||||
IsTransientPollingException(exception.InnerException, cancellationToken);
|
||||
}
|
||||
|
||||
private static string BuildLowStorageNotificationDetail(
|
||||
Domain.Entities.LiveRoom liveRoom,
|
||||
int affectedSessionCount,
|
||||
int forcedStopCount,
|
||||
string storageDetail)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine($"Platform: {liveRoom.Platform}");
|
||||
builder.AppendLine($"Room ID: {liveRoom.RoomId}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(liveRoom.AnchorName))
|
||||
{
|
||||
builder.AppendLine($"Anchor: {liveRoom.AnchorName}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(liveRoom.Title))
|
||||
{
|
||||
builder.AppendLine($"Title: {liveRoom.Title}");
|
||||
}
|
||||
|
||||
builder.AppendLine($"Affected sessions: {affectedSessionCount}");
|
||||
builder.AppendLine($"Forced stop attempts: {forcedStopCount}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(storageDetail))
|
||||
{
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("Storage detail:");
|
||||
builder.AppendLine(storageDetail);
|
||||
}
|
||||
|
||||
return builder.ToString().Trim();
|
||||
}
|
||||
|
||||
private static string BuildPollingFailureDetail(Exception exception)
|
||||
{
|
||||
var root = exception.GetBaseException();
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
using System.Security.Authentication;
|
||||
using LiveRecorder.Application.Common;
|
||||
using LiveRecorder.Application.Abstractions.Settings;
|
||||
using LiveRecorder.Application.Models.Settings;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Services;
|
||||
|
||||
@@ -12,11 +14,11 @@ public sealed class PlatformHttpClientFactory
|
||||
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(20);
|
||||
private static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly ISystemSettingsService _systemSettingsService;
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
|
||||
public PlatformHttpClientFactory(ISystemSettingsService systemSettingsService)
|
||||
public PlatformHttpClientFactory(IServiceScopeFactory serviceScopeFactory)
|
||||
{
|
||||
_systemSettingsService = systemSettingsService;
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
}
|
||||
|
||||
public async Task<HttpClient> CreateAsync(
|
||||
@@ -24,7 +26,9 @@ public sealed class PlatformHttpClientFactory
|
||||
bool forceDirectConnection,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var systemSettingsService = scope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
|
||||
var settings = await systemSettingsService.GetAsync(cancellationToken);
|
||||
var proxy = forceDirectConnection ? null : BuildProxy(platform, settings);
|
||||
var handler = new SocketsHttpHandler
|
||||
{
|
||||
@@ -54,15 +58,14 @@ public sealed class PlatformHttpClientFactory
|
||||
|
||||
private static IWebProxy? BuildProxy(LivePlatformType platform, SystemSettingsDto settings)
|
||||
{
|
||||
var proxySettings = platform switch
|
||||
if (!LivePlatformCatalog.TryGet(platform, out _))
|
||||
{
|
||||
LivePlatformType.Douyin => settings.DouyinProxy,
|
||||
LivePlatformType.Bilibili => settings.BilibiliProxy,
|
||||
LivePlatformType.Huya => settings.HuyaProxy,
|
||||
_ => null
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
if (proxySettings is null || !proxySettings.Enabled || string.IsNullOrWhiteSpace(proxySettings.ProxyUrl))
|
||||
var proxySettings = settings.GetPlatformRequestSettings(platform).Proxy;
|
||||
|
||||
if (!proxySettings.Enabled || string.IsNullOrWhiteSpace(proxySettings.ProxyUrl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -50,7 +50,9 @@ public sealed class RecoveryService
|
||||
CheckedPath = storage.CheckedPath,
|
||||
AvailableBytes = storage.AvailableBytes,
|
||||
RequiredBytes = storage.RequiredBytes,
|
||||
Message = storage.Message
|
||||
Message = storage.Message,
|
||||
Tier = storage.Tier.ToString(),
|
||||
UsagePercent = storage.UsagePercent
|
||||
},
|
||||
LiveRooms = liveRooms,
|
||||
Finalizations = finalizations
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using LiveRecorder.Infrastructure.Persistence;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -7,7 +8,8 @@ namespace LiveRecorder.Infrastructure.Services;
|
||||
public sealed class RetentionCleanupBackgroundService : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan CleanupInterval = TimeSpan.FromHours(24);
|
||||
|
||||
private static readonly TimeSpan ErrorBaseDelay = TimeSpan.FromSeconds(10);
|
||||
private static readonly TimeSpan ErrorMaxDelay = TimeSpan.FromMinutes(5);
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
private readonly ILogger<RetentionCleanupBackgroundService> _logger;
|
||||
|
||||
@@ -21,13 +23,31 @@ public sealed class RetentionCleanupBackgroundService : BackgroundService
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var consecutiveErrors = 0;
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Skip if circuit is already open
|
||||
if (DatabaseCircuitBreaker.IsOpen)
|
||||
{
|
||||
_logger.LogDebug("Retention cleanup skipped: database circuit breaker is open");
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var cleanupService = scope.ServiceProvider.GetRequiredService<RetentionCleanupService>();
|
||||
await cleanupService.TryEnqueueAsync(ignoreEnabledSetting: false, cancellationToken: stoppingToken);
|
||||
|
||||
consecutiveErrors = 0;
|
||||
}
|
||||
catch (DatabaseCircuitOpenException)
|
||||
{
|
||||
// Silent — circuit is already logged
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||
continue;
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
@@ -35,7 +55,38 @@ public sealed class RetentionCleanupBackgroundService : BackgroundService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Retention cleanup background task failed");
|
||||
var isDatabaseError = DatabaseCircuitBreaker.IsNonTransient(ex) ||
|
||||
ex is Npgsql.NpgsqlException ||
|
||||
ex is Microsoft.EntityFrameworkCore.DbUpdateException;
|
||||
|
||||
consecutiveErrors++;
|
||||
var delay = TimeSpan.FromMilliseconds(
|
||||
Math.Min(ErrorBaseDelay.TotalMilliseconds * Math.Pow(2, Math.Min(consecutiveErrors - 1, 6)),
|
||||
ErrorMaxDelay.TotalMilliseconds));
|
||||
|
||||
if (isDatabaseError)
|
||||
{
|
||||
DatabaseCircuitBreaker.RecordFailure();
|
||||
_logger.LogWarning(ex,
|
||||
"Retention cleanup: database error (#{ErrorCount}). Circuit: Open={IsOpen}, Failures={Failures}",
|
||||
consecutiveErrors,
|
||||
DatabaseCircuitBreaker.IsOpen,
|
||||
DatabaseCircuitBreaker.ConsecutiveFailures);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError(ex, "Retention cleanup background task failed");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
|
||||
@@ -24,7 +24,11 @@ public sealed class StorageGuardService : IStorageGuardService
|
||||
{
|
||||
if (!settings.EnableStorageGuard)
|
||||
{
|
||||
return new StorageGuardResult(false, true, ResolveOutputRoot(settings.OutputRoot), long.MaxValue, 0, "Storage guard is disabled.");
|
||||
return new StorageGuardResult(false, true, ResolveOutputRoot(settings.OutputRoot), long.MaxValue, 0, "Storage guard is disabled.")
|
||||
{
|
||||
Tier = StorageTier.Green,
|
||||
UsagePercent = 0
|
||||
};
|
||||
}
|
||||
|
||||
var checkedPath = ResolveOutputRoot(settings.OutputRoot);
|
||||
@@ -35,17 +39,54 @@ public sealed class StorageGuardService : IStorageGuardService
|
||||
{
|
||||
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}";
|
||||
var totalBytes = drive.TotalSize;
|
||||
var usedBytes = Math.Max(0, totalBytes - availableBytes);
|
||||
var usagePercent = totalBytes > 0 ? (double)usedBytes / totalBytes * 100.0 : 0;
|
||||
var freePercent = 100.0 - usagePercent;
|
||||
|
||||
return new StorageGuardResult(true, hasEnoughSpace, checkedPath, availableBytes, requiredBytes, message);
|
||||
// Determine tier using configurable thresholds
|
||||
var greenThreshold = Math.Clamp(settings.StorageGreenThresholdPercent, 5, 90);
|
||||
var redThreshold = Math.Clamp(settings.StorageRedThresholdPercent, 1, greenThreshold - 1);
|
||||
|
||||
StorageTier tier;
|
||||
if (freePercent >= greenThreshold)
|
||||
{
|
||||
tier = StorageTier.Green;
|
||||
}
|
||||
else if (freePercent >= redThreshold)
|
||||
{
|
||||
tier = StorageTier.Yellow;
|
||||
}
|
||||
else
|
||||
{
|
||||
tier = StorageTier.Red;
|
||||
}
|
||||
|
||||
var hasEnoughSpace = availableBytes >= requiredBytes;
|
||||
var message = tier switch
|
||||
{
|
||||
StorageTier.Green => $"Storage is healthy. free={FormatBytes(availableBytes)} ({freePercent:F1}%), required={FormatBytes(requiredBytes)}, path={checkedPath}",
|
||||
StorageTier.Yellow => $"Storage is low. free={FormatBytes(availableBytes)} ({freePercent:F1}%), required={FormatBytes(requiredBytes)}, path={checkedPath}. New recordings paused, existing recordings continue.",
|
||||
StorageTier.Red => $"Storage is critically low. free={FormatBytes(availableBytes)} ({freePercent:F1}%), required={FormatBytes(requiredBytes)}, path={checkedPath}. All recordings paused, uploads continue.",
|
||||
_ => 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)
|
||||
{
|
||||
Tier = tier,
|
||||
UsagePercent = Math.Round(usagePercent, 1)
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Storage guard failed to inspect output root {OutputRoot}", checkedPath);
|
||||
return new StorageGuardResult(true, false, checkedPath, 0, requiredBytes, $"Unable to inspect storage path {checkedPath}: {ex.Message}");
|
||||
return new StorageGuardResult(true, false, checkedPath, 0, requiredBytes, $"Unable to inspect storage path {checkedPath}: {ex.Message}")
|
||||
{
|
||||
Tier = StorageTier.Red,
|
||||
UsagePercent = 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,4 +118,3 @@ public sealed class StorageGuardService : IStorageGuardService
|
||||
return $"{display:0.##} {units[unitIndex]}";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,10 @@ public sealed class WebhookNotificationService : IWebhookNotificationService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task SendLiveStartedAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default)
|
||||
public async Task SendLiveStartedAsync(
|
||||
LiveRoom liveRoom,
|
||||
CancellationToken cancellationToken = default,
|
||||
string? eventScriptOutput = null)
|
||||
{
|
||||
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||
if (!settings.EnableWebhookNotification || !settings.NotifyWebhookOnLiveStarted)
|
||||
@@ -49,7 +52,8 @@ public sealed class WebhookNotificationService : IWebhookNotificationService
|
||||
source: "LiveRoomStatus",
|
||||
liveRoom: liveRoom,
|
||||
recordTask: null,
|
||||
report: null);
|
||||
report: null,
|
||||
eventScriptOutput: eventScriptOutput);
|
||||
var variables = BuildTemplateVariables(payload, report: null);
|
||||
|
||||
await SendInternalAsync(
|
||||
@@ -66,7 +70,8 @@ public sealed class WebhookNotificationService : IWebhookNotificationService
|
||||
string? detail = null,
|
||||
LiveRoom? liveRoom = null,
|
||||
RecordTask? recordTask = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default,
|
||||
string? eventScriptOutput = null)
|
||||
{
|
||||
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||
if (!settings.EnableWebhookNotification || !settings.NotifyWebhookOnException)
|
||||
@@ -81,7 +86,8 @@ public sealed class WebhookNotificationService : IWebhookNotificationService
|
||||
source,
|
||||
liveRoom,
|
||||
recordTask,
|
||||
report: null);
|
||||
report: null,
|
||||
eventScriptOutput: eventScriptOutput);
|
||||
var variables = BuildTemplateVariables(payload, report: null);
|
||||
|
||||
await SendInternalAsync(
|
||||
@@ -111,7 +117,8 @@ public sealed class WebhookNotificationService : IWebhookNotificationService
|
||||
"DailyReview",
|
||||
liveRoom: null,
|
||||
recordTask: null,
|
||||
report);
|
||||
report: report,
|
||||
eventScriptOutput: null);
|
||||
var variables = BuildTemplateVariables(payload, report);
|
||||
|
||||
await SendInternalAsync(
|
||||
@@ -160,7 +167,8 @@ public sealed class WebhookNotificationService : IWebhookNotificationService
|
||||
"SettingsTest",
|
||||
sampleLiveRoom,
|
||||
recordTask: null,
|
||||
report: null);
|
||||
report: null,
|
||||
eventScriptOutput: "line one from script\nline two from script");
|
||||
var variables = BuildTemplateVariables(payload, report: null);
|
||||
|
||||
try
|
||||
@@ -339,7 +347,8 @@ public sealed class WebhookNotificationService : IWebhookNotificationService
|
||||
string source,
|
||||
LiveRoom? liveRoom,
|
||||
RecordTask? recordTask,
|
||||
DailyReviewReportDto? report)
|
||||
DailyReviewReportDto? report,
|
||||
string? eventScriptOutput)
|
||||
{
|
||||
var payload = new Dictionary<string, object?>
|
||||
{
|
||||
@@ -349,6 +358,7 @@ public sealed class WebhookNotificationService : IWebhookNotificationService
|
||||
["summary"] = summary,
|
||||
["detail"] = detail,
|
||||
["source"] = source,
|
||||
["eventScriptOutput"] = NormalizeNotificationText(eventScriptOutput),
|
||||
["liveRoom"] = liveRoom is null ? null : new Dictionary<string, object?>
|
||||
{
|
||||
["id"] = liveRoom.Id,
|
||||
@@ -403,6 +413,11 @@ public sealed class WebhookNotificationService : IWebhookNotificationService
|
||||
["source"] = payload["source"]
|
||||
};
|
||||
|
||||
if (payload.TryGetValue("eventScriptOutput", out var eventScriptOutput))
|
||||
{
|
||||
variables["eventScriptOutput"] = eventScriptOutput;
|
||||
}
|
||||
|
||||
if (payload.TryGetValue("liveRoom", out var liveRoomPayload) &&
|
||||
liveRoomPayload is IReadOnlyDictionary<string, object?> liveRoom)
|
||||
{
|
||||
@@ -436,6 +451,24 @@ public sealed class WebhookNotificationService : IWebhookNotificationService
|
||||
return variables;
|
||||
}
|
||||
|
||||
private static string NormalizeNotificationText(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
const int maxLength = 8000;
|
||||
const string suffix = "... [truncated]";
|
||||
var trimmed = value.Trim();
|
||||
if (trimmed.Length <= maxLength)
|
||||
{
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return string.Concat(trimmed[..(maxLength - suffix.Length)], suffix);
|
||||
}
|
||||
|
||||
private static string Truncate(string? value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using LiveRecorder.Application.Models.RecordTasks;
|
||||
using LiveRecorder.Application.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace LiveRecorder.WebApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/bandwidth")]
|
||||
public sealed class BandwidthController : ControllerBase
|
||||
{
|
||||
private readonly BandwidthStatisticsService _bandwidthService;
|
||||
|
||||
public BandwidthController(BandwidthStatisticsService bandwidthService)
|
||||
{
|
||||
_bandwidthService = bandwidthService;
|
||||
}
|
||||
|
||||
[HttpGet("session/{id:guid}")]
|
||||
public async Task<ActionResult<BandwidthTimelineDto>> GetSessionTimeline(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _bandwidthService.GetSessionTimelineAsync(id, cancellationToken);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("daily")]
|
||||
public async Task<ActionResult<BandwidthSummaryDto>> GetDaily(
|
||||
[FromQuery] string? date = null,
|
||||
[FromQuery] int utcOffsetMinutes = 480,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var targetDate = date is not null && DateOnly.TryParse(date, out var parsed)
|
||||
? parsed
|
||||
: DateOnly.FromDateTime(DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromMinutes(utcOffsetMinutes)).DateTime);
|
||||
|
||||
var result = await _bandwidthService.GetDailySummaryAsync(targetDate, utcOffsetMinutes, cancellationToken);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using LiveRecorder.Application.Models.Reports;
|
||||
using LiveRecorder.Application.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace LiveRecorder.WebApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/dashboard")]
|
||||
public sealed class DashboardController : ControllerBase
|
||||
{
|
||||
private readonly DashboardService _dashboardService;
|
||||
|
||||
public DashboardController(DashboardService dashboardService)
|
||||
{
|
||||
_dashboardService = dashboardService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<DashboardDto>> Get(CancellationToken cancellationToken) =>
|
||||
Ok(await _dashboardService.GetDashboardAsync(cancellationToken));
|
||||
}
|
||||
@@ -18,9 +18,10 @@ public sealed class MediaBrowserController : ControllerBase
|
||||
[HttpGet("browser")]
|
||||
public async Task<ActionResult<MediaBrowserResponseDto>> Browse(
|
||||
[FromQuery] string? path,
|
||||
CancellationToken cancellationToken)
|
||||
[FromQuery] bool includeMetadata = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Ok(await _mediaBrowserService.BrowseAsync(path, cancellationToken));
|
||||
return Ok(await _mediaBrowserService.BrowseAsync(path, includeMetadata, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("file")]
|
||||
@@ -37,6 +38,55 @@ public sealed class MediaBrowserController : ControllerBase
|
||||
: PhysicalFile(filePath, contentType, enableRangeProcessing: contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[HttpGet("thumbnail")]
|
||||
public async Task<IActionResult> GetThumbnail(
|
||||
[FromQuery] string path,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filePath = await _mediaBrowserService.ResolveFilePathAsync(path, cancellationToken);
|
||||
// Build thumbnail path: the same way FfmpegVideoMetadataService does
|
||||
var settingsOutputRoot = filePath;
|
||||
// We need the output root. Use the service to resolve it.
|
||||
// Simpler approach: serve the thumbnail from the .thumbnails dir relative to the file
|
||||
var dirName = Path.GetDirectoryName(filePath);
|
||||
if (string.IsNullOrWhiteSpace(dirName))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Walk up to find output root by looking for .thumbnails directory
|
||||
var currentDir = dirName;
|
||||
string? thumbDir = null;
|
||||
while (currentDir is not null && Directory.Exists(currentDir))
|
||||
{
|
||||
var candidate = Path.Combine(currentDir, ".thumbnails");
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
thumbDir = candidate;
|
||||
break;
|
||||
}
|
||||
|
||||
var parent = Directory.GetParent(currentDir);
|
||||
currentDir = parent?.FullName;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(thumbDir))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Find the thumbnail file matching the relative path pattern
|
||||
var relativePath = path.Replace('\\', '/').TrimStart('/').Replace('/', '_');
|
||||
var thumbPath = Path.Combine(thumbDir, $"{relativePath}.jpg");
|
||||
|
||||
if (!System.IO.File.Exists(thumbPath))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return PhysicalFile(thumbPath, "image/jpeg");
|
||||
}
|
||||
|
||||
[HttpPost("transcode-file")]
|
||||
public async Task<ActionResult<TranscodeMediaFileResultDto>> TranscodeFile(
|
||||
[FromBody] TranscodeMediaFileRequest request,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using LiveRecorder.Application.Abstractions.Persistence;
|
||||
using LiveRecorder.Application.Models.Cleanup;
|
||||
using LiveRecorder.Application.Models.RecordTasks;
|
||||
using LiveRecorder.Application.Services;
|
||||
using LiveRecorder.Application.Abstractions.Recording;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using LiveRecorder.Infrastructure.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
@@ -16,15 +19,18 @@ public sealed class RecordSessionsController : ControllerBase
|
||||
private readonly RecordSessionService _recordSessionService;
|
||||
private readonly RecordUploadService _recordUploadService;
|
||||
private readonly CleanupOperationCoordinator _cleanupOperationCoordinator;
|
||||
private readonly IDanmakuService _danmakuService;
|
||||
|
||||
public RecordSessionsController(
|
||||
RecordSessionService recordSessionService,
|
||||
RecordUploadService recordUploadService,
|
||||
CleanupOperationCoordinator cleanupOperationCoordinator)
|
||||
CleanupOperationCoordinator cleanupOperationCoordinator,
|
||||
IDanmakuService danmakuService)
|
||||
{
|
||||
_recordSessionService = recordSessionService;
|
||||
_recordUploadService = recordUploadService;
|
||||
_cleanupOperationCoordinator = cleanupOperationCoordinator;
|
||||
_danmakuService = danmakuService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -100,4 +106,68 @@ public sealed class RecordSessionsController : ControllerBase
|
||||
[HttpPost("{id:guid}/upload")]
|
||||
public async Task<ActionResult<RecordArtifactUploadBatchResultDto>> Upload(Guid id, CancellationToken cancellationToken) =>
|
||||
Ok(await _recordUploadService.UploadSessionAsync(id, cancellationToken));
|
||||
|
||||
[HttpGet("{id:guid}/danmaku")]
|
||||
public async Task<ActionResult<SessionDanmakuResponseDto>> GetDanmaku(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _danmakuService.GetSessionDanmakuAsync(id, cancellationToken);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/playlist")]
|
||||
public async Task<ActionResult<SessionPlaylistDto>> GetPlaylist(
|
||||
Guid id,
|
||||
[FromServices] IRecordMediaService recordMediaService,
|
||||
[FromServices] IRecordSessionRepository sessionRepository,
|
||||
[FromServices] LinkGenerator linkGenerator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var session = await sessionRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (session is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var segments = new List<SessionPlaylistSegmentDto>();
|
||||
foreach (var task in session.RecordTasks
|
||||
.Where(item => item.Status is RecordTaskStatus.Completed or RecordTaskStatus.Stopped)
|
||||
.OrderBy(item => item.SegmentIndex)
|
||||
.ThenBy(item => item.CreatedAt))
|
||||
{
|
||||
try
|
||||
{
|
||||
var ticket = await recordMediaService.CreatePreviewTicketAsync(task.Id, cancellationToken);
|
||||
var ticketUrl = linkGenerator.GetUriByAction(
|
||||
HttpContext,
|
||||
action: nameof(MediaController.GetRecordTaskMedia),
|
||||
controller: "Media",
|
||||
values: new { ticket = ticket.Ticket })
|
||||
?? $"{Request.Scheme}://{Request.Host}/media/record-tasks/{ticket.Ticket}";
|
||||
|
||||
segments.Add(new SessionPlaylistSegmentDto
|
||||
{
|
||||
RecordTaskId = task.Id,
|
||||
SegmentIndex = task.SegmentIndex,
|
||||
PreviewTicketUrl = ticketUrl,
|
||||
DurationSeconds = task.DurationSeconds
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip segments that can't be previewed
|
||||
}
|
||||
}
|
||||
|
||||
if (segments.Count == 0)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return Ok(new SessionPlaylistDto
|
||||
{
|
||||
RecordSessionId = session.Id,
|
||||
LiveRoomTitle = session.LiveRoom?.Title ?? "-",
|
||||
Segments = segments
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,17 +13,20 @@ public sealed class RecordTasksController : ControllerBase
|
||||
private readonly RecordService _recordService;
|
||||
private readonly RecordUploadService _recordUploadService;
|
||||
private readonly IRecordMediaService _recordMediaService;
|
||||
private readonly IDanmakuService _danmakuService;
|
||||
private readonly LinkGenerator _linkGenerator;
|
||||
|
||||
public RecordTasksController(
|
||||
RecordService recordService,
|
||||
RecordUploadService recordUploadService,
|
||||
IRecordMediaService recordMediaService,
|
||||
IDanmakuService danmakuService,
|
||||
LinkGenerator linkGenerator)
|
||||
{
|
||||
_recordService = recordService;
|
||||
_recordUploadService = recordUploadService;
|
||||
_recordMediaService = recordMediaService;
|
||||
_danmakuService = danmakuService;
|
||||
_linkGenerator = linkGenerator;
|
||||
}
|
||||
|
||||
@@ -58,6 +61,12 @@ public sealed class RecordTasksController : ControllerBase
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(await _recordService.DeleteTasksAsync(request, cancellationToken));
|
||||
|
||||
[HttpPost("delete-missing-files")]
|
||||
public async Task<ActionResult<DeleteCompletedRecordTasksResultDto>> DeleteMissingFiles(
|
||||
[FromBody] DeleteMissingFileRecordTasksRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(await _recordService.DeleteMissingFileTasksAsync(request, cancellationToken));
|
||||
|
||||
[HttpPost("{id:guid}/preview-ticket")]
|
||||
public async Task<ActionResult<RecordPreviewTicketDto>> CreatePreviewTicket(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -89,4 +98,11 @@ public sealed class RecordTasksController : ControllerBase
|
||||
[HttpPost("{id:guid}/upload")]
|
||||
public async Task<ActionResult<RecordArtifactUploadItemResultDto>> Upload(Guid id, CancellationToken cancellationToken) =>
|
||||
Ok(await _recordUploadService.UploadTaskAsync(id, cancellationToken));
|
||||
|
||||
[HttpGet("{id:guid}/danmaku")]
|
||||
public async Task<ActionResult<DanmakuResponseDto>> GetDanmaku(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _danmakuService.GetTaskDanmakuAsync(id, cancellationToken);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using LiveRecorder.Application.Abstractions.Settings;
|
||||
using LiveRecorder.Application.Abstractions.Notifications;
|
||||
using LiveRecorder.Application.Common;
|
||||
using LiveRecorder.Application.Models.Cleanup;
|
||||
using LiveRecorder.Application.Abstractions.Scripting;
|
||||
using LiveRecorder.Application.Models.Settings;
|
||||
@@ -7,6 +8,7 @@ using LiveRecorder.Infrastructure.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
|
||||
namespace LiveRecorder.WebApi.Controllers;
|
||||
|
||||
@@ -61,12 +63,13 @@ public sealed class SettingsController : ControllerBase
|
||||
|
||||
[HttpPost("import")]
|
||||
public async Task<ActionResult<SystemSettingsDto>> Import(
|
||||
[FromBody] SystemSettingsDto request,
|
||||
[FromBody] JsonElement request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(request);
|
||||
var payload = request.GetRawText();
|
||||
var updateRequest = JsonSerializer.Deserialize<UpdateSystemSettingsRequest>(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web))
|
||||
?? throw new InvalidOperationException("Unable to deserialize imported settings.");
|
||||
ApplyLegacyPlatformSettings(request, updateRequest);
|
||||
return Ok(await _systemSettingsService.UpdateAsync(updateRequest, cancellationToken));
|
||||
}
|
||||
|
||||
@@ -98,4 +101,59 @@ public sealed class SettingsController : ControllerBase
|
||||
?? throw new InvalidOperationException("Retention cleanup is disabled.");
|
||||
return Ok(operation);
|
||||
}
|
||||
|
||||
private static void ApplyLegacyPlatformSettings(JsonElement root, UpdateSystemSettingsRequest request)
|
||||
{
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
MergeLegacyProxy(root, request, "douyinProxy", LivePlatformType.Douyin);
|
||||
MergeLegacyProxy(root, request, "bilibiliProxy", LivePlatformType.Bilibili);
|
||||
MergeLegacyProxy(root, request, "huyaProxy", LivePlatformType.Huya);
|
||||
MergeLegacyString(root, request, "douyinUserAgent", LivePlatformType.Douyin, static (settings, value) => settings.UserAgent = value);
|
||||
MergeLegacyString(root, request, "douyinReferer", LivePlatformType.Douyin, static (settings, value) => settings.Referer = value);
|
||||
MergeLegacyString(root, request, "douyinCookie", LivePlatformType.Douyin, static (settings, value) => settings.Cookie = value);
|
||||
}
|
||||
|
||||
private static void MergeLegacyProxy(
|
||||
JsonElement root,
|
||||
UpdateSystemSettingsRequest request,
|
||||
string propertyName,
|
||||
LivePlatformType platformType)
|
||||
{
|
||||
if (!root.TryGetProperty(propertyName, out var proxyElement) || proxyElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var target = request.GetPlatformRequestSettings(platformType);
|
||||
if (proxyElement.TryGetProperty("enabled", out var enabledElement) &&
|
||||
enabledElement.ValueKind is JsonValueKind.True or JsonValueKind.False)
|
||||
{
|
||||
target.Proxy.Enabled = enabledElement.GetBoolean();
|
||||
}
|
||||
|
||||
if (proxyElement.TryGetProperty("proxyUrl", out var urlElement) &&
|
||||
urlElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
target.Proxy.ProxyUrl = urlElement.GetString()?.Trim() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static void MergeLegacyString(
|
||||
JsonElement root,
|
||||
UpdateSystemSettingsRequest request,
|
||||
string propertyName,
|
||||
LivePlatformType platformType,
|
||||
Action<PlatformRequestSettingsDto, string> applyValue)
|
||||
{
|
||||
if (!root.TryGetProperty(propertyName, out var valueElement) || valueElement.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
applyValue(request.GetPlatformRequestSettings(platformType), valueElement.GetString()?.Trim() ?? string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,22 +4,21 @@ 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
|
||||
|
||||
# Workaround for QEMU ARM64 emulation
|
||||
ENV DOTNET_EnableWriteXorExecute=0
|
||||
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
|
||||
ENV DOTNET_GCConserveMemory=9
|
||||
|
||||
RUN dotnet restore "src/LiveRecorder.WebApi/LiveRecorder.WebApi.csproj" -p:RestoreUseStaticGraphEvaluation=true
|
||||
RUN dotnet publish "src/LiveRecorder.WebApi/LiveRecorder.WebApi.csproj" -c Release -o /app/publish /p:UseAppHost=false /p:DebugType=None /p:DebugSymbols=false /maxcpucount:1
|
||||
|
||||
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 curl jq \
|
||||
RUN sed -i 's|deb.debian.org|mirrors.tuna.tsinghua.edu.cn|g' /etc/apt/sources.list.d/debian.sources \
|
||||
&& apt-get update -o Acquire::ForceIPv4=true -o Acquire::Retries=5 \
|
||||
&& apt-get install -o Acquire::ForceIPv4=true -o Acquire::Retries=5 -y --no-install-recommends ffmpeg nodejs ca-certificates curl jq \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user