67 changed files with 7941 additions and 964 deletions
+2
View File
@@ -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
View File
@@ -1,5 +1,5 @@
pipeline {
agent { label '构建机1' }
agent { label '閺嬪嫬缂撻張?' }
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} \
+17
View File
@@ -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
+1151
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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"
}
}
+5
View File
@@ -1,6 +1,7 @@
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";
@@ -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;
File diff suppressed because it is too large Load Diff
+68
View File
@@ -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>
+85
View File
@@ -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>
+115
View File
@@ -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>
+299
View File
@@ -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>
+3
View File
@@ -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");
+126 -87
View File
@@ -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,17 @@
--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-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-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,43 +80,43 @@ 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);
@@ -147,14 +148,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 +201,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 +220,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;
@@ -231,7 +257,9 @@ select:focus-visible {
overflow: hidden;
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 +280,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 +313,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;
@@ -768,7 +796,7 @@ 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);
@@ -800,9 +828,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 {
+67 -7
View File
@@ -441,9 +441,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;
@@ -482,9 +480,6 @@ export interface SystemSettings {
notifyWebhookOnLiveStarted: boolean;
notifyWebhookOnException: boolean;
webhookBodyTemplate: string;
douyinUserAgent: string;
douyinReferer: string;
douyinCookie: string;
}
export interface PlatformProxySettings {
@@ -492,6 +487,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 +753,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> = {
+42
View File
@@ -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));
}
}
+18 -34
View File
@@ -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));
+312 -74
View File
@@ -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,主播: 熊宇一&#10;https://live.douyin.com/262011082654"
placeholder="https://live.douyin.com/845878323112,主播: 熊宇一&#10;https://www.twitch.tv/example_channel&#10;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;
}
+26 -1
View File
@@ -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;
+88 -32
View File
@@ -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 {
@@ -818,7 +822,7 @@ onBeforeUnmount(() => {
<div class="page-kicker">录制工作台</div>
<h1 class="page-title">录制任务</h1>
<p class="page-subtitle">
按直播会话聚合展示分片任务列表会自动接收状态转码进度和分片变保留手动刷新入口用于兜底
按直播会话聚合展示真实录制任务自动接收状态转码进度和分片变适合观察开播检测到归档上传的完整链路
</p>
</div>
@@ -856,7 +860,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 +879,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>
@@ -926,7 +934,13 @@ onBeforeUnmount(() => {
</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 +949,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 +1005,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 +1093,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 +1167,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 +1368,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 +1774,10 @@ onBeforeUnmount(() => {
}
@media (max-width: 960px) {
.record-feature-strip {
grid-template-columns: 1fr;
}
.header-actions {
align-self: stretch;
justify-content: stretch;
+54 -33
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -1,9 +1,13 @@
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")
@@ -42,4 +46,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,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,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,
@@ -14,6 +14,8 @@ public interface ILiveDanmakuAdapter
public interface ILiveDanmakuAdapterFactory
{
ILiveDanmakuAdapter GetByPlatform(LivePlatformType platformType);
ILiveDanmakuAdapter? TryGetByPlatform(LivePlatformType platformType);
}
public interface ILiveDanmakuConnection : IAsyncDisposable
@@ -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,
@@ -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();
}
@@ -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;
@@ -105,11 +142,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();
@@ -189,6 +223,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 +262,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
@@ -292,11 +411,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();
@@ -376,6 +492,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 +531,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 +644,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 +683,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; }
@@ -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);
}
}
@@ -1,5 +1,6 @@
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;
@@ -154,21 +155,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),
@@ -274,13 +261,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 +270,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();
@@ -351,12 +329,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);
@@ -398,14 +379,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;
@@ -498,6 +515,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,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("&amp;", "&", 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();
}
}
@@ -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
};
}
}
@@ -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);
@@ -30,12 +30,15 @@ public sealed class EventScriptService : IEventScriptService
_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,
@@ -50,12 +53,15 @@ 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,
@@ -70,7 +76,7 @@ public sealed class EventScriptService : IEventScriptService
cancellationToken);
}
public async Task RunSegmentCompletedAsync(
public async Task<EventScriptExecutionResultDto?> RunSegmentCompletedAsync(
LiveRoom? liveRoom,
RecordSession recordSession,
RecordTask recordTask,
@@ -93,7 +99,7 @@ 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,
@@ -156,7 +162,15 @@ 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,
@@ -172,10 +186,10 @@ public sealed class EventScriptService : IEventScriptService
{
if (!enabled)
{
return;
return null;
}
await ExecuteAsync(
var outcome = await ExecuteAsync(
scriptMode,
scriptPath,
scriptContent,
@@ -187,6 +201,8 @@ public sealed class EventScriptService : IEventScriptService
recordSessionId,
recordTaskId,
cancellationToken);
return MapOutcome(outcome);
}
private async Task<ScriptExecutionOutcome> ExecuteAsync(
@@ -1401,7 +1401,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,
@@ -1,6 +1,7 @@
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;
@@ -54,15 +55,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;
}
@@ -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))
@@ -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);
}
}
+3 -3
View File
@@ -17,9 +17,9 @@ RUN dotnet publish "src/LiveRecorder.WebApi/LiveRecorder.WebApi.csproj" -c Relea
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
+18
View File
@@ -15,10 +15,19 @@ using LiveRecorder.Infrastructure.Persistence;
using LiveRecorder.Infrastructure.Persistence.Repositories;
using LiveRecorder.Infrastructure.Platforms.Bilibili;
using LiveRecorder.Infrastructure.Platforms.Bilibili.Danmaku;
using LiveRecorder.Infrastructure.Platforms.Common;
using LiveRecorder.Infrastructure.Platforms.Douyu;
using LiveRecorder.Infrastructure.Platforms.Douyin;
using LiveRecorder.Infrastructure.Platforms.Douyin.Danmaku;
using LiveRecorder.Infrastructure.Platforms.Douyin.Signing;
using LiveRecorder.Infrastructure.Platforms.Huya;
using LiveRecorder.Infrastructure.Platforms.Kuaishou;
using LiveRecorder.Infrastructure.Platforms.Migu;
using LiveRecorder.Infrastructure.Platforms.PandaTV;
using LiveRecorder.Infrastructure.Platforms.TikTok;
using LiveRecorder.Infrastructure.Platforms.Twitch;
using LiveRecorder.Infrastructure.Platforms.Xiaohongshu;
using LiveRecorder.Infrastructure.Platforms.YouTube;
using LiveRecorder.Infrastructure.Services;
using LiveRecorder.WebApi.Middleware;
using Microsoft.EntityFrameworkCore;
@@ -172,6 +181,7 @@ builder.Services.AddScoped<CleanupOperationCoordinator>();
builder.Services.AddScoped<RetentionCleanupService>();
builder.Services.AddScoped<StoppedOrphanRecordSessionCleanupService>();
builder.Services.AddScoped<PlatformHttpClientFactory>();
builder.Services.AddScoped<PlatformHttpRequestService>();
builder.Services.AddScoped<RecordUploadService>();
builder.Services.AddScoped<DatabaseInitializer>();
builder.Services.AddScoped<SqliteToPostgresMigrationService>();
@@ -184,6 +194,14 @@ builder.Services.AddSingleton<DouyinLiveWsSignatureSigner>();
builder.Services.AddScoped<ILivePlatformAdapter, DouyinLivePlatformAdapter>();
builder.Services.AddScoped<ILivePlatformAdapter, BilibiliLivePlatformAdapter>();
builder.Services.AddScoped<ILivePlatformAdapter, HuyaLivePlatformAdapter>();
builder.Services.AddScoped<ILivePlatformAdapter, DouyuLivePlatformAdapter>();
builder.Services.AddScoped<ILivePlatformAdapter, KuaishouLivePlatformAdapter>();
builder.Services.AddScoped<ILivePlatformAdapter, TikTokLivePlatformAdapter>();
builder.Services.AddScoped<ILivePlatformAdapter, XiaohongshuLivePlatformAdapter>();
builder.Services.AddScoped<ILivePlatformAdapter, YouTubeLivePlatformAdapter>();
builder.Services.AddScoped<ILivePlatformAdapter, TwitchLivePlatformAdapter>();
builder.Services.AddScoped<ILivePlatformAdapter, PandaTvLivePlatformAdapter>();
builder.Services.AddScoped<ILivePlatformAdapter, MiguLivePlatformAdapter>();
builder.Services.AddScoped<ILivePlatformAdapterFactory, LivePlatformAdapterFactory>();
builder.Services.AddScoped<ILiveDanmakuAdapter, DouyinDanmakuAdapter>();
builder.Services.AddScoped<ILiveDanmakuAdapter, BilibiliDanmakuAdapter>();
@@ -0,0 +1,24 @@
using LiveRecorder.Application.Common;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Tests;
public sealed class LivePlatformCatalogTests
{
[Fact]
public void All_contains_expected_new_platforms()
{
var platforms = LivePlatformCatalog.All.ToDictionary(static item => item.Type);
Assert.Contains(LivePlatformType.Douyu, platforms.Keys);
Assert.Contains(LivePlatformType.Kuaishou, platforms.Keys);
Assert.Contains(LivePlatformType.TikTok, platforms.Keys);
Assert.Contains(LivePlatformType.Xiaohongshu, platforms.Keys);
Assert.Contains(LivePlatformType.YouTube, platforms.Keys);
Assert.Contains(LivePlatformType.Twitch, platforms.Keys);
Assert.Contains(LivePlatformType.PandaTV, platforms.Keys);
Assert.Contains(LivePlatformType.Migu, platforms.Keys);
Assert.Equal("youtube", LivePlatformCatalog.GetKey(LivePlatformType.YouTube));
Assert.Equal("Twitch", LivePlatformCatalog.GetDisplayName(LivePlatformType.Twitch));
}
}
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\LiveRecorder.Application\LiveRecorder.Application.csproj" />
<ProjectReference Include="..\..\src\LiveRecorder.Domain\LiveRecorder.Domain.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,71 @@
using System.Reflection;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Application.Services;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Tests;
public sealed class LiveRoomServiceLocalParsingTests
{
[Theory]
[InlineData("https://www.huya.com/660000", LivePlatformType.Huya, "660000", "https://www.huya.com/660000")]
[InlineData("https://www.douyu.com/9999", LivePlatformType.Douyu, "9999", "https://www.douyu.com/9999")]
[InlineData("https://live.kuaishou.com/u/3xabc123", LivePlatformType.Kuaishou, "u/3xabc123", "https://live.kuaishou.com/u/3xabc123")]
[InlineData("https://www.tiktok.com/@creator/live", LivePlatformType.TikTok, "creator", "https://www.tiktok.com/@creator/live")]
[InlineData("https://www.xiaohongshu.com/live/room-100", LivePlatformType.Xiaohongshu, "live/room-100", "https://www.xiaohongshu.com/live/room-100")]
[InlineData("https://www.youtube.com/watch?v=abcDEF12345", LivePlatformType.YouTube, "abcDEF12345", "https://www.youtube.com/watch?v=abcDEF12345")]
[InlineData("https://www.twitch.tv/some_channel", LivePlatformType.Twitch, "some_channel", "https://www.twitch.tv/some_channel")]
[InlineData("https://www.pandalive.co.kr/live/room77", LivePlatformType.PandaTV, "live/room77", "https://www.pandalive.co.kr/live/room77")]
[InlineData("https://www.miguvideo.com/live/8888", LivePlatformType.Migu, "live/8888", "https://www.miguvideo.com/live/8888")]
public void ParseRoomLocally_parses_supported_urls(
string input,
LivePlatformType expectedPlatform,
string expectedRoomId,
string expectedNormalizedUrl)
{
var parsed = ParseRoomLocally(input, platformOverride: null);
Assert.Equal(expectedPlatform, parsed.PlatformType);
Assert.Equal(expectedRoomId, parsed.RoomId);
Assert.Equal(expectedNormalizedUrl, parsed.NormalizedUrl);
}
[Theory]
[InlineData("some_channel", LivePlatformType.Twitch, "some_channel", "https://www.twitch.tv/some_channel")]
[InlineData("abcDEF12345", LivePlatformType.YouTube, "abcDEF12345", "https://www.youtube.com/watch?v=abcDEF12345")]
public void ParseRoomLocally_supports_direct_ids_with_platform_override(
string input,
LivePlatformType platformOverride,
string expectedRoomId,
string expectedNormalizedUrl)
{
var parsed = ParseRoomLocally(input, platformOverride);
Assert.Equal(platformOverride, parsed.PlatformType);
Assert.Equal(expectedRoomId, parsed.RoomId);
Assert.Equal(expectedNormalizedUrl, parsed.NormalizedUrl);
}
[Fact]
public void ParseRoomLocally_requires_detection_when_platform_cannot_be_inferred()
{
var exception = Assert.Throws<NotSupportedException>(() => ParseRoomLocally("creator-room", platformOverride: null));
Assert.Contains("Unable to infer the platform", exception.Message);
}
private static ParsedLiveRoom ParseRoomLocally(string input, LivePlatformType? platformOverride)
{
var method = typeof(LiveRoomService).GetMethod("ParseRoomLocally", BindingFlags.NonPublic | BindingFlags.Static)
?? throw new InvalidOperationException("ParseRoomLocally reflection lookup failed.");
try
{
return (ParsedLiveRoom)(method.Invoke(null, [input, platformOverride]) ??
throw new InvalidOperationException("ParseRoomLocally returned null."));
}
catch (TargetInvocationException ex) when (ex.InnerException is not null)
{
throw ex.InnerException;
}
}
}
@@ -0,0 +1,152 @@
using LiveRecorder.Application.Abstractions.Notifications;
using LiveRecorder.Application.Abstractions.Scripting;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Application.Models.Reports;
using LiveRecorder.Application.Models.Settings;
using LiveRecorder.Application.Services;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Tests;
public sealed class LiveRoomStatusServiceTests
{
[Fact]
public async Task ApplySnapshotAsync_passes_event_script_output_to_notifications()
{
var email = new FakeEmailNotificationService();
var webhook = new FakeWebhookNotificationService();
var eventScript = new FakeEventScriptService
{
LiveStartedResult = new EventScriptExecutionResultDto
{
Success = true,
Message = "ok",
CustomLogOutput = "line one from script\nline two from script"
}
};
var service = new LiveRoomStatusService(email, webhook, eventScript);
var now = DateTimeOffset.UtcNow;
var liveRoom = new LiveRoom(
LivePlatformType.Douyin,
"https://live.douyin.com/123456",
"123456",
"https://live.douyin.com/123456",
now);
var snapshot = new LiveStatusSnapshot(
true,
"Sample title",
"Sample anchor",
"anchor-1",
AvatarUrl: null,
CoverUrl: null,
1,
"live");
await service.ApplySnapshotAsync(liveRoom, snapshot, now);
Assert.Equal(eventScript.LiveStartedResult.CustomLogOutput, email.LastLiveStartedEventScriptOutput);
Assert.Equal(eventScript.LiveStartedResult.CustomLogOutput, webhook.LastLiveStartedEventScriptOutput);
Assert.True(liveRoom.HasSentLiveNotificationForCurrentSession);
}
private sealed class FakeEmailNotificationService : IEmailNotificationService
{
public string? LastLiveStartedEventScriptOutput { get; private set; }
public Task SendLiveStartedAsync(
LiveRoom liveRoom,
CancellationToken cancellationToken = default,
string? eventScriptOutput = null)
{
LastLiveStartedEventScriptOutput = eventScriptOutput;
return Task.CompletedTask;
}
public Task SendExceptionAsync(
string source,
string summary,
string? detail = null,
LiveRoom? liveRoom = null,
RecordTask? recordTask = null,
CancellationToken cancellationToken = default,
string? eventScriptOutput = null) => Task.CompletedTask;
public Task SendTestAsync(SendTestEmailRequest request, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task SendDailyReviewAsync(DailyReviewReportDto report, CancellationToken cancellationToken = default) => Task.CompletedTask;
}
private sealed class FakeWebhookNotificationService : IWebhookNotificationService
{
public string? LastLiveStartedEventScriptOutput { get; private set; }
public Task SendLiveStartedAsync(
LiveRoom liveRoom,
CancellationToken cancellationToken = default,
string? eventScriptOutput = null)
{
LastLiveStartedEventScriptOutput = eventScriptOutput;
return Task.CompletedTask;
}
public Task SendExceptionAsync(
string source,
string summary,
string? detail = null,
LiveRoom? liveRoom = null,
RecordTask? recordTask = null,
CancellationToken cancellationToken = default,
string? eventScriptOutput = null) => Task.CompletedTask;
public Task<WebhookTestResultDto> SendTestAsync(
SendTestWebhookRequest request,
CancellationToken cancellationToken = default) =>
Task.FromResult(new WebhookTestResultDto
{
Success = true,
Message = "ok"
});
public Task SendDailyReviewAsync(
DailyReviewReportDto report,
CancellationToken cancellationToken = default) => Task.CompletedTask;
}
private sealed class FakeEventScriptService : IEventScriptService
{
public EventScriptExecutionResultDto? LiveStartedResult { get; init; }
public Task<EventScriptExecutionResultDto?> RunLiveStartedAsync(
LiveRoom liveRoom,
DateTimeOffset occurredAt,
CancellationToken cancellationToken = default) =>
Task.FromResult(LiveStartedResult);
public Task<EventScriptExecutionResultDto?> RunLiveEndedAsync(
LiveRoom liveRoom,
DateTimeOffset occurredAt,
CancellationToken cancellationToken = default) =>
Task.FromResult<EventScriptExecutionResultDto?>(null);
public Task<EventScriptExecutionResultDto?> RunSegmentCompletedAsync(
LiveRoom? liveRoom,
RecordSession recordSession,
RecordTask recordTask,
RecordResult? recordResult,
string segmentFilePath,
DateTimeOffset occurredAt,
bool forceRun = false,
CancellationToken cancellationToken = default) =>
Task.FromResult<EventScriptExecutionResultDto?>(null);
public Task<EventScriptTestResultDto> TestAsync(
TestEventScriptRequest request,
CancellationToken cancellationToken = default) =>
Task.FromResult(new EventScriptTestResultDto
{
Success = true,
Message = "ok"
});
}
}
@@ -0,0 +1,100 @@
using LiveRecorder.Application.Abstractions.Persistence;
using LiveRecorder.Application.Services;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Tests;
public sealed class SystemSettingsServiceTests
{
[Fact]
public async Task GetAsync_reads_new_and_legacy_platform_settings()
{
var repository = new InMemoryAppSettingRepository(
[
new AppSetting("platform_request.tiktok.proxy.enabled", "true", DateTimeOffset.UtcNow),
new AppSetting("platform_request.tiktok.proxy.url", "http://127.0.0.1:8899", DateTimeOffset.UtcNow),
new AppSetting("platform_request.tiktok.user_agent", "TikTok UA", DateTimeOffset.UtcNow),
new AppSetting("platform_request.tiktok.referer", "https://www.tiktok.com/", DateTimeOffset.UtcNow),
new AppSetting("platform_request.tiktok.cookie", "sessionid=tiktok", DateTimeOffset.UtcNow),
new AppSetting("platform_proxy.douyin.enabled", "true", DateTimeOffset.UtcNow),
new AppSetting("platform_proxy.douyin.url", "http://127.0.0.1:7890", DateTimeOffset.UtcNow),
new AppSetting("douyin.user_agent", "Legacy Douyin UA", DateTimeOffset.UtcNow),
new AppSetting("douyin.referer", "https://live.douyin.com/", DateTimeOffset.UtcNow),
new AppSetting("douyin.cookie", "ttwid=legacy", DateTimeOffset.UtcNow)
]);
var service = new SystemSettingsService(repository, new NoOpUnitOfWork());
var settings = await service.GetAsync();
var tiktok = settings.GetPlatformRequestSettings(LivePlatformType.TikTok);
Assert.True(tiktok.Proxy.Enabled);
Assert.Equal("http://127.0.0.1:8899", tiktok.Proxy.ProxyUrl);
Assert.Equal("TikTok UA", tiktok.UserAgent);
Assert.Equal("sessionid=tiktok", tiktok.Cookie);
var douyin = settings.GetPlatformRequestSettings(LivePlatformType.Douyin);
Assert.True(douyin.Proxy.Enabled);
Assert.Equal("http://127.0.0.1:7890", douyin.Proxy.ProxyUrl);
Assert.Equal("Legacy Douyin UA", douyin.UserAgent);
Assert.Equal("ttwid=legacy", douyin.Cookie);
}
[Fact]
public async Task UpdateAsync_persists_platform_request_keys()
{
var repository = new InMemoryAppSettingRepository([]);
var service = new SystemSettingsService(repository, new NoOpUnitOfWork());
var request = new Application.Models.Settings.UpdateSystemSettingsRequest
{
PlatformRequestSettings = Application.Models.Settings.SystemSettingsDto.CreatePlatformRequestSettingsMap()
};
request.GetPlatformRequestSettings(LivePlatformType.YouTube).Proxy.Enabled = true;
request.GetPlatformRequestSettings(LivePlatformType.YouTube).Proxy.ProxyUrl = "http://127.0.0.1:8001";
request.GetPlatformRequestSettings(LivePlatformType.YouTube).UserAgent = "YouTube UA";
request.GetPlatformRequestSettings(LivePlatformType.Twitch).Cookie = "auth-token=123";
await service.UpdateAsync(request);
var allSettings = await repository.ListAsync();
Assert.Contains(allSettings, static item => item.Key == "platform_request.youtube.proxy.enabled" && item.Value == "True");
Assert.Contains(allSettings, static item => item.Key == "platform_request.youtube.proxy.url" && item.Value == "http://127.0.0.1:8001");
Assert.Contains(allSettings, static item => item.Key == "platform_request.youtube.user_agent" && item.Value == "YouTube UA");
Assert.Contains(allSettings, static item => item.Key == "platform_request.twitch.cookie" && item.Value == "auth-token=123");
}
private sealed class InMemoryAppSettingRepository : IAppSettingRepository
{
private readonly Dictionary<string, AppSetting> _items;
public InMemoryAppSettingRepository(IEnumerable<AppSetting> items)
{
_items = items.ToDictionary(static item => item.Key, StringComparer.OrdinalIgnoreCase);
}
public Task<AppSetting?> GetByKeyAsync(string key, CancellationToken cancellationToken = default)
{
_items.TryGetValue(key, out var item);
return Task.FromResult(item);
}
public Task<IReadOnlyList<AppSetting>> ListAsync(CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<AppSetting>>(_items.Values.ToList());
public Task AddAsync(AppSetting setting, CancellationToken cancellationToken = default)
{
_items[setting.Key] = setting;
return Task.CompletedTask;
}
public void Update(AppSetting setting)
{
_items[setting.Key] = setting;
}
}
private sealed class NoOpUnitOfWork : IUnitOfWork
{
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(0);
}
}
+1
View File
@@ -0,0 +1 @@
global using Xunit;