feat: expand platform adapters and preview tooling
This commit is contained in:
@@ -13,6 +13,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveRecorder.Application",
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveRecorder.Infrastructure", "src\LiveRecorder.Infrastructure\LiveRecorder.Infrastructure.csproj", "{A502FCC8-83F9-402B-A027-D020D34624E1}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveRecorder.Infrastructure", "src\LiveRecorder.Infrastructure\LiveRecorder.Infrastructure.csproj", "{A502FCC8-83F9-402B-A027-D020D34624E1}"
|
||||||
EndProject
|
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
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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|x64.Build.0 = Release|Any CPU
|
||||||
{A502FCC8-83F9-402B-A027-D020D34624E1}.Release|x86.ActiveCfg = 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
|
{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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -80,5 +96,6 @@ Global
|
|||||||
{CEE984AA-CA08-48B3-B341-BD2C1C68CC1F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{CEE984AA-CA08-48B3-B341-BD2C1C68CC1F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{3B807934-A995-4F7F-8A4E-878D161F95A1} = {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}
|
{A502FCC8-83F9-402B-A027-D020D34624E1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{51C97AC8-CDF9-4C3C-AAEC-7C9D2F3A1273} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
Generated
+1151
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@
|
|||||||
"@vitejs/plugin-vue": "^5.2.3",
|
"@vitejs/plugin-vue": "^5.2.3",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"vite": "^6.2.0",
|
"vite": "^6.2.0",
|
||||||
|
"vite-plugin-vue-devtools": "^8.1.2",
|
||||||
"vue-tsc": "^2.2.0"
|
"vue-tsc": "^2.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { ElNotification } from "element-plus";
|
import { ElNotification } from "element-plus";
|
||||||
import { markBackendAvailable, markBackendUnavailable } from "@/composables/useBackendStatus";
|
import { markBackendAvailable, markBackendUnavailable } from "@/composables/useBackendStatus";
|
||||||
|
import { isNoBackendPreviewMode } from "@/utils/devPreview";
|
||||||
|
|
||||||
export const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
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) {
|
function notifyBackendUnavailable(message: string) {
|
||||||
|
if (isNoBackendPreviewMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastBackendUnavailableNotificationAt < backendUnavailableNotificationIntervalMs) {
|
if (now - lastBackendUnavailableNotificationAt < backendUnavailableNotificationIntervalMs) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -5,5 +5,8 @@ import "element-plus/dist/index.css";
|
|||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
import router from "./router";
|
import router from "./router";
|
||||||
import "./styles/main.css";
|
import "./styles/main.css";
|
||||||
|
import { ensurePreviewSession } from "./utils/devPreview";
|
||||||
|
|
||||||
|
ensurePreviewSession();
|
||||||
|
|
||||||
createApp(App).use(createPinia()).use(router).use(ElementPlus).mount("#app");
|
createApp(App).use(createPinia()).use(router).use(ElementPlus).mount("#app");
|
||||||
|
|||||||
+67
-7
@@ -441,9 +441,7 @@ export interface SystemSettings {
|
|||||||
enableAutoUpload: boolean;
|
enableAutoUpload: boolean;
|
||||||
deleteLocalFilesAfterUpload: boolean;
|
deleteLocalFilesAfterUpload: boolean;
|
||||||
uploadTarget: number;
|
uploadTarget: number;
|
||||||
douyinProxy: PlatformProxySettings;
|
platformRequestSettings: Record<string, PlatformRequestSettings>;
|
||||||
bilibiliProxy: PlatformProxySettings;
|
|
||||||
huyaProxy: PlatformProxySettings;
|
|
||||||
webDavUpload: WebDavUploadSettings;
|
webDavUpload: WebDavUploadSettings;
|
||||||
s3Upload: S3UploadSettings;
|
s3Upload: S3UploadSettings;
|
||||||
enableEventScripts: boolean;
|
enableEventScripts: boolean;
|
||||||
@@ -482,9 +480,6 @@ export interface SystemSettings {
|
|||||||
notifyWebhookOnLiveStarted: boolean;
|
notifyWebhookOnLiveStarted: boolean;
|
||||||
notifyWebhookOnException: boolean;
|
notifyWebhookOnException: boolean;
|
||||||
webhookBodyTemplate: string;
|
webhookBodyTemplate: string;
|
||||||
douyinUserAgent: string;
|
|
||||||
douyinReferer: string;
|
|
||||||
douyinCookie: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlatformProxySettings {
|
export interface PlatformProxySettings {
|
||||||
@@ -492,6 +487,63 @@ export interface PlatformProxySettings {
|
|||||||
proxyUrl: string;
|
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 {
|
export interface WebDavUploadSettings {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
basePath: string;
|
basePath: string;
|
||||||
@@ -701,7 +753,15 @@ export const platformLabelMap: Record<number, string> = {
|
|||||||
0: "未知",
|
0: "未知",
|
||||||
1: "Douyin",
|
1: "Douyin",
|
||||||
2: "Bilibili",
|
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> = {
|
export const uploadTargetLabelMap: Record<number, string> = {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import vue from "@vitejs/plugin-vue";
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
import VueDevTools from "vite-plugin-vue-devtools";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ command }) => ({
|
||||||
plugins: [vue()],
|
plugins: [
|
||||||
|
...(command === "serve" ? [VueDevTools()] : []),
|
||||||
|
vue()
|
||||||
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "src")
|
"@": path.resolve(__dirname, "src")
|
||||||
@@ -42,4 +46,4 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
|
|||||||
+6
-2
@@ -6,7 +6,10 @@ namespace LiveRecorder.Application.Abstractions.Notifications;
|
|||||||
|
|
||||||
public interface IEmailNotificationService
|
public interface IEmailNotificationService
|
||||||
{
|
{
|
||||||
Task SendLiveStartedAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default);
|
Task SendLiveStartedAsync(
|
||||||
|
LiveRoom liveRoom,
|
||||||
|
CancellationToken cancellationToken = default,
|
||||||
|
string? eventScriptOutput = null);
|
||||||
|
|
||||||
Task SendExceptionAsync(
|
Task SendExceptionAsync(
|
||||||
string source,
|
string source,
|
||||||
@@ -14,7 +17,8 @@ public interface IEmailNotificationService
|
|||||||
string? detail = null,
|
string? detail = null,
|
||||||
LiveRoom? liveRoom = null,
|
LiveRoom? liveRoom = null,
|
||||||
RecordTask? recordTask = null,
|
RecordTask? recordTask = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default,
|
||||||
|
string? eventScriptOutput = null);
|
||||||
|
|
||||||
Task SendTestAsync(SendTestEmailRequest request, CancellationToken cancellationToken = default);
|
Task SendTestAsync(SendTestEmailRequest request, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
|||||||
+6
-2
@@ -6,7 +6,10 @@ namespace LiveRecorder.Application.Abstractions.Notifications;
|
|||||||
|
|
||||||
public interface IWebhookNotificationService
|
public interface IWebhookNotificationService
|
||||||
{
|
{
|
||||||
Task SendLiveStartedAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default);
|
Task SendLiveStartedAsync(
|
||||||
|
LiveRoom liveRoom,
|
||||||
|
CancellationToken cancellationToken = default,
|
||||||
|
string? eventScriptOutput = null);
|
||||||
|
|
||||||
Task SendExceptionAsync(
|
Task SendExceptionAsync(
|
||||||
string source,
|
string source,
|
||||||
@@ -14,7 +17,8 @@ public interface IWebhookNotificationService
|
|||||||
string? detail = null,
|
string? detail = null,
|
||||||
LiveRoom? liveRoom = null,
|
LiveRoom? liveRoom = null,
|
||||||
RecordTask? recordTask = null,
|
RecordTask? recordTask = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default,
|
||||||
|
string? eventScriptOutput = null);
|
||||||
|
|
||||||
Task<WebhookTestResultDto> SendTestAsync(
|
Task<WebhookTestResultDto> SendTestAsync(
|
||||||
SendTestWebhookRequest request,
|
SendTestWebhookRequest request,
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ public interface ILiveDanmakuAdapter
|
|||||||
public interface ILiveDanmakuAdapterFactory
|
public interface ILiveDanmakuAdapterFactory
|
||||||
{
|
{
|
||||||
ILiveDanmakuAdapter GetByPlatform(LivePlatformType platformType);
|
ILiveDanmakuAdapter GetByPlatform(LivePlatformType platformType);
|
||||||
|
|
||||||
|
ILiveDanmakuAdapter? TryGetByPlatform(LivePlatformType platformType);
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface ILiveDanmakuConnection : IAsyncDisposable
|
public interface ILiveDanmakuConnection : IAsyncDisposable
|
||||||
|
|||||||
@@ -5,11 +5,17 @@ namespace LiveRecorder.Application.Abstractions.Scripting;
|
|||||||
|
|
||||||
public interface IEventScriptService
|
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,
|
LiveRoom? liveRoom,
|
||||||
RecordSession recordSession,
|
RecordSession recordSession,
|
||||||
RecordTask recordTask,
|
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.Application.Models.Cleanup;
|
||||||
|
using LiveRecorder.Domain.Enums;
|
||||||
|
|
||||||
namespace LiveRecorder.Application.Models.Settings;
|
namespace LiveRecorder.Application.Models.Settings;
|
||||||
|
|
||||||
@@ -17,6 +19,41 @@ public sealed class PlatformProxySettingsDto
|
|||||||
public string ProxyUrl { get; set; } = string.Empty;
|
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 sealed class WebDavUploadSettingsDto
|
||||||
{
|
{
|
||||||
public string Endpoint { get; set; } = string.Empty;
|
public string Endpoint { get; set; } = string.Empty;
|
||||||
@@ -105,11 +142,8 @@ public sealed class SystemSettingsDto
|
|||||||
|
|
||||||
public UploadTargetType UploadTarget { get; set; } = UploadTargetType.None;
|
public UploadTargetType UploadTarget { get; set; } = UploadTargetType.None;
|
||||||
|
|
||||||
public PlatformProxySettingsDto DouyinProxy { get; set; } = new();
|
public IDictionary<string, PlatformRequestSettingsDto> PlatformRequestSettings { get; set; } =
|
||||||
|
CreatePlatformRequestSettingsMap();
|
||||||
public PlatformProxySettingsDto BilibiliProxy { get; set; } = new();
|
|
||||||
|
|
||||||
public PlatformProxySettingsDto HuyaProxy { get; set; } = new();
|
|
||||||
|
|
||||||
public WebDavUploadSettingsDto WebDavUpload { get; set; } = new();
|
public WebDavUploadSettingsDto WebDavUpload { get; set; } = new();
|
||||||
|
|
||||||
@@ -189,6 +223,10 @@ public sealed class SystemSettingsDto
|
|||||||
<li><strong>Detected At (Beijing Time):</strong> {{detectedAtUtc}}</li>
|
<li><strong>Detected At (Beijing Time):</strong> {{detectedAtUtc}}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
|
<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>
|
</div>
|
||||||
""";
|
""";
|
||||||
|
|
||||||
@@ -224,12 +262,93 @@ public sealed class SystemSettingsDto
|
|||||||
|
|
||||||
public bool NotifyWebhookOnException { get; set; } = true;
|
public bool NotifyWebhookOnException { get; set; } = true;
|
||||||
|
|
||||||
public string DouyinUserAgent { get; set; } =
|
[JsonIgnore]
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36";
|
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
|
public sealed class UpdateSystemSettingsRequest
|
||||||
@@ -292,11 +411,8 @@ public sealed class UpdateSystemSettingsRequest
|
|||||||
|
|
||||||
public UploadTargetType UploadTarget { get; set; } = UploadTargetType.None;
|
public UploadTargetType UploadTarget { get; set; } = UploadTargetType.None;
|
||||||
|
|
||||||
public PlatformProxySettingsDto DouyinProxy { get; set; } = new();
|
public IDictionary<string, PlatformRequestSettingsDto> PlatformRequestSettings { get; set; } =
|
||||||
|
SystemSettingsDto.CreatePlatformRequestSettingsMap();
|
||||||
public PlatformProxySettingsDto BilibiliProxy { get; set; } = new();
|
|
||||||
|
|
||||||
public PlatformProxySettingsDto HuyaProxy { get; set; } = new();
|
|
||||||
|
|
||||||
public WebDavUploadSettingsDto WebDavUpload { get; set; } = new();
|
public WebDavUploadSettingsDto WebDavUpload { get; set; } = new();
|
||||||
|
|
||||||
@@ -376,6 +492,10 @@ public sealed class UpdateSystemSettingsRequest
|
|||||||
<li><strong>Detected At (Beijing Time):</strong> {{detectedAtUtc}}</li>
|
<li><strong>Detected At (Beijing Time):</strong> {{detectedAtUtc}}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
|
<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>
|
</div>
|
||||||
""";
|
""";
|
||||||
|
|
||||||
@@ -411,12 +531,85 @@ public sealed class UpdateSystemSettingsRequest
|
|||||||
|
|
||||||
public bool NotifyWebhookOnException { get; set; } = true;
|
public bool NotifyWebhookOnException { get; set; } = true;
|
||||||
|
|
||||||
public string DouyinUserAgent { get; set; } =
|
[JsonIgnore]
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36";
|
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
|
public sealed class SendTestEmailRequest
|
||||||
@@ -451,6 +644,10 @@ public sealed class SendTestEmailRequest
|
|||||||
<li><strong>Detected At (Beijing Time):</strong> {{detectedAtUtc}}</li>
|
<li><strong>Detected At (Beijing Time):</strong> {{detectedAtUtc}}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
|
<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>
|
</div>
|
||||||
""";
|
""";
|
||||||
|
|
||||||
@@ -486,6 +683,17 @@ public sealed class TestEventScriptRequest
|
|||||||
public int TimeoutSeconds { get; set; } = 60;
|
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 sealed class EventScriptTestResultDto
|
||||||
{
|
{
|
||||||
public bool Success { get; init; }
|
public bool Success { get; init; }
|
||||||
|
|||||||
@@ -21,4 +21,7 @@ public sealed class LiveDanmakuAdapterFactory : ILiveDanmakuAdapterFactory
|
|||||||
|
|
||||||
return adapter;
|
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+)\/?(?:[#\?].*)?$""",
|
"""^(?:(?:https?:\/\/)?live\.bilibili\.com\/(?:blanc\/|h5\/)?)?(?<id>\d+)\/?(?:[#\?].*)?$""",
|
||||||
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
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 ILiveRoomRepository _liveRoomRepository;
|
||||||
private readonly IRecordSessionRepository _recordSessionRepository;
|
private readonly IRecordSessionRepository _recordSessionRepository;
|
||||||
private readonly ILivePlatformAdapterFactory _livePlatformAdapterFactory;
|
private readonly ILivePlatformAdapterFactory _livePlatformAdapterFactory;
|
||||||
@@ -507,6 +515,15 @@ public sealed class LiveRoomService
|
|||||||
{
|
{
|
||||||
LivePlatformType.Douyin => ParseDouyinRoomLocally(input),
|
LivePlatformType.Douyin => ParseDouyinRoomLocally(input),
|
||||||
LivePlatformType.Bilibili => ParseBilibiliRoomLocally(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(
|
_ => throw new NotSupportedException(
|
||||||
"This platform still requires live room detection during import. Please enable detection or choose a supported platform.")
|
"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;
|
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(
|
throw new NotSupportedException(
|
||||||
"Unable to infer the platform without detection. Please specify a platform override or keep detection enabled.");
|
"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}");
|
$"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)
|
private static string? ExtractDouyinRoomId(string input)
|
||||||
{
|
{
|
||||||
var trimmedInput = input.Trim();
|
var trimmedInput = input.Trim();
|
||||||
@@ -593,6 +725,56 @@ public sealed class LiveRoomService
|
|||||||
.FirstOrDefault(static item => !string.IsNullOrWhiteSpace(item) && item.All(char.IsDigit));
|
.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(
|
private async Task<Dictionary<Guid, RecordingExecutionSettings>> BuildEffectiveSettingsLookupAsync(
|
||||||
IReadOnlyCollection<LiveRoom> rooms,
|
IReadOnlyCollection<LiveRoom> rooms,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -60,9 +60,15 @@ public sealed class LiveRoomStatusService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _emailNotificationService.SendLiveStartedAsync(liveRoom, cancellationToken);
|
var eventScriptResult = await _eventScriptService.RunLiveStartedAsync(liveRoom, observedAt, cancellationToken);
|
||||||
await _webhookNotificationService.SendLiveStartedAsync(liveRoom, cancellationToken);
|
await _emailNotificationService.SendLiveStartedAsync(
|
||||||
await _eventScriptService.RunLiveStartedAsync(liveRoom, observedAt, cancellationToken);
|
liveRoom,
|
||||||
|
cancellationToken,
|
||||||
|
eventScriptOutput: eventScriptResult?.CustomLogOutput);
|
||||||
|
await _webhookNotificationService.SendLiveStartedAsync(
|
||||||
|
liveRoom,
|
||||||
|
cancellationToken,
|
||||||
|
eventScriptOutput: eventScriptResult?.CustomLogOutput);
|
||||||
liveRoom.MarkLiveNotificationSent(observedAt);
|
liveRoom.MarkLiveNotificationSent(observedAt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using LiveRecorder.Application.Abstractions.Persistence;
|
using LiveRecorder.Application.Abstractions.Persistence;
|
||||||
using LiveRecorder.Application.Abstractions.Settings;
|
using LiveRecorder.Application.Abstractions.Settings;
|
||||||
|
using LiveRecorder.Application.Common;
|
||||||
using LiveRecorder.Application.Models.Cleanup;
|
using LiveRecorder.Application.Models.Cleanup;
|
||||||
using LiveRecorder.Application.Models.Settings;
|
using LiveRecorder.Application.Models.Settings;
|
||||||
using LiveRecorder.Domain.Entities;
|
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 = Enum.TryParse(GetValue(lookup, UploadTargetKey, "None"), true, out UploadTargetType uploadTarget)
|
||||||
? uploadTarget
|
? uploadTarget
|
||||||
: UploadTargetType.None,
|
: UploadTargetType.None,
|
||||||
DouyinProxy = new PlatformProxySettingsDto
|
PlatformRequestSettings = BuildPlatformRequestSettingsMap(lookup),
|
||||||
{
|
|
||||||
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)
|
|
||||||
},
|
|
||||||
WebDavUpload = new WebDavUploadSettingsDto
|
WebDavUpload = new WebDavUploadSettingsDto
|
||||||
{
|
{
|
||||||
Endpoint = GetValue(lookup, WebDavEndpointKey, string.Empty),
|
Endpoint = GetValue(lookup, WebDavEndpointKey, string.Empty),
|
||||||
@@ -274,13 +261,7 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
|||||||
WebhookBodyTemplate = GetValue(lookup, WebhookBodyTemplateKey, string.Empty),
|
WebhookBodyTemplate = GetValue(lookup, WebhookBodyTemplateKey, string.Empty),
|
||||||
WebhookTimeoutSeconds = GetIntValue(lookup, WebhookTimeoutSecondsKey, 15, 1, 300),
|
WebhookTimeoutSeconds = GetIntValue(lookup, WebhookTimeoutSecondsKey, 15, 1, 300),
|
||||||
NotifyWebhookOnLiveStarted = bool.TryParse(GetValue(lookup, NotifyWebhookOnLiveStartedKey, "true"), out var notifyWebhookOnLiveStarted) && notifyWebhookOnLiveStarted,
|
NotifyWebhookOnLiveStarted = bool.TryParse(GetValue(lookup, NotifyWebhookOnLiveStartedKey, "true"), out var notifyWebhookOnLiveStarted) && notifyWebhookOnLiveStarted,
|
||||||
NotifyWebhookOnException = bool.TryParse(GetValue(lookup, NotifyWebhookOnExceptionKey, "true"), out var notifyWebhookOnException) && notifyWebhookOnException,
|
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)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,9 +270,6 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
|||||||
ArgumentNullException.ThrowIfNull(request);
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
var now = DateTimeOffset.UtcNow;
|
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 webDavUpload = request.WebDavUpload ?? new WebDavUploadSettingsDto();
|
||||||
var s3Upload = request.S3Upload ?? new S3UploadSettingsDto();
|
var s3Upload = request.S3Upload ?? new S3UploadSettingsDto();
|
||||||
|
|
||||||
@@ -351,12 +329,15 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
|||||||
await UpsertAsync(S3SecretKeyKey, s3Upload.SecretKey, now, cancellationToken);
|
await UpsertAsync(S3SecretKeyKey, s3Upload.SecretKey, now, cancellationToken);
|
||||||
await UpsertAsync(S3PrefixKey, s3Upload.Prefix.Trim(), now, cancellationToken);
|
await UpsertAsync(S3PrefixKey, s3Upload.Prefix.Trim(), now, cancellationToken);
|
||||||
await UpsertAsync(S3ForcePathStyleKey, s3Upload.ForcePathStyle.ToString(), now, cancellationToken);
|
await UpsertAsync(S3ForcePathStyleKey, s3Upload.ForcePathStyle.ToString(), now, cancellationToken);
|
||||||
await UpsertAsync(DouyinProxyEnabledKey, douyinProxy.Enabled.ToString(), now, cancellationToken);
|
foreach (var platformDefinition in LivePlatformCatalog.All)
|
||||||
await UpsertAsync(DouyinProxyUrlKey, douyinProxy.ProxyUrl.Trim(), now, cancellationToken);
|
{
|
||||||
await UpsertAsync(BilibiliProxyEnabledKey, bilibiliProxy.Enabled.ToString(), now, cancellationToken);
|
var platformRequestSettings = request.GetPlatformRequestSettings(platformDefinition.Type);
|
||||||
await UpsertAsync(BilibiliProxyUrlKey, bilibiliProxy.ProxyUrl.Trim(), now, cancellationToken);
|
await UpsertAsync(GetPlatformProxyEnabledKey(platformDefinition.Key), platformRequestSettings.Proxy.Enabled.ToString(), now, cancellationToken);
|
||||||
await UpsertAsync(HuyaProxyEnabledKey, huyaProxy.Enabled.ToString(), now, cancellationToken);
|
await UpsertAsync(GetPlatformProxyUrlKey(platformDefinition.Key), platformRequestSettings.Proxy.ProxyUrl.Trim(), now, cancellationToken);
|
||||||
await UpsertAsync(HuyaProxyUrlKey, huyaProxy.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(EnableEventScriptsKey, request.EnableEventScripts.ToString(), now, cancellationToken);
|
||||||
await UpsertAsync(EnableLiveStartedScriptKey, request.EnableLiveStartedScript.ToString(), now, cancellationToken);
|
await UpsertAsync(EnableLiveStartedScriptKey, request.EnableLiveStartedScript.ToString(), now, cancellationToken);
|
||||||
await UpsertAsync(LiveStartedScriptModeKey, NormalizeEventScriptMode(request.LiveStartedScriptMode), 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(WebhookTimeoutSecondsKey, Math.Clamp(request.WebhookTimeoutSeconds, 1, 300).ToString(), now, cancellationToken);
|
||||||
await UpsertAsync(NotifyWebhookOnLiveStartedKey, request.NotifyWebhookOnLiveStarted.ToString(), now, cancellationToken);
|
await UpsertAsync(NotifyWebhookOnLiveStartedKey, request.NotifyWebhookOnLiveStarted.ToString(), now, cancellationToken);
|
||||||
await UpsertAsync(NotifyWebhookOnExceptionKey, request.NotifyWebhookOnException.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);
|
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||||
return await GetAsync(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) =>
|
private static string GetValue(IReadOnlyDictionary<string, string> lookup, string key, string fallback) =>
|
||||||
lookup.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value) ? value : 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)
|
.OrderBy(static item => item)
|
||||||
.ToArray());
|
.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)
|
private async Task UpsertAsync(string key, string value, DateTimeOffset updatedAt, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var existing = await _appSettingRepository.GetByKeyAsync(key, cancellationToken);
|
var existing = await _appSettingRepository.GetByKeyAsync(key, cancellationToken);
|
||||||
|
|||||||
@@ -7,5 +7,11 @@ public enum LivePlatformType
|
|||||||
Bilibili = 2,
|
Bilibili = 2,
|
||||||
Huya = 3,
|
Huya = 3,
|
||||||
Douyu = 4,
|
Douyu = 4,
|
||||||
Kuaishou = 5
|
Kuaishou = 5,
|
||||||
|
TikTok = 6,
|
||||||
|
Xiaohongshu = 7,
|
||||||
|
YouTube = 8,
|
||||||
|
Twitch = 9,
|
||||||
|
PandaTV = 10,
|
||||||
|
Migu = 11
|
||||||
}
|
}
|
||||||
|
|||||||
+168
@@ -0,0 +1,168 @@
|
|||||||
|
using LiveRecorder.Application.Abstractions.Platforms;
|
||||||
|
using LiveRecorder.Domain.Enums;
|
||||||
|
|
||||||
|
namespace LiveRecorder.Infrastructure.Platforms.Common;
|
||||||
|
|
||||||
|
public abstract class PathBasedPageLivePlatformAdapterBase : ILivePlatformAdapter
|
||||||
|
{
|
||||||
|
private readonly PlatformHttpRequestService _requestService;
|
||||||
|
|
||||||
|
protected PathBasedPageLivePlatformAdapterBase(PlatformHttpRequestService requestService)
|
||||||
|
{
|
||||||
|
_requestService = requestService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract LivePlatformType PlatformType { get; }
|
||||||
|
|
||||||
|
protected abstract IReadOnlyList<string> SupportedHosts { get; }
|
||||||
|
|
||||||
|
protected abstract string PlatformRootUrl { get; }
|
||||||
|
|
||||||
|
public bool CanHandle(string input)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(input))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryExtractRoomId(input, out _))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SupportedHosts.Any(host => input.Contains(host, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual async Task<ParsedLiveRoom> ParseRoomAsync(string input, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (TryExtractRoomId(input, out var roomId))
|
||||||
|
{
|
||||||
|
return new ParsedLiveRoom(PlatformType, roomId, input.Trim(), BuildPageUrl(roomId));
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = await _requestService.GetStringAsync(
|
||||||
|
PlatformType,
|
||||||
|
input.Trim(),
|
||||||
|
referer: PlatformRootUrl,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
roomId = response.FinalUri is not null && TryExtractRoomId(response.FinalUri.AbsoluteUri, out var resolvedRoomId)
|
||||||
|
? resolvedRoomId
|
||||||
|
: TryExtractRoomId(response.Body, out var bodyRoomId)
|
||||||
|
? bodyRoomId
|
||||||
|
: null;
|
||||||
|
if (string.IsNullOrWhiteSpace(roomId))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Unable to extract the {PlatformType} room id from the input.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ParsedLiveRoom(
|
||||||
|
PlatformType,
|
||||||
|
roomId,
|
||||||
|
input.Trim(),
|
||||||
|
response.FinalUri?.AbsoluteUri ?? BuildPageUrl(roomId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual async Task<LiveStatusSnapshot> GetLiveStatusAsync(string roomId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var pageUrl = BuildPageUrl(roomId);
|
||||||
|
var response = await _requestService.GetStringAsync(
|
||||||
|
PlatformType,
|
||||||
|
pageUrl,
|
||||||
|
referer: PlatformRootUrl,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
var options = ExtractStreamOptions(response.Body);
|
||||||
|
var isLive = options.Count > 0;
|
||||||
|
return new LiveStatusSnapshot(
|
||||||
|
isLive,
|
||||||
|
ExtractTitle(response.Body),
|
||||||
|
ExtractAnchorName(response.Body),
|
||||||
|
roomId,
|
||||||
|
ExtractAvatarUrl(response.Body),
|
||||||
|
ExtractCoverUrl(response.Body),
|
||||||
|
isLive ? 1 : 0,
|
||||||
|
isLive ? "live" : "offline");
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual async Task<StreamUrlResult> GetStreamUrlAsync(
|
||||||
|
string roomId,
|
||||||
|
string? preferredQuality = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var pageUrl = BuildPageUrl(roomId);
|
||||||
|
var response = await _requestService.GetStringAsync(
|
||||||
|
PlatformType,
|
||||||
|
pageUrl,
|
||||||
|
referer: PlatformRootUrl,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
var options = ExtractStreamOptions(response.Body);
|
||||||
|
if (options.Count == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"{PlatformType} did not return any playable stream URL.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var selected = PlatformAdapterUtilities.SelectQuality(options, preferredQuality);
|
||||||
|
var inputHeaders = await _requestService.BuildStreamInputHeadersAsync(
|
||||||
|
PlatformType,
|
||||||
|
pageUrl,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
return new StreamUrlResult(
|
||||||
|
selected.QualityKey,
|
||||||
|
selected.Protocol,
|
||||||
|
selected.Url,
|
||||||
|
inputHeaders,
|
||||||
|
options);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected virtual string BuildPageUrl(string roomId) => $"{PlatformRootUrl.TrimEnd('/')}/{roomId.TrimStart('/')}";
|
||||||
|
|
||||||
|
protected virtual string? ExtractTitle(string html) =>
|
||||||
|
PlatformAdapterUtilities.ExtractMetaContent(html, "og:title", "twitter:title", "title");
|
||||||
|
|
||||||
|
protected virtual string? ExtractAnchorName(string html) =>
|
||||||
|
PlatformAdapterUtilities.ExtractMetaContent(html, "author", "profile:username");
|
||||||
|
|
||||||
|
protected virtual string? ExtractAvatarUrl(string html) => null;
|
||||||
|
|
||||||
|
protected virtual string? ExtractCoverUrl(string html) =>
|
||||||
|
PlatformAdapterUtilities.ExtractMetaContent(html, "og:image", "twitter:image");
|
||||||
|
|
||||||
|
protected virtual IReadOnlyList<StreamQualityOption> ExtractStreamOptions(string html)
|
||||||
|
{
|
||||||
|
var options = new List<StreamQualityOption>();
|
||||||
|
foreach (var url in PlatformAdapterUtilities.ExtractPlayableUrls(html, ".m3u8"))
|
||||||
|
{
|
||||||
|
options.Add(new StreamQualityOption("origin", "Origin", url, "hls", 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var url in PlatformAdapterUtilities.ExtractPlayableUrls(html, ".flv"))
|
||||||
|
{
|
||||||
|
options.Add(new StreamQualityOption("origin", "Origin", url, "flv", 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
return options
|
||||||
|
.DistinctBy(static item => item.Url, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected virtual bool TryExtractRoomId(string input, out string roomId)
|
||||||
|
{
|
||||||
|
roomId = string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(input))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Uri.TryCreate(input.Trim(), UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SupportedHosts.Any(host => uri.Host.Contains(host, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
roomId = uri.AbsolutePath.Trim('/');
|
||||||
|
return !string.IsNullOrWhiteSpace(roomId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using LiveRecorder.Application.Abstractions.Platforms;
|
||||||
|
|
||||||
|
namespace LiveRecorder.Infrastructure.Platforms.Common;
|
||||||
|
|
||||||
|
internal static class PlatformAdapterUtilities
|
||||||
|
{
|
||||||
|
private static readonly Regex MetaTagRegex = new(
|
||||||
|
"<meta[^>]+(?:property|name)=[\"'](?<name>[^\"']+)[\"'][^>]+content=[\"'](?<content>[^\"']+)[\"'][^>]*>",
|
||||||
|
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||||
|
|
||||||
|
private static readonly Regex UrlRegex = new(
|
||||||
|
@"https?:\\?/\\?/[^""'<>\\\s]+",
|
||||||
|
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||||
|
|
||||||
|
public static string? ExtractMetaContent(string html, params string[] names)
|
||||||
|
{
|
||||||
|
foreach (Match match in MetaTagRegex.Matches(html))
|
||||||
|
{
|
||||||
|
var name = WebUtility.HtmlDecode(match.Groups["name"].Value);
|
||||||
|
if (names.Any(candidate => string.Equals(candidate, name, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return WebUtility.HtmlDecode(match.Groups["content"].Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string DecodeEscapedPayload(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = value
|
||||||
|
.Replace("\\u002F", "/", StringComparison.OrdinalIgnoreCase)
|
||||||
|
.Replace("\\/", "/", StringComparison.Ordinal)
|
||||||
|
.Replace("&", "&", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Regex.Unescape(normalized);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<string> ExtractPlayableUrls(string html, params string[] requiredFragments)
|
||||||
|
{
|
||||||
|
var urls = UrlRegex.Matches(html)
|
||||||
|
.Select(static match => DecodeEscapedPayload(match.Value))
|
||||||
|
.Where(url => requiredFragments.Length == 0 ||
|
||||||
|
requiredFragments.Any(fragment => url.Contains(fragment, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? ExtractJsonObjectAfterMarker(string html, string marker)
|
||||||
|
{
|
||||||
|
var markerIndex = html.IndexOf(marker, StringComparison.Ordinal);
|
||||||
|
if (markerIndex < 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var objectStart = html.IndexOf('{', markerIndex + marker.Length);
|
||||||
|
if (objectStart < 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExtractBalancedBlock(html, objectStart, '{', '}');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? ExtractJsonArrayAfterMarker(string html, string marker)
|
||||||
|
{
|
||||||
|
var markerIndex = html.IndexOf(marker, StringComparison.Ordinal);
|
||||||
|
if (markerIndex < 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var arrayStart = html.IndexOf('[', markerIndex + marker.Length);
|
||||||
|
if (arrayStart < 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExtractBalancedBlock(html, arrayStart, '[', ']');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? ExtractScriptTagJsonById(string html, string id)
|
||||||
|
{
|
||||||
|
var pattern = $"""<script[^>]+id=["']{Regex.Escape(id)}["'][^>]*>(?<json>[\s\S]*?)</script>""";
|
||||||
|
var match = Regex.Match(html, pattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||||
|
return match.Success ? match.Groups["json"].Value.Trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static JsonDocument? TryParseJson(string? payload)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(payload))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonDocument.Parse(payload);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? FindFirstString(JsonElement element, params string[] propertyNames)
|
||||||
|
{
|
||||||
|
foreach (var propertyName in propertyNames)
|
||||||
|
{
|
||||||
|
var result = FindFirstString(element, propertyName);
|
||||||
|
if (!string.IsNullOrWhiteSpace(result))
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? FindFirstString(JsonElement element, string propertyName)
|
||||||
|
{
|
||||||
|
if (element.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
foreach (var property in element.EnumerateObject())
|
||||||
|
{
|
||||||
|
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var scalar = ReadScalarString(property.Value);
|
||||||
|
if (!string.IsNullOrWhiteSpace(scalar))
|
||||||
|
{
|
||||||
|
return scalar;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var nested = FindFirstString(property.Value, propertyName);
|
||||||
|
if (!string.IsNullOrWhiteSpace(nested))
|
||||||
|
{
|
||||||
|
return nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (element.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var item in element.EnumerateArray())
|
||||||
|
{
|
||||||
|
var nested = FindFirstString(item, propertyName);
|
||||||
|
if (!string.IsNullOrWhiteSpace(nested))
|
||||||
|
{
|
||||||
|
return nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int? FindFirstInt(JsonElement element, params string[] propertyNames)
|
||||||
|
{
|
||||||
|
foreach (var propertyName in propertyNames)
|
||||||
|
{
|
||||||
|
var result = FindFirstInt(element, propertyName);
|
||||||
|
if (result.HasValue)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int? FindFirstInt(JsonElement element, string propertyName)
|
||||||
|
{
|
||||||
|
if (element.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
foreach (var property in element.EnumerateObject())
|
||||||
|
{
|
||||||
|
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (property.Value.ValueKind == JsonValueKind.Number && property.Value.TryGetInt32(out var intValue))
|
||||||
|
{
|
||||||
|
return intValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (property.Value.ValueKind == JsonValueKind.String &&
|
||||||
|
int.TryParse(property.Value.GetString(), out intValue))
|
||||||
|
{
|
||||||
|
return intValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var nested = FindFirstInt(property.Value, propertyName);
|
||||||
|
if (nested.HasValue)
|
||||||
|
{
|
||||||
|
return nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (element.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var item in element.EnumerateArray())
|
||||||
|
{
|
||||||
|
var nested = FindFirstInt(item, propertyName);
|
||||||
|
if (nested.HasValue)
|
||||||
|
{
|
||||||
|
return nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static JsonElement? FindFirstObject(JsonElement element, string propertyName)
|
||||||
|
{
|
||||||
|
if (element.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
foreach (var property in element.EnumerateObject())
|
||||||
|
{
|
||||||
|
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return property.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nested = FindFirstObject(property.Value, propertyName);
|
||||||
|
if (nested.HasValue)
|
||||||
|
{
|
||||||
|
return nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (element.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var item in element.EnumerateArray())
|
||||||
|
{
|
||||||
|
var nested = FindFirstObject(item, propertyName);
|
||||||
|
if (nested.HasValue)
|
||||||
|
{
|
||||||
|
return nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static StreamQualityOption SelectQuality(IReadOnlyList<StreamQualityOption> options, string? preferredQuality)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(preferredQuality))
|
||||||
|
{
|
||||||
|
var normalized = preferredQuality.Trim();
|
||||||
|
var exact = options
|
||||||
|
.Where(item =>
|
||||||
|
item.QualityKey.Equals(normalized, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
item.QualityName.Equals(normalized, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.OrderByDescending(static item => item.Rank)
|
||||||
|
.FirstOrDefault();
|
||||||
|
if (exact is not null)
|
||||||
|
{
|
||||||
|
return exact;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options
|
||||||
|
.OrderByDescending(static item => item.Rank)
|
||||||
|
.First();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int GetQualityRank(string? qualityLabel)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(qualityLabel))
|
||||||
|
{
|
||||||
|
return 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = qualityLabel.Trim().ToLowerInvariant();
|
||||||
|
return normalized switch
|
||||||
|
{
|
||||||
|
"origin" or "source" or "raw" or "uhd" or "4k" => 100,
|
||||||
|
"full_hd" or "fullhd" or "fhd" or "1080p" => 90,
|
||||||
|
"hd" or "720p" => 80,
|
||||||
|
"sd" or "540p" or "480p" => 70,
|
||||||
|
"ld" or "360p" => 60,
|
||||||
|
_ when normalized.Contains("origin", StringComparison.Ordinal) => 100,
|
||||||
|
_ when normalized.Contains("1080", StringComparison.Ordinal) => 90,
|
||||||
|
_ when normalized.Contains("720", StringComparison.Ordinal) => 80,
|
||||||
|
_ when normalized.Contains("540", StringComparison.Ordinal) => 70,
|
||||||
|
_ when normalized.Contains("480", StringComparison.Ordinal) => 70,
|
||||||
|
_ when normalized.Contains("360", StringComparison.Ordinal) => 60,
|
||||||
|
_ => 50
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string InferProtocolFromUrl(string url)
|
||||||
|
{
|
||||||
|
if (url.Contains(".m3u8", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return "hls";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.Contains(".mpd", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return "dash";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.Contains(".flv", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return "flv";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "http";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? ExtractFirstPathSegment(Uri uri, int index = 0)
|
||||||
|
{
|
||||||
|
return uri.Segments
|
||||||
|
.Select(static item => item.Trim('/'))
|
||||||
|
.Where(static item => !string.IsNullOrWhiteSpace(item))
|
||||||
|
.Skip(index)
|
||||||
|
.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractBalancedBlock(string html, int startIndex, char openChar, char closeChar)
|
||||||
|
{
|
||||||
|
var depth = 0;
|
||||||
|
var inString = false;
|
||||||
|
var escaped = false;
|
||||||
|
|
||||||
|
for (var index = startIndex; index < html.Length; index++)
|
||||||
|
{
|
||||||
|
var character = html[index];
|
||||||
|
|
||||||
|
if (inString)
|
||||||
|
{
|
||||||
|
if (escaped)
|
||||||
|
{
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character == '\\')
|
||||||
|
{
|
||||||
|
escaped = true;
|
||||||
|
}
|
||||||
|
else if (character == '"')
|
||||||
|
{
|
||||||
|
inString = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character == '"')
|
||||||
|
{
|
||||||
|
inString = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character == openChar)
|
||||||
|
{
|
||||||
|
depth++;
|
||||||
|
}
|
||||||
|
else if (character == closeChar)
|
||||||
|
{
|
||||||
|
depth--;
|
||||||
|
if (depth == 0)
|
||||||
|
{
|
||||||
|
return html[startIndex..(index + 1)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ReadScalarString(JsonElement element)
|
||||||
|
{
|
||||||
|
return element.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.String => element.GetString(),
|
||||||
|
JsonValueKind.Number => element.GetRawText(),
|
||||||
|
JsonValueKind.True => bool.TrueString.ToLowerInvariant(),
|
||||||
|
JsonValueKind.False => bool.FalseString.ToLowerInvariant(),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using LiveRecorder.Application.Abstractions.Platforms;
|
||||||
|
using LiveRecorder.Application.Abstractions.Settings;
|
||||||
|
using LiveRecorder.Application.Models.Settings;
|
||||||
|
using LiveRecorder.Domain.Enums;
|
||||||
|
using LiveRecorder.Infrastructure.Services;
|
||||||
|
|
||||||
|
namespace LiveRecorder.Infrastructure.Platforms.Common;
|
||||||
|
|
||||||
|
public sealed class PlatformHttpRequestService
|
||||||
|
{
|
||||||
|
private readonly PlatformHttpClientFactory _platformHttpClientFactory;
|
||||||
|
private readonly ISystemSettingsService _systemSettingsService;
|
||||||
|
|
||||||
|
public PlatformHttpRequestService(
|
||||||
|
PlatformHttpClientFactory platformHttpClientFactory,
|
||||||
|
ISystemSettingsService systemSettingsService)
|
||||||
|
{
|
||||||
|
_platformHttpClientFactory = platformHttpClientFactory;
|
||||||
|
_systemSettingsService = systemSettingsService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PlatformHttpResponse> GetStringAsync(
|
||||||
|
LivePlatformType platform,
|
||||||
|
string requestUri,
|
||||||
|
string? referer = null,
|
||||||
|
IReadOnlyDictionary<string, string>? additionalHeaders = null,
|
||||||
|
bool forceDirectConnection = false,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var profile = await GetProfileAsync(platform, referer, cancellationToken);
|
||||||
|
using var client = await _platformHttpClientFactory.CreateAsync(platform, forceDirectConnection, cancellationToken);
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||||
|
ApplyHeaders(request, profile, additionalHeaders);
|
||||||
|
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken);
|
||||||
|
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
return new PlatformHttpResponse(response.RequestMessage?.RequestUri, response.StatusCode, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<JsonDocument> GetJsonAsync(
|
||||||
|
LivePlatformType platform,
|
||||||
|
string requestUri,
|
||||||
|
string? referer = null,
|
||||||
|
IReadOnlyDictionary<string, string>? additionalHeaders = null,
|
||||||
|
bool forceDirectConnection = false,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var response = await GetStringAsync(
|
||||||
|
platform,
|
||||||
|
requestUri,
|
||||||
|
referer,
|
||||||
|
additionalHeaders,
|
||||||
|
forceDirectConnection,
|
||||||
|
cancellationToken);
|
||||||
|
return JsonDocument.Parse(response.Body);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PlatformHttpResponse> SendJsonAsync(
|
||||||
|
LivePlatformType platform,
|
||||||
|
string requestUri,
|
||||||
|
object payload,
|
||||||
|
string? referer = null,
|
||||||
|
IReadOnlyDictionary<string, string>? additionalHeaders = null,
|
||||||
|
bool forceDirectConnection = false,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var profile = await GetProfileAsync(platform, referer, cancellationToken);
|
||||||
|
using var client = await _platformHttpClientFactory.CreateAsync(platform, forceDirectConnection, cancellationToken);
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, requestUri)
|
||||||
|
{
|
||||||
|
Content = new StringContent(
|
||||||
|
JsonSerializer.Serialize(payload),
|
||||||
|
Encoding.UTF8,
|
||||||
|
"application/json")
|
||||||
|
};
|
||||||
|
ApplyHeaders(request, profile, additionalHeaders);
|
||||||
|
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken);
|
||||||
|
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
return new PlatformHttpResponse(response.RequestMessage?.RequestUri, response.StatusCode, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PlatformRequestProfile> GetProfileAsync(
|
||||||
|
LivePlatformType platform,
|
||||||
|
string? referer = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||||
|
var platformSettings = settings.GetPlatformRequestSettings(platform);
|
||||||
|
return new PlatformRequestProfile(
|
||||||
|
platformSettings.UserAgent,
|
||||||
|
string.IsNullOrWhiteSpace(referer) ? platformSettings.Referer : referer.Trim(),
|
||||||
|
platformSettings.Cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<StreamInputHeaders> BuildStreamInputHeadersAsync(
|
||||||
|
LivePlatformType platform,
|
||||||
|
string? referer = null,
|
||||||
|
IReadOnlyDictionary<string, string>? additionalHeaders = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var profile = await GetProfileAsync(platform, referer, cancellationToken);
|
||||||
|
return new StreamInputHeaders(
|
||||||
|
profile.UserAgent,
|
||||||
|
profile.Referer,
|
||||||
|
string.IsNullOrWhiteSpace(profile.Cookie) ? null : profile.Cookie,
|
||||||
|
additionalHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ApplyHeaders(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
PlatformRequestProfile profile,
|
||||||
|
IReadOnlyDictionary<string, string>? additionalHeaders = null)
|
||||||
|
{
|
||||||
|
request.Headers.Accept.Clear();
|
||||||
|
request.Headers.Accept.ParseAdd("*/*");
|
||||||
|
request.Headers.AcceptLanguage.Clear();
|
||||||
|
request.Headers.AcceptLanguage.ParseAdd("zh-CN,zh;q=0.9,en;q=0.8");
|
||||||
|
request.Headers.TryAddWithoutValidation("User-Agent", profile.UserAgent);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(profile.Referer))
|
||||||
|
{
|
||||||
|
request.Headers.Referrer = new Uri(profile.Referer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(profile.Cookie))
|
||||||
|
{
|
||||||
|
request.Headers.TryAddWithoutValidation("Cookie", profile.Cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (additionalHeaders is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (key, value) in additionalHeaders)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.Headers.TryAddWithoutValidation(key, value) && request.Content is not null)
|
||||||
|
{
|
||||||
|
request.Content.Headers.TryAddWithoutValidation(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record PlatformRequestProfile(
|
||||||
|
string UserAgent,
|
||||||
|
string Referer,
|
||||||
|
string Cookie);
|
||||||
|
|
||||||
|
public sealed record PlatformHttpResponse(
|
||||||
|
Uri? FinalUri,
|
||||||
|
HttpStatusCode StatusCode,
|
||||||
|
string Body);
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using LiveRecorder.Application.Abstractions.Platforms;
|
||||||
|
using LiveRecorder.Domain.Enums;
|
||||||
|
using LiveRecorder.Infrastructure.Platforms.Common;
|
||||||
|
|
||||||
|
namespace LiveRecorder.Infrastructure.Platforms.Douyu;
|
||||||
|
|
||||||
|
public sealed class DouyuLivePlatformAdapter : ILivePlatformAdapter
|
||||||
|
{
|
||||||
|
private readonly PlatformHttpRequestService _requestService;
|
||||||
|
|
||||||
|
public DouyuLivePlatformAdapter(PlatformHttpRequestService requestService)
|
||||||
|
{
|
||||||
|
_requestService = requestService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LivePlatformType PlatformType => LivePlatformType.Douyu;
|
||||||
|
|
||||||
|
public bool CanHandle(string input) =>
|
||||||
|
!string.IsNullOrWhiteSpace(input) &&
|
||||||
|
(input.Contains("douyu.com", StringComparison.OrdinalIgnoreCase) || !Uri.IsWellFormedUriString(input, UriKind.Absolute));
|
||||||
|
|
||||||
|
public Task<ParsedLiveRoom> ParseRoomAsync(string input, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var roomId = ExtractRoomId(input);
|
||||||
|
if (string.IsNullOrWhiteSpace(roomId))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Unable to extract the Douyu room id from the input.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(new ParsedLiveRoom(
|
||||||
|
PlatformType,
|
||||||
|
roomId,
|
||||||
|
input.Trim(),
|
||||||
|
BuildPageUrl(roomId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LiveStatusSnapshot> GetLiveStatusAsync(string roomId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var document = await GetRoomDocumentAsync(roomId, cancellationToken);
|
||||||
|
var data = document.RootElement.TryGetProperty("room", out var roomElement) ? roomElement :
|
||||||
|
document.RootElement.TryGetProperty("data", out roomElement) ? roomElement : default;
|
||||||
|
var status = PlatformAdapterUtilities.FindFirstInt(data, "show_status", "room_status", "videoLoop");
|
||||||
|
var options = ParseStreamOptions(data);
|
||||||
|
var isLive = options.Count > 0 || status == 1;
|
||||||
|
|
||||||
|
return new LiveStatusSnapshot(
|
||||||
|
isLive,
|
||||||
|
PlatformAdapterUtilities.FindFirstString(data, "room_name", "roomName"),
|
||||||
|
PlatformAdapterUtilities.FindFirstString(data, "nickname", "owner_name"),
|
||||||
|
PlatformAdapterUtilities.FindFirstString(data, "owner_uid", "up_id", "rid"),
|
||||||
|
PlatformAdapterUtilities.FindFirstString(data, "avatar"),
|
||||||
|
PlatformAdapterUtilities.FindFirstString(data, "room_thumb", "room_pic"),
|
||||||
|
status ?? (isLive ? 1 : 0),
|
||||||
|
status?.ToString() ?? (isLive ? "live" : "offline"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<StreamUrlResult> GetStreamUrlAsync(
|
||||||
|
string roomId,
|
||||||
|
string? preferredQuality = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var document = await GetRoomDocumentAsync(roomId, cancellationToken);
|
||||||
|
var data = document.RootElement.TryGetProperty("room", out var roomElement) ? roomElement :
|
||||||
|
document.RootElement.TryGetProperty("data", out roomElement) ? roomElement : default;
|
||||||
|
var options = ParseStreamOptions(data);
|
||||||
|
if (options.Count == 0)
|
||||||
|
{
|
||||||
|
var page = await _requestService.GetStringAsync(
|
||||||
|
PlatformType,
|
||||||
|
BuildPageUrl(roomId),
|
||||||
|
referer: "https://www.douyu.com/",
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
options = PlatformAdapterUtilities.ExtractPlayableUrls(page.Body, ".m3u8")
|
||||||
|
.Select(static url => new StreamQualityOption("origin", "Origin", url, "hls", 90))
|
||||||
|
.Concat(PlatformAdapterUtilities.ExtractPlayableUrls(page.Body, ".flv")
|
||||||
|
.Select(static url => new StreamQualityOption("origin", "Origin", url, "flv", 100)))
|
||||||
|
.DistinctBy(static item => item.Url, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.Count == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Douyu did not return any playable stream URL.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var selected = PlatformAdapterUtilities.SelectQuality(options, preferredQuality);
|
||||||
|
var inputHeaders = await _requestService.BuildStreamInputHeadersAsync(
|
||||||
|
PlatformType,
|
||||||
|
BuildPageUrl(roomId),
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
return new StreamUrlResult(
|
||||||
|
selected.QualityKey,
|
||||||
|
selected.Protocol,
|
||||||
|
selected.Url,
|
||||||
|
inputHeaders,
|
||||||
|
options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<JsonDocument> GetRoomDocumentAsync(string roomId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _requestService.GetJsonAsync(
|
||||||
|
PlatformType,
|
||||||
|
$"https://www.douyu.com/betard/{Uri.EscapeDataString(roomId)}",
|
||||||
|
referer: BuildPageUrl(roomId),
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<StreamQualityOption> ParseStreamOptions(JsonElement data)
|
||||||
|
{
|
||||||
|
var options = new List<StreamQualityOption>();
|
||||||
|
|
||||||
|
AddStream(options, "origin", "Origin", "flv", JoinDouyuUrl(
|
||||||
|
PlatformAdapterUtilities.FindFirstString(data, "flv_url"),
|
||||||
|
PlatformAdapterUtilities.FindFirstString(data, "flv_live")));
|
||||||
|
AddStream(options, "origin", "Origin", "hls", JoinDouyuUrl(
|
||||||
|
PlatformAdapterUtilities.FindFirstString(data, "hls_url"),
|
||||||
|
PlatformAdapterUtilities.FindFirstString(data, "hls_live")));
|
||||||
|
AddStream(options, "origin", "Origin", "rtmp", JoinDouyuUrl(
|
||||||
|
PlatformAdapterUtilities.FindFirstString(data, "rtmp_url"),
|
||||||
|
PlatformAdapterUtilities.FindFirstString(data, "rtmp_live")));
|
||||||
|
|
||||||
|
AddStream(options, "origin", "Origin", PlatformAdapterUtilities.InferProtocolFromUrl(
|
||||||
|
PlatformAdapterUtilities.FindFirstString(data, "stream_url") ?? string.Empty),
|
||||||
|
PlatformAdapterUtilities.FindFirstString(data, "stream_url"));
|
||||||
|
|
||||||
|
return options
|
||||||
|
.Where(static item => !string.IsNullOrWhiteSpace(item.Url))
|
||||||
|
.DistinctBy(static item => item.Url, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddStream(
|
||||||
|
ICollection<StreamQualityOption> options,
|
||||||
|
string qualityKey,
|
||||||
|
string qualityName,
|
||||||
|
string protocol,
|
||||||
|
string? url)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(url))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.Add(new StreamQualityOption(qualityKey, qualityName, url, protocol, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? JoinDouyuUrl(string? baseUrl, string? liveName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(liveName))
|
||||||
|
{
|
||||||
|
return baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (liveName.Contains("://", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return liveName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{baseUrl.TrimEnd('/')}/{liveName.TrimStart('/')}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildPageUrl(string roomId) => $"https://www.douyu.com/{roomId}";
|
||||||
|
|
||||||
|
private static string? ExtractRoomId(string? input)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(input))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmedInput = input.Trim().Trim('/');
|
||||||
|
if (!Uri.TryCreate(trimmedInput, UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
return trimmedInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!uri.Host.Contains("douyu.com", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return uri.Segments
|
||||||
|
.Select(static item => item.Trim('/'))
|
||||||
|
.FirstOrDefault(static item => !string.IsNullOrWhiteSpace(item));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,25 +1,230 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using LiveRecorder.Application.Abstractions.Platforms;
|
using LiveRecorder.Application.Abstractions.Platforms;
|
||||||
using LiveRecorder.Domain.Enums;
|
using LiveRecorder.Domain.Enums;
|
||||||
|
using LiveRecorder.Infrastructure.Platforms.Common;
|
||||||
|
|
||||||
namespace LiveRecorder.Infrastructure.Platforms.Huya;
|
namespace LiveRecorder.Infrastructure.Platforms.Huya;
|
||||||
|
|
||||||
public sealed class HuyaLivePlatformAdapter : ILivePlatformAdapter
|
public sealed class HuyaLivePlatformAdapter : ILivePlatformAdapter
|
||||||
{
|
{
|
||||||
|
private readonly PlatformHttpRequestService _requestService;
|
||||||
|
|
||||||
|
public HuyaLivePlatformAdapter(PlatformHttpRequestService requestService)
|
||||||
|
{
|
||||||
|
_requestService = requestService;
|
||||||
|
}
|
||||||
|
|
||||||
public LivePlatformType PlatformType => LivePlatformType.Huya;
|
public LivePlatformType PlatformType => LivePlatformType.Huya;
|
||||||
|
|
||||||
public bool CanHandle(string input) =>
|
public bool CanHandle(string input) =>
|
||||||
!string.IsNullOrWhiteSpace(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) =>
|
public Task<ParsedLiveRoom> ParseRoomAsync(string input, CancellationToken cancellationToken = default)
|
||||||
throw new NotSupportedException("Huya 适配器尚未实现。");
|
{
|
||||||
|
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) =>
|
return Task.FromResult(new ParsedLiveRoom(
|
||||||
throw new NotSupportedException("Huya 适配器尚未实现。");
|
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 roomId,
|
||||||
string? preferredQuality = null,
|
string? preferredQuality = null,
|
||||||
CancellationToken cancellationToken = default) =>
|
CancellationToken cancellationToken = default)
|
||||||
throw new NotSupportedException("Huya 适配器尚未实现。");
|
{
|
||||||
|
using var document = await GetProfileDocumentAsync(roomId, cancellationToken);
|
||||||
|
var options = ParseStreamOptions(document.RootElement);
|
||||||
|
if (options.Count == 0)
|
||||||
|
{
|
||||||
|
var page = await _requestService.GetStringAsync(
|
||||||
|
PlatformType,
|
||||||
|
BuildPageUrl(roomId),
|
||||||
|
referer: "https://www.huya.com/",
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
options = PlatformAdapterUtilities.ExtractPlayableUrls(page.Body, ".m3u8")
|
||||||
|
.Select(static url => new StreamQualityOption("origin", "Origin", url, "hls", 90))
|
||||||
|
.Concat(PlatformAdapterUtilities.ExtractPlayableUrls(page.Body, ".flv")
|
||||||
|
.Select(static url => new StreamQualityOption("origin", "Origin", url, "flv", 100)))
|
||||||
|
.DistinctBy(static item => item.Url, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.Count == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Huya did not return any playable stream URL.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var selected = PlatformAdapterUtilities.SelectQuality(options, preferredQuality);
|
||||||
|
var inputHeaders = await _requestService.BuildStreamInputHeadersAsync(
|
||||||
|
PlatformType,
|
||||||
|
BuildPageUrl(roomId),
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
return new StreamUrlResult(
|
||||||
|
selected.QualityKey,
|
||||||
|
selected.Protocol,
|
||||||
|
selected.Url,
|
||||||
|
inputHeaders,
|
||||||
|
options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<JsonDocument> GetProfileDocumentAsync(string roomId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _requestService.GetJsonAsync(
|
||||||
|
PlatformType,
|
||||||
|
$"https://mp.huya.com/cache.php?m=Live&do=profileRoom&roomid={Uri.EscapeDataString(roomId)}",
|
||||||
|
referer: BuildPageUrl(roomId),
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<StreamQualityOption> ParseStreamOptions(JsonElement root)
|
||||||
|
{
|
||||||
|
var streamInfoList = PlatformAdapterUtilities.FindFirstObject(root, "gameStreamInfoList") ??
|
||||||
|
PlatformAdapterUtilities.FindFirstObject(root, "streamInfoList");
|
||||||
|
if (!streamInfoList.HasValue || streamInfoList.Value.ValueKind != JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var options = new List<StreamQualityOption>();
|
||||||
|
foreach (var stream in streamInfoList.Value.EnumerateArray())
|
||||||
|
{
|
||||||
|
var streamName = PlatformAdapterUtilities.FindFirstString(stream, "sStreamName");
|
||||||
|
if (string.IsNullOrWhiteSpace(streamName))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var qualityName = PlatformAdapterUtilities.FindFirstString(stream, "sDisplayName", "iBitRate") ?? "origin";
|
||||||
|
var qualityKey = MapQualityKey(qualityName);
|
||||||
|
|
||||||
|
var flvUrl = BuildUrl(
|
||||||
|
PlatformAdapterUtilities.FindFirstString(stream, "sFlvUrl"),
|
||||||
|
streamName,
|
||||||
|
PlatformAdapterUtilities.FindFirstString(stream, "sFlvUrlSuffix"),
|
||||||
|
PlatformAdapterUtilities.FindFirstString(stream, "sFlvAntiCode"));
|
||||||
|
if (!string.IsNullOrWhiteSpace(flvUrl))
|
||||||
|
{
|
||||||
|
options.Add(new StreamQualityOption(
|
||||||
|
qualityKey,
|
||||||
|
qualityName,
|
||||||
|
flvUrl,
|
||||||
|
"flv",
|
||||||
|
PlatformAdapterUtilities.GetQualityRank(qualityKey)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var hlsUrl = BuildUrl(
|
||||||
|
PlatformAdapterUtilities.FindFirstString(stream, "sHlsUrl"),
|
||||||
|
streamName,
|
||||||
|
PlatformAdapterUtilities.FindFirstString(stream, "sHlsUrlSuffix"),
|
||||||
|
PlatformAdapterUtilities.FindFirstString(stream, "sHlsAntiCode"));
|
||||||
|
if (!string.IsNullOrWhiteSpace(hlsUrl))
|
||||||
|
{
|
||||||
|
options.Add(new StreamQualityOption(
|
||||||
|
qualityKey,
|
||||||
|
qualityName,
|
||||||
|
hlsUrl,
|
||||||
|
"hls",
|
||||||
|
PlatformAdapterUtilities.GetQualityRank(qualityKey) - 5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options
|
||||||
|
.DistinctBy(static item => item.Url, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderByDescending(static item => item.Rank)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? BuildUrl(string? baseUrl, string? streamName, string? suffix, string? query)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(streamName) || string.IsNullOrWhiteSpace(suffix))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var url = $"{baseUrl.TrimEnd('/')}/{streamName}.{suffix.TrimStart('.')}";
|
||||||
|
return string.IsNullOrWhiteSpace(query) ? url : $"{url}?{query.TrimStart('?')}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string MapQualityKey(string qualityName)
|
||||||
|
{
|
||||||
|
var normalized = qualityName.Trim();
|
||||||
|
if (normalized.Contains("蓝光", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalized.Contains("1080", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return "FULL_HD";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.Contains("超清", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalized.Contains("高清", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalized.Contains("720", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return "HD";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.Contains("标清", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalized.Contains("流畅", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return "SD";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "origin";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildPageUrl(string roomId) => $"https://www.huya.com/{roomId}";
|
||||||
|
|
||||||
|
private static string? ExtractRoomId(string? input)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(input))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmedInput = input.Trim().Trim('/');
|
||||||
|
if (!Uri.TryCreate(trimmedInput, UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
return trimmedInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!uri.Host.Contains("huya.com", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return uri.Segments
|
||||||
|
.Select(static item => item.Trim('/'))
|
||||||
|
.FirstOrDefault(static item => !string.IsNullOrWhiteSpace(item));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using LiveRecorder.Domain.Enums;
|
||||||
|
using LiveRecorder.Infrastructure.Platforms.Common;
|
||||||
|
|
||||||
|
namespace LiveRecorder.Infrastructure.Platforms.Kuaishou;
|
||||||
|
|
||||||
|
public sealed class KuaishouLivePlatformAdapter : PathBasedPageLivePlatformAdapterBase
|
||||||
|
{
|
||||||
|
public KuaishouLivePlatformAdapter(PlatformHttpRequestService requestService)
|
||||||
|
: base(requestService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public override LivePlatformType PlatformType => LivePlatformType.Kuaishou;
|
||||||
|
|
||||||
|
protected override IReadOnlyList<string> SupportedHosts => ["live.kuaishou.com", "v.kuaishou.com"];
|
||||||
|
|
||||||
|
protected override string PlatformRootUrl => "https://live.kuaishou.com/";
|
||||||
|
|
||||||
|
protected override string? ExtractAnchorName(string html)
|
||||||
|
{
|
||||||
|
var title = ExtractTitle(html);
|
||||||
|
if (string.IsNullOrWhiteSpace(title))
|
||||||
|
{
|
||||||
|
return base.ExtractAnchorName(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
var match = Regex.Match(title, @"^(?<name>.+?)(?:的直播|直播中| - 快手直播)", RegexOptions.CultureInvariant);
|
||||||
|
return match.Success ? match.Groups["name"].Value.Trim() : base.ExtractAnchorName(html);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using LiveRecorder.Domain.Enums;
|
||||||
|
using LiveRecorder.Infrastructure.Platforms.Common;
|
||||||
|
|
||||||
|
namespace LiveRecorder.Infrastructure.Platforms.Migu;
|
||||||
|
|
||||||
|
public sealed class MiguLivePlatformAdapter : PathBasedPageLivePlatformAdapterBase
|
||||||
|
{
|
||||||
|
public MiguLivePlatformAdapter(PlatformHttpRequestService requestService)
|
||||||
|
: base(requestService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public override LivePlatformType PlatformType => LivePlatformType.Migu;
|
||||||
|
|
||||||
|
protected override IReadOnlyList<string> SupportedHosts => ["miguvideo.com"];
|
||||||
|
|
||||||
|
protected override string PlatformRootUrl => "https://www.miguvideo.com/";
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using LiveRecorder.Domain.Enums;
|
||||||
|
using LiveRecorder.Infrastructure.Platforms.Common;
|
||||||
|
|
||||||
|
namespace LiveRecorder.Infrastructure.Platforms.PandaTV;
|
||||||
|
|
||||||
|
public sealed class PandaTvLivePlatformAdapter : PathBasedPageLivePlatformAdapterBase
|
||||||
|
{
|
||||||
|
public PandaTvLivePlatformAdapter(PlatformHttpRequestService requestService)
|
||||||
|
: base(requestService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public override LivePlatformType PlatformType => LivePlatformType.PandaTV;
|
||||||
|
|
||||||
|
protected override IReadOnlyList<string> SupportedHosts => ["pandalive.co.kr"];
|
||||||
|
|
||||||
|
protected override string PlatformRootUrl => "https://www.pandalive.co.kr/";
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using LiveRecorder.Application.Abstractions.Platforms;
|
||||||
|
using LiveRecorder.Domain.Enums;
|
||||||
|
using LiveRecorder.Infrastructure.Platforms.Common;
|
||||||
|
|
||||||
|
namespace LiveRecorder.Infrastructure.Platforms.TikTok;
|
||||||
|
|
||||||
|
public sealed class TikTokLivePlatformAdapter : ILivePlatformAdapter
|
||||||
|
{
|
||||||
|
private static readonly Regex HandleRegex = new(
|
||||||
|
@"@(?<handle>[A-Za-z0-9._-]{2,})",
|
||||||
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||||
|
|
||||||
|
private readonly PlatformHttpRequestService _requestService;
|
||||||
|
|
||||||
|
public TikTokLivePlatformAdapter(PlatformHttpRequestService requestService)
|
||||||
|
{
|
||||||
|
_requestService = requestService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LivePlatformType PlatformType => LivePlatformType.TikTok;
|
||||||
|
|
||||||
|
public bool CanHandle(string input) =>
|
||||||
|
!string.IsNullOrWhiteSpace(input) &&
|
||||||
|
(input.Contains("tiktok.com", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
input.Contains("vt.tiktok.com", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
HandleRegex.IsMatch(input));
|
||||||
|
|
||||||
|
public async Task<ParsedLiveRoom> ParseRoomAsync(string input, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var handle = ExtractHandle(input);
|
||||||
|
if (string.IsNullOrWhiteSpace(handle))
|
||||||
|
{
|
||||||
|
var response = await _requestService.GetStringAsync(
|
||||||
|
PlatformType,
|
||||||
|
input.Trim(),
|
||||||
|
referer: "https://www.tiktok.com/",
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
handle = ExtractHandle(response.FinalUri?.AbsoluteUri) ?? ExtractHandle(response.Body);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(handle))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Unable to extract the TikTok live handle from the input.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ParsedLiveRoom(
|
||||||
|
PlatformType,
|
||||||
|
handle,
|
||||||
|
input.Trim(),
|
||||||
|
BuildPageUrl(handle));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LiveStatusSnapshot> GetLiveStatusAsync(string roomId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var page = await _requestService.GetStringAsync(
|
||||||
|
PlatformType,
|
||||||
|
BuildPageUrl(roomId),
|
||||||
|
referer: "https://www.tiktok.com/",
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
var options = ExtractStreamOptions(page.Body);
|
||||||
|
var title = PlatformAdapterUtilities.ExtractMetaContent(page.Body, "og:title", "twitter:title");
|
||||||
|
var anchorName = PlatformAdapterUtilities.ExtractMetaContent(page.Body, "og:description") ??
|
||||||
|
$"@{roomId}";
|
||||||
|
var cover = PlatformAdapterUtilities.ExtractMetaContent(page.Body, "og:image", "twitter:image");
|
||||||
|
var isLive = options.Count > 0 || page.Body.Contains("\"isLive\":true", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
return new LiveStatusSnapshot(
|
||||||
|
isLive,
|
||||||
|
title,
|
||||||
|
anchorName,
|
||||||
|
$"@{roomId}",
|
||||||
|
AvatarUrl: null,
|
||||||
|
CoverUrl: cover,
|
||||||
|
isLive ? 1 : 0,
|
||||||
|
isLive ? "live" : "offline");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<StreamUrlResult> GetStreamUrlAsync(
|
||||||
|
string roomId,
|
||||||
|
string? preferredQuality = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var page = await _requestService.GetStringAsync(
|
||||||
|
PlatformType,
|
||||||
|
BuildPageUrl(roomId),
|
||||||
|
referer: "https://www.tiktok.com/",
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
var options = ExtractStreamOptions(page.Body);
|
||||||
|
if (options.Count == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("TikTok did not return any playable stream URL.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var selected = PlatformAdapterUtilities.SelectQuality(options, preferredQuality);
|
||||||
|
var inputHeaders = await _requestService.BuildStreamInputHeadersAsync(
|
||||||
|
PlatformType,
|
||||||
|
BuildPageUrl(roomId),
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
return new StreamUrlResult(
|
||||||
|
selected.QualityKey,
|
||||||
|
selected.Protocol,
|
||||||
|
selected.Url,
|
||||||
|
inputHeaders,
|
||||||
|
options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<StreamQualityOption> ExtractStreamOptions(string html)
|
||||||
|
{
|
||||||
|
var options = new List<StreamQualityOption>();
|
||||||
|
var scriptPayload = PlatformAdapterUtilities.ExtractScriptTagJsonById(html, "SIGI_STATE") ??
|
||||||
|
PlatformAdapterUtilities.ExtractScriptTagJsonById(html, "__UNIVERSAL_DATA_FOR_REHYDRATION__");
|
||||||
|
using var scriptDocument = PlatformAdapterUtilities.TryParseJson(scriptPayload);
|
||||||
|
if (scriptDocument is not null)
|
||||||
|
{
|
||||||
|
var hlsUrl = PlatformAdapterUtilities.FindFirstString(scriptDocument.RootElement, "hls_pull_url");
|
||||||
|
var flvUrl = PlatformAdapterUtilities.FindFirstString(scriptDocument.RootElement, "flv_pull_url");
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(flvUrl))
|
||||||
|
{
|
||||||
|
options.Add(new StreamQualityOption("origin", "Origin", flvUrl, "flv", 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(hlsUrl))
|
||||||
|
{
|
||||||
|
options.Add(new StreamQualityOption("origin", "Origin", hlsUrl, "hls", 90));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var url in PlatformAdapterUtilities.ExtractPlayableUrls(html, ".m3u8"))
|
||||||
|
{
|
||||||
|
options.Add(new StreamQualityOption("origin", "Origin", url, "hls", 90));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var url in PlatformAdapterUtilities.ExtractPlayableUrls(html, ".flv"))
|
||||||
|
{
|
||||||
|
options.Add(new StreamQualityOption("origin", "Origin", url, "flv", 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
return options
|
||||||
|
.DistinctBy(static item => item.Url, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderByDescending(static item => item.Rank)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildPageUrl(string handle) => $"https://www.tiktok.com/@{handle}/live";
|
||||||
|
|
||||||
|
private static string? ExtractHandle(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var match = HandleRegex.Match(value);
|
||||||
|
return match.Success ? match.Groups["handle"].Value : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using LiveRecorder.Application.Abstractions.Platforms;
|
||||||
|
using LiveRecorder.Domain.Enums;
|
||||||
|
using LiveRecorder.Infrastructure.Platforms.Common;
|
||||||
|
|
||||||
|
namespace LiveRecorder.Infrastructure.Platforms.Twitch;
|
||||||
|
|
||||||
|
public sealed class TwitchLivePlatformAdapter : ILivePlatformAdapter
|
||||||
|
{
|
||||||
|
private const string ClientId = "kimne78kx3ncx6brgo4mv6wki5h1ko";
|
||||||
|
|
||||||
|
private static readonly Regex LoginRegex = new(
|
||||||
|
@"^(?<login>[A-Za-z0-9_]{3,25})$",
|
||||||
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||||
|
|
||||||
|
private readonly PlatformHttpRequestService _requestService;
|
||||||
|
|
||||||
|
public TwitchLivePlatformAdapter(PlatformHttpRequestService requestService)
|
||||||
|
{
|
||||||
|
_requestService = requestService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LivePlatformType PlatformType => LivePlatformType.Twitch;
|
||||||
|
|
||||||
|
public bool CanHandle(string input)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(input))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return input.Contains("twitch.tv", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
LoginRegex.IsMatch(input.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ParsedLiveRoom> ParseRoomAsync(string input, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var login = ExtractLogin(input);
|
||||||
|
if (string.IsNullOrWhiteSpace(login))
|
||||||
|
{
|
||||||
|
var response = await _requestService.GetStringAsync(
|
||||||
|
PlatformType,
|
||||||
|
input.Trim(),
|
||||||
|
referer: "https://www.twitch.tv/",
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
login = ExtractLogin(response.FinalUri?.AbsoluteUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(login))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Unable to extract the Twitch channel login from the input.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ParsedLiveRoom(
|
||||||
|
PlatformType,
|
||||||
|
login,
|
||||||
|
input.Trim(),
|
||||||
|
BuildChannelUrl(login));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LiveStatusSnapshot> GetLiveStatusAsync(string roomId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var payload = await ExecuteGraphQlAsync(
|
||||||
|
operationName: "StreamMetadata",
|
||||||
|
query: """
|
||||||
|
query StreamMetadata($login: String!) {
|
||||||
|
user(login: $login) {
|
||||||
|
id
|
||||||
|
login
|
||||||
|
displayName
|
||||||
|
profileImageURL(width: 300)
|
||||||
|
stream {
|
||||||
|
id
|
||||||
|
type
|
||||||
|
title
|
||||||
|
previewImageURL(width: 1920, height: 1080)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
variables: new { login = roomId },
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var user = payload.RootElement.TryGetProperty("data", out var dataElement) &&
|
||||||
|
dataElement.TryGetProperty("user", out var userElement)
|
||||||
|
? userElement
|
||||||
|
: default;
|
||||||
|
if (user.ValueKind != JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Twitch channel '{roomId}' was not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var stream = user.TryGetProperty("stream", out var streamElement) ? streamElement : default;
|
||||||
|
var isLive = stream.ValueKind == JsonValueKind.Object &&
|
||||||
|
stream.TryGetProperty("type", out var typeElement) &&
|
||||||
|
string.Equals(typeElement.GetString(), "live", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
return new LiveStatusSnapshot(
|
||||||
|
isLive,
|
||||||
|
isLive ? PlatformAdapterUtilities.FindFirstString(stream, "title") : null,
|
||||||
|
PlatformAdapterUtilities.FindFirstString(user, "displayName"),
|
||||||
|
PlatformAdapterUtilities.FindFirstString(user, "id"),
|
||||||
|
PlatformAdapterUtilities.FindFirstString(user, "profileImageURL"),
|
||||||
|
isLive ? PlatformAdapterUtilities.FindFirstString(stream, "previewImageURL") : null,
|
||||||
|
isLive ? 1 : 0,
|
||||||
|
isLive ? "live" : "offline");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<StreamUrlResult> GetStreamUrlAsync(
|
||||||
|
string roomId,
|
||||||
|
string? preferredQuality = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var payload = await ExecuteGraphQlAsync(
|
||||||
|
operationName: "PlaybackAccessToken",
|
||||||
|
query: """
|
||||||
|
query PlaybackAccessToken($login: String!) {
|
||||||
|
streamPlaybackAccessToken(
|
||||||
|
channelName: $login
|
||||||
|
params: {
|
||||||
|
platform: "web"
|
||||||
|
playerBackend: "mediaplayer"
|
||||||
|
playerType: "site"
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
value
|
||||||
|
signature
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
variables: new { login = roomId },
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (!payload.RootElement.TryGetProperty("data", out var dataElement) ||
|
||||||
|
!dataElement.TryGetProperty("streamPlaybackAccessToken", out var tokenElement))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Twitch did not return a playback access token.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = PlatformAdapterUtilities.FindFirstString(tokenElement, "value");
|
||||||
|
var signature = PlatformAdapterUtilities.FindFirstString(tokenElement, "signature");
|
||||||
|
if (string.IsNullOrWhiteSpace(token) || string.IsNullOrWhiteSpace(signature))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Twitch returned an empty playback access token.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var url = $"https://usher.ttvnw.net/api/channel/hls/{roomId}.m3u8" +
|
||||||
|
$"?allow_source=true&allow_audio_only=true&fast_bread=true&player_backend=mediaplayer" +
|
||||||
|
$"&playlist_include_framerate=true&reassignments_supported=true" +
|
||||||
|
$"&sig={Uri.EscapeDataString(signature)}&token={Uri.EscapeDataString(token)}";
|
||||||
|
|
||||||
|
var selected = new StreamQualityOption("origin", "Origin", url, "hls", 100);
|
||||||
|
var inputHeaders = await _requestService.BuildStreamInputHeadersAsync(
|
||||||
|
PlatformType,
|
||||||
|
BuildChannelUrl(roomId),
|
||||||
|
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["Client-Id"] = ClientId,
|
||||||
|
["Origin"] = "https://www.twitch.tv"
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
return new StreamUrlResult(
|
||||||
|
selected.QualityKey,
|
||||||
|
selected.Protocol,
|
||||||
|
selected.Url,
|
||||||
|
inputHeaders,
|
||||||
|
[selected]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<JsonDocument> ExecuteGraphQlAsync(
|
||||||
|
string operationName,
|
||||||
|
string query,
|
||||||
|
object variables,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var response = await _requestService.SendJsonAsync(
|
||||||
|
PlatformType,
|
||||||
|
"https://gql.twitch.tv/gql",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
operationName,
|
||||||
|
query,
|
||||||
|
variables
|
||||||
|
},
|
||||||
|
referer: "https://www.twitch.tv/",
|
||||||
|
additionalHeaders: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["Client-Id"] = ClientId
|
||||||
|
},
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
return JsonDocument.Parse(response.Body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildChannelUrl(string login) => $"https://www.twitch.tv/{login}";
|
||||||
|
|
||||||
|
private static string? ExtractLogin(string? input)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(input))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmedInput = input.Trim().Trim('/');
|
||||||
|
var directMatch = LoginRegex.Match(trimmedInput);
|
||||||
|
if (directMatch.Success)
|
||||||
|
{
|
||||||
|
return directMatch.Groups["login"].Value.ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Uri.TryCreate(trimmedInput, UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!uri.Host.Contains("twitch.tv", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var login = PlatformAdapterUtilities.ExtractFirstPathSegment(uri);
|
||||||
|
if (string.IsNullOrWhiteSpace(login))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(login, "directory", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(login, "videos", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return login.ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
using LiveRecorder.Domain.Enums;
|
||||||
|
using LiveRecorder.Infrastructure.Platforms.Common;
|
||||||
|
|
||||||
|
namespace LiveRecorder.Infrastructure.Platforms.Xiaohongshu;
|
||||||
|
|
||||||
|
public sealed class XiaohongshuLivePlatformAdapter : PathBasedPageLivePlatformAdapterBase
|
||||||
|
{
|
||||||
|
public XiaohongshuLivePlatformAdapter(PlatformHttpRequestService requestService)
|
||||||
|
: base(requestService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public override LivePlatformType PlatformType => LivePlatformType.Xiaohongshu;
|
||||||
|
|
||||||
|
protected override IReadOnlyList<string> SupportedHosts => ["xiaohongshu.com", "xhslink.com"];
|
||||||
|
|
||||||
|
protected override string PlatformRootUrl => "https://www.xiaohongshu.com/";
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using LiveRecorder.Application.Abstractions.Platforms;
|
||||||
|
using LiveRecorder.Domain.Enums;
|
||||||
|
using LiveRecorder.Infrastructure.Platforms.Common;
|
||||||
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
|
|
||||||
|
namespace LiveRecorder.Infrastructure.Platforms.YouTube;
|
||||||
|
|
||||||
|
public sealed class YouTubeLivePlatformAdapter : ILivePlatformAdapter
|
||||||
|
{
|
||||||
|
private static readonly Regex VideoIdRegex = new(
|
||||||
|
@"^[A-Za-z0-9_-]{11}$",
|
||||||
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||||
|
|
||||||
|
private readonly PlatformHttpRequestService _requestService;
|
||||||
|
|
||||||
|
public YouTubeLivePlatformAdapter(PlatformHttpRequestService requestService)
|
||||||
|
{
|
||||||
|
_requestService = requestService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LivePlatformType PlatformType => LivePlatformType.YouTube;
|
||||||
|
|
||||||
|
public bool CanHandle(string input)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(input))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return input.Contains("youtube.com", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
input.Contains("youtu.be", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
VideoIdRegex.IsMatch(input.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ParsedLiveRoom> ParseRoomAsync(string input, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var trimmedInput = input.Trim();
|
||||||
|
var videoId = ExtractVideoId(trimmedInput);
|
||||||
|
if (!string.IsNullOrWhiteSpace(videoId))
|
||||||
|
{
|
||||||
|
return new ParsedLiveRoom(
|
||||||
|
PlatformType,
|
||||||
|
videoId,
|
||||||
|
trimmedInput,
|
||||||
|
BuildWatchUrl(videoId));
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = await _requestService.GetStringAsync(
|
||||||
|
PlatformType,
|
||||||
|
trimmedInput,
|
||||||
|
referer: "https://www.youtube.com/",
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
videoId = ExtractVideoId(response.FinalUri?.AbsoluteUri) ?? ExtractVideoId(response.Body);
|
||||||
|
if (string.IsNullOrWhiteSpace(videoId))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Unable to extract the YouTube video id from the input.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ParsedLiveRoom(
|
||||||
|
PlatformType,
|
||||||
|
videoId,
|
||||||
|
trimmedInput,
|
||||||
|
BuildWatchUrl(videoId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LiveStatusSnapshot> GetLiveStatusAsync(string roomId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var page = await GetWatchPageAsync(roomId, cancellationToken);
|
||||||
|
var playerResponse = GetPlayerResponse(page.Body);
|
||||||
|
|
||||||
|
var videoDetails = playerResponse.RootElement.TryGetProperty("videoDetails", out var detailsElement)
|
||||||
|
? detailsElement
|
||||||
|
: default;
|
||||||
|
var microformat = TryGetNested(playerResponse.RootElement, out var microformatElement, "microformat", "playerMicroformatRenderer")
|
||||||
|
? microformatElement
|
||||||
|
: default;
|
||||||
|
var thumbnails = PlatformAdapterUtilities.FindFirstObject(videoDetails, "thumbnail");
|
||||||
|
|
||||||
|
var isLive = GetBoolean(videoDetails, "isLive") ||
|
||||||
|
GetBoolean(videoDetails, "isLiveContent") ||
|
||||||
|
GetBoolean(microformat, "isLiveNow") ||
|
||||||
|
page.Body.Contains("\"isLiveNow\":true", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var title = PlatformAdapterUtilities.FindFirstString(videoDetails, "title") ??
|
||||||
|
PlatformAdapterUtilities.ExtractMetaContent(page.Body, "og:title", "twitter:title");
|
||||||
|
var anchorName = PlatformAdapterUtilities.FindFirstString(videoDetails, "author") ??
|
||||||
|
PlatformAdapterUtilities.FindFirstString(microformat, "ownerChannelName");
|
||||||
|
var coverUrl = SelectBestThumbnail(thumbnails) ??
|
||||||
|
PlatformAdapterUtilities.ExtractMetaContent(page.Body, "og:image", "twitter:image");
|
||||||
|
var anchorId = PlatformAdapterUtilities.FindFirstString(videoDetails, "channelId");
|
||||||
|
|
||||||
|
return new LiveStatusSnapshot(
|
||||||
|
isLive,
|
||||||
|
title,
|
||||||
|
anchorName,
|
||||||
|
anchorId,
|
||||||
|
AvatarUrl: null,
|
||||||
|
CoverUrl: coverUrl,
|
||||||
|
isLive ? 1 : 0,
|
||||||
|
isLive ? "live" : "offline");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<StreamUrlResult> GetStreamUrlAsync(
|
||||||
|
string roomId,
|
||||||
|
string? preferredQuality = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var page = await GetWatchPageAsync(roomId, cancellationToken);
|
||||||
|
var playerResponse = GetPlayerResponse(page.Body);
|
||||||
|
var streamingData = playerResponse.RootElement.TryGetProperty("streamingData", out var element)
|
||||||
|
? element
|
||||||
|
: throw new InvalidOperationException("YouTube page did not expose a streamingData payload.");
|
||||||
|
|
||||||
|
var hlsManifestUrl = PlatformAdapterUtilities.FindFirstString(streamingData, "hlsManifestUrl");
|
||||||
|
var dashManifestUrl = PlatformAdapterUtilities.FindFirstString(streamingData, "dashManifestUrl");
|
||||||
|
var selectedUrl = !string.IsNullOrWhiteSpace(hlsManifestUrl) ? hlsManifestUrl : dashManifestUrl;
|
||||||
|
if (string.IsNullOrWhiteSpace(selectedUrl))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("YouTube did not return a playable live stream URL.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedProtocol = PlatformAdapterUtilities.InferProtocolFromUrl(selectedUrl);
|
||||||
|
var selectedQuality = new StreamQualityOption("origin", "Origin", selectedUrl, selectedProtocol, 100);
|
||||||
|
var inputHeaders = await _requestService.BuildStreamInputHeadersAsync(
|
||||||
|
PlatformType,
|
||||||
|
BuildWatchUrl(roomId),
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
return new StreamUrlResult(
|
||||||
|
selectedQuality.QualityKey,
|
||||||
|
selectedQuality.Protocol,
|
||||||
|
selectedQuality.Url,
|
||||||
|
inputHeaders,
|
||||||
|
[selectedQuality]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<PlatformHttpResponse> GetWatchPageAsync(string roomId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _requestService.GetStringAsync(
|
||||||
|
PlatformType,
|
||||||
|
BuildWatchUrl(roomId),
|
||||||
|
referer: "https://www.youtube.com/",
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonDocument GetPlayerResponse(string html)
|
||||||
|
{
|
||||||
|
var payload = PlatformAdapterUtilities.ExtractJsonObjectAfterMarker(html, "var ytInitialPlayerResponse = ") ??
|
||||||
|
PlatformAdapterUtilities.ExtractJsonObjectAfterMarker(html, "ytInitialPlayerResponse = ") ??
|
||||||
|
PlatformAdapterUtilities.ExtractJsonObjectAfterMarker(html, "\"playerResponse\":");
|
||||||
|
var document = PlatformAdapterUtilities.TryParseJson(payload);
|
||||||
|
if (document is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Unable to parse the YouTube player response.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildWatchUrl(string videoId) => $"https://www.youtube.com/watch?v={videoId}";
|
||||||
|
|
||||||
|
private static string? ExtractVideoId(string? input)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(input))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmedInput = input.Trim();
|
||||||
|
if (VideoIdRegex.IsMatch(trimmedInput))
|
||||||
|
{
|
||||||
|
return trimmedInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Uri.TryCreate(trimmedInput, UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
if (uri.Host.Contains("youtu.be", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var pathSegment = PlatformAdapterUtilities.ExtractFirstPathSegment(uri);
|
||||||
|
if (!string.IsNullOrWhiteSpace(pathSegment) && VideoIdRegex.IsMatch(pathSegment))
|
||||||
|
{
|
||||||
|
return pathSegment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||||
|
var fromQuery = query.TryGetValue("v", out var videoIdValue)
|
||||||
|
? videoIdValue.ToString()
|
||||||
|
: null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(fromQuery) && VideoIdRegex.IsMatch(fromQuery))
|
||||||
|
{
|
||||||
|
return fromQuery;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var match = Regex.Match(trimmedInput, @"[?&]v=(?<id>[A-Za-z0-9_-]{11})", RegexOptions.CultureInvariant);
|
||||||
|
if (match.Success)
|
||||||
|
{
|
||||||
|
return match.Groups["id"].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
match = Regex.Match(trimmedInput, @"/(?<id>[A-Za-z0-9_-]{11})(?:[/?#]|$)", RegexOptions.CultureInvariant);
|
||||||
|
return match.Success ? match.Groups["id"].Value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? SelectBestThumbnail(JsonElement? thumbnailElement)
|
||||||
|
{
|
||||||
|
if (!thumbnailElement.HasValue || thumbnailElement.Value.ValueKind == JsonValueKind.Undefined)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (thumbnailElement.Value.ValueKind == JsonValueKind.Object &&
|
||||||
|
thumbnailElement.Value.TryGetProperty("thumbnails", out var thumbnails) &&
|
||||||
|
thumbnails.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
return thumbnails.EnumerateArray()
|
||||||
|
.Select(static item => PlatformAdapterUtilities.FindFirstString(item, "url"))
|
||||||
|
.Where(static item => !string.IsNullOrWhiteSpace(item))
|
||||||
|
.LastOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
return PlatformAdapterUtilities.FindFirstString(thumbnailElement.Value, "url");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetNested(JsonElement element, out JsonElement value, params string[] path)
|
||||||
|
{
|
||||||
|
value = element;
|
||||||
|
foreach (var segment in path)
|
||||||
|
{
|
||||||
|
if (value.ValueKind != JsonValueKind.Object || !value.TryGetProperty(segment, out value))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool GetBoolean(JsonElement element, string propertyName)
|
||||||
|
{
|
||||||
|
if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(propertyName, out var property))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return property.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.True => true,
|
||||||
|
JsonValueKind.False => false,
|
||||||
|
JsonValueKind.String => bool.TryParse(property.GetString(), out var value) && value,
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,7 +28,10 @@ public sealed class EmailNotificationService : IEmailNotificationService
|
|||||||
_logger = logger;
|
_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);
|
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||||
if (!settings.EnableEmailNotification || !settings.NotifyOnLiveStarted)
|
if (!settings.EnableEmailNotification || !settings.NotifyOnLiveStarted)
|
||||||
@@ -43,7 +46,8 @@ public sealed class EmailNotificationService : IEmailNotificationService
|
|||||||
["title"] = liveRoom.Title,
|
["title"] = liveRoom.Title,
|
||||||
["anchor"] = liveRoom.AnchorName,
|
["anchor"] = liveRoom.AnchorName,
|
||||||
["sourceUrl"] = liveRoom.SourceUrl,
|
["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);
|
var subject = RenderSubject(settings.EmailLiveStartedSubjectTemplate, tokens);
|
||||||
@@ -58,7 +62,8 @@ public sealed class EmailNotificationService : IEmailNotificationService
|
|||||||
string? detail = null,
|
string? detail = null,
|
||||||
LiveRoom? liveRoom = null,
|
LiveRoom? liveRoom = null,
|
||||||
RecordTask? recordTask = null,
|
RecordTask? recordTask = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default,
|
||||||
|
string? eventScriptOutput = null)
|
||||||
{
|
{
|
||||||
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||||
if (!settings.EnableEmailNotification || !settings.NotifyOnException)
|
if (!settings.EnableEmailNotification || !settings.NotifyOnException)
|
||||||
@@ -75,7 +80,8 @@ public sealed class EmailNotificationService : IEmailNotificationService
|
|||||||
["roomId"] = liveRoom?.RoomId,
|
["roomId"] = liveRoom?.RoomId,
|
||||||
["recordTaskId"] = recordTask?.Id.ToString(),
|
["recordTaskId"] = recordTask?.Id.ToString(),
|
||||||
["taskStatus"] = recordTask?.Status.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);
|
var subject = RenderSubject(settings.EmailExceptionSubjectTemplate, tokens);
|
||||||
@@ -114,7 +120,8 @@ public sealed class EmailNotificationService : IEmailNotificationService
|
|||||||
["title"] = "Sample Live Title",
|
["title"] = "Sample Live Title",
|
||||||
["anchor"] = "Sample Anchor",
|
["anchor"] = "Sample Anchor",
|
||||||
["sourceUrl"] = "https://live.douyin.com/123456789",
|
["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?>
|
var sampleExceptionTokens = CreateTokenMap(new Dictionary<string, string?>
|
||||||
{
|
{
|
||||||
@@ -125,7 +132,8 @@ public sealed class EmailNotificationService : IEmailNotificationService
|
|||||||
["roomId"] = "123456789",
|
["roomId"] = "123456789",
|
||||||
["recordTaskId"] = Guid.NewGuid().ToString(),
|
["recordTaskId"] = Guid.NewGuid().ToString(),
|
||||||
["taskStatus"] = "Running",
|
["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 = $$"""
|
var body = $$"""
|
||||||
@@ -275,6 +283,24 @@ public sealed class EmailNotificationService : IEmailNotificationService
|
|||||||
return tokens;
|
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)
|
private static string RenderSubject(string template, IReadOnlyDictionary<string, string> tokens)
|
||||||
{
|
{
|
||||||
var rendered = RenderTemplate(template, tokens, htmlEncodeValues: false);
|
var rendered = RenderTemplate(template, tokens, htmlEncodeValues: false);
|
||||||
|
|||||||
@@ -30,12 +30,15 @@ public sealed class EventScriptService : IEventScriptService
|
|||||||
_logger = logger;
|
_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 settings = await _settingsService.GetAsync(cancellationToken);
|
||||||
var environment = BuildLiveRoomEnvironment(liveRoom, occurredAt);
|
var environment = BuildLiveRoomEnvironment(liveRoom, occurredAt);
|
||||||
environment["LIVE_RECORDER_EVENT"] = "live_started";
|
environment["LIVE_RECORDER_EVENT"] = "live_started";
|
||||||
await RunAsync(
|
return await RunAsync(
|
||||||
settings.EnableEventScripts && settings.EnableLiveStartedScript,
|
settings.EnableEventScripts && settings.EnableLiveStartedScript,
|
||||||
settings.LiveStartedScriptMode,
|
settings.LiveStartedScriptMode,
|
||||||
settings.LiveStartedScriptPath,
|
settings.LiveStartedScriptPath,
|
||||||
@@ -50,12 +53,15 @@ public sealed class EventScriptService : IEventScriptService
|
|||||||
cancellationToken);
|
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 settings = await _settingsService.GetAsync(cancellationToken);
|
||||||
var environment = BuildLiveRoomEnvironment(liveRoom, occurredAt);
|
var environment = BuildLiveRoomEnvironment(liveRoom, occurredAt);
|
||||||
environment["LIVE_RECORDER_EVENT"] = "live_ended";
|
environment["LIVE_RECORDER_EVENT"] = "live_ended";
|
||||||
await RunAsync(
|
return await RunAsync(
|
||||||
settings.EnableEventScripts && settings.EnableLiveEndedScript,
|
settings.EnableEventScripts && settings.EnableLiveEndedScript,
|
||||||
settings.LiveEndedScriptMode,
|
settings.LiveEndedScriptMode,
|
||||||
settings.LiveEndedScriptPath,
|
settings.LiveEndedScriptPath,
|
||||||
@@ -70,7 +76,7 @@ public sealed class EventScriptService : IEventScriptService
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RunSegmentCompletedAsync(
|
public async Task<EventScriptExecutionResultDto?> RunSegmentCompletedAsync(
|
||||||
LiveRoom? liveRoom,
|
LiveRoom? liveRoom,
|
||||||
RecordSession recordSession,
|
RecordSession recordSession,
|
||||||
RecordTask recordTask,
|
RecordTask recordTask,
|
||||||
@@ -93,7 +99,7 @@ public sealed class EventScriptService : IEventScriptService
|
|||||||
environment["LIVE_RECORDER_TASK_STATUS"] = recordTask.Status.ToString();
|
environment["LIVE_RECORDER_TASK_STATUS"] = recordTask.Status.ToString();
|
||||||
environment["LIVE_RECORDER_SESSION_STATUS"] = recordSession.Status.ToString();
|
environment["LIVE_RECORDER_SESSION_STATUS"] = recordSession.Status.ToString();
|
||||||
|
|
||||||
await RunAsync(
|
return await RunAsync(
|
||||||
forceRun || (settings.EnableEventScripts && settings.EnableSegmentCompletedScript),
|
forceRun || (settings.EnableEventScripts && settings.EnableSegmentCompletedScript),
|
||||||
settings.SegmentCompletedScriptMode,
|
settings.SegmentCompletedScriptMode,
|
||||||
settings.SegmentCompletedScriptPath,
|
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,
|
bool enabled,
|
||||||
string scriptMode,
|
string scriptMode,
|
||||||
string scriptPath,
|
string scriptPath,
|
||||||
@@ -172,10 +186,10 @@ public sealed class EventScriptService : IEventScriptService
|
|||||||
{
|
{
|
||||||
if (!enabled)
|
if (!enabled)
|
||||||
{
|
{
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ExecuteAsync(
|
var outcome = await ExecuteAsync(
|
||||||
scriptMode,
|
scriptMode,
|
||||||
scriptPath,
|
scriptPath,
|
||||||
scriptContent,
|
scriptContent,
|
||||||
@@ -187,6 +201,8 @@ public sealed class EventScriptService : IEventScriptService
|
|||||||
recordSessionId,
|
recordSessionId,
|
||||||
recordTaskId,
|
recordTaskId,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
|
return MapOutcome(outcome);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<ScriptExecutionOutcome> ExecuteAsync(
|
private async Task<ScriptExecutionOutcome> ExecuteAsync(
|
||||||
|
|||||||
@@ -1401,7 +1401,20 @@ public sealed partial class FfmpegService
|
|||||||
}
|
}
|
||||||
|
|
||||||
includeNonChatEvents = runtime.RecordingSettings.DanmakuIncludeNonChatEvents;
|
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(
|
runtime.DanmakuConnection = await adapter.ConnectAsync(
|
||||||
new DanmakuConnectionContext(
|
new DanmakuConnectionContext(
|
||||||
liveRoom.Id,
|
liveRoom.Id,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Security;
|
using System.Net.Security;
|
||||||
using System.Security.Authentication;
|
using System.Security.Authentication;
|
||||||
|
using LiveRecorder.Application.Common;
|
||||||
using LiveRecorder.Application.Abstractions.Settings;
|
using LiveRecorder.Application.Abstractions.Settings;
|
||||||
using LiveRecorder.Application.Models.Settings;
|
using LiveRecorder.Application.Models.Settings;
|
||||||
using LiveRecorder.Domain.Enums;
|
using LiveRecorder.Domain.Enums;
|
||||||
@@ -54,15 +55,14 @@ public sealed class PlatformHttpClientFactory
|
|||||||
|
|
||||||
private static IWebProxy? BuildProxy(LivePlatformType platform, SystemSettingsDto settings)
|
private static IWebProxy? BuildProxy(LivePlatformType platform, SystemSettingsDto settings)
|
||||||
{
|
{
|
||||||
var proxySettings = platform switch
|
if (!LivePlatformCatalog.TryGet(platform, out _))
|
||||||
{
|
{
|
||||||
LivePlatformType.Douyin => settings.DouyinProxy,
|
return null;
|
||||||
LivePlatformType.Bilibili => settings.BilibiliProxy,
|
}
|
||||||
LivePlatformType.Huya => settings.HuyaProxy,
|
|
||||||
_ => 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ public sealed class WebhookNotificationService : IWebhookNotificationService
|
|||||||
_logger = logger;
|
_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);
|
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||||
if (!settings.EnableWebhookNotification || !settings.NotifyWebhookOnLiveStarted)
|
if (!settings.EnableWebhookNotification || !settings.NotifyWebhookOnLiveStarted)
|
||||||
@@ -49,7 +52,8 @@ public sealed class WebhookNotificationService : IWebhookNotificationService
|
|||||||
source: "LiveRoomStatus",
|
source: "LiveRoomStatus",
|
||||||
liveRoom: liveRoom,
|
liveRoom: liveRoom,
|
||||||
recordTask: null,
|
recordTask: null,
|
||||||
report: null);
|
report: null,
|
||||||
|
eventScriptOutput: eventScriptOutput);
|
||||||
var variables = BuildTemplateVariables(payload, report: null);
|
var variables = BuildTemplateVariables(payload, report: null);
|
||||||
|
|
||||||
await SendInternalAsync(
|
await SendInternalAsync(
|
||||||
@@ -66,7 +70,8 @@ public sealed class WebhookNotificationService : IWebhookNotificationService
|
|||||||
string? detail = null,
|
string? detail = null,
|
||||||
LiveRoom? liveRoom = null,
|
LiveRoom? liveRoom = null,
|
||||||
RecordTask? recordTask = null,
|
RecordTask? recordTask = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default,
|
||||||
|
string? eventScriptOutput = null)
|
||||||
{
|
{
|
||||||
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||||
if (!settings.EnableWebhookNotification || !settings.NotifyWebhookOnException)
|
if (!settings.EnableWebhookNotification || !settings.NotifyWebhookOnException)
|
||||||
@@ -81,7 +86,8 @@ public sealed class WebhookNotificationService : IWebhookNotificationService
|
|||||||
source,
|
source,
|
||||||
liveRoom,
|
liveRoom,
|
||||||
recordTask,
|
recordTask,
|
||||||
report: null);
|
report: null,
|
||||||
|
eventScriptOutput: eventScriptOutput);
|
||||||
var variables = BuildTemplateVariables(payload, report: null);
|
var variables = BuildTemplateVariables(payload, report: null);
|
||||||
|
|
||||||
await SendInternalAsync(
|
await SendInternalAsync(
|
||||||
@@ -111,7 +117,8 @@ public sealed class WebhookNotificationService : IWebhookNotificationService
|
|||||||
"DailyReview",
|
"DailyReview",
|
||||||
liveRoom: null,
|
liveRoom: null,
|
||||||
recordTask: null,
|
recordTask: null,
|
||||||
report);
|
report: report,
|
||||||
|
eventScriptOutput: null);
|
||||||
var variables = BuildTemplateVariables(payload, report);
|
var variables = BuildTemplateVariables(payload, report);
|
||||||
|
|
||||||
await SendInternalAsync(
|
await SendInternalAsync(
|
||||||
@@ -160,7 +167,8 @@ public sealed class WebhookNotificationService : IWebhookNotificationService
|
|||||||
"SettingsTest",
|
"SettingsTest",
|
||||||
sampleLiveRoom,
|
sampleLiveRoom,
|
||||||
recordTask: null,
|
recordTask: null,
|
||||||
report: null);
|
report: null,
|
||||||
|
eventScriptOutput: "line one from script\nline two from script");
|
||||||
var variables = BuildTemplateVariables(payload, report: null);
|
var variables = BuildTemplateVariables(payload, report: null);
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -339,7 +347,8 @@ public sealed class WebhookNotificationService : IWebhookNotificationService
|
|||||||
string source,
|
string source,
|
||||||
LiveRoom? liveRoom,
|
LiveRoom? liveRoom,
|
||||||
RecordTask? recordTask,
|
RecordTask? recordTask,
|
||||||
DailyReviewReportDto? report)
|
DailyReviewReportDto? report,
|
||||||
|
string? eventScriptOutput)
|
||||||
{
|
{
|
||||||
var payload = new Dictionary<string, object?>
|
var payload = new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
@@ -349,6 +358,7 @@ public sealed class WebhookNotificationService : IWebhookNotificationService
|
|||||||
["summary"] = summary,
|
["summary"] = summary,
|
||||||
["detail"] = detail,
|
["detail"] = detail,
|
||||||
["source"] = source,
|
["source"] = source,
|
||||||
|
["eventScriptOutput"] = NormalizeNotificationText(eventScriptOutput),
|
||||||
["liveRoom"] = liveRoom is null ? null : new Dictionary<string, object?>
|
["liveRoom"] = liveRoom is null ? null : new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
["id"] = liveRoom.Id,
|
["id"] = liveRoom.Id,
|
||||||
@@ -403,6 +413,11 @@ public sealed class WebhookNotificationService : IWebhookNotificationService
|
|||||||
["source"] = payload["source"]
|
["source"] = payload["source"]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (payload.TryGetValue("eventScriptOutput", out var eventScriptOutput))
|
||||||
|
{
|
||||||
|
variables["eventScriptOutput"] = eventScriptOutput;
|
||||||
|
}
|
||||||
|
|
||||||
if (payload.TryGetValue("liveRoom", out var liveRoomPayload) &&
|
if (payload.TryGetValue("liveRoom", out var liveRoomPayload) &&
|
||||||
liveRoomPayload is IReadOnlyDictionary<string, object?> liveRoom)
|
liveRoomPayload is IReadOnlyDictionary<string, object?> liveRoom)
|
||||||
{
|
{
|
||||||
@@ -436,6 +451,24 @@ public sealed class WebhookNotificationService : IWebhookNotificationService
|
|||||||
return variables;
|
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)
|
private static string Truncate(string? value, int maxLength)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value))
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using LiveRecorder.Application.Abstractions.Settings;
|
using LiveRecorder.Application.Abstractions.Settings;
|
||||||
using LiveRecorder.Application.Abstractions.Notifications;
|
using LiveRecorder.Application.Abstractions.Notifications;
|
||||||
|
using LiveRecorder.Application.Common;
|
||||||
using LiveRecorder.Application.Models.Cleanup;
|
using LiveRecorder.Application.Models.Cleanup;
|
||||||
using LiveRecorder.Application.Abstractions.Scripting;
|
using LiveRecorder.Application.Abstractions.Scripting;
|
||||||
using LiveRecorder.Application.Models.Settings;
|
using LiveRecorder.Application.Models.Settings;
|
||||||
@@ -7,6 +8,7 @@ using LiveRecorder.Infrastructure.Services;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using LiveRecorder.Domain.Enums;
|
||||||
|
|
||||||
namespace LiveRecorder.WebApi.Controllers;
|
namespace LiveRecorder.WebApi.Controllers;
|
||||||
|
|
||||||
@@ -61,12 +63,13 @@ public sealed class SettingsController : ControllerBase
|
|||||||
|
|
||||||
[HttpPost("import")]
|
[HttpPost("import")]
|
||||||
public async Task<ActionResult<SystemSettingsDto>> Import(
|
public async Task<ActionResult<SystemSettingsDto>> Import(
|
||||||
[FromBody] SystemSettingsDto request,
|
[FromBody] JsonElement request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var payload = JsonSerializer.Serialize(request);
|
var payload = request.GetRawText();
|
||||||
var updateRequest = JsonSerializer.Deserialize<UpdateSystemSettingsRequest>(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web))
|
var updateRequest = JsonSerializer.Deserialize<UpdateSystemSettingsRequest>(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web))
|
||||||
?? throw new InvalidOperationException("Unable to deserialize imported settings.");
|
?? throw new InvalidOperationException("Unable to deserialize imported settings.");
|
||||||
|
ApplyLegacyPlatformSettings(request, updateRequest);
|
||||||
return Ok(await _systemSettingsService.UpdateAsync(updateRequest, cancellationToken));
|
return Ok(await _systemSettingsService.UpdateAsync(updateRequest, cancellationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,4 +101,59 @@ public sealed class SettingsController : ControllerBase
|
|||||||
?? throw new InvalidOperationException("Retention cleanup is disabled.");
|
?? throw new InvalidOperationException("Retention cleanup is disabled.");
|
||||||
return Ok(operation);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,10 +15,19 @@ using LiveRecorder.Infrastructure.Persistence;
|
|||||||
using LiveRecorder.Infrastructure.Persistence.Repositories;
|
using LiveRecorder.Infrastructure.Persistence.Repositories;
|
||||||
using LiveRecorder.Infrastructure.Platforms.Bilibili;
|
using LiveRecorder.Infrastructure.Platforms.Bilibili;
|
||||||
using LiveRecorder.Infrastructure.Platforms.Bilibili.Danmaku;
|
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;
|
||||||
using LiveRecorder.Infrastructure.Platforms.Douyin.Danmaku;
|
using LiveRecorder.Infrastructure.Platforms.Douyin.Danmaku;
|
||||||
using LiveRecorder.Infrastructure.Platforms.Douyin.Signing;
|
using LiveRecorder.Infrastructure.Platforms.Douyin.Signing;
|
||||||
using LiveRecorder.Infrastructure.Platforms.Huya;
|
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.Infrastructure.Services;
|
||||||
using LiveRecorder.WebApi.Middleware;
|
using LiveRecorder.WebApi.Middleware;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -172,6 +181,7 @@ builder.Services.AddScoped<CleanupOperationCoordinator>();
|
|||||||
builder.Services.AddScoped<RetentionCleanupService>();
|
builder.Services.AddScoped<RetentionCleanupService>();
|
||||||
builder.Services.AddScoped<StoppedOrphanRecordSessionCleanupService>();
|
builder.Services.AddScoped<StoppedOrphanRecordSessionCleanupService>();
|
||||||
builder.Services.AddScoped<PlatformHttpClientFactory>();
|
builder.Services.AddScoped<PlatformHttpClientFactory>();
|
||||||
|
builder.Services.AddScoped<PlatformHttpRequestService>();
|
||||||
builder.Services.AddScoped<RecordUploadService>();
|
builder.Services.AddScoped<RecordUploadService>();
|
||||||
builder.Services.AddScoped<DatabaseInitializer>();
|
builder.Services.AddScoped<DatabaseInitializer>();
|
||||||
builder.Services.AddScoped<SqliteToPostgresMigrationService>();
|
builder.Services.AddScoped<SqliteToPostgresMigrationService>();
|
||||||
@@ -184,6 +194,14 @@ builder.Services.AddSingleton<DouyinLiveWsSignatureSigner>();
|
|||||||
builder.Services.AddScoped<ILivePlatformAdapter, DouyinLivePlatformAdapter>();
|
builder.Services.AddScoped<ILivePlatformAdapter, DouyinLivePlatformAdapter>();
|
||||||
builder.Services.AddScoped<ILivePlatformAdapter, BilibiliLivePlatformAdapter>();
|
builder.Services.AddScoped<ILivePlatformAdapter, BilibiliLivePlatformAdapter>();
|
||||||
builder.Services.AddScoped<ILivePlatformAdapter, HuyaLivePlatformAdapter>();
|
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<ILivePlatformAdapterFactory, LivePlatformAdapterFactory>();
|
||||||
builder.Services.AddScoped<ILiveDanmakuAdapter, DouyinDanmakuAdapter>();
|
builder.Services.AddScoped<ILiveDanmakuAdapter, DouyinDanmakuAdapter>();
|
||||||
builder.Services.AddScoped<ILiveDanmakuAdapter, BilibiliDanmakuAdapter>();
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
global using Xunit;
|
||||||
Reference in New Issue
Block a user