From 1c892259a9eb092c7f53f8b9cee15039d3137be4 Mon Sep 17 00:00:00 2001 From: nanxun Date: Thu, 16 Apr 2026 15:19:48 +0800 Subject: [PATCH] 1111 --- .codex_tmp_danmaku/index.js | 7 + .codex_tmp_danmaku/probe.js | 80 + .codex_tmp_danmaku/xbogus.js | 563 +++++ .gitignore | 11 + ConsoleApp1/ConsoleApp1.csproj | 14 + ConsoleApp1/Program.cs | 40 + LiveRecorder.sln | 99 + README.md | 193 ++ dycast_repo | 1 + frontend/index.html | 12 + frontend/package-lock.json | 2046 +++++++++++++++++ frontend/package.json | 25 + frontend/src/App.vue | 3 + frontend/src/api/client.ts | 33 + frontend/src/components/layout/MainLayout.vue | 237 ++ frontend/src/env.d.ts | 6 + frontend/src/main.ts | 9 + frontend/src/router/index.ts | 74 + frontend/src/stores/auth.ts | 60 + frontend/src/styles/main.css | 552 +++++ frontend/src/types.ts | 202 ++ frontend/src/views/LiveRoomsView.vue | 570 +++++ frontend/src/views/LoginView.vue | 192 ++ frontend/src/views/LogsView.vue | 154 ++ frontend/src/views/RecordTaskDetailView.vue | 340 +++ frontend/src/views/RecordTasksView.vue | 738 ++++++ frontend/src/views/SettingsView.vue | 657 ++++++ frontend/tsconfig.json | 21 + frontend/vite.config.ts | 45 + .../Abstractions/Auth/IAuthService.cs | 12 + .../Abstractions/Logging/ISystemLogService.cs | 24 + .../IEmailNotificationService.cs | 19 + .../Persistence/PersistenceContracts.cs | 121 + .../Platforms/DanmakuContracts.cs | 42 + .../Platforms/PlatformContracts.cs | 60 + .../Abstractions/Recording/IFfmpegService.cs | 32 + .../Recording/IRecordMediaService.cs | 17 + .../Settings/ISystemSettingsService.cs | 10 + .../Common/PasswordHasher.cs | 39 + .../LiveRecorder.Application.csproj | 14 + .../Models/Auth/AuthModels.cs | 30 + .../Models/LiveRooms/LiveRoomModels.cs | 46 + .../Models/Logs/SystemLogDto.cs | 24 + .../Models/RecordTasks/RecordSessionModels.cs | 59 + .../Models/RecordTasks/RecordTaskModels.cs | 109 + .../Models/Settings/SettingsModels.cs | 267 +++ .../Services/AuthService.cs | 115 + .../Services/LiveDanmakuAdapterFactory.cs | 24 + .../Services/LivePlatformAdapterFactory.cs | 37 + .../Services/LiveRoomService.cs | 175 ++ .../Services/LiveRoomStatusService.cs | 39 + .../Services/RecordModelMapper.cs | 106 + .../Services/RecordService.cs | 670 ++++++ .../Services/RecordSessionService.cs | 285 +++ ...toppedOrphanRecordSessionCleanupService.cs | 184 ++ .../Services/SystemLogService.cs | 65 + .../Services/SystemSettingsService.cs | 226 ++ .../Entities/AppSetting.cs | 30 + src/LiveRecorder.Domain/Entities/LiveRoom.cs | 103 + .../Entities/RecordResult.cs | 73 + .../Entities/RecordSession.cs | 140 ++ .../Entities/RecordTask.cs | 140 ++ .../Entities/SystemLogEntry.cs | 49 + .../Entities/UserAccount.cs | 35 + .../Entities/UserSession.cs | 38 + .../Enums/LivePlatformType.cs | 11 + .../Enums/LiveRoomAvailabilityStatus.cs | 8 + .../Enums/RecordOutputFormat.cs | 7 + .../Enums/RecordSaveMode.cs | 7 + .../Enums/RecordSessionStatus.cs | 12 + .../Enums/RecordTaskStatus.cs | 12 + .../Enums/RecordingTemplateType.cs | 8 + .../Enums/SystemLogLevel.cs | 9 + .../LiveRecorder.Domain.csproj | 9 + .../LiveRecorder.Infrastructure.csproj | 29 + .../Persistence/DatabaseInitializer.cs | 312 +++ .../Persistence/LiveRecorderDbContext.cs | 145 ++ .../Persistence/Repositories/Repositories.cs | 325 +++ .../Bilibili/BilibiliLivePlatformAdapter.cs | 25 + .../Douyin/Danmaku/DouyinDanmakuAdapter.cs | 147 ++ .../Douyin/Danmaku/DouyinDanmakuProtocol.cs | 480 ++++ .../Platforms/Douyin/DouyinHttpClient.cs | 681 ++++++ .../Douyin/DouyinLivePlatformAdapter.cs | 424 ++++ .../Douyin/Signing/DouyinXBogusSigner.cs | 65 + .../Platforms/Douyin/Signing/abogus.js | 551 +++++ .../Platforms/Douyin/Signing/index.js | 7 + .../Platforms/Douyin/Signing/sign-xbogus.js | 8 + .../Platforms/Douyin/Signing/xbogus.js | 563 +++++ .../Platforms/Huya/HuyaLivePlatformAdapter.cs | 25 + .../Services/EmailNotificationService.cs | 265 +++ .../Services/FfmpegService.Runtime.cs | 767 ++++++ .../Services/FfmpegService.Utils.cs | 399 ++++ .../Services/FfmpegService.cs | 380 +++ .../LiveRoomPollingBackgroundService.cs | 243 ++ .../Services/RecordMediaService.cs | 102 + .../Services/SessionDanmakuXmlRecorder.cs | 236 ++ .../Controllers/AuthController.cs | 36 + .../Controllers/LiveRoomsController.cs | 50 + .../Controllers/LogsController.cs | 26 + .../Controllers/MediaController.cs | 32 + .../Controllers/RecordSessionsController.cs | 38 + .../Controllers/RecordTasksController.cs | 64 + .../Controllers/SettingsController.cs | 41 + .../LiveRecorder.WebApi.csproj | 18 + .../LiveRecorder.WebApi.http | 6 + .../ApiTokenAuthenticationMiddleware.cs | 49 + .../Middleware/ExceptionHandlingMiddleware.cs | 38 + src/LiveRecorder.WebApi/Program.cs | 181 ++ .../Properties/launchSettings.json | 41 + .../appsettings.Development.json | 8 + src/LiveRecorder.WebApi/appsettings.json | 18 + src/LiveRecorder.WebApi/live-recorder.db | Bin 0 -> 352256 bytes src/LiveRecorder.WebApi/live-recorder.db-shm | Bin 0 -> 32768 bytes src/LiveRecorder.WebApi/live-recorder.db-wal | Bin 0 -> 1005312 bytes ...五人格赛事_COA9小组赛Day6_24482384478_00001.xml | 3 + .../084002_第五人格赛事_COA9小组赛Day6_24482384478_00001.xml | 3 + .../084717_第五人格赛事_COA9小组赛Day6_24482384478_00001.xml | 3 + .../084924_第五人格赛事_COA9小组赛Day6_24482384478_00001.xml | 3 + .../132847_Wd_Wd正在直播__00001.xml | 3 + .../100501_第五人格赛事_COA9小组赛Day6__00001.xml | 3 + .../061914_第五人格赛事_COA9小组赛Day7__00001.xml | 3 + .../071935_第五人格赛事_COA9小组赛Day7__00001.xml | 3 + .../092236_第五人格赛事_COA9小组赛Day6__00001.xml | 3 + .../092339_第五人格赛事_COA9小组赛Day6__00001.xml | 3 + .../114940_第五人格赛事_COA9小组赛Day6__00001.xml | 3 + .../115804_第五人格赛事_COA9小组赛Day6__00001.xml | 3 + ...浆(S邦邦)_百星S邦邦 娱乐主播!一把__00001.xml | 3 + 127 files changed, 17390 insertions(+) create mode 100644 .codex_tmp_danmaku/index.js create mode 100644 .codex_tmp_danmaku/probe.js create mode 100644 .codex_tmp_danmaku/xbogus.js create mode 100644 .gitignore create mode 100644 ConsoleApp1/ConsoleApp1.csproj create mode 100644 ConsoleApp1/Program.cs create mode 100644 LiveRecorder.sln create mode 100644 README.md create mode 160000 dycast_repo create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/components/layout/MainLayout.vue create mode 100644 frontend/src/env.d.ts create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/router/index.ts create mode 100644 frontend/src/stores/auth.ts create mode 100644 frontend/src/styles/main.css create mode 100644 frontend/src/types.ts create mode 100644 frontend/src/views/LiveRoomsView.vue create mode 100644 frontend/src/views/LoginView.vue create mode 100644 frontend/src/views/LogsView.vue create mode 100644 frontend/src/views/RecordTaskDetailView.vue create mode 100644 frontend/src/views/RecordTasksView.vue create mode 100644 frontend/src/views/SettingsView.vue create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 src/LiveRecorder.Application/Abstractions/Auth/IAuthService.cs create mode 100644 src/LiveRecorder.Application/Abstractions/Logging/ISystemLogService.cs create mode 100644 src/LiveRecorder.Application/Abstractions/Notifications/IEmailNotificationService.cs create mode 100644 src/LiveRecorder.Application/Abstractions/Persistence/PersistenceContracts.cs create mode 100644 src/LiveRecorder.Application/Abstractions/Platforms/DanmakuContracts.cs create mode 100644 src/LiveRecorder.Application/Abstractions/Platforms/PlatformContracts.cs create mode 100644 src/LiveRecorder.Application/Abstractions/Recording/IFfmpegService.cs create mode 100644 src/LiveRecorder.Application/Abstractions/Recording/IRecordMediaService.cs create mode 100644 src/LiveRecorder.Application/Abstractions/Settings/ISystemSettingsService.cs create mode 100644 src/LiveRecorder.Application/Common/PasswordHasher.cs create mode 100644 src/LiveRecorder.Application/LiveRecorder.Application.csproj create mode 100644 src/LiveRecorder.Application/Models/Auth/AuthModels.cs create mode 100644 src/LiveRecorder.Application/Models/LiveRooms/LiveRoomModels.cs create mode 100644 src/LiveRecorder.Application/Models/Logs/SystemLogDto.cs create mode 100644 src/LiveRecorder.Application/Models/RecordTasks/RecordSessionModels.cs create mode 100644 src/LiveRecorder.Application/Models/RecordTasks/RecordTaskModels.cs create mode 100644 src/LiveRecorder.Application/Models/Settings/SettingsModels.cs create mode 100644 src/LiveRecorder.Application/Services/AuthService.cs create mode 100644 src/LiveRecorder.Application/Services/LiveDanmakuAdapterFactory.cs create mode 100644 src/LiveRecorder.Application/Services/LivePlatformAdapterFactory.cs create mode 100644 src/LiveRecorder.Application/Services/LiveRoomService.cs create mode 100644 src/LiveRecorder.Application/Services/LiveRoomStatusService.cs create mode 100644 src/LiveRecorder.Application/Services/RecordModelMapper.cs create mode 100644 src/LiveRecorder.Application/Services/RecordService.cs create mode 100644 src/LiveRecorder.Application/Services/RecordSessionService.cs create mode 100644 src/LiveRecorder.Application/Services/StoppedOrphanRecordSessionCleanupService.cs create mode 100644 src/LiveRecorder.Application/Services/SystemLogService.cs create mode 100644 src/LiveRecorder.Application/Services/SystemSettingsService.cs create mode 100644 src/LiveRecorder.Domain/Entities/AppSetting.cs create mode 100644 src/LiveRecorder.Domain/Entities/LiveRoom.cs create mode 100644 src/LiveRecorder.Domain/Entities/RecordResult.cs create mode 100644 src/LiveRecorder.Domain/Entities/RecordSession.cs create mode 100644 src/LiveRecorder.Domain/Entities/RecordTask.cs create mode 100644 src/LiveRecorder.Domain/Entities/SystemLogEntry.cs create mode 100644 src/LiveRecorder.Domain/Entities/UserAccount.cs create mode 100644 src/LiveRecorder.Domain/Entities/UserSession.cs create mode 100644 src/LiveRecorder.Domain/Enums/LivePlatformType.cs create mode 100644 src/LiveRecorder.Domain/Enums/LiveRoomAvailabilityStatus.cs create mode 100644 src/LiveRecorder.Domain/Enums/RecordOutputFormat.cs create mode 100644 src/LiveRecorder.Domain/Enums/RecordSaveMode.cs create mode 100644 src/LiveRecorder.Domain/Enums/RecordSessionStatus.cs create mode 100644 src/LiveRecorder.Domain/Enums/RecordTaskStatus.cs create mode 100644 src/LiveRecorder.Domain/Enums/RecordingTemplateType.cs create mode 100644 src/LiveRecorder.Domain/Enums/SystemLogLevel.cs create mode 100644 src/LiveRecorder.Domain/LiveRecorder.Domain.csproj create mode 100644 src/LiveRecorder.Infrastructure/LiveRecorder.Infrastructure.csproj create mode 100644 src/LiveRecorder.Infrastructure/Persistence/DatabaseInitializer.cs create mode 100644 src/LiveRecorder.Infrastructure/Persistence/LiveRecorderDbContext.cs create mode 100644 src/LiveRecorder.Infrastructure/Persistence/Repositories/Repositories.cs create mode 100644 src/LiveRecorder.Infrastructure/Platforms/Bilibili/BilibiliLivePlatformAdapter.cs create mode 100644 src/LiveRecorder.Infrastructure/Platforms/Douyin/Danmaku/DouyinDanmakuAdapter.cs create mode 100644 src/LiveRecorder.Infrastructure/Platforms/Douyin/Danmaku/DouyinDanmakuProtocol.cs create mode 100644 src/LiveRecorder.Infrastructure/Platforms/Douyin/DouyinHttpClient.cs create mode 100644 src/LiveRecorder.Infrastructure/Platforms/Douyin/DouyinLivePlatformAdapter.cs create mode 100644 src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/DouyinXBogusSigner.cs create mode 100644 src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/abogus.js create mode 100644 src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/index.js create mode 100644 src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/sign-xbogus.js create mode 100644 src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/xbogus.js create mode 100644 src/LiveRecorder.Infrastructure/Platforms/Huya/HuyaLivePlatformAdapter.cs create mode 100644 src/LiveRecorder.Infrastructure/Services/EmailNotificationService.cs create mode 100644 src/LiveRecorder.Infrastructure/Services/FfmpegService.Runtime.cs create mode 100644 src/LiveRecorder.Infrastructure/Services/FfmpegService.Utils.cs create mode 100644 src/LiveRecorder.Infrastructure/Services/FfmpegService.cs create mode 100644 src/LiveRecorder.Infrastructure/Services/LiveRoomPollingBackgroundService.cs create mode 100644 src/LiveRecorder.Infrastructure/Services/RecordMediaService.cs create mode 100644 src/LiveRecorder.Infrastructure/Services/SessionDanmakuXmlRecorder.cs create mode 100644 src/LiveRecorder.WebApi/Controllers/AuthController.cs create mode 100644 src/LiveRecorder.WebApi/Controllers/LiveRoomsController.cs create mode 100644 src/LiveRecorder.WebApi/Controllers/LogsController.cs create mode 100644 src/LiveRecorder.WebApi/Controllers/MediaController.cs create mode 100644 src/LiveRecorder.WebApi/Controllers/RecordSessionsController.cs create mode 100644 src/LiveRecorder.WebApi/Controllers/RecordTasksController.cs create mode 100644 src/LiveRecorder.WebApi/Controllers/SettingsController.cs create mode 100644 src/LiveRecorder.WebApi/LiveRecorder.WebApi.csproj create mode 100644 src/LiveRecorder.WebApi/LiveRecorder.WebApi.http create mode 100644 src/LiveRecorder.WebApi/Middleware/ApiTokenAuthenticationMiddleware.cs create mode 100644 src/LiveRecorder.WebApi/Middleware/ExceptionHandlingMiddleware.cs create mode 100644 src/LiveRecorder.WebApi/Program.cs create mode 100644 src/LiveRecorder.WebApi/Properties/launchSettings.json create mode 100644 src/LiveRecorder.WebApi/appsettings.Development.json create mode 100644 src/LiveRecorder.WebApi/appsettings.json create mode 100644 src/LiveRecorder.WebApi/live-recorder.db create mode 100644 src/LiveRecorder.WebApi/live-recorder.db-shm create mode 100644 src/LiveRecorder.WebApi/live-recorder.db-wal create mode 100644 src/LiveRecorder.WebApi/records/Douyin/2026-21-15/第五人格赛事/092132_第五人格赛事_COA9小组赛Day6_24482384478_00001.xml create mode 100644 src/LiveRecorder.WebApi/records/Douyin/2026-40-15/第五人格赛事/084002_第五人格赛事_COA9小组赛Day6_24482384478/084002_第五人格赛事_COA9小组赛Day6_24482384478_00001.xml create mode 100644 src/LiveRecorder.WebApi/records/Douyin/2026-47-15/第五人格赛事/084717_第五人格赛事_COA9小组赛Day6_24482384478/084717_第五人格赛事_COA9小组赛Day6_24482384478_00001.xml create mode 100644 src/LiveRecorder.WebApi/records/Douyin/2026-49-15/第五人格赛事/084924_第五人格赛事_COA9小组赛Day6_24482384478/084924_第五人格赛事_COA9小组赛Day6_24482384478_00001.xml create mode 100644 src/LiveRecorder.WebApi/records/Douyin/Wd/2026-28-15/132847_Wd_Wd正在直播__00001.xml create mode 100644 src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-05-15/100501_第五人格赛事_COA9小组赛Day6__00001.xml create mode 100644 src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-19-16/061914_第五人格赛事_COA9小组赛Day7__00001.xml create mode 100644 src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-19-16/071935_第五人格赛事_COA9小组赛Day7__00001.xml create mode 100644 src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-22-15/092236_第五人格赛事_COA9小组赛Day6__00001.xml create mode 100644 src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-23-15/092339_第五人格赛事_COA9小组赛Day6__00001.xml create mode 100644 src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-49-15/114940_第五人格赛事_COA9小组赛Day6__00001.xml create mode 100644 src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-58-15/115804_第五人格赛事_COA9小组赛Day6__00001.xml create mode 100644 src/LiveRecorder.WebApi/records/Douyin/豆浆(S邦邦)/2026-00-16/030005_豆浆(S邦邦)_百星S邦邦 娱乐主播!一把__00001.xml diff --git a/.codex_tmp_danmaku/index.js b/.codex_tmp_danmaku/index.js new file mode 100644 index 0000000..d0769c3 --- /dev/null +++ b/.codex_tmp_danmaku/index.js @@ -0,0 +1,7 @@ +// index.js +const sign = require('./xbogus'); + +module.exports = function (url, userAgent) { + return sign(url.split("?")[1], userAgent); +}; + diff --git a/.codex_tmp_danmaku/probe.js b/.codex_tmp_danmaku/probe.js new file mode 100644 index 0000000..96fae88 --- /dev/null +++ b/.codex_tmp_danmaku/probe.js @@ -0,0 +1,80 @@ +const fs = require('fs'); +const path = require('path'); +const sqlite3 = require('node:sqlite'); +const https = require('https'); +const sign = require(path.join(process.argv[2], 'index.js')); +const db = new sqlite3.DatabaseSync(process.argv[3]); +const rows = db.prepare("select Key, Value from AppSettings where Key in ('douyin.cookie','douyin.user_agent')").all(); +const settings = Object.fromEntries(rows.map(r => [r.Key, r.Value])); +const webRid = '24482384478'; +function request(url, headers) { + return new Promise((resolve, reject) => { + https.get(url, { headers }, res => { + const chunks = []; + res.on('data', chunk => chunks.push(chunk)); + res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + }).on('error', reject); + }); +} +(async () => { + const headers = { + 'user-agent': settings['douyin.user_agent'] || 'Mozilla/5.0', + 'referer': `https://live.douyin.com/${webRid}`, + 'origin': 'https://live.douyin.com', + 'cookie': settings['douyin.cookie'] || '' + }; + const html = await request(`https://live.douyin.com/${webRid}`, headers); + const match = html.match(/self\.__pace_f\.push\(\[1,"[a-z]?:\[\\"\$\\",\\"\$L\d+\\",null,([\s\S]+?state[\s\S]+?)\]\\n"\]\)<\/script>/); + const json = match ? match[1].replace(/\\{1,7}"/g, '"') : ''; + const roomId = (json.match(/\"roomId\":\"(\d+)\"/) || [])[1]; + const uniqueId = '7300000000000000000'; + const msToken = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuv'; + const params = new URLSearchParams({ + aid: '6383', + app_name: 'douyin_web', + browser_language: 'zh-CN', + browser_name: 'Mozilla', + browser_online: 'true', + browser_platform: 'Win32', + browser_version: settings['douyin.user_agent'] || 'Mozilla/5.0', + cookie_enabled: 'true', + cursor: '', + device_id: '', + device_platform: 'web', + did_rule: '3', + endpoint: 'live_pc', + fetch_rule: '1', + identity: 'audience', + insert_task_id: '', + internal_ext: '', + last_rtt: '0', + live_id: '1', + live_reason: '', + need_persist_msg_count: '15', + resp_content_type: 'protobuf', + screen_height: '1080', + screen_width: '1920', + support_wrds: '1', + tz_name: 'Asia/Shanghai', + version_code: '180800', + room_id: roomId, + user_unique_id: uniqueId, + live_pc: roomId, + msToken + }); + const rawQuery = params.toString(); + const xb = sign(`https://live.douyin.com/webcast/im/fetch/?${rawQuery}`, headers['user-agent']); + params.set('a_bogus', xb); + const url = `https://live.douyin.com/webcast/im/fetch/?${params.toString()}`; + const body = await new Promise((resolve, reject) => { + https.get(url, { headers: { ...headers, accept: 'application/protobuf, application/octet-stream, */*' } }, res => { + const chunks = []; + res.on('data', chunk => chunks.push(chunk)); + res.on('end', () => resolve(Buffer.concat(chunks))); + }).on('error', reject); + }); + console.log('ROOM_ID', roomId); + console.log('A_BOGUS_LEN', xb.length); + console.log('RESP_LEN', body.length); + console.log('RESP_HEAD', Array.from(body.subarray(0, 48))); +})(); diff --git a/.codex_tmp_danmaku/xbogus.js b/.codex_tmp_danmaku/xbogus.js new file mode 100644 index 0000000..044cab5 --- /dev/null +++ b/.codex_tmp_danmaku/xbogus.js @@ -0,0 +1,563 @@ +var window = null; + +function _0x5cd844(e) { + var b = { + exports: {} + }; + return e(b, b.exports), b.exports +} +jsvmp = function(e, b, a) { + function f(e, b, a) { + return (f = function() { + if ("undefined" == typeof Reflect || !Reflect.construct || Reflect.construct.sham) return !1; + if ("function" == typeof Proxy) return !0; + try { + return Date.prototype.toString.call(Reflect.construct(Date, [], function() {})), !0 + } catch (e) { + return !1 + } + }() ? Reflect.construct : function(e, b, a) { + var f = [null]; + f.push.apply(f, b); + var c = new(Function.bind.apply(e, f)); + return a && function(e, b) { + (Object.setPrototypeOf || function(e, b) { + return e.__proto__ = b, e + })(e, b) + }(c, a.prototype), c + }).apply(null, arguments) + } + + function c(e) { + return function(e) { + if (Array.isArray(e)) { + for (var b = 0, a = new Array(e.length); b < e.length; b++) a[b] = e[b]; + return a + } + }(e) || function(e) { + if (Symbol.iterator in Object(e) || "[object Arguments]" === Object.prototype.toString.call(e)) return Array.from(e) + }(e) || function() { + throw new TypeError("Invalid attempt to spread non-iterable instance") + }() + } + for (var r = [], t = 0, d = [], i = 0, n = function(e, b) { + var a = e[b++], + f = e[b], + c = parseInt("" + a + f, 16); + if (c >> 7 == 0) return [1, c]; + if (c >> 6 == 2) { + var r = parseInt("" + e[++b] + e[++b], 16); + return c &= 63, [2, r = (c <<= 8) + r] + } + if (c >> 6 == 3) { + var t = parseInt("" + e[++b] + e[++b], 16), + d = parseInt("" + e[++b] + e[++b], 16); + return c &= 63, [3, d = (c <<= 16) + (t <<= 8) + d] + } + }, s = function(e, b) { + var a = parseInt("" + e[b] + e[b + 1], 16); + return a > 127 ? -256 + a : a + }, o = function(e, b) { + var a = parseInt("" + e[b] + e[b + 1] + e[b + 2] + e[b + 3], 16); + return a > 32767 ? -65536 + a : a + }, l = function(e, b) { + var a = parseInt("" + e[b] + e[b + 1] + e[b + 2] + e[b + 3] + e[b + 4] + e[b + 5] + e[b + 6] + e[b + 7], 16); + return a > 2147483647 ? 0 + a : a + }, _ = function(e, b) { + return parseInt("" + e[b] + e[b + 1], 16) + }, x = function(e, b) { + return parseInt("" + e[b] + e[b + 1] + e[b + 2] + e[b + 3], 16) + }, u = u || this || window, h = (e.length, 0), p = "", y = h; y < h + 16; y++) { + var v = "" + e[y++] + e[y]; + v = parseInt(v, 16), p += String.fromCharCode(v) + } + if ("HNOJ@?RC" != p) throw new Error("error magic number " + p); + parseInt("" + e[h += 16] + e[h + 1], 16), h += 8, t = 0; + for (var g = 0; g < 4; g++) { + var w = h + 2 * g, + A = parseInt("" + e[w++] + e[w], 16); + t += (3 & A) << 2 * g + } + h += 16; + var C = parseInt("" + e[h += 8] + e[h + 1] + e[h + 2] + e[h + 3] + e[h + 4] + e[h + 5] + e[h + 6] + e[h + 7], 16), + m = C, + S = h += 8, + z = x(e, h += C); + z[1], h += 4, r = { + p: [], + q: [] + }; + for (var B = 0; B < z; B++) { + for (var R = n(e, h), q = h += 2 * R[0], I = r.p.length, k = 0; k < R[1]; k++) { + var j = n(e, q); + r.p.push(j[1]), q += 2 * j[0] + } + h = q, r.q.push([I, r.p.length]) + } + var O = { + 5: 1, + 6: 1, + 70: 1, + 22: 1, + 23: 1, + 37: 1, + 73: 1 + }, + U = { + 72: 1 + }, + D = { + 74: 1 + }, + N = { + 11: 1, + 12: 1, + 24: 1, + 26: 1, + 27: 1, + 31: 1 + }, + J = { + 10: 1 + }, + L = { + 2: 1, + 29: 1, + 30: 1, + 20: 1 + }, + T = [], + E = []; + + function M(e, b, a) { + for (var f = b; f < b + a;) { + var c = _(e, f); + T[f] = c, f += 2, U[c] ? (E[f] = s(e, f), f += 2) : O[c] ? (E[f] = o(e, f), f += 4) : D[c] ? (E[f] = l(e, f), f += 8) : N[c] ? (E[f] = _(e, f), f += 2) : J[c] ? (E[f] = x(e, f), f += 4) : L[c] && (E[f] = x(e, f), f += 4) + } + } + return F(e, S, m / 2, [], b, a); + + function P(e, b, a, n, h, p, y, v) { + null == p && (p = this); + var g, w, A, C, m = [], + S = 0; + y && (w = y); + var z, B, R = b, + q = R + 2 * a; + if (!v) + for (; R < q;) { + var I = parseInt("" + e[R] + e[R + 1], 16); + R += 2; + var j = 3 & (z = 13 * I % 241); + if (z >>= 2, j < 1) + if (j = 3 & z, z >>= 2, j < 1) { + if ((j = z) < 1) return [1, m[S--]]; + j < 5 ? (w = m[S--], m[S] = m[S] * w) : j < 7 ? (w = m[S--], m[S] = m[S] != w) : j < 14 ? (A = m[S--], C = m[S--], (j = m[S--]).x === P ? j.y >= 1 ? m[++S] = F(e, j.c, j.l, A, j.z, C, null, 1) : (m[++S] = F(e, j.c, j.l, A, j.z, C, null, 0), j.y++) : m[++S] = j.apply(C, A)) : j < 16 && (B = o(e, R), (g = function b() { + var a = arguments; + return b.y > 0 || b.y++, F(e, b.c, b.l, a, b.z, this, null, 0) + }).c = R + 4, g.l = B - 2, g.x = P, g.y = 0, g.z = h, m[S] = g, R += 2 * B - 2) + } else if (j < 2)(j = z) > 8 ? (w = m[S--], m[S] = typeof w) : j > 4 ? m[S -= 1] = m[S][m[S + 1]] : j > 2 && (A = m[S--], (j = m[S]).x === P ? j.y >= 1 ? m[S] = F(e, j.c, j.l, [A], j.z, C, null, 1) : (m[S] = F(e, j.c, j.l, [A], j.z, C, null, 0), j.y++) : m[S] = j(A)); + else if (j < 3) { + if ((j = z) < 9) { + for (w = m[S--], B = x(e, R), j = "", k = r.q[B][0]; k < r.q[B][1]; k++) j += String.fromCharCode(t ^ r.p[k]); + R += 4, m[S--][j] = w + } else if (j < 13) throw m[S--] + } else(j = z) < 1 ? m[++S] = null : j < 3 ? (w = m[S--], m[S] = m[S] >= w) : j < 12 && (m[++S] = void 0); + else if (j < 2) + if (j = 3 & z, z >>= 2, j < 1) + if ((j = z) < 5) { + B = o(e, R); + try { + if (d[i][2] = 1, 1 == (w = P(e, R + 4, B - 3, [], h, p, null, 0))[0]) return w + } catch (b) { + if (d[i] && d[i][1] && 1 == (w = P(e, d[i][1][0], d[i][1][1], [], h, p, b, 0))[0]) return w + } finally { + if (d[i] && d[i][0] && 1 == (w = P(e, d[i][0][0], d[i][0][1], [], h, p, null, 0))[0]) return w; + d[i] = 0, i-- + } + R += 2 * B - 2 + } else j < 7 ? (B = _(e, R), R += 2, m[S -= B] = 0 === B ? new m[S] : f(m[S], c(m.slice(S + 1, S + B + 1)))) : j < 9 && (w = m[S--], m[S] = m[S] & w); + else if (j < 2) + if ((j = z) > 12) m[++S] = s(e, R), R += 2; + else if (j > 10) w = m[S--], m[S] = m[S] << w; + else if (j > 8) { + for (B = x(e, R), j = "", k = r.q[B][0]; k < r.q[B][1]; k++) j += String.fromCharCode(t ^ r.p[k]); + R += 4, m[S] = m[S][j] + } else j > 6 && (A = m[S--], w = delete m[S--][A]); + else if (j < 3)(j = z) < 2 ? m[++S] = w : j < 11 ? (w = m[S -= 2][m[S + 1]] = m[S + 2], S--) : j < 13 && (w = m[S], m[++S] = w); + else if ((j = z) > 12) m[++S] = p; + else if (j > 5) w = m[S--], m[S] = m[S] !== w; + else if (j > 3) w = m[S--], m[S] = m[S] / w; + else if (j > 1) { + if ((B = o(e, R)) < 0) { + v = 1, M(e, b, 2 * a), R += 2 * B - 2; + break + } + R += 2 * B - 2 + } else j > -1 && (m[S] = !m[S]); + else if (j < 3) + if (j = 3 & z, z >>= 2, j < 1)(j = z) > 13 ? (m[++S] = o(e, R), R += 4) : j > 11 ? (w = m[S--], m[S] = m[S] >> w) : j > 9 ? (B = _(e, R), R += 2, w = m[S--], h[B] = w) : j > 7 ? (B = x(e, R), R += 4, A = S + 1, m[S -= B - 1] = B ? m.slice(S, A) : []) : j > 0 && (w = m[S--], m[S] = m[S] > w); + else if (j < 2)(j = z) > 12 ? (w = m[S - 1], A = m[S], m[++S] = w, m[++S] = A) : j > 3 ? (w = m[S--], m[S] = m[S] == w) : j > 1 ? (w = m[S--], m[S] = m[S] + w) : j > -1 && (m[++S] = u); + else if (j < 3) { + if ((j = z) > 13) m[++S] = !1; + else if (j > 6) w = m[S--], m[S] = m[S] instanceof w; + else if (j > 4) w = m[S--], m[S] = m[S] % w; + else if (j > 2) + if (m[S--]) R += 4; + else { + if ((B = o(e, R)) < 0) { + v = 1, M(e, b, 2 * a), R += 2 * B - 2; + break + } + R += 2 * B - 2 + } + else if (j > 0) { + for (B = x(e, R), w = "", k = r.q[B][0]; k < r.q[B][1]; k++) w += String.fromCharCode(t ^ r.p[k]); + m[++S] = w, R += 4 + } + } else(j = z) > 7 ? (w = m[S--], m[S] = m[S] | w) : j > 5 ? (B = _(e, R), R += 2, m[++S] = h["$" + B]) : j > 3 && (B = o(e, R), d[i][0] && !d[i][2] ? d[i][1] = [R + 4, B - 3] : d[i++] = [0, [R + 4, B - 3], 0], R += 2 * B - 2); + else if (j = 3 & z, z >>= 2, j > 2)(j = z) > 13 ? (m[++S] = l(e, R), R += 8) : j > 11 ? (w = m[S--], m[S] = m[S] >>> w) : j > 9 ? m[++S] = !0 : j > 7 ? (B = _(e, R), R += 2, m[S] = m[S][B]) : j > 0 && (w = m[S--], m[S] = m[S] < w); + else if (j > 1)(j = z) > 10 ? (B = o(e, R), d[++i] = [ + [R + 4, B - 3], 0, 0 + ], R += 2 * B - 2) : j > 8 ? (w = m[S--], m[S] = m[S] ^ w) : j > 6 && (w = m[S--]); + else if (j > 0) { + if ((j = z) > 7) w = m[S--], m[S] = m[S] in w; + else if (j > 5) m[S] = ++m[S]; + else if (j > 3) B = _(e, R), R += 2, w = h[B], m[++S] = w; + else if (j > 1) { + var O = 0, + U = m[S].length, + D = m[S]; + m[++S] = function() { + var e = O < U; + if (e) { + var b = D[O++]; + m[++S] = b + } + m[++S] = e + } + } + } else if ((j = z) > 13) w = m[S], m[S] = m[S - 1], m[S - 1] = w; + else if (j > 4) w = m[S--], m[S] = m[S] === w; + else if (j > 2) w = m[S--], m[S] = m[S] - w; + else if (j > 0) { + for (B = x(e, R), j = "", k = r.q[B][0]; k < r.q[B][1]; k++) j += String.fromCharCode(t ^ r.p[k]); + j = +j, R += 4, m[++S] = j + } + } + if (v) + for (; R < q;) + if (I = T[R], R += 2, j = 3 & (z = 13 * I % 241), z >>= 2, j > 2) + if (j = 3 & z, z >>= 2, j > 2)(j = z) < 2 ? (w = m[S--], m[S] = m[S] < w) : j < 9 ? (B = E[R], R += 2, m[S] = m[S][B]) : j < 11 ? m[++S] = !0 : j < 13 ? (w = m[S--], m[S] = m[S] >>> w) : j < 15 && (m[++S] = E[R], R += 8); + else if (j > 1)(j = z) < 6 || (j < 8 ? w = m[S--] : j < 10 ? (w = m[S--], m[S] = m[S] ^ w) : j < 12 && (B = E[R], d[++i] = [ + [R + 4, B - 3], 0, 0 + ], R += 2 * B - 2)); + else if (j > 0)(j = z) > 7 ? (w = m[S--], m[S] = m[S] in w) : j > 5 ? m[S] = ++m[S] : j > 3 ? (B = E[R], R += 2, w = h[B], m[++S] = w) : j > 1 && (O = 0, U = m[S].length, D = m[S], m[++S] = function() { + var e = O < U; + if (e) { + var b = D[O++]; + m[++S] = b + } + m[++S] = e + }); + else if ((j = z) < 2) { + for (B = E[R], j = "", k = r.q[B][0]; k < r.q[B][1]; k++) j += String.fromCharCode(t ^ r.p[k]); + j = +j, R += 4, m[++S] = j + } else j < 4 ? (w = m[S--], m[S] = m[S] - w) : j < 6 ? (w = m[S--], m[S] = m[S] === w) : j < 15 && (w = m[S], m[S] = m[S - 1], m[S - 1] = w); + else if (j > 1) + if (j = 3 & z, z >>= 2, j < 1)(j = z) > 13 ? (m[++S] = E[R], R += 4) : j > 11 ? (w = m[S--], m[S] = m[S] >> w) : j > 9 ? (B = E[R], R += 2, w = m[S--], h[B] = w) : j > 7 ? (B = E[R], R += 4, A = S + 1, m[S -= B - 1] = B ? m.slice(S, A) : []) : j > 0 && (w = m[S--], m[S] = m[S] > w); + else if (j < 2)(j = z) < 1 ? m[++S] = u : j < 3 ? (w = m[S--], m[S] = m[S] + w) : j < 5 ? (w = m[S--], m[S] = m[S] == w) : j < 14 && (w = m[S - 1], A = m[S], m[++S] = w, m[++S] = A); + else if (j < 3) { + if ((j = z) > 13) m[++S] = !1; + else if (j > 6) w = m[S--], m[S] = m[S] instanceof w; + else if (j > 4) w = m[S--], m[S] = m[S] % w; + else if (j > 2) m[S--] ? R += 4 : R += 2 * (B = E[R]) - 2; + else if (j > 0) { + for (B = E[R], w = "", k = r.q[B][0]; k < r.q[B][1]; k++) w += String.fromCharCode(t ^ r.p[k]); + m[++S] = w, R += 4 + } + } else(j = z) > 7 ? (w = m[S--], m[S] = m[S] | w) : j > 5 ? (B = E[R], R += 2, m[++S] = h["$" + B]) : j > 3 && (B = E[R], d[i][0] && !d[i][2] ? d[i][1] = [R + 4, B - 3] : d[i++] = [0, [R + 4, B - 3], 0], R += 2 * B - 2); + else if (j > 0) + if (j = 3 & z, z >>= 2, j < 1) { + if ((j = z) > 9); + else if (j > 7) w = m[S--], m[S] = m[S] & w; + else if (j > 5) B = E[R], R += 2, m[S -= B] = 0 === B ? new m[S] : f(m[S], c(m.slice(S + 1, S + B + 1))); + else if (j > 3) { + B = E[R]; + try { + if (d[i][2] = 1, 1 == (w = P(e, R + 4, B - 3, [], h, p, null, 0))[0]) return w + } catch (b) { + if (d[i] && d[i][1] && 1 == (w = P(e, d[i][1][0], d[i][1][1], [], h, p, b, 0))[0]) return w + } finally { + if (d[i] && d[i][0] && 1 == (w = P(e, d[i][0][0], d[i][0][1], [], h, p, null, 0))[0]) return w; + d[i] = 0, i-- + } + R += 2 * B - 2 + } + } else if (j < 2) + if ((j = z) < 8) A = m[S--], w = delete m[S--][A]; + else if (j < 10) { + for (B = E[R], j = "", k = r.q[B][0]; k < r.q[B][1]; k++) j += String.fromCharCode(t ^ r.p[k]); + R += 4, m[S] = m[S][j] + } else j < 12 ? (w = m[S--], m[S] = m[S] << w) : j < 14 && (m[++S] = E[R], R += 2); + else j < 3 ? (j = z) < 2 ? m[++S] = w : j < 11 ? (w = m[S -= 2][m[S + 1]] = m[S + 2], S--) : j < 13 && (w = m[S], m[++S] = w) : (j = z) > 12 ? m[++S] = p : j > 5 ? (w = m[S--], m[S] = m[S] !== w) : j > 3 ? (w = m[S--], m[S] = m[S] / w) : j > 1 ? R += 2 * (B = E[R]) - 2 : j > -1 && (m[S] = !m[S]); + else if (j = 3 & z, z >>= 2, j < 1) { + if ((j = z) < 1) return [1, m[S--]]; + j < 5 ? (w = m[S--], m[S] = m[S] * w) : j < 7 ? (w = m[S--], m[S] = m[S] != w) : j < 14 ? (A = m[S--], C = m[S--], (j = m[S--]).x === P ? j.y >= 1 ? m[++S] = F(e, j.c, j.l, A, j.z, C, null, 1) : (m[++S] = F(e, j.c, j.l, A, j.z, C, null, 0), j.y++) : m[++S] = j.apply(C, A)) : j < 16 && (B = E[R], (g = function b() { + var a = arguments; + return b.y > 0 || b.y++, F(e, b.c, b.l, a, b.z, this, null, 0) + }).c = R + 4, g.l = B - 2, g.x = P, g.y = 0, g.z = h, m[S] = g, R += 2 * B - 2) + } else if (j < 2)(j = z) > 8 ? (w = m[S--], m[S] = typeof w) : j > 4 ? m[S -= 1] = m[S][m[S + 1]] : j > 2 && (A = m[S--], (j = m[S]).x === P ? j.y >= 1 ? m[S] = F(e, j.c, j.l, [A], j.z, C, null, 1) : (m[S] = F(e, j.c, j.l, [A], j.z, C, null, 0), j.y++) : m[S] = j(A)); + else if (j < 3) { + if ((j = z) < 9) { + for (w = m[S--], B = E[R], j = "", k = r.q[B][0]; k < r.q[B][1]; k++) j += String.fromCharCode(t ^ r.p[k]); + R += 4, m[S--][j] = w + } else if (j < 13) throw m[S--] + } else(j = z) < 1 ? m[++S] = null : j < 3 ? (w = m[S--], m[S] = m[S] >= w) : j < 12 && (m[++S] = void 0); + return [0, null] + } + + function F(e, b, a, f, c, r, t, d) { + null == r && (r = this), c && !c.d && (c.d = 0, c.$0 = c, c[1] = {}); + var i, n, s = {}, + o = s.d = c ? c.d + 1 : 0; + for (s["$" + o] = s, n = 0; n < o; n++) s[i = "$" + n] = c[i]; + for (n = 0, o = s.length = f.length; n < o; n++) s[n] = f[n]; + return d && !T[b] && M(e, b, 2 * a), T[b] ? P(e, b, a, 0, s, r, null, 1)[1] : P(e, b, a, 0, s, r, null, 0)[1] + } +}; +var _0x397dc7 = "undefined" != typeof globalThis ? globalThis : void 0 !== window ? window : "undefined" != typeof global ? global : "undefined" != typeof self ? self : {}, + _0x124d1a = _0x5cd844(function(_0x770f81) { + ! function() { + var _0x250d36 = "input is invalid type", + _0x4cfaee = !1, + _0x1702f9 = {}, + _0x5ccbb3 = !_0x4cfaee && "object" == typeof self, + _0x54d876 = !_0x1702f9.JS_MD5_NO_NODE_JS && "object" == typeof process && process.versions && process.versions.node, + _0x185caf; + _0x54d876 ? _0x1702f9 = _0x397dc7 : _0x5ccbb3 && (_0x1702f9 = self); + var _0x17dcbf = !_0x1702f9.JS_MD5_NO_COMMON_JS && _0x770f81.exports, + _0x554fed = !1, + _0x2de28f = !_0x1702f9.JS_MD5_NO_ARRAY_BUFFER && "undefined" != typeof ArrayBuffer, + _0x3a9a1b = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"], + _0x465562 = [128, 32768, 8388608, -2147483648], + _0x20b37e = [0, 8, 16, 24], + _0x323604 = ["hex", "array", "digest", "buffer", "arrayBuffer", "base64"], + _0x2c185e = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "+", "/"], + _0x4b59e0 = []; + if (_0x2de28f) { + var _0x395837 = new ArrayBuffer(68); + _0x185caf = new Uint8Array(_0x395837), _0x4b59e0 = new Uint32Array(_0x395837) + }!_0x1702f9.JS_MD5_NO_NODE_JS && Array.isArray || (Array.isArray = function(e) { + return "[object Array]" === Object.prototype.toString.call(e) + }), _0x2de28f && (_0x1702f9.JS_MD5_NO_ARRAY_BUFFER_IS_VIEW || !ArrayBuffer.isView) && (ArrayBuffer.isView = function(e) { + return "object" == typeof e && e.buffer && e.buffer.constructor === ArrayBuffer + }); + var _0x4e9930 = function(e) { + return function(b) { + return new _0x5887c8(!0).update(b)[e]() + } + }, + _0x38ba77 = function() { + var e = _0x4e9930("hex"); + _0x54d876 && (e = _0x474989(e)), e.create = function() { + return new _0x5887c8 + }, e.update = function(b) { + return e.create().update(b) + }; + for (var b = 0; b < _0x323604.length; ++b) { + var a = _0x323604[b]; + e[a] = _0x4e9930(a) + } + return e + }, + _0x474989 = function(_0x57eeaa) { + var _0x114910, _0x226465 = eval("require('crypto');"), + _0x1f6ae0 = eval("require('buffer')['Buffer'];"); + return function(e) { + if ("string" == typeof e) return _0x226465.createHash("md5").update(e, "utf8").digest("hex"); + if (null == e) throw _0x250d36; + return e.constructor === ArrayBuffer && (e = new Uint8Array(e)), Array.isArray(e) || ArrayBuffer.isView(e) || e.constructor === _0x1f6ae0 ? _0x226465.createHash("md5").update(new _0x1f6ae0.from(e)).digest("hex") : _0x57eeaa(e) + } + }; + + function _0x5887c8(e) { + if (e) _0x4b59e0[0] = _0x4b59e0[16] = _0x4b59e0[1] = _0x4b59e0[2] = _0x4b59e0[3] = _0x4b59e0[4] = _0x4b59e0[5] = _0x4b59e0[6] = _0x4b59e0[7] = _0x4b59e0[8] = _0x4b59e0[9] = _0x4b59e0[10] = _0x4b59e0[11] = _0x4b59e0[12] = _0x4b59e0[13] = _0x4b59e0[14] = _0x4b59e0[15] = 0, this.blocks = _0x4b59e0, this.buffer8 = _0x185caf; + else if (_0x2de28f) { + var b = new ArrayBuffer(68); + this.buffer8 = new Uint8Array(b), this.blocks = new Uint32Array(b) + } else this.blocks = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + this.h0 = this.h1 = this.h2 = this.h3 = this.start = this.bytes = this.hBytes = 0, this.finalized = this.hashed = !1, this.first = !0 + } + _0x5887c8.prototype.update = function(e) { + if (!this.finalized) { + var b, a = typeof e; + if ("string" !== a) { + if ("object" !== a || null === e) throw _0x250d36; + if (_0x2de28f && e.constructor === ArrayBuffer) e = new Uint8Array(e); + else if (!(Array.isArray(e) || _0x2de28f && ArrayBuffer.isView(e))) throw _0x250d36; + b = !0 + } + for (var f, c, r = 0, t = e.length, d = this.blocks, i = this.buffer8; r < t;) { + if (this.hashed && (this.hashed = !1, d[0] = d[16], d[16] = d[1] = d[2] = d[3] = d[4] = d[5] = d[6] = d[7] = d[8] = d[9] = d[10] = d[11] = d[12] = d[13] = d[14] = d[15] = 0), b) + if (_0x2de28f) + for (c = this.start; r < t && c < 64; ++r) i[c++] = e[r]; + else + for (c = this.start; r < t && c < 64; ++r) d[c >> 2] |= e[r] << _0x20b37e[3 & c++]; + else if (_0x2de28f) + for (c = this.start; r < t && c < 64; ++r)(f = e.charCodeAt(r)) < 128 ? i[c++] = f : f < 2048 ? (i[c++] = 192 | f >> 6, i[c++] = 128 | 63 & f) : f < 55296 || f >= 57344 ? (i[c++] = 224 | f >> 12, i[c++] = 128 | f >> 6 & 63, i[c++] = 128 | 63 & f) : (f = 65536 + ((1023 & f) << 10 | 1023 & e.charCodeAt(++r)), i[c++] = 240 | f >> 18, i[c++] = 128 | f >> 12 & 63, i[c++] = 128 | f >> 6 & 63, i[c++] = 128 | 63 & f); + else + for (c = this.start; r < t && c < 64; ++r)(f = e.charCodeAt(r)) < 128 ? d[c >> 2] |= f << _0x20b37e[3 & c++] : f < 2048 ? (d[c >> 2] |= (192 | f >> 6) << _0x20b37e[3 & c++], d[c >> 2] |= (128 | 63 & f) << _0x20b37e[3 & c++]) : f < 55296 || f >= 57344 ? (d[c >> 2] |= (224 | f >> 12) << _0x20b37e[3 & c++], d[c >> 2] |= (128 | f >> 6 & 63) << _0x20b37e[3 & c++], d[c >> 2] |= (128 | 63 & f) << _0x20b37e[3 & c++]) : (f = 65536 + ((1023 & f) << 10 | 1023 & e.charCodeAt(++r)), d[c >> 2] |= (240 | f >> 18) << _0x20b37e[3 & c++], d[c >> 2] |= (128 | f >> 12 & 63) << _0x20b37e[3 & c++], d[c >> 2] |= (128 | f >> 6 & 63) << _0x20b37e[3 & c++], d[c >> 2] |= (128 | 63 & f) << _0x20b37e[3 & c++]); + this.lastByteIndex = c, this.bytes += c - this.start, c >= 64 ? (this.start = c - 64, this.hash(), this.hashed = !0) : this.start = c + } + return this.bytes > 4294967295 && (this.hBytes += this.bytes / 4294967296 << 0, this.bytes = this.bytes % 4294967296), this + } + }, _0x5887c8.prototype.finalize = function() { + if (!this.finalized) { + this.finalized = !0; + var e = this.blocks, + b = this.lastByteIndex; + e[b >> 2] |= _0x465562[3 & b], b >= 56 && (this.hashed || this.hash(), e[0] = e[16], e[16] = e[1] = e[2] = e[3] = e[4] = e[5] = e[6] = e[7] = e[8] = e[9] = e[10] = e[11] = e[12] = e[13] = e[14] = e[15] = 0), e[14] = this.bytes << 3, e[15] = this.hBytes << 3 | this.bytes >>> 29, this.hash() + } + }, _0x5887c8.prototype.hash = function() { + var e, b, a, f, c, r, t = this.blocks; + this.first ? b = ((b = ((e = ((e = t[0] - 680876937) << 7 | e >>> 25) - 271733879 << 0) ^ (a = ((a = (-271733879 ^ (f = ((f = (-1732584194 ^ 2004318071 & e) + t[1] - 117830708) << 12 | f >>> 20) + e << 0) & (-271733879 ^ e)) + t[2] - 1126478375) << 17 | a >>> 15) + f << 0) & (f ^ e)) + t[3] - 1316259209) << 22 | b >>> 10) + a << 0 : (e = this.h0, b = this.h1, a = this.h2, b = ((b += ((e = ((e += ((f = this.h3) ^ b & (a ^ f)) + t[0] - 680876936) << 7 | e >>> 25) + b << 0) ^ (a = ((a += (b ^ (f = ((f += (a ^ e & (b ^ a)) + t[1] - 389564586) << 12 | f >>> 20) + e << 0) & (e ^ b)) + t[2] + 606105819) << 17 | a >>> 15) + f << 0) & (f ^ e)) + t[3] - 1044525330) << 22 | b >>> 10) + a << 0), b = ((b += ((e = ((e += (f ^ b & (a ^ f)) + t[4] - 176418897) << 7 | e >>> 25) + b << 0) ^ (a = ((a += (b ^ (f = ((f += (a ^ e & (b ^ a)) + t[5] + 1200080426) << 12 | f >>> 20) + e << 0) & (e ^ b)) + t[6] - 1473231341) << 17 | a >>> 15) + f << 0) & (f ^ e)) + t[7] - 45705983) << 22 | b >>> 10) + a << 0, b = ((b += ((e = ((e += (f ^ b & (a ^ f)) + t[8] + 1770035416) << 7 | e >>> 25) + b << 0) ^ (a = ((a += (b ^ (f = ((f += (a ^ e & (b ^ a)) + t[9] - 1958414417) << 12 | f >>> 20) + e << 0) & (e ^ b)) + t[10] - 42063) << 17 | a >>> 15) + f << 0) & (f ^ e)) + t[11] - 1990404162) << 22 | b >>> 10) + a << 0, b = ((b += ((e = ((e += (f ^ b & (a ^ f)) + t[12] + 1804603682) << 7 | e >>> 25) + b << 0) ^ (a = ((a += (b ^ (f = ((f += (a ^ e & (b ^ a)) + t[13] - 40341101) << 12 | f >>> 20) + e << 0) & (e ^ b)) + t[14] - 1502002290) << 17 | a >>> 15) + f << 0) & (f ^ e)) + t[15] + 1236535329) << 22 | b >>> 10) + a << 0, b = ((b += ((f = ((f += (b ^ a & ((e = ((e += (a ^ f & (b ^ a)) + t[1] - 165796510) << 5 | e >>> 27) + b << 0) ^ b)) + t[6] - 1069501632) << 9 | f >>> 23) + e << 0) ^ e & ((a = ((a += (e ^ b & (f ^ e)) + t[11] + 643717713) << 14 | a >>> 18) + f << 0) ^ f)) + t[0] - 373897302) << 20 | b >>> 12) + a << 0, b = ((b += ((f = ((f += (b ^ a & ((e = ((e += (a ^ f & (b ^ a)) + t[5] - 701558691) << 5 | e >>> 27) + b << 0) ^ b)) + t[10] + 38016083) << 9 | f >>> 23) + e << 0) ^ e & ((a = ((a += (e ^ b & (f ^ e)) + t[15] - 660478335) << 14 | a >>> 18) + f << 0) ^ f)) + t[4] - 405537848) << 20 | b >>> 12) + a << 0, b = ((b += ((f = ((f += (b ^ a & ((e = ((e += (a ^ f & (b ^ a)) + t[9] + 568446438) << 5 | e >>> 27) + b << 0) ^ b)) + t[14] - 1019803690) << 9 | f >>> 23) + e << 0) ^ e & ((a = ((a += (e ^ b & (f ^ e)) + t[3] - 187363961) << 14 | a >>> 18) + f << 0) ^ f)) + t[8] + 1163531501) << 20 | b >>> 12) + a << 0, b = ((b += ((f = ((f += (b ^ a & ((e = ((e += (a ^ f & (b ^ a)) + t[13] - 1444681467) << 5 | e >>> 27) + b << 0) ^ b)) + t[2] - 51403784) << 9 | f >>> 23) + e << 0) ^ e & ((a = ((a += (e ^ b & (f ^ e)) + t[7] + 1735328473) << 14 | a >>> 18) + f << 0) ^ f)) + t[12] - 1926607734) << 20 | b >>> 12) + a << 0, b = ((b += ((r = (f = ((f += ((c = b ^ a) ^ (e = ((e += (c ^ f) + t[5] - 378558) << 4 | e >>> 28) + b << 0)) + t[8] - 2022574463) << 11 | f >>> 21) + e << 0) ^ e) ^ (a = ((a += (r ^ b) + t[11] + 1839030562) << 16 | a >>> 16) + f << 0)) + t[14] - 35309556) << 23 | b >>> 9) + a << 0, b = ((b += ((r = (f = ((f += ((c = b ^ a) ^ (e = ((e += (c ^ f) + t[1] - 1530992060) << 4 | e >>> 28) + b << 0)) + t[4] + 1272893353) << 11 | f >>> 21) + e << 0) ^ e) ^ (a = ((a += (r ^ b) + t[7] - 155497632) << 16 | a >>> 16) + f << 0)) + t[10] - 1094730640) << 23 | b >>> 9) + a << 0, b = ((b += ((r = (f = ((f += ((c = b ^ a) ^ (e = ((e += (c ^ f) + t[13] + 681279174) << 4 | e >>> 28) + b << 0)) + t[0] - 358537222) << 11 | f >>> 21) + e << 0) ^ e) ^ (a = ((a += (r ^ b) + t[3] - 722521979) << 16 | a >>> 16) + f << 0)) + t[6] + 76029189) << 23 | b >>> 9) + a << 0, b = ((b += ((r = (f = ((f += ((c = b ^ a) ^ (e = ((e += (c ^ f) + t[9] - 640364487) << 4 | e >>> 28) + b << 0)) + t[12] - 421815835) << 11 | f >>> 21) + e << 0) ^ e) ^ (a = ((a += (r ^ b) + t[15] + 530742520) << 16 | a >>> 16) + f << 0)) + t[2] - 995338651) << 23 | b >>> 9) + a << 0, b = ((b += ((f = ((f += (b ^ ((e = ((e += (a ^ (b | ~f)) + t[0] - 198630844) << 6 | e >>> 26) + b << 0) | ~a)) + t[7] + 1126891415) << 10 | f >>> 22) + e << 0) ^ ((a = ((a += (e ^ (f | ~b)) + t[14] - 1416354905) << 15 | a >>> 17) + f << 0) | ~e)) + t[5] - 57434055) << 21 | b >>> 11) + a << 0, b = ((b += ((f = ((f += (b ^ ((e = ((e += (a ^ (b | ~f)) + t[12] + 1700485571) << 6 | e >>> 26) + b << 0) | ~a)) + t[3] - 1894986606) << 10 | f >>> 22) + e << 0) ^ ((a = ((a += (e ^ (f | ~b)) + t[10] - 1051523) << 15 | a >>> 17) + f << 0) | ~e)) + t[1] - 2054922799) << 21 | b >>> 11) + a << 0, b = ((b += ((f = ((f += (b ^ ((e = ((e += (a ^ (b | ~f)) + t[8] + 1873313359) << 6 | e >>> 26) + b << 0) | ~a)) + t[15] - 30611744) << 10 | f >>> 22) + e << 0) ^ ((a = ((a += (e ^ (f | ~b)) + t[6] - 1560198380) << 15 | a >>> 17) + f << 0) | ~e)) + t[13] + 1309151649) << 21 | b >>> 11) + a << 0, b = ((b += ((f = ((f += (b ^ ((e = ((e += (a ^ (b | ~f)) + t[4] - 145523070) << 6 | e >>> 26) + b << 0) | ~a)) + t[11] - 1120210379) << 10 | f >>> 22) + e << 0) ^ ((a = ((a += (e ^ (f | ~b)) + t[2] + 718787259) << 15 | a >>> 17) + f << 0) | ~e)) + t[9] - 343485551) << 21 | b >>> 11) + a << 0, this.first ? (this.h0 = e + 1732584193 << 0, this.h1 = b - 271733879 << 0, this.h2 = a - 1732584194 << 0, this.h3 = f + 271733878 << 0, this.first = !1) : (this.h0 = this.h0 + e << 0, this.h1 = this.h1 + b << 0, this.h2 = this.h2 + a << 0, this.h3 = this.h3 + f << 0) + }, _0x5887c8.prototype.hex = function() { + this.finalize(); + var e = this.h0, + b = this.h1, + a = this.h2, + f = this.h3; + return _0x3a9a1b[e >> 4 & 15] + _0x3a9a1b[15 & e] + _0x3a9a1b[e >> 12 & 15] + _0x3a9a1b[e >> 8 & 15] + _0x3a9a1b[e >> 20 & 15] + _0x3a9a1b[e >> 16 & 15] + _0x3a9a1b[e >> 28 & 15] + _0x3a9a1b[e >> 24 & 15] + _0x3a9a1b[b >> 4 & 15] + _0x3a9a1b[15 & b] + _0x3a9a1b[b >> 12 & 15] + _0x3a9a1b[b >> 8 & 15] + _0x3a9a1b[b >> 20 & 15] + _0x3a9a1b[b >> 16 & 15] + _0x3a9a1b[b >> 28 & 15] + _0x3a9a1b[b >> 24 & 15] + _0x3a9a1b[a >> 4 & 15] + _0x3a9a1b[15 & a] + _0x3a9a1b[a >> 12 & 15] + _0x3a9a1b[a >> 8 & 15] + _0x3a9a1b[a >> 20 & 15] + _0x3a9a1b[a >> 16 & 15] + _0x3a9a1b[a >> 28 & 15] + _0x3a9a1b[a >> 24 & 15] + _0x3a9a1b[f >> 4 & 15] + _0x3a9a1b[15 & f] + _0x3a9a1b[f >> 12 & 15] + _0x3a9a1b[f >> 8 & 15] + _0x3a9a1b[f >> 20 & 15] + _0x3a9a1b[f >> 16 & 15] + _0x3a9a1b[f >> 28 & 15] + _0x3a9a1b[f >> 24 & 15] + }, _0x5887c8.prototype.toString = _0x5887c8.prototype.hex, _0x5887c8.prototype.digest = function() { + this.finalize(); + var e = this.h0, + b = this.h1, + a = this.h2, + f = this.h3; + return [255 & e, e >> 8 & 255, e >> 16 & 255, e >> 24 & 255, 255 & b, b >> 8 & 255, b >> 16 & 255, b >> 24 & 255, 255 & a, a >> 8 & 255, a >> 16 & 255, a >> 24 & 255, 255 & f, f >> 8 & 255, f >> 16 & 255, f >> 24 & 255] + }, _0x5887c8.prototype.array = _0x5887c8.prototype.digest, _0x5887c8.prototype.arrayBuffer = function() { + this.finalize(); + var e = new ArrayBuffer(16), + b = new Uint32Array(e); + return b[0] = this.h0, b[1] = this.h1, b[2] = this.h2, b[3] = this.h3, e + }, _0x5887c8.prototype.buffer = _0x5887c8.prototype.arrayBuffer, _0x5887c8.prototype.base64 = function() { + for (var e, b, a, f = "", c = this.array(), r = 0; r < 15;) e = c[r++], b = c[r++], a = c[r++], f += _0x2c185e[e >>> 2] + _0x2c185e[63 & (e << 4 | b >>> 4)] + _0x2c185e[63 & (b << 2 | a >>> 6)] + _0x2c185e[63 & a]; + return f + (_0x2c185e[(e = c[r]) >>> 2] + _0x2c185e[e << 4 & 63] + "==") + }; + var _0x4dd781 = _0x38ba77(); + _0x17dcbf ? _0x770f81.exports = _0x4dd781 : (_0x1702f9.md5 = _0x4dd781, _0x554fed && (void 0)(function() { + return _0x4dd781 + })) + }() + }); + +function _0x178cef(e) { + return jsvmp("484e4f4a403f52430038001eab0015840e8ee21a00000000000000621b000200001d000146000306000e271f001b000200021d00010500121b001b000b021b000b04041d0001071b000b0500000003000126207575757575757575757575757575757575757575757575757575757575757575", [, , void 0 !== _0x124d1a ? _0x124d1a : void 0, _0x178cef, e]) +} +for (var _0xb55f3e = { + boe: !1, + aid: 0, + dfp: !1, + sdi: !1, + enablePathList: [], + _enablePathListRegex: [], + urlRewriteRules: [], + _urlRewriteRules: [], + initialized: !1, + enableTrack: !1, + track: { + unitTime: 0, + unitAmount: 0, + fre: 0 + }, + triggerUnload: !1, + region: "", + regionConf: {}, + umode: 0, + v: !1, + perf: !1, + xxbg: !0 + }, _0x3eaf64 = { + debug: function(e, b) { + let a = !1; + a = !1 + } + }, _0x233455 = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"], _0x2e9f6d = [], _0x511f86 = [], _0x3d35de = 0; _0x3d35de < 256; _0x3d35de++) _0x2e9f6d[_0x3d35de] = _0x233455[_0x3d35de >> 4 & 15] + _0x233455[15 & _0x3d35de], _0x3d35de < 16 && (_0x3d35de < 10 ? _0x511f86[48 + _0x3d35de] = _0x3d35de : _0x511f86[87 + _0x3d35de] = _0x3d35de); +var _0x2ce54d = function(e) { + for (var b = e.length, a = "", f = 0; f < b;) a += _0x2e9f6d[e[f++]]; + return a + }, + _0x5960a2 = function(e) { + for (var b = e.length >> 1, a = b << 1, f = new Uint8Array(b), c = 0, r = 0; r < a;) f[c++] = _0x511f86[e.charCodeAt(r++)] << 4 | _0x511f86[e.charCodeAt(r++)]; + return f + }, + _0x4e46b6 = { + encode: _0x2ce54d, + decode: _0x5960a2 + }; + +function sign(e, b) { + return jsvmp("484e4f4a403f5243001f240fbf2031ccf317480300000000000007181b0002012e1d00921b000b171b000b02402217000a1c1b000b1726402217000c1c1b000b170200004017002646000306000e271f001b000200021d00920500121b001b000b031b000b17041d0092071b000b041e012f17000d1b000b05260a0000101c1b000b06260a0000101c1b001b000b071e01301d00931b001b000b081e00081d00941b0048021d00951b001b000b1b1d00961b0048401d009e1b001b000b031b000b16041d009f1b001b000b09221e0131241b000b031b000b09221e0131241b000b1e0a000110040a0001101d00d51b001b000b09221e0131241b000b031b000b09221e0131241b000b180a000110040a0001101d00d71b001b000b0a1e00101d00d91b001b000b0b261b000b1a1b000b190a0002101d00db1b001b000b0c261b000b221b000b210a0002101d00dc1b001b000b0d261b000b230200200a0002101d00dd1b001b000b09221e0131241b000b031b000b24040a0001101d00df1b001b000b0e1a00221e00de240a0000104903e82b1d00e31b001b000b0f260a0000101d00e41b001b000b1d1d00e71b001b000b1a4901002b1d00e81b001b000b1a4901002c1d00ea1b001b000b191d00f21b001b000b1f480e191d00f81b001b000b1f480f191d00f91b001b000b20480e191d00fb1b001b000b20480f191d00fe1b001b000b25480e191d01001b001b000b25480f191d01011b001b000b264818344900ff2f1d01031b001b000b264810344900ff2f1d01321b001b000b264808344900ff2f1d01331b001b000b264800344900ff2f1d01341b001b000b274818344900ff2f1d01351b001b000b274810344900ff2f1d01361b001b000b274808344900ff2f1d01371b001b000b274800344900ff2f1d01381b001b000b281b000b29311b000b2a311b000b2b311b000b2c311b000b2d311b000b2e311b000b2f311b000b30311b000b31311b000b32311b000b33311b000b34311b000b35311b000b36311b000b37311b000b38311b000b39311d01391b004900ff1d013a1b001b000b10261b000b281b000b2a1b000b2c1b000b2e1b000b301b000b321b000b341b000b361b000b381b000b3a1b000b291b000b2b1b000b2d1b000b2f1b000b311b000b331b000b351b000b371b000b390a0013101d013b1b001b000b0c261b000b111b000b3b041b000b3c0a0002101d013c1b001b000b12261b000b1c1b000b3b1b000b3d0a0003101d013d1b001b000b13261b000b3e0200240a0002101d013e1b000b3f0000013f000126207575757575757575757575757575757575757575757575757575757575757575012b0e7776757a7d7643617c637661676a027a77065c717976706708777671667474766107767d65707c77760374766707707c7d607c7f7607757a61767166740a7c66677661447a77677b0a7a7d7d7661447a77677b0b7c666776615b767a747b670b7a7d7d76615b767a747b6709666076615274767d670b677c5f7c64766150726076077a7d77766b5c7508767f767067617c7d09667d7776757a7d76770963617c677c676a637608677c4067617a7d740470727f7f0763617c7076606010487c71797670673363617c707660604e067c717976706705677a677f76047d7c7776012e0125012402602341525150575655545b5a59585f5e5d5c43424140474645444b4a49727170777675747b7a79787f7e7d7c63626160676665646b6a6923222120272625242b2a383c2e0260224157787763747b2749586042512b233c5e75656420254b5a22412126384446527f567a245d5f717c624a475c4366697e5579597d616a6b2a5b45547072406750762e0260214157787763747b2749586042512b233c5e75656420254b5a224121263e4446527f567a245d5f717c624a475c4366697e5579597d616a6b2a5b45547072406750762e02602041525150575655545b5a59585f5e5d5c43424140474645444b4a49727170777675747b7a79787f7e7d7c63626160676665646b6a6923222120272625242b2a3e4c2e012a022222067f767d74677b0a707b7261507c7776526702222306707b726152670f487c717976706733447a7d777c644e08577c70667e767d6712487c7179767067335d72657a7472677c614e057960777c7e10487c7179767067335b7a60677c616a4e07637f66747a7d60084c637b727d677c7e0b70727f7f437b727d677c7e0b4c4c7d7a747b677e726176055266777a7c1850727d65726041767d7776617a7d74507c7d67766b6721570964767177617a657661137476675c647d43617c637661676a5d727e7660097f727d74667274766006707b617c7e760761667d677a7e7607707c7d7d767067144c4c64767177617a6576614c7665727f66726776134c4c60767f767d7a667e4c7665727f667267761b4c4c64767177617a6576614c6070617a63674c75667d70677a7c7d174c4c64767177617a6576614c6070617a63674c75667d70154c4c64767177617a6576614c6070617a63674c757d134c4c756b77617a6576614c7665727f66726776124c4c77617a6576614c667d64617263637677154c4c64767177617a6576614c667d64617263637677114c4c77617a6576614c7665727f66726776144c4c60767f767d7a667e4c667d64617263637677144c4c756b77617a6576614c667d64617263637677094c60767f767d7a667e0c70727f7f40767f767d7a667e164c40767f767d7a667e4c5a57564c4176707c6177766108777c70667e767d670478766a60057e7267707b06417674566b630a4f3748723e694e77704c067072707b764c04607c7e7608707675407b72616308507675407b72616305767c72637a16767c44767151617c64607661577a60637267707b76610f717a7d775c717976706752606a7d700e7a60565c44767151617c646076610120047c63767d0467766067097a7d707c747d7a677c077c7d7661617c6104707c77761242465c47524c564b5056565756574c5641410e607660607a7c7d40677c61727476076076675a67767e10607c7e7658766a5b766176516a6776770a61767e7c65765a67767e097a7d77766b767757510c437c7a7d6776615665767d670e5e40437c7a7d6776615665767d670d706176726776567f767e767d670670727d65726009677c5772677246415f076176637f727076034f603901740a7d72677a6576707c777614487c717976706733437f66747a7d526161726a4e4a4d7b676763602c294f3c4f3c3b48233e2a4e68223f206e3b4f3d48233e2a4e68223f206e3a68206e6f48723e75233e2a4e68223f276e3b2948723e75233e2a4e68223f276e3a68246e3a0127087f7c7072677a7c7d047b61767504757a7f76107b676763293c3c7f7c70727f7b7c606708637f7267757c617e02222102222007647a7d777c646002222703647a7d02222607727d77617c7a77022225057f7a7d666b022224067a637b7c7d7602222b047a63727702222a047a637c77022123037e7270022122097e72707a7d677c607b0c7e72704c637c64766163703a0470617c60036b22220570617a7c6005756b7a7c6004637a787602212102212002212702212602212502212402212b08757a6176757c6b3c067c637661723c05337c63613c05337c63673c07707b617c7e763c0867617a77767d673c047e607a7602212a0220230665767d777c6106547c7c747f760e4c637261727e40647a67707b5c7d0a777a61767067407a747d0a707c7d607a6067767d670660647a67707b03777c7e07637b727d677c7e047b7c7c7840525150575655545b5a59585f5e5d5c43424140474645444b4a49727170777675747b7a79787f7e7d7c63626160676665646b6a6923222120272625242b2a3e3d03727a77017d01750161096067726167477a7e7601670972717a7f7a677a76600a677a7e766067727e6322137b72617764726176507c7d70666161767d706a0c7776657a70765e767e7c616a087f727d74667274760a6176607c7f66677a7c7d0f7265727a7f4176607c7f66677a7c7d0960706176767d477c630a60706176767d5f767567107776657a7076437a6b767f4172677a7c0a63617c77667067406671077172676776616a016309677c66707b5a7d757c08677a7e76697c7d760a677a7e766067727e6321077463665a7d757c0b7960557c7d67605f7a60670b637f66747a7d605f7a60670a677a7e766067727e63200a76657661507c7c787a760767674c60707a77017e0b606a7d67726b5661617c610c7d72677a65765f767d74677b056167705a43097563457661607a7c7d0b4c4c657661607a7c7d4c4c08707f7a767d675a770a677a7e766067727e63270b766b67767d77557a767f77046366607b03727f7f04677b767d097172607625274c707b0c75617c7e507b7261507c7776067125274c2023022022087172607625274c23022021087172607625274c22022020087172607625274c2102202702202602202507747667477a7e760220240b777c7e5d7c6745727f7a77096066716067617a7d740863617c677c707c7f02202b02202a01230e222323232323232322222323232302272302272207757c616176727f02272104717c776a096067617a7d747a756a02686e0b717c776a45727f216067610a717c776a4c7b72607b2e01350366617f02272005626676616a0a72607c7f774c607a747d096372677b7d727e762e0967674c6476717a772e063566667a772e0227270227260e4c716a6776774c6076704c777a770227250a27212a272a2524212a25097576457661607a7c7d0227240e4c232151274925647c232323232202272b02272a05607f7a7076022623074056505a5d555c037d7c6409677a7e766067727e6305757f7c7c610661727d777c7e0f7476674747447671507c7c787a7660056767647a770867674c6476717a770767674476715a770b67674c6476717a774c65210967674476717a7745210761667d7d7a7d7405757f66607b087e7c65765f7a60670660637f7a70760671765e7c657609707f7a70785f7a6067077176507f7a70780c78766a717c7261775f7a60670a717658766a717c7261770b7270677a657640677267760b647a7d777c6440677267760360477e05676172707808667d7a67477a7e76037270700a667d7a67527e7c667d670871767b72657a7c61077e6074476a637603645a5707727a775f7a60670b63617a6572706a5e7c777606706660677c7e067260607a747d0f4456514c5756455a50564c5a5d555c0479607c7d0a6176747a7c7d507c7d75096176637c616746617f04766b7a67094b3e5e403e404746510c4b3e5e403e43524a5f5c525720232323232323232323232323232323232323232323232323232323232323232320772722772b70772a2b75232371212327762a2b23232a2a2b7670752b272124760165066671707c7776067776707c777602262202262102262002262702262602262502262402262b02262a022523022522022521022520", [, , void 0, void 0 !== _0x178cef ? _0x178cef : void 0, { + boe: !1, + aid: 0, + dfp: !1, + sdi: !1, + enablePathList: [], + _enablePathListRegex: [/\/web\/report/], + urlRewriteRules: [], + _urlRewriteRules: [], + initialized: !1, + enableTrack: !1, + track: { + unitTime: 0, + unitAmount: 0, + fre: 0 + }, + triggerUnload: !1, + region: "", + regionConf: {}, + umode: 0, + v: !1, + perf: !1, + xxbg: !0 + }, () => 0, () => "03v", { + ubcode: 0 + }, { + bogusIndex: 0, + msNewTokenList: [], + moveList: [], + clickList: [], + keyboardList: [], + activeState: [], + aidList: [], + envcode: 0, + msToken: "", + msStatus: 0, + __ac_testid: "", + ttwid: "", + tt_webid: "", + tt_webid_v2: "" + }, void 0 !== _0x4e46b6 ? _0x4e46b6 : void 0, { + userAgent: b + }, (e, b) => { + let a = new Uint8Array(3); + return a[0] = e / 256, a[1] = e % 256, a[2] = b % 256, String.fromCharCode.apply(null, a) + }, (e, b) => { + let a, f = [], + c = 0, + r = ""; + for (let e = 0; e < 256; e++) f[e] = e; + for (let b = 0; b < 256; b++) c = (c + f[b] + e.charCodeAt(b % e.length)) % 256, a = f[b], f[b] = f[c], f[c] = a; + let t = 0; + c = 0; + for (let e = 0; e < b.length; e++) c = (c + f[t = (t + 1) % 256]) % 256, a = f[t], f[t] = f[c], f[c] = a, r += String.fromCharCode(b.charCodeAt(e) ^ f[(f[t] + f[c]) % 256]); + return r + }, (e, b) => jsvmp("484e4f4a403f524300281018f7b851f02d296e5b00000000000004a21b0002001d1d001e1b00131e00061a001d001f1b000b070200200200210d1b000b070200220200230d1b000b070200240200250d1b000b070200260200270d1b001b000b071b000b05191d00031b000200001d00281b0048001d00291b000b041e002a1b000b0b4803283b1700f11b001b000b04221e002b241b001e0029222d1b00241d00290a0001104900ff2f4810331b000b04221e002b241b001e0029222d1b00241d00290a0001104900ff2f480833301b000b04221e002b241b001e0029222d1b00241d00290a0001104900ff2f301d002c1b00220b091b000b08221e002d241b000b0a4a00fc00002f4812340a000110281d00281b00220b091b000b08221e002d241b000b0a4a0003f0002f480c340a000110281d00281b00220b091b000b08221e002d241b000b0a490fc02f4806340a000110281d00281b00220b091b000b08221e002d241b000b0a483f2f0a000110281d002816ff031b000b041e002a1b000b0b294800391700e01b001b000b04221e002b241b001e0029222d1b00241d00290a0001104900ff2f4810331b000b041e002a1b000b0b3917001e1b000b04221e002b241b000b0b0a0001104900ff2f4808331600054800301d002c1b00220b091b000b08221e002d241b000b0a4a00fc00002f4812340a000110281d00281b00220b091b000b08221e002d241b000b0a4a0003f0002f480c340a000110281d00281b00220b091b000b041e002a1b000b0b3917001e1b000b08221e002d241b000b0a490fc02f4806340a0001101600071b000b06281d00281b00220b091b000b06281d00281b000b090000002e000126207575757575757575757575757575757575757575757575757575757575757575012b0e7776757a7d7643617c637661676a027a77065c717976706708777671667474766107767d65707c77760374766707707c7d607c7f7607757a61767166740a7c66677661447a77677b0a7a7d7d7661447a77677b0b7c666776615b767a747b670b7a7d7d76615b767a747b6709666076615274767d670b677c5f7c64766150726076077a7d77766b5c7508767f767067617c7d09667d7776757a7d76770963617c677c676a637608677c4067617a7d740470727f7f0763617c7076606010487c71797670673363617c707660604e067c717976706705677a677f76047d7c7776012e0125012402602341525150575655545b5a59585f5e5d5c43424140474645444b4a49727170777675747b7a79787f7e7d7c63626160676665646b6a6923222120272625242b2a383c2e0260224157787763747b2749586042512b233c5e75656420254b5a22412126384446527f567a245d5f717c624a475c4366697e5579597d616a6b2a5b45547072406750762e0260214157787763747b2749586042512b233c5e75656420254b5a224121263e4446527f567a245d5f717c624a475c4366697e5579597d616a6b2a5b45547072406750762e02602041525150575655545b5a59585f5e5d5c43424140474645444b4a49727170777675747b7a79787f7e7d7c63626160676665646b6a6923222120272625242b2a3e4c2e012a022222067f767d74677b0a707b7261507c7776526702222306707b72615267", [, , , , e, b]), "undefined" != typeof Date ? Date : void 0, () => 0, (e, b, a, f, c, r, t, d, i, n, s, o, l, _, x, u, h, p, y) => { + let v = new Uint8Array(19); + return v[0] = e, v[1] = s, v[2] = b, v[3] = o, v[4] = a, v[5] = l, v[6] = f, v[7] = _, v[8] = c, v[9] = x, v[10] = r, v[11] = u, v[12] = t, v[13] = h, v[14] = d, v[15] = p, v[16] = i, v[17] = y, v[18] = n, String.fromCharCode.apply(null, v) + }, e => String.fromCharCode(e), (e, b, a) => String.fromCharCode(e) + String.fromCharCode(b) + a, (e, b) => jsvmp("484e4f4a403f524300281018f7b851f02d296e5b00000000000004a21b0002001d1d001e1b00131e00061a001d001f1b000b070200200200210d1b000b070200220200230d1b000b070200240200250d1b000b070200260200270d1b001b000b071b000b05191d00031b000200001d00281b0048001d00291b000b041e002a1b000b0b4803283b1700f11b001b000b04221e002b241b001e0029222d1b00241d00290a0001104900ff2f4810331b000b04221e002b241b001e0029222d1b00241d00290a0001104900ff2f480833301b000b04221e002b241b001e0029222d1b00241d00290a0001104900ff2f301d002c1b00220b091b000b08221e002d241b000b0a4a00fc00002f4812340a000110281d00281b00220b091b000b08221e002d241b000b0a4a0003f0002f480c340a000110281d00281b00220b091b000b08221e002d241b000b0a490fc02f4806340a000110281d00281b00220b091b000b08221e002d241b000b0a483f2f0a000110281d002816ff031b000b041e002a1b000b0b294800391700e01b001b000b04221e002b241b001e0029222d1b00241d00290a0001104900ff2f4810331b000b041e002a1b000b0b3917001e1b000b04221e002b241b000b0b0a0001104900ff2f4808331600054800301d002c1b00220b091b000b08221e002d241b000b0a4a00fc00002f4812340a000110281d00281b00220b091b000b08221e002d241b000b0a4a0003f0002f480c340a000110281d00281b00220b091b000b041e002a1b000b0b3917001e1b000b08221e002d241b000b0a490fc02f4806340a0001101600071b000b06281d00281b00220b091b000b06281d00281b000b090000002e000126207575757575757575757575757575757575757575757575757575757575757575012b0e7776757a7d7643617c637661676a027a77065c717976706708777671667474766107767d65707c77760374766707707c7d607c7f7607757a61767166740a7c66677661447a77677b0a7a7d7d7661447a77677b0b7c666776615b767a747b670b7a7d7d76615b767a747b6709666076615274767d670b677c5f7c64766150726076077a7d77766b5c7508767f767067617c7d09667d7776757a7d76770963617c677c676a637608677c4067617a7d740470727f7f0763617c7076606010487c71797670673363617c707660604e067c717976706705677a677f76047d7c7776012e0125012402602341525150575655545b5a59585f5e5d5c43424140474645444b4a49727170777675747b7a79787f7e7d7c63626160676665646b6a6923222120272625242b2a383c2e0260224157787763747b2749586042512b233c5e75656420254b5a22412126384446527f567a245d5f717c624a475c4366697e5579597d616a6b2a5b45547072406750762e0260214157787763747b2749586042512b233c5e75656420254b5a224121263e4446527f567a245d5f717c624a475c4366697e5579597d616a6b2a5b45547072406750762e02602041525150575655545b5a59585f5e5d5c43424140474645444b4a49727170777675747b7a79787f7e7d7c63626160676665646b6a6923222120272625242b2a3e4c2e012a022222067f767d74677b0a707b7261507c7776526702222306707b72615267", [, , , , e, b]), , sign, e, void 0]) +} + +//export text function +module.exports = sign; diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fb7f9f --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +**/bin/ +**/obj/ +**/.vs/ +**/*.user +**/*.suo +frontend/node_modules/ +frontend/dist/ +build.log +webapi-build.log +webapi-build-no-restore.log +artifacts/ diff --git a/ConsoleApp1/ConsoleApp1.csproj b/ConsoleApp1/ConsoleApp1.csproj new file mode 100644 index 0000000..05b303a --- /dev/null +++ b/ConsoleApp1/ConsoleApp1.csproj @@ -0,0 +1,14 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + diff --git a/ConsoleApp1/Program.cs b/ConsoleApp1/Program.cs new file mode 100644 index 0000000..f2d9383 --- /dev/null +++ b/ConsoleApp1/Program.cs @@ -0,0 +1,40 @@ +using WebSocketSharp; +using WebSocketSharp.Server; + +namespace ConsoleApp1 +{ + internal class Program + { + static void Main(string[] args) + { + var wssv = new WebSocketServer("ws://0.0.0.0:8080"); + + wssv.AddWebSocketService("/ws"); + + wssv.Start(); + Console.WriteLine("WebSocket服务器已启动 ws://localhost:8080/ws"); + + Console.ReadKey(); + wssv.Stop(); + } + } + + // 处理连接 + public class WsHandler : WebSocketBehavior + { + protected override void OnOpen() + { + Console.WriteLine("客户端连接"); + } + + protected override void OnMessage(MessageEventArgs e) + { + Console.WriteLine($"收到消息: {e.Data}"); + } + + protected override void OnClose(CloseEventArgs e) + { + Console.WriteLine("客户端断开"); + } + } +} diff --git a/LiveRecorder.sln b/LiveRecorder.sln new file mode 100644 index 0000000..213a278 --- /dev/null +++ b/LiveRecorder.sln @@ -0,0 +1,99 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveRecorder.WebApi", "src\LiveRecorder.WebApi\LiveRecorder.WebApi.csproj", "{71D7526B-862F-4DAD-A229-76EFBC24661D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveRecorder.Domain", "src\LiveRecorder.Domain\LiveRecorder.Domain.csproj", "{CEE984AA-CA08-48B3-B341-BD2C1C68CC1F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveRecorder.Application", "src\LiveRecorder.Application\LiveRecorder.Application.csproj", "{3B807934-A995-4F7F-8A4E-878D161F95A1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveRecorder.Infrastructure", "src\LiveRecorder.Infrastructure\LiveRecorder.Infrastructure.csproj", "{A502FCC8-83F9-402B-A027-D020D34624E1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConsoleApp1", "ConsoleApp1\ConsoleApp1.csproj", "{A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {71D7526B-862F-4DAD-A229-76EFBC24661D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {71D7526B-862F-4DAD-A229-76EFBC24661D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {71D7526B-862F-4DAD-A229-76EFBC24661D}.Debug|x64.ActiveCfg = Debug|Any CPU + {71D7526B-862F-4DAD-A229-76EFBC24661D}.Debug|x64.Build.0 = Debug|Any CPU + {71D7526B-862F-4DAD-A229-76EFBC24661D}.Debug|x86.ActiveCfg = Debug|Any CPU + {71D7526B-862F-4DAD-A229-76EFBC24661D}.Debug|x86.Build.0 = Debug|Any CPU + {71D7526B-862F-4DAD-A229-76EFBC24661D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {71D7526B-862F-4DAD-A229-76EFBC24661D}.Release|Any CPU.Build.0 = Release|Any CPU + {71D7526B-862F-4DAD-A229-76EFBC24661D}.Release|x64.ActiveCfg = Release|Any CPU + {71D7526B-862F-4DAD-A229-76EFBC24661D}.Release|x64.Build.0 = Release|Any CPU + {71D7526B-862F-4DAD-A229-76EFBC24661D}.Release|x86.ActiveCfg = Release|Any CPU + {71D7526B-862F-4DAD-A229-76EFBC24661D}.Release|x86.Build.0 = Release|Any CPU + {CEE984AA-CA08-48B3-B341-BD2C1C68CC1F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CEE984AA-CA08-48B3-B341-BD2C1C68CC1F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CEE984AA-CA08-48B3-B341-BD2C1C68CC1F}.Debug|x64.ActiveCfg = Debug|Any CPU + {CEE984AA-CA08-48B3-B341-BD2C1C68CC1F}.Debug|x64.Build.0 = Debug|Any CPU + {CEE984AA-CA08-48B3-B341-BD2C1C68CC1F}.Debug|x86.ActiveCfg = Debug|Any CPU + {CEE984AA-CA08-48B3-B341-BD2C1C68CC1F}.Debug|x86.Build.0 = Debug|Any CPU + {CEE984AA-CA08-48B3-B341-BD2C1C68CC1F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CEE984AA-CA08-48B3-B341-BD2C1C68CC1F}.Release|Any CPU.Build.0 = Release|Any CPU + {CEE984AA-CA08-48B3-B341-BD2C1C68CC1F}.Release|x64.ActiveCfg = Release|Any CPU + {CEE984AA-CA08-48B3-B341-BD2C1C68CC1F}.Release|x64.Build.0 = Release|Any CPU + {CEE984AA-CA08-48B3-B341-BD2C1C68CC1F}.Release|x86.ActiveCfg = Release|Any CPU + {CEE984AA-CA08-48B3-B341-BD2C1C68CC1F}.Release|x86.Build.0 = Release|Any CPU + {3B807934-A995-4F7F-8A4E-878D161F95A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3B807934-A995-4F7F-8A4E-878D161F95A1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3B807934-A995-4F7F-8A4E-878D161F95A1}.Debug|x64.ActiveCfg = Debug|Any CPU + {3B807934-A995-4F7F-8A4E-878D161F95A1}.Debug|x64.Build.0 = Debug|Any CPU + {3B807934-A995-4F7F-8A4E-878D161F95A1}.Debug|x86.ActiveCfg = Debug|Any CPU + {3B807934-A995-4F7F-8A4E-878D161F95A1}.Debug|x86.Build.0 = Debug|Any CPU + {3B807934-A995-4F7F-8A4E-878D161F95A1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3B807934-A995-4F7F-8A4E-878D161F95A1}.Release|Any CPU.Build.0 = Release|Any CPU + {3B807934-A995-4F7F-8A4E-878D161F95A1}.Release|x64.ActiveCfg = Release|Any CPU + {3B807934-A995-4F7F-8A4E-878D161F95A1}.Release|x64.Build.0 = Release|Any CPU + {3B807934-A995-4F7F-8A4E-878D161F95A1}.Release|x86.ActiveCfg = Release|Any CPU + {3B807934-A995-4F7F-8A4E-878D161F95A1}.Release|x86.Build.0 = Release|Any CPU + {A502FCC8-83F9-402B-A027-D020D34624E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A502FCC8-83F9-402B-A027-D020D34624E1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A502FCC8-83F9-402B-A027-D020D34624E1}.Debug|x64.ActiveCfg = Debug|Any CPU + {A502FCC8-83F9-402B-A027-D020D34624E1}.Debug|x64.Build.0 = Debug|Any CPU + {A502FCC8-83F9-402B-A027-D020D34624E1}.Debug|x86.ActiveCfg = Debug|Any CPU + {A502FCC8-83F9-402B-A027-D020D34624E1}.Debug|x86.Build.0 = Debug|Any CPU + {A502FCC8-83F9-402B-A027-D020D34624E1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A502FCC8-83F9-402B-A027-D020D34624E1}.Release|Any CPU.Build.0 = Release|Any CPU + {A502FCC8-83F9-402B-A027-D020D34624E1}.Release|x64.ActiveCfg = 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.Build.0 = Release|Any CPU + {A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Debug|x64.ActiveCfg = Debug|Any CPU + {A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Debug|x64.Build.0 = Debug|Any CPU + {A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Debug|x86.ActiveCfg = Debug|Any CPU + {A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Debug|x86.Build.0 = Debug|Any CPU + {A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Release|Any CPU.Build.0 = Release|Any CPU + {A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Release|x64.ActiveCfg = Release|Any CPU + {A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Release|x64.Build.0 = Release|Any CPU + {A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Release|x86.ActiveCfg = Release|Any CPU + {A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {71D7526B-862F-4DAD-A229-76EFBC24661D} = {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} + {A502FCC8-83F9-402B-A027-D020D34624E1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {A39485F2-EBFE-451E-B9C0-D60ACFD6D8BE} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md new file mode 100644 index 0000000..e8ad531 --- /dev/null +++ b/README.md @@ -0,0 +1,193 @@ +# Live Recorder + +Live Recorder is a multi-platform live recording system built with: + +- Backend: `.NET 8 Web API + EF Core + SQLite` +- Frontend: `Vue 3 + TypeScript + Element Plus` + +The current production-ready adapter is `DouyinLivePlatformAdapter`. The architecture keeps platform-specific logic isolated so Bilibili, Huya, Douyu, and Kuaishou can be added without changing application services. + +## Architecture + +```text +. +├─ src +│ ├─ LiveRecorder.Domain +│ │ ├─ Entities +│ │ └─ Enums +│ ├─ LiveRecorder.Application +│ │ ├─ Abstractions +│ │ ├─ Common +│ │ ├─ Models +│ │ └─ Services +│ ├─ LiveRecorder.Infrastructure +│ │ ├─ Persistence +│ │ ├─ Platforms +│ │ │ ├─ Douyin +│ │ │ ├─ Bilibili +│ │ │ └─ Huya +│ │ └─ Services +│ └─ LiveRecorder.WebApi +│ ├─ Controllers +│ └─ Middleware +└─ frontend + └─ src + ├─ api + ├─ components + ├─ router + ├─ stores + └─ views +``` + +## Core design + +### Domain + +- `LiveRoom`: platform, room id, source URL, title, anchor, cover, availability status +- `RecordTask`: recording state, quality, output format, ffmpeg process id, output path +- `RecordResult`: final file or segment directory, file size, duration, final status +- `SystemLogEntry`: persistent system log +- `AppSetting`: runtime settings store +- `UserAccount`, `UserSession`: lightweight token-based sign-in + +### Application + +- `ILivePlatformAdapter` + - `ParseRoomAsync` + - `GetLiveStatusAsync` + - `GetStreamUrlAsync` +- `ILivePlatformAdapterFactory` +- `LiveRoomService` +- `RecordService` +- `SystemSettingsService` +- `SystemLogService` +- `AuthService` + +### Infrastructure + +- `DouyinHttpClient` + - wraps User-Agent, Referer, Cookie, and `ttwid` handling +- `DouyinLivePlatformAdapter` + - share-link resolution, room id extraction, status check, stream selection +- `FfmpegService` + - single file mode, segmented mode, reconnect arguments, recording templates +- `LiveRoomPollingBackgroundService` + - background polling scheduler for live detection and auto-start recording +- `LiveRecorderDbContext` + +### Web API + +- `AuthController` +- `LiveRoomsController` +- `RecordTasksController` +- `LogsController` +- `SettingsController` +- `ApiTokenAuthenticationMiddleware` +- `ExceptionHandlingMiddleware` + +## Implemented features + +### Douyin adapter + +- resolve share links and redirects +- extract real `roomId` +- call `https://live.douyin.com/webcast/room/web/enter/` +- preload `ttwid` when needed +- parse both: + - `live_core_sdk_data.pull_data.stream_data` + - `flv_pull_url / hls_pull_url_map` +- select the highest quality by default or respect configured quality + +### Background polling scheduler + +- configurable polling interval +- configurable enable/disable switch +- configurable auto-start switch +- updates room status in background +- auto-creates a recording task when a room is live and no task is running +- also works as a recovery layer when ffmpeg exits unexpectedly while the room is still live + +### Recording enhancements + +- save format setting: `MP4` or `TS` +- save mode setting: + - `SingleFile` + - `Segmented` +- recording template setting: + - `StreamCopy` + - `BalancedMp4` + - `ArchiveTs` +- reconnect tuning: + - enable/disable reconnect + - max reconnect delay + - read/write timeout + +### Frontend + +- login page +- live room management +- recording task list +- recording task detail +- log viewer +- system settings +- route lazy loading +- vendor chunk splitting in Vite + +## Settings exposed in UI + +- ffmpeg path +- output root +- default quality +- live save format +- save mode +- recording template +- segment duration +- reconnect switch and reconnect delay +- read/write timeout +- background polling switch +- auto-start-on-live switch +- polling interval +- Douyin User-Agent / Referer / Cookie + +## Run + +### Backend + +```powershell +dotnet restore LiveRecorder.sln +dotnet build LiveRecorder.sln --no-restore -m:1 +dotnet run --project src/LiveRecorder.WebApi/LiveRecorder.WebApi.csproj --urls http://localhost:5000 +``` + +Default account: + +- username: `admin` +- password: `Admin@123` + +Swagger: + +- [http://localhost:5000/swagger](http://localhost:5000/swagger) + +### Frontend + +```powershell +cd frontend +npm install +npm run dev +``` + +Frontend dev server: + +- [http://localhost:5173](http://localhost:5173) + +## Verified locally + +- `dotnet build LiveRecorder.sln --no-restore -m:1` +- `npm run build` + +## Next steps + +1. Implement full adapters for Bilibili, Huya, Douyu, and Kuaishou. +2. Add per-room recording preferences instead of relying only on global defaults. +3. Add full-text log search and time-range filters. +4. Replace lightweight token sessions with JWT or external SSO if needed. diff --git a/dycast_repo b/dycast_repo new file mode 160000 index 0000000..dc3ab9a --- /dev/null +++ b/dycast_repo @@ -0,0 +1 @@ +Subproject commit dc3ab9ad4633b21ec255942e5262bba78750a1c3 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..a11aff5 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Live Recorder Console + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..8a6775b --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2046 @@ +{ + "name": "live-recorder-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "live-recorder-frontend", + "version": "0.1.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.1", + "axios": "^1.9.0", + "element-plus": "^2.10.1", + "pinia": "^2.1.7", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.3", + "typescript": "^5.7.3", + "vite": "^6.2.0", + "vue-tsc": "^2.2.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", + "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.32", + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", + "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmmirror.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.32.tgz", + "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.32.tgz", + "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", + "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/runtime-core": "3.5.32", + "@vue/shared": "3.5.32", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.32.tgz", + "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "vue": "3.5.32" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-12.0.0.tgz", + "integrity": "sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "12.0.0", + "@vueuse/shared": "12.0.0", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-12.0.0.tgz", + "integrity": "sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-12.0.0.tgz", + "integrity": "sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==", + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.15.0", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/element-plus": { + "version": "2.13.7", + "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.13.7.tgz", + "integrity": "sha512-XdHATFZOyzVFL1DaHQ90IOJQSg9UnSAV+bhDW+YB5UoZ0Hxs50mwqjqfwXkuwpSag+VXXizVcErBR6Movo5daw==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "12.0.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.19", + "lodash": "^4.17.23", + "lodash-es": "^4.17.23", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.2.4" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.32.tgz", + "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-sfc": "3.5.32", + "@vue/runtime-dom": "3.5.32", + "@vue/server-renderer": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.2.6", + "resolved": "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-3.2.6.tgz", + "integrity": "sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==", + "license": "MIT" + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1f29d0d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "live-recorder-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@element-plus/icons-vue": "^2.3.1", + "axios": "^1.9.0", + "element-plus": "^2.10.1", + "pinia": "^2.1.7", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.3", + "typescript": "^5.7.3", + "vite": "^6.2.0", + "vue-tsc": "^2.2.0" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..98240ae --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,3 @@ + diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..c04eb9d --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,33 @@ +import axios from "axios"; + +const apiClient = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL ?? "/api", + timeout: 30000 +}); + +apiClient.interceptors.request.use((config) => { + const token = localStorage.getItem("live-recorder-token"); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + + return config; +}); + +apiClient.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + localStorage.removeItem("live-recorder-token"); + localStorage.removeItem("live-recorder-user"); + + if (window.location.pathname !== "/login") { + window.location.href = "/login"; + } + } + + return Promise.reject(error); + } +); + +export default apiClient; diff --git a/frontend/src/components/layout/MainLayout.vue b/frontend/src/components/layout/MainLayout.vue new file mode 100644 index 0000000..e6d6d0d --- /dev/null +++ b/frontend/src/components/layout/MainLayout.vue @@ -0,0 +1,237 @@ + + + + + diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts new file mode 100644 index 0000000..015a333 --- /dev/null +++ b/frontend/src/env.d.ts @@ -0,0 +1,6 @@ +declare module "*.vue" { + import type { DefineComponent } from "vue"; + + const component: DefineComponent, Record, unknown>; + export default component; +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..ba1c71a --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,9 @@ +import { createApp } from "vue"; +import { createPinia } from "pinia"; +import ElementPlus from "element-plus"; +import "element-plus/dist/index.css"; +import App from "./App.vue"; +import router from "./router"; +import "./styles/main.css"; + +createApp(App).use(createPinia()).use(router).use(ElementPlus).mount("#app"); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..47e8b7f --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,74 @@ +import { createRouter, createWebHistory } from "vue-router"; +import { useAuthStore } from "@/stores/auth"; + +const LoginView = () => import("@/views/LoginView.vue"); +const MainLayout = () => import("@/components/layout/MainLayout.vue"); +const LiveRoomsView = () => import("@/views/LiveRoomsView.vue"); +const RecordTasksView = () => import("@/views/RecordTasksView.vue"); +const RecordTaskDetailView = () => import("@/views/RecordTaskDetailView.vue"); +const LogsView = () => import("@/views/LogsView.vue"); +const SettingsView = () => import("@/views/SettingsView.vue"); + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: "/login", + name: "login", + component: LoginView, + meta: { anonymous: true } + }, + { + path: "/", + component: MainLayout, + children: [ + { + path: "", + redirect: "/live-rooms" + }, + { + path: "live-rooms", + name: "live-rooms", + component: LiveRoomsView + }, + { + path: "record-tasks", + name: "record-tasks", + component: RecordTasksView + }, + { + path: "record-tasks/:id", + name: "record-task-detail", + component: RecordTaskDetailView, + props: true + }, + { + path: "logs", + name: "logs", + component: LogsView + }, + { + path: "settings", + name: "settings", + component: SettingsView + } + ] + } + ] +}); + +router.beforeEach((to) => { + const authStore = useAuthStore(); + + if (!to.meta.anonymous && !authStore.isAuthenticated) { + return { name: "login" }; + } + + if (to.name === "login" && authStore.isAuthenticated) { + return { name: "live-rooms" }; + } + + return true; +}); + +export default router; diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts new file mode 100644 index 0000000..f3663de --- /dev/null +++ b/frontend/src/stores/auth.ts @@ -0,0 +1,60 @@ +import { computed, ref } from "vue"; +import { defineStore } from "pinia"; +import apiClient from "@/api/client"; +import type { AuthenticatedUser, LoginResponse } from "@/types"; + +const USER_STORAGE_KEY = "live-recorder-user"; +const TOKEN_STORAGE_KEY = "live-recorder-token"; + +export const useAuthStore = defineStore("auth", () => { + const token = ref(localStorage.getItem(TOKEN_STORAGE_KEY) ?? ""); + const user = ref( + (() => { + const raw = localStorage.getItem(USER_STORAGE_KEY); + return raw ? (JSON.parse(raw) as AuthenticatedUser) : null; + })() + ); + + const isAuthenticated = computed(() => Boolean(token.value)); + + function persistSession(session: LoginResponse) { + token.value = session.token; + user.value = session.user; + localStorage.setItem(TOKEN_STORAGE_KEY, session.token); + localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(session.user)); + } + + function clearSession() { + token.value = ""; + user.value = null; + localStorage.removeItem(TOKEN_STORAGE_KEY); + localStorage.removeItem(USER_STORAGE_KEY); + } + + async function login(username: string, password: string) { + const { data } = await apiClient.post("/auth/login", { + username, + password + }); + + persistSession(data); + return data; + } + + async function logout() { + try { + await apiClient.post("/auth/logout"); + } finally { + clearSession(); + } + } + + return { + token, + user, + isAuthenticated, + login, + logout, + clearSession + }; +}); diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css new file mode 100644 index 0000000..e3207a7 --- /dev/null +++ b/frontend/src/styles/main.css @@ -0,0 +1,552 @@ +:root { + color-scheme: light; + color: #171614; + background: #f6f4ef; + font-family: "SF Pro Display", "SF Pro Text", "PingFang SC", "Helvetica Neue", sans-serif; + + --bg-base: #f6f4ef; + --bg-soft: #fbfaf7; + --surface: rgba(255, 255, 255, 0.82); + --surface-strong: #fffcf7; + --surface-muted: #f3f0ea; + --border-light: rgba(82, 74, 63, 0.08); + --border-base: rgba(82, 74, 63, 0.12); + --border-strong: rgba(82, 74, 63, 0.18); + --text-primary: #171614; + --text-secondary: #6d675f; + --text-tertiary: #948d83; + --accent: #3e5f7c; + --accent-strong: #2f4c66; + --accent-soft: rgba(62, 95, 124, 0.12); + --danger-soft: rgba(183, 80, 70, 0.1); + --shadow-soft: 0 10px 28px rgba(31, 24, 18, 0.05); + --shadow-card: 0 18px 48px rgba(31, 24, 18, 0.07); + --shadow-hover: 0 20px 40px rgba(31, 24, 18, 0.1); + + --el-color-primary: var(--accent); + --el-color-primary-light-3: #6683a0; + --el-color-primary-light-5: #8da4ba; + --el-color-primary-light-7: #bcc9d7; + --el-color-primary-light-8: #d8e0e8; + --el-color-primary-light-9: #edf2f6; + --el-color-primary-dark-2: var(--accent-strong); + --el-text-color-primary: var(--text-primary); + --el-text-color-regular: var(--text-secondary); + --el-text-color-secondary: var(--text-tertiary); + --el-border-color: var(--border-base); + --el-border-color-light: var(--border-light); + --el-border-radius-base: 10px; + --el-bg-color: transparent; + --el-fill-color-blank: rgba(255, 255, 255, 0.72); + --el-fill-color-light: rgba(244, 241, 236, 0.94); + --el-mask-color: rgba(17, 19, 17, 0.35); +} + +* { + box-sizing: border-box; +} + +*::selection { + color: #ffffff; + background: rgba(62, 95, 124, 0.75); +} + +html, +body, +#app { + margin: 0; + min-height: 100%; +} + +body { + color: var(--text-primary); + background: + radial-gradient(circle at top left, rgba(255, 255, 255, 0.8) 0, rgba(255, 255, 255, 0) 32%), + radial-gradient(circle at top right, rgba(62, 95, 124, 0.08) 0, rgba(62, 95, 124, 0) 28%), + linear-gradient(180deg, #faf8f3 0%, #f6f4ef 42%, #f1eee9 100%); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +body, +button, +input, +textarea, +select { + font: inherit; +} + +a { + color: inherit; + text-decoration: none; +} + +button:focus-visible, +input:focus-visible, +textarea:focus-visible, +select:focus-visible { + outline: none; +} + +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + margin-bottom: 4px; +} + +.page-header > div:first-child { + flex: 1; + min-width: 0; +} + +.page-title { + margin: 0; + font-size: clamp(28px, 3vw, 38px); + font-weight: 700; + letter-spacing: -0.04em; + line-height: 1.02; + color: var(--text-primary); +} + +.page-subtitle { + max-width: 760px; + margin: 12px 0 0; + color: var(--text-secondary); + font-size: 14px; + line-height: 1.8; +} + +.surface-card { + position: relative; + overflow: hidden; + border-radius: 12px; + border: 1px solid var(--border-base); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.9) 0%, rgba(255, 252, 247, 0.84) 100%); + box-shadow: var(--shadow-soft); + backdrop-filter: blur(14px); + transition: + transform 0.22s ease, + box-shadow 0.22s ease, + border-color 0.22s ease; +} + +.surface-card::before { + content: ""; + position: absolute; + inset: 0 0 auto; + height: 1px; + background: linear-gradient(90deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0)); + pointer-events: none; +} + +.surface-card:hover { + transform: translateY(-2px); + border-color: var(--border-strong); + box-shadow: var(--shadow-card); +} + +.surface-card .el-card__body { + padding: 22px; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(208px, 1fr)); + gap: 16px; + margin-bottom: 2px; +} + +.stat-card { + position: relative; + overflow: hidden; + padding: 18px 18px 16px; + border-radius: 12px; + border: 1px solid var(--border-light); + background: + radial-gradient(circle at top right, rgba(62, 95, 124, 0.08) 0, rgba(62, 95, 124, 0) 48%), + linear-gradient(180deg, rgba(255, 255, 255, 0.86) 0%, rgba(250, 248, 244, 0.92) 100%); + box-shadow: 0 12px 26px rgba(31, 24, 18, 0.05); + transition: + transform 0.22s ease, + box-shadow 0.22s ease, + border-color 0.22s ease; +} + +.stat-card::after { + content: ""; + position: absolute; + top: 14px; + right: 14px; + width: 10px; + height: 10px; + border-radius: 999px; + background: rgba(62, 95, 124, 0.12); + box-shadow: 0 0 0 6px rgba(62, 95, 124, 0.05); +} + +.stat-card:hover { + transform: translateY(-3px); + border-color: var(--border-base); + box-shadow: 0 18px 32px rgba(31, 24, 18, 0.08); +} + +.stat-card__label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-tertiary); +} + +.stat-card__value { + margin-top: 14px; + font-size: 32px; + font-weight: 700; + letter-spacing: -0.05em; + color: var(--text-primary); +} + +.stat-card__hint { + margin-top: 8px; + color: var(--text-secondary); + font-size: 13px; + line-height: 1.65; +} + +.section-title { + margin: 0 0 6px; + font-size: 17px; + font-weight: 650; + letter-spacing: -0.02em; + color: var(--text-primary); +} + +.section-subtitle { + margin: 0 0 18px; + color: var(--text-secondary); + font-size: 13px; + line-height: 1.75; +} + +.token-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; +} + +.token-chip { + display: inline-flex; + align-items: center; + padding: 7px 11px; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent); + font-size: 12px; + font-weight: 600; + letter-spacing: -0.01em; +} + +.monospace { + font-family: "SF Mono", "Cascadia Code", "JetBrains Mono", "Consolas", monospace; +} + +.el-card { + --el-card-border-color: transparent; + background: transparent; +} + +.el-button { + height: 38px; + padding: 0 15px; + border-radius: 10px; + font-weight: 600; + color: var(--text-primary); + letter-spacing: -0.01em; + transition: + transform 0.18s ease, + box-shadow 0.18s ease, + border-color 0.18s ease, + background-color 0.18s ease, + color 0.18s ease; +} + +.el-button.el-button--default:not(.is-text):not(.is-link) { + border-color: rgba(91, 110, 129, 0.16); + background: rgba(255, 255, 255, 0.86); + color: #22303e; +} + +.el-button:not(.is-disabled):hover { + transform: translateY(-1px); + box-shadow: 0 10px 20px rgba(31, 24, 18, 0.08); +} + +.el-button:not(.is-disabled):active { + transform: translateY(0); + box-shadow: 0 6px 14px rgba(31, 24, 18, 0.06); +} + +.el-button.el-button--primary { + background: linear-gradient(180deg, #4a6984 0%, #3e5f7c 100%) !important; + border-color: #35516a !important; + color: #f7fafc !important; + box-shadow: 0 12px 22px rgba(62, 95, 124, 0.2); +} + +.el-button.el-button--primary:not(.is-disabled):hover { + background: linear-gradient(180deg, #3d5b75 0%, #2f4c66 100%) !important; + border-color: #2f4c66 !important; + color: #f7fafc !important; +} + +.el-button.el-button--primary:not(.is-disabled):active { + background: #2f4c66 !important; + border-color: #29445c !important; +} + +.el-button.is-plain:not(.el-button--danger):not(.el-button--primary) { + background: rgba(62, 95, 124, 0.08) !important; + border-color: rgba(62, 95, 124, 0.18) !important; + color: #35536d !important; +} + +.el-button.el-button--danger.is-plain { + background: rgba(183, 80, 70, 0.08) !important; + border-color: rgba(183, 80, 70, 0.22) !important; + color: #a1473d !important; +} + +.el-button.is-disabled, +.el-button.is-disabled:hover, +.el-button.is-disabled:active { + background: rgba(233, 237, 241, 0.92) !important; + border-color: rgba(91, 110, 129, 0.12) !important; + color: #9aa5b1 !important; + box-shadow: none !important; + transform: none !important; +} + +.el-button.is-text { + padding-left: 8px; + padding-right: 8px; +} + +.el-input, +.el-select, +.el-date-editor, +.el-input-number { + width: 100%; +} + +.el-input__wrapper, +.el-select__wrapper, +.el-textarea__inner, +.el-input-number, +.el-input-number__decrease, +.el-input-number__increase { + border-radius: 10px; +} + +.el-input__wrapper, +.el-select__wrapper, +.el-textarea__inner, +.el-input-number { + background: rgba(255, 255, 255, 0.72); + box-shadow: inset 0 0 0 1px rgba(82, 74, 63, 0.1) !important; + transition: + box-shadow 0.18s ease, + background-color 0.18s ease, + transform 0.18s ease; +} + +.el-input__wrapper:hover, +.el-select__wrapper:hover, +.el-textarea__inner:hover, +.el-input-number:hover { + box-shadow: inset 0 0 0 1px rgba(82, 74, 63, 0.16) !important; +} + +.el-input__wrapper.is-focus, +.el-select__wrapper.is-focused, +.el-textarea__inner:focus, +.el-input-number:focus-within { + background: rgba(255, 255, 255, 0.92); + box-shadow: + inset 0 0 0 1px rgba(62, 95, 124, 0.34), + 0 0 0 4px rgba(62, 95, 124, 0.08) !important; +} + +.el-textarea__inner { + line-height: 1.7; +} + +.el-form-item { + margin-bottom: 18px; +} + +.el-form-item__label { + padding-bottom: 8px; + font-size: 13px; + font-weight: 600; + color: var(--text-primary) !important; +} + +.el-switch { + --el-switch-on-color: var(--accent); + --el-switch-off-color: rgba(120, 114, 105, 0.28); +} + +.el-tag { + border: none; + border-radius: 999px; + height: 28px; + padding: 0 10px; + font-weight: 600; + letter-spacing: -0.01em; +} + +.el-tag--success { + background: rgba(40, 132, 95, 0.12); + color: #1d6d53; +} + +.el-tag--warning { + background: rgba(189, 131, 37, 0.12); + color: #8a6221; +} + +.el-tag--danger { + background: rgba(183, 80, 70, 0.12); + color: #9c4036; +} + +.el-tag--info, +.el-tag.el-tag--primary { + background: rgba(101, 95, 86, 0.12); + color: #5d554d; +} + +.el-table { + --el-table-border-color: transparent; + --el-table-row-hover-bg-color: rgba(62, 95, 124, 0.05); + --el-table-current-row-bg-color: rgba(62, 95, 124, 0.07); + --el-table-header-bg-color: transparent; + --el-table-bg-color: transparent; + --el-fill-color-blank: transparent; + background: transparent; +} + +.el-table::before, +.el-table__inner-wrapper::before { + display: none; +} + +.el-table th.el-table__cell { + padding: 4px 0 12px; + border-bottom: 1px solid var(--border-base); + background: transparent; +} + +.el-table th.el-table__cell > .cell { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-tertiary); +} + +.el-table td.el-table__cell { + padding: 16px 0; + border-bottom: 1px solid rgba(82, 74, 63, 0.06); + background: transparent; +} + +.el-table .cell { + line-height: 1.45; +} + +.el-table tr { + background: transparent; +} + +.el-descriptions { + --el-descriptions-table-border: 1px solid rgba(82, 74, 63, 0.08); +} + +.el-descriptions__body .el-descriptions__table { + border-radius: 10px; + overflow: hidden; +} + +.el-descriptions__body .el-descriptions__label.el-descriptions__cell.is-bordered-label { + background: var(--surface-muted); + color: var(--text-tertiary); + font-weight: 600; +} + +.el-descriptions__body .el-descriptions__content.el-descriptions__cell.is-bordered-content { + background: rgba(255, 255, 255, 0.72); + color: var(--text-primary); +} + +.el-dialog { + border-radius: 16px; + border: 1px solid var(--border-base); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(252, 249, 244, 0.94)); + box-shadow: var(--shadow-hover); +} + +.el-dialog__header { + margin: 0; + padding: 22px 24px 10px; +} + +.el-dialog__title { + font-size: 18px; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--text-primary); +} + +.el-dialog__body { + padding: 12px 24px 6px; +} + +.el-dialog__footer { + padding: 12px 24px 22px; +} + +.el-empty__description, +.el-result__subtitle, +.el-empty__description p { + color: var(--text-secondary); +} + +@media (max-width: 1200px) { + .stats-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 768px) { + .page-header { + flex-direction: column; + align-items: flex-start; + } + + .page-title { + font-size: 30px; + } + + .stats-grid { + grid-template-columns: 1fr; + } + + .surface-card .el-card__body { + padding: 18px; + } +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..f732bb0 --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,202 @@ +export interface AuthenticatedUser { + userId: string; + username: string; + displayName: string; + token: string; + expiresAt: string; +} + +export interface LoginResponse { + token: string; + expiresAt: string; + user: AuthenticatedUser; +} + +export interface LiveRoom { + id: string; + platform: number; + platformName: string; + sourceUrl: string; + roomId: string; + normalizedUrl: string; + title?: string; + anchorName?: string; + coverUrl?: string; + isEnabled: boolean; + availabilityStatus: number; + lastCheckedAt?: string; + createdAt: string; + updatedAt: string; +} + +export interface RecordTask { + id: string; + liveRoomId: string; + recordSessionId: string; + segmentIndex: number; + liveRoomTitle: string; + platform: number; + roomId: string; + status: number; + preferredQuality: string; + outputFormat: number; + streamUrl?: string; + outputFilePath?: string; + recorderProcessId?: number; + errorMessage?: string; + createdAt: string; + startedAt?: string; + endedAt?: string; + durationSeconds?: number; +} + +export interface RecordResult { + id: string; + filePath: string; + fileSizeBytes?: number; + durationSeconds?: number; + danmakuFilePath?: string; + danmakuMessageCount: number; + finalStatus: number; + errorMessage?: string; + createdAt: string; +} + +export interface RecordSession { + id: string; + liveRoomId: string; + liveRoomTitle: string; + platform: number; + roomId: string; + status: number; + preferredQuality: string; + outputFormat: number; + saveMode: number; + activeSegmentIndex: number; + segmentCount: number; + recorderProcessId?: number; + errorMessage?: string; + createdAt: string; + startedAt?: string; + endedAt?: string; + totalFileSizeBytes: number; + totalDanmakuMessageCount: number; + tasks: RecordTask[]; +} + +export interface SystemLog { + id: string; + level: number; + category: string; + message: string; + detail?: string; + liveRoomId?: string; + recordSessionId?: string; + recordTaskId?: string; + createdAt: string; +} + +export interface RecordTaskDetail { + task: RecordTask; + result?: RecordResult; + logs: SystemLog[]; +} + +export interface DeleteCompletedRecordTasksResult { + deletedTaskIds: string[]; + deletedFilePaths: string[]; + deletedDanmakuPaths: string[]; + deletedSessionIds: string[]; + warnings: string[]; +} + +export interface RecordPreviewTicket { + url: string; + expiresAt: string; +} + +export interface RecordSessionDetail { + session: RecordSession; + logs: SystemLog[]; +} + +export interface SystemSettings { + ffmpegPath: string; + outputRoot: string; + outputDirectoryTemplate: string; + outputFileNameTemplate: string; + defaultQuality: string; + defaultOutputFormat: number; + saveMode: number; + recordingTemplate: number; + segmentDurationMinutes: number; + enableAutoReconnect: boolean; + reconnectDelayMaxSeconds: number; + readWriteTimeoutMilliseconds: number; + enableDanmakuRecording: boolean; + danmakuIncludeNonChatEvents: boolean; + danmakuMinPollIntervalMilliseconds: number; + danmakuRetryDelayMaxSeconds: number; + enableBackgroundPolling: boolean; + autoStartRecordingOnLive: boolean; + pollingIntervalSeconds: number; + enableEmailNotification: boolean; + emailSmtpHost: string; + emailSmtpPort: number; + emailUseSsl: boolean; + emailUsername: string; + emailPassword: string; + emailFromAddress: string; + emailFromDisplayName: string; + emailToAddresses: string; + notifyOnLiveStarted: boolean; + notifyOnException: boolean; + emailLiveStartedSubjectTemplate: string; + emailLiveStartedBodyTemplateHtml: string; + emailExceptionSubjectTemplate: string; + emailExceptionBodyTemplateHtml: string; + douyinUserAgent: string; + douyinReferer: string; + douyinCookie: string; +} + +export const availabilityLabelMap: Record = { + 0: "Unknown", + 1: "Offline", + 2: "Live" +}; + +export const taskStatusLabelMap: Record = { + 0: "Pending", + 1: "Starting", + 2: "Running", + 3: "Stopping", + 4: "Completed", + 5: "Failed", + 6: "Stopped" +}; + +export const sessionStatusLabelMap = taskStatusLabelMap; + +export const logLevelLabelMap: Record = { + 0: "Trace", + 1: "Info", + 2: "Warning", + 3: "Error" +}; + +export const outputFormatLabelMap: Record = { + 0: "MP4", + 1: "TS" +}; + +export const saveModeLabelMap: Record = { + 0: "Single File", + 1: "Segmented" +}; + +export const recordingTemplateLabelMap: Record = { + 0: "Stream Copy", + 1: "Balanced MP4", + 2: "Archive TS" +}; diff --git a/frontend/src/views/LiveRoomsView.vue b/frontend/src/views/LiveRoomsView.vue new file mode 100644 index 0000000..63205f1 --- /dev/null +++ b/frontend/src/views/LiveRoomsView.vue @@ -0,0 +1,570 @@ + + + + + diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue new file mode 100644 index 0000000..72557c4 --- /dev/null +++ b/frontend/src/views/LoginView.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/frontend/src/views/LogsView.vue b/frontend/src/views/LogsView.vue new file mode 100644 index 0000000..c71cf22 --- /dev/null +++ b/frontend/src/views/LogsView.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/frontend/src/views/RecordTaskDetailView.vue b/frontend/src/views/RecordTaskDetailView.vue new file mode 100644 index 0000000..6122dee --- /dev/null +++ b/frontend/src/views/RecordTaskDetailView.vue @@ -0,0 +1,340 @@ + + + + + diff --git a/frontend/src/views/RecordTasksView.vue b/frontend/src/views/RecordTasksView.vue new file mode 100644 index 0000000..812c933 --- /dev/null +++ b/frontend/src/views/RecordTasksView.vue @@ -0,0 +1,738 @@ + + + + + diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue new file mode 100644 index 0000000..0a8a977 --- /dev/null +++ b/frontend/src/views/SettingsView.vue @@ -0,0 +1,657 @@ + + + + + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..244d8a3 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "types": ["vite/client"], + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..16c89c7 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,45 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; +import path from "node:path"; + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + "@": path.resolve(__dirname, "src") + } + }, + build: { + chunkSizeWarningLimit: 900, + rollupOptions: { + output: { + manualChunks(id) { + if (id.includes("node_modules/element-plus")) { + return "element-plus"; + } + + if (id.includes("node_modules/@element-plus/icons-vue")) { + return "element-plus-icons"; + } + + if (id.includes("node_modules/vue") || id.includes("node_modules/pinia") || id.includes("node_modules/vue-router")) { + return "vue-vendor"; + } + + if (id.includes("node_modules/axios")) { + return "network-vendor"; + } + } + } + } + }, + server: { + port: 5173, + proxy: { + "/api": { + target: "http://localhost:5000", + changeOrigin: true + } + } + } +}); diff --git a/src/LiveRecorder.Application/Abstractions/Auth/IAuthService.cs b/src/LiveRecorder.Application/Abstractions/Auth/IAuthService.cs new file mode 100644 index 0000000..252cb79 --- /dev/null +++ b/src/LiveRecorder.Application/Abstractions/Auth/IAuthService.cs @@ -0,0 +1,12 @@ +using LiveRecorder.Application.Models.Auth; + +namespace LiveRecorder.Application.Abstractions.Auth; + +public interface IAuthService +{ + Task LoginAsync(LoginRequest request, CancellationToken cancellationToken = default); + + Task LogoutAsync(string token, CancellationToken cancellationToken = default); + + Task ValidateTokenAsync(string token, CancellationToken cancellationToken = default); +} diff --git a/src/LiveRecorder.Application/Abstractions/Logging/ISystemLogService.cs b/src/LiveRecorder.Application/Abstractions/Logging/ISystemLogService.cs new file mode 100644 index 0000000..131ee47 --- /dev/null +++ b/src/LiveRecorder.Application/Abstractions/Logging/ISystemLogService.cs @@ -0,0 +1,24 @@ +using LiveRecorder.Application.Models.Logs; +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Abstractions.Logging; + +public interface ISystemLogService +{ + Task WriteAsync( + SystemLogLevel level, + string category, + string message, + string? detail = null, + Guid? liveRoomId = null, + Guid? recordSessionId = null, + Guid? recordTaskId = null, + CancellationToken cancellationToken = default); + + Task> ListAsync( + Guid? liveRoomId = null, + Guid? recordSessionId = null, + Guid? recordTaskId = null, + int take = 200, + CancellationToken cancellationToken = default); +} diff --git a/src/LiveRecorder.Application/Abstractions/Notifications/IEmailNotificationService.cs b/src/LiveRecorder.Application/Abstractions/Notifications/IEmailNotificationService.cs new file mode 100644 index 0000000..d834d45 --- /dev/null +++ b/src/LiveRecorder.Application/Abstractions/Notifications/IEmailNotificationService.cs @@ -0,0 +1,19 @@ +using LiveRecorder.Domain.Entities; +using LiveRecorder.Application.Models.Settings; + +namespace LiveRecorder.Application.Abstractions.Notifications; + +public interface IEmailNotificationService +{ + Task SendLiveStartedAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default); + + Task SendExceptionAsync( + string source, + string summary, + string? detail = null, + LiveRoom? liveRoom = null, + RecordTask? recordTask = null, + CancellationToken cancellationToken = default); + + Task SendTestAsync(SendTestEmailRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/LiveRecorder.Application/Abstractions/Persistence/PersistenceContracts.cs b/src/LiveRecorder.Application/Abstractions/Persistence/PersistenceContracts.cs new file mode 100644 index 0000000..79ea6b7 --- /dev/null +++ b/src/LiveRecorder.Application/Abstractions/Persistence/PersistenceContracts.cs @@ -0,0 +1,121 @@ +using LiveRecorder.Domain.Entities; +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Abstractions.Persistence; + +public interface IAppSettingRepository +{ + Task GetByKeyAsync(string key, CancellationToken cancellationToken = default); + + Task> ListAsync(CancellationToken cancellationToken = default); + + Task AddAsync(AppSetting setting, CancellationToken cancellationToken = default); + + void Update(AppSetting setting); +} + +public interface ILiveRoomRepository +{ + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + Task GetByPlatformRoomIdAsync( + LivePlatformType platformType, + string roomId, + CancellationToken cancellationToken = default); + + Task> ListAsync(CancellationToken cancellationToken = default); + + Task AddAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default); + + void Remove(LiveRoom liveRoom); +} + +public interface IRecordTaskRepository +{ + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + Task> GetByIdsAsync(IReadOnlyCollection ids, CancellationToken cancellationToken = default); + + Task> ListAsync(Guid? liveRoomId = null, CancellationToken cancellationToken = default); + + Task> ListBySessionIdAsync(Guid recordSessionId, CancellationToken cancellationToken = default); + + Task GetRunningByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default); + + Task AddAsync(RecordTask recordTask, CancellationToken cancellationToken = default); + + void Remove(RecordTask recordTask); + + void RemoveRange(IEnumerable recordTasks); +} + +public interface IRecordSessionRepository +{ + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + Task> ListAsync(Guid? liveRoomId = null, CancellationToken cancellationToken = default); + + Task GetActiveByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default); + + Task AddAsync(RecordSession recordSession, CancellationToken cancellationToken = default); + + void Remove(RecordSession recordSession); +} + +public interface IRecordResultRepository +{ + Task GetByTaskIdAsync(Guid recordTaskId, CancellationToken cancellationToken = default); + + Task AddAsync(RecordResult recordResult, CancellationToken cancellationToken = default); + + void Update(RecordResult recordResult); + + void Remove(RecordResult recordResult); + + void RemoveRange(IEnumerable recordResults); +} + +public interface ISystemLogRepository +{ + Task AddAsync(SystemLogEntry entry, CancellationToken cancellationToken = default); + + Task> ListByRecordTaskIdsAsync( + IReadOnlyCollection recordTaskIds, + CancellationToken cancellationToken = default); + + Task> ListByRecordSessionIdsAsync( + IReadOnlyCollection recordSessionIds, + CancellationToken cancellationToken = default); + + Task> ListAsync( + Guid? liveRoomId = null, + Guid? recordSessionId = null, + Guid? recordTaskId = null, + int take = 200, + CancellationToken cancellationToken = default); + + void RemoveRange(IEnumerable entries); +} + +public interface IUserAccountRepository +{ + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + Task GetByUsernameAsync(string username, CancellationToken cancellationToken = default); + + Task AddAsync(UserAccount userAccount, CancellationToken cancellationToken = default); +} + +public interface IUserSessionRepository +{ + Task GetByTokenAsync(string token, CancellationToken cancellationToken = default); + + Task AddAsync(UserSession session, CancellationToken cancellationToken = default); + + void Update(UserSession session); +} + +public interface IUnitOfWork +{ + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/LiveRecorder.Application/Abstractions/Platforms/DanmakuContracts.cs b/src/LiveRecorder.Application/Abstractions/Platforms/DanmakuContracts.cs new file mode 100644 index 0000000..052b4e6 --- /dev/null +++ b/src/LiveRecorder.Application/Abstractions/Platforms/DanmakuContracts.cs @@ -0,0 +1,42 @@ +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Abstractions.Platforms; + +public interface ILiveDanmakuAdapter +{ + LivePlatformType PlatformType { get; } + + bool CanHandle(LivePlatformType platformType); + + Task ConnectAsync(DanmakuConnectionContext context, CancellationToken cancellationToken = default); +} + +public interface ILiveDanmakuAdapterFactory +{ + ILiveDanmakuAdapter GetByPlatform(LivePlatformType platformType); +} + +public interface ILiveDanmakuConnection : IAsyncDisposable +{ + Task StartAsync(Func onEvent, CancellationToken cancellationToken = default); +} + +public sealed record DanmakuConnectionContext( + Guid LiveRoomId, + Guid RecordSessionId, + LivePlatformType PlatformType, + string RoomId, + string? AnchorName, + string? Title, + string? SourceUrl, + int MinPollIntervalMilliseconds = 1000, + int RetryDelayMaxSeconds = 15); + +public sealed record DanmakuEvent( + string Type, + string? User, + string? UserId, + string? Content, + DateTimeOffset OccurredAt, + string RawPayload, + IReadOnlyDictionary? Extra = null); diff --git a/src/LiveRecorder.Application/Abstractions/Platforms/PlatformContracts.cs b/src/LiveRecorder.Application/Abstractions/Platforms/PlatformContracts.cs new file mode 100644 index 0000000..e409418 --- /dev/null +++ b/src/LiveRecorder.Application/Abstractions/Platforms/PlatformContracts.cs @@ -0,0 +1,60 @@ +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Abstractions.Platforms; + +public interface ILivePlatformAdapter +{ + LivePlatformType PlatformType { get; } + + bool CanHandle(string input); + + Task ParseRoomAsync(string input, CancellationToken cancellationToken = default); + + Task GetLiveStatusAsync(string roomId, CancellationToken cancellationToken = default); + + Task GetStreamUrlAsync( + string roomId, + string? preferredQuality = null, + CancellationToken cancellationToken = default); +} + +public interface ILivePlatformAdapterFactory +{ + ILivePlatformAdapter GetByPlatform(LivePlatformType platformType); + + ILivePlatformAdapter GetByInput(string input); +} + +public sealed record ParsedLiveRoom( + LivePlatformType PlatformType, + string RoomId, + string SourceUrl, + string NormalizedUrl); + +public sealed record LiveStatusSnapshot( + bool IsLive, + string? Title, + string? AnchorName, + string? CoverUrl, + int? StatusCode, + string? RawStatus); + +public sealed record StreamQualityOption( + string QualityKey, + string QualityName, + string Url, + string Protocol, + int Rank); + +public sealed record StreamInputHeaders( + string? UserAgent, + string? Referer, + string? Cookie, + IReadOnlyDictionary? AdditionalHeaders = null); + +public sealed record StreamUrlResult( + string SelectedQuality, + string SelectedProtocol, + string SelectedUrl, + StreamInputHeaders? InputHeaders, + IReadOnlyList AvailableQualities); diff --git a/src/LiveRecorder.Application/Abstractions/Recording/IFfmpegService.cs b/src/LiveRecorder.Application/Abstractions/Recording/IFfmpegService.cs new file mode 100644 index 0000000..09645b1 --- /dev/null +++ b/src/LiveRecorder.Application/Abstractions/Recording/IFfmpegService.cs @@ -0,0 +1,32 @@ +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Domain.Entities; + +namespace LiveRecorder.Application.Abstractions.Recording; + +public interface IFfmpegService +{ + Task StartAsync( + RecordSession recordSession, + RecordTask initialTask, + StreamUrlResult streamUrlResult, + CancellationToken cancellationToken = default); + + Task CompleteAsync(Guid recordSessionId, CancellationToken cancellationToken = default); + + Task StopAsync(Guid recordSessionId, CancellationToken cancellationToken = default); + + Task StopAndWaitAsync( + Guid recordSessionId, + bool markAsCompletedOnExit, + TimeSpan timeout, + CancellationToken cancellationToken = default); + + Task KillAndWaitAsync( + Guid recordSessionId, + TimeSpan timeout, + CancellationToken cancellationToken = default); + + Task TryReconcileInactiveSessionAsync(Guid recordSessionId, CancellationToken cancellationToken = default); + + bool IsRunning(Guid recordSessionId); +} diff --git a/src/LiveRecorder.Application/Abstractions/Recording/IRecordMediaService.cs b/src/LiveRecorder.Application/Abstractions/Recording/IRecordMediaService.cs new file mode 100644 index 0000000..2c4d57f --- /dev/null +++ b/src/LiveRecorder.Application/Abstractions/Recording/IRecordMediaService.cs @@ -0,0 +1,17 @@ +namespace LiveRecorder.Application.Abstractions.Recording; + +public interface IRecordMediaService +{ + Task CreatePreviewTicketAsync(Guid recordTaskId, CancellationToken cancellationToken = default); + + Task ResolvePreviewAsync(string ticket, CancellationToken cancellationToken = default); +} + +public sealed record RecordPreviewTicketGrant( + string Ticket, + DateTimeOffset ExpiresAt); + +public sealed record RecordMediaFile( + Guid RecordTaskId, + string FilePath, + string ContentType); diff --git a/src/LiveRecorder.Application/Abstractions/Settings/ISystemSettingsService.cs b/src/LiveRecorder.Application/Abstractions/Settings/ISystemSettingsService.cs new file mode 100644 index 0000000..757aaf2 --- /dev/null +++ b/src/LiveRecorder.Application/Abstractions/Settings/ISystemSettingsService.cs @@ -0,0 +1,10 @@ +using LiveRecorder.Application.Models.Settings; + +namespace LiveRecorder.Application.Abstractions.Settings; + +public interface ISystemSettingsService +{ + Task GetAsync(CancellationToken cancellationToken = default); + + Task UpdateAsync(UpdateSystemSettingsRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/LiveRecorder.Application/Common/PasswordHasher.cs b/src/LiveRecorder.Application/Common/PasswordHasher.cs new file mode 100644 index 0000000..a81fc89 --- /dev/null +++ b/src/LiveRecorder.Application/Common/PasswordHasher.cs @@ -0,0 +1,39 @@ +using System.Security.Cryptography; + +namespace LiveRecorder.Application.Common; + +public static class PasswordHasher +{ + public static string Hash(string password) + { + var salt = RandomNumberGenerator.GetBytes(16); + var hash = Rfc2898DeriveBytes.Pbkdf2( + password, + salt, + 100_000, + HashAlgorithmName.SHA256, + 32); + + return $"{Convert.ToBase64String(salt)}.{Convert.ToBase64String(hash)}"; + } + + public static bool Verify(string password, string passwordHash) + { + var parts = passwordHash.Split('.', 2, StringSplitOptions.TrimEntries); + if (parts.Length != 2) + { + return false; + } + + var salt = Convert.FromBase64String(parts[0]); + var storedHash = Convert.FromBase64String(parts[1]); + var computedHash = Rfc2898DeriveBytes.Pbkdf2( + password, + salt, + 100_000, + HashAlgorithmName.SHA256, + storedHash.Length); + + return CryptographicOperations.FixedTimeEquals(storedHash, computedHash); + } +} diff --git a/src/LiveRecorder.Application/LiveRecorder.Application.csproj b/src/LiveRecorder.Application/LiveRecorder.Application.csproj new file mode 100644 index 0000000..2b88a54 --- /dev/null +++ b/src/LiveRecorder.Application/LiveRecorder.Application.csproj @@ -0,0 +1,14 @@ + + + + + + + + + net8.0 + enable + enable + + + diff --git a/src/LiveRecorder.Application/Models/Auth/AuthModels.cs b/src/LiveRecorder.Application/Models/Auth/AuthModels.cs new file mode 100644 index 0000000..e9e5a81 --- /dev/null +++ b/src/LiveRecorder.Application/Models/Auth/AuthModels.cs @@ -0,0 +1,30 @@ +namespace LiveRecorder.Application.Models.Auth; + +public sealed class LoginRequest +{ + public string Username { get; set; } = string.Empty; + + public string Password { get; set; } = string.Empty; +} + +public sealed class AuthenticatedUser +{ + public required Guid UserId { get; init; } + + public required string Username { get; init; } + + public required string DisplayName { get; init; } + + public required string Token { get; init; } + + public required DateTimeOffset ExpiresAt { get; init; } +} + +public sealed class LoginResponse +{ + public required string Token { get; init; } + + public required DateTimeOffset ExpiresAt { get; init; } + + public required AuthenticatedUser User { get; init; } +} diff --git a/src/LiveRecorder.Application/Models/LiveRooms/LiveRoomModels.cs b/src/LiveRecorder.Application/Models/LiveRooms/LiveRoomModels.cs new file mode 100644 index 0000000..69d2f7c --- /dev/null +++ b/src/LiveRecorder.Application/Models/LiveRooms/LiveRoomModels.cs @@ -0,0 +1,46 @@ +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Models.LiveRooms; + +public sealed class CreateLiveRoomRequest +{ + public string Url { get; set; } = string.Empty; + + public LivePlatformType? PlatformOverride { get; set; } +} + +public sealed class LiveRoomDto +{ + public Guid Id { get; init; } + + public required LivePlatformType Platform { get; init; } + + public required string PlatformName { get; init; } + + public required string SourceUrl { get; init; } + + public required string RoomId { get; init; } + + public required string NormalizedUrl { get; init; } + + public string? Title { get; init; } + + public string? AnchorName { get; init; } + + public string? CoverUrl { get; init; } + + public bool IsEnabled { get; init; } + + public required LiveRoomAvailabilityStatus AvailabilityStatus { get; init; } + + public DateTimeOffset? LastCheckedAt { get; init; } + + public DateTimeOffset CreatedAt { get; init; } + + public DateTimeOffset UpdatedAt { get; init; } +} + +public sealed class SetLiveRoomEnabledRequest +{ + public bool IsEnabled { get; set; } +} diff --git a/src/LiveRecorder.Application/Models/Logs/SystemLogDto.cs b/src/LiveRecorder.Application/Models/Logs/SystemLogDto.cs new file mode 100644 index 0000000..669d41f --- /dev/null +++ b/src/LiveRecorder.Application/Models/Logs/SystemLogDto.cs @@ -0,0 +1,24 @@ +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Models.Logs; + +public sealed class SystemLogDto +{ + public Guid Id { get; init; } + + public required SystemLogLevel Level { get; init; } + + public required string Category { get; init; } + + public required string Message { get; init; } + + public string? Detail { get; init; } + + public Guid? LiveRoomId { get; init; } + + public Guid? RecordSessionId { get; init; } + + public Guid? RecordTaskId { get; init; } + + public DateTimeOffset CreatedAt { get; init; } +} diff --git a/src/LiveRecorder.Application/Models/RecordTasks/RecordSessionModels.cs b/src/LiveRecorder.Application/Models/RecordTasks/RecordSessionModels.cs new file mode 100644 index 0000000..f9f0011 --- /dev/null +++ b/src/LiveRecorder.Application/Models/RecordTasks/RecordSessionModels.cs @@ -0,0 +1,59 @@ +using LiveRecorder.Application.Models.Logs; +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Models.RecordTasks; + +public sealed class RecordSessionDto +{ + public Guid Id { get; init; } + + public Guid LiveRoomId { get; init; } + + public required string LiveRoomTitle { get; init; } + + public required LivePlatformType Platform { get; init; } + + public required string RoomId { get; init; } + + public required RecordSessionStatus Status { get; init; } + + public required string PreferredQuality { get; init; } + + public required RecordOutputFormat OutputFormat { get; init; } + + public required RecordSaveMode SaveMode { get; init; } + + public int ActiveSegmentIndex { get; init; } + + public int SegmentCount { get; init; } + + public int? RecorderProcessId { get; init; } + + public string? ErrorMessage { get; init; } + + public DateTimeOffset CreatedAt { get; init; } + + public DateTimeOffset? StartedAt { get; init; } + + public DateTimeOffset? EndedAt { get; init; } + + public long TotalFileSizeBytes { get; init; } + + public int TotalDanmakuMessageCount { get; init; } + + public required IReadOnlyList Tasks { get; init; } +} + +public sealed class RecordSessionDetailDto +{ + public required RecordSessionDto Session { get; init; } + + public required IReadOnlyList Logs { get; init; } +} + +public sealed class DeleteRecordSessionsRequest +{ + public IReadOnlyList SessionIds { get; set; } = []; + + public bool DeleteFiles { get; set; } +} diff --git a/src/LiveRecorder.Application/Models/RecordTasks/RecordTaskModels.cs b/src/LiveRecorder.Application/Models/RecordTasks/RecordTaskModels.cs new file mode 100644 index 0000000..7bcb4f9 --- /dev/null +++ b/src/LiveRecorder.Application/Models/RecordTasks/RecordTaskModels.cs @@ -0,0 +1,109 @@ +using LiveRecorder.Application.Models.Logs; +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Models.RecordTasks; + +public sealed class StartRecordTaskRequest +{ + public Guid LiveRoomId { get; set; } + + public string? PreferredQuality { get; set; } + + public RecordOutputFormat? OutputFormat { get; set; } +} + +public sealed class RecordResultDto +{ + public Guid Id { get; init; } + + public required string FilePath { get; init; } + + public long? FileSizeBytes { get; init; } + + public double? DurationSeconds { get; init; } + + public string? DanmakuFilePath { get; init; } + + public int DanmakuMessageCount { get; init; } + + public required RecordTaskStatus FinalStatus { get; init; } + + public string? ErrorMessage { get; init; } + + public DateTimeOffset CreatedAt { get; init; } +} + +public sealed class RecordTaskDto +{ + public Guid Id { get; init; } + + public Guid LiveRoomId { get; init; } + + public Guid RecordSessionId { get; init; } + + public int SegmentIndex { get; init; } + + public required string LiveRoomTitle { get; init; } + + public required LivePlatformType Platform { get; init; } + + public required string RoomId { get; init; } + + public required RecordTaskStatus Status { get; init; } + + public required string PreferredQuality { get; init; } + + public required RecordOutputFormat OutputFormat { get; init; } + + public string? StreamUrl { get; init; } + + public string? OutputFilePath { get; init; } + + public int? RecorderProcessId { get; init; } + + public string? ErrorMessage { get; init; } + + public DateTimeOffset CreatedAt { get; init; } + + public DateTimeOffset? StartedAt { get; init; } + + public DateTimeOffset? EndedAt { get; init; } + + public double? DurationSeconds { get; init; } +} + +public sealed class RecordTaskDetailDto +{ + public required RecordTaskDto Task { get; init; } + + public RecordResultDto? Result { get; init; } + + public required IReadOnlyList Logs { get; init; } +} + +public sealed class DeleteCompletedRecordTasksRequest +{ + public IReadOnlyList TaskIds { get; set; } = []; + + public bool DeleteFiles { get; set; } +} + +public sealed class DeleteCompletedRecordTasksResultDto +{ + public required IReadOnlyList DeletedTaskIds { get; init; } + + public required IReadOnlyList DeletedFilePaths { get; init; } + + public required IReadOnlyList DeletedDanmakuPaths { get; init; } + + public required IReadOnlyList DeletedSessionIds { get; init; } + + public required IReadOnlyList Warnings { get; init; } +} + +public sealed class RecordPreviewTicketDto +{ + public required string Url { get; init; } + + public DateTimeOffset ExpiresAt { get; init; } +} diff --git a/src/LiveRecorder.Application/Models/Settings/SettingsModels.cs b/src/LiveRecorder.Application/Models/Settings/SettingsModels.cs new file mode 100644 index 0000000..20c707d --- /dev/null +++ b/src/LiveRecorder.Application/Models/Settings/SettingsModels.cs @@ -0,0 +1,267 @@ +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Models.Settings; + +public sealed class SystemSettingsDto +{ + public string FfmpegPath { get; set; } = "ffmpeg"; + + public string OutputRoot { get; set; } = "records"; + + public string OutputDirectoryTemplate { get; set; } = "{platform}/{yyyy}/{MM}/{dd}/{anchor}"; + + public string OutputFileNameTemplate { get; set; } = "{HHmmss}_{anchor}_{title}_{roomId}"; + + public string DefaultQuality { get; set; } = "origin"; + + public RecordOutputFormat DefaultOutputFormat { get; set; } = RecordOutputFormat.Mp4; + + public RecordSaveMode SaveMode { get; set; } = RecordSaveMode.SingleFile; + + public RecordingTemplateType RecordingTemplate { get; set; } = RecordingTemplateType.StreamCopy; + + public int SegmentDurationMinutes { get; set; } = 30; + + public bool EnableAutoReconnect { get; set; } = true; + + public int ReconnectDelayMaxSeconds { get; set; } = 5; + + public int ReadWriteTimeoutMilliseconds { get; set; } = 15000000; + + public bool EnableDanmakuRecording { get; set; } = true; + + public bool DanmakuIncludeNonChatEvents { get; set; } = true; + + public int DanmakuMinPollIntervalMilliseconds { get; set; } = 1000; + + public int DanmakuRetryDelayMaxSeconds { get; set; } = 15; + + public bool EnableBackgroundPolling { get; set; } = true; + + public bool AutoStartRecordingOnLive { get; set; } = true; + + public int PollingIntervalSeconds { get; set; } = 60; + + public bool EnableEmailNotification { get; set; } = false; + + public string EmailSmtpHost { get; set; } = string.Empty; + + public int EmailSmtpPort { get; set; } = 587; + + public bool EmailUseSsl { get; set; } = true; + + public string EmailUsername { get; set; } = string.Empty; + + public string EmailPassword { get; set; } = string.Empty; + + public string EmailFromAddress { get; set; } = string.Empty; + + public string EmailFromDisplayName { get; set; } = "Live Recorder"; + + public string EmailToAddresses { get; set; } = string.Empty; + + public bool NotifyOnLiveStarted { get; set; } = true; + + public bool NotifyOnException { get; set; } = true; + + public string EmailLiveStartedSubjectTemplate { get; set; } = "[{{appName}}] Live started: {{anchor}} {{title}} ({{roomId}})"; + + public string EmailLiveStartedBodyTemplateHtml { get; set; } = """ +
+

Live started

+

The monitored live room is now online.

+
    +
  • Platform: {{platform}}
  • +
  • Room ID: {{roomId}}
  • +
  • Title: {{title}}
  • +
  • Anchor: {{anchor}}
  • +
  • Detected At (UTC): {{detectedAtUtc}}
  • +
+

Source URL: {{sourceUrl}}

+
+"""; + + public string EmailExceptionSubjectTemplate { get; set; } = "[{{appName}}] Exception: {{source}}"; + + public string EmailExceptionBodyTemplateHtml { get; set; } = """ +
+

Exception detected

+

{{summary}}

+
    +
  • Source: {{source}}
  • +
  • Live Room ID: {{liveRoomId}}
  • +
  • Room ID: {{roomId}}
  • +
  • Record Task ID: {{recordTaskId}}
  • +
  • Task Status: {{taskStatus}}
  • +
  • Occurred At (UTC): {{occurredAtUtc}}
  • +
+
{{detail}}
+
+"""; + + public string DouyinUserAgent { get; set; } = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"; + + public string DouyinReferer { get; set; } = "https://live.douyin.com/"; + + public string DouyinCookie { get; set; } = string.Empty; +} + +public sealed class UpdateSystemSettingsRequest +{ + public string FfmpegPath { get; set; } = "ffmpeg"; + + public string OutputRoot { get; set; } = "records"; + + public string OutputDirectoryTemplate { get; set; } = "{platform}/{yyyy}/{MM}/{dd}/{anchor}"; + + public string OutputFileNameTemplate { get; set; } = "{HHmmss}_{anchor}_{title}_{roomId}"; + + public string DefaultQuality { get; set; } = "origin"; + + public RecordOutputFormat DefaultOutputFormat { get; set; } = RecordOutputFormat.Mp4; + + public RecordSaveMode SaveMode { get; set; } = RecordSaveMode.SingleFile; + + public RecordingTemplateType RecordingTemplate { get; set; } = RecordingTemplateType.StreamCopy; + + public int SegmentDurationMinutes { get; set; } = 30; + + public bool EnableAutoReconnect { get; set; } = true; + + public int ReconnectDelayMaxSeconds { get; set; } = 5; + + public int ReadWriteTimeoutMilliseconds { get; set; } = 15000000; + + public bool EnableDanmakuRecording { get; set; } = true; + + public bool DanmakuIncludeNonChatEvents { get; set; } = true; + + public int DanmakuMinPollIntervalMilliseconds { get; set; } = 1000; + + public int DanmakuRetryDelayMaxSeconds { get; set; } = 15; + + public bool EnableBackgroundPolling { get; set; } = true; + + public bool AutoStartRecordingOnLive { get; set; } = true; + + public int PollingIntervalSeconds { get; set; } = 60; + + public bool EnableEmailNotification { get; set; } = false; + + public string EmailSmtpHost { get; set; } = string.Empty; + + public int EmailSmtpPort { get; set; } = 587; + + public bool EmailUseSsl { get; set; } = true; + + public string EmailUsername { get; set; } = string.Empty; + + public string EmailPassword { get; set; } = string.Empty; + + public string EmailFromAddress { get; set; } = string.Empty; + + public string EmailFromDisplayName { get; set; } = "Live Recorder"; + + public string EmailToAddresses { get; set; } = string.Empty; + + public bool NotifyOnLiveStarted { get; set; } = true; + + public bool NotifyOnException { get; set; } = true; + + public string EmailLiveStartedSubjectTemplate { get; set; } = "[{{appName}}] Live started: {{anchor}} {{title}} ({{roomId}})"; + + public string EmailLiveStartedBodyTemplateHtml { get; set; } = """ +
+

Live started

+

The monitored live room is now online.

+
    +
  • Platform: {{platform}}
  • +
  • Room ID: {{roomId}}
  • +
  • Title: {{title}}
  • +
  • Anchor: {{anchor}}
  • +
  • Detected At (UTC): {{detectedAtUtc}}
  • +
+

Source URL: {{sourceUrl}}

+
+"""; + + public string EmailExceptionSubjectTemplate { get; set; } = "[{{appName}}] Exception: {{source}}"; + + public string EmailExceptionBodyTemplateHtml { get; set; } = """ +
+

Exception detected

+

{{summary}}

+
    +
  • Source: {{source}}
  • +
  • Live Room ID: {{liveRoomId}}
  • +
  • Room ID: {{roomId}}
  • +
  • Record Task ID: {{recordTaskId}}
  • +
  • Task Status: {{taskStatus}}
  • +
  • Occurred At (UTC): {{occurredAtUtc}}
  • +
+
{{detail}}
+
+"""; + + public string DouyinUserAgent { get; set; } = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"; + + public string DouyinReferer { get; set; } = "https://live.douyin.com/"; + + public string DouyinCookie { get; set; } = string.Empty; +} + +public sealed class SendTestEmailRequest +{ + public string EmailSmtpHost { get; set; } = string.Empty; + + public int EmailSmtpPort { get; set; } = 587; + + public bool EmailUseSsl { get; set; } = true; + + public string EmailUsername { get; set; } = string.Empty; + + public string EmailPassword { get; set; } = string.Empty; + + public string EmailFromAddress { get; set; } = string.Empty; + + public string EmailFromDisplayName { get; set; } = "Live Recorder"; + + public string EmailToAddresses { get; set; } = string.Empty; + + public string EmailLiveStartedSubjectTemplate { get; set; } = "[{{appName}}] Live started: {{anchor}} {{title}} ({{roomId}})"; + + public string EmailLiveStartedBodyTemplateHtml { get; set; } = """ +
+

Live started

+

The monitored live room is now online.

+
    +
  • Platform: {{platform}}
  • +
  • Room ID: {{roomId}}
  • +
  • Title: {{title}}
  • +
  • Anchor: {{anchor}}
  • +
  • Detected At (UTC): {{detectedAtUtc}}
  • +
+

Source URL: {{sourceUrl}}

+
+"""; + + public string EmailExceptionSubjectTemplate { get; set; } = "[{{appName}}] Exception: {{source}}"; + + public string EmailExceptionBodyTemplateHtml { get; set; } = """ +
+

Exception detected

+

{{summary}}

+
    +
  • Source: {{source}}
  • +
  • Live Room ID: {{liveRoomId}}
  • +
  • Room ID: {{roomId}}
  • +
  • Record Task ID: {{recordTaskId}}
  • +
  • Task Status: {{taskStatus}}
  • +
  • Occurred At (UTC): {{occurredAtUtc}}
  • +
+
{{detail}}
+
+"""; +} diff --git a/src/LiveRecorder.Application/Services/AuthService.cs b/src/LiveRecorder.Application/Services/AuthService.cs new file mode 100644 index 0000000..7e9dcf1 --- /dev/null +++ b/src/LiveRecorder.Application/Services/AuthService.cs @@ -0,0 +1,115 @@ +using System.Security.Cryptography; +using LiveRecorder.Application.Abstractions.Auth; +using LiveRecorder.Application.Abstractions.Persistence; +using LiveRecorder.Application.Common; +using LiveRecorder.Application.Models.Auth; +using LiveRecorder.Domain.Entities; + +namespace LiveRecorder.Application.Services; + +public sealed class AuthService : IAuthService +{ + private readonly IUserAccountRepository _userAccountRepository; + private readonly IUserSessionRepository _userSessionRepository; + private readonly IUnitOfWork _unitOfWork; + + public AuthService( + IUserAccountRepository userAccountRepository, + IUserSessionRepository userSessionRepository, + IUnitOfWork unitOfWork) + { + _userAccountRepository = userAccountRepository; + _userSessionRepository = userSessionRepository; + _unitOfWork = unitOfWork; + } + + public async Task LoginAsync(LoginRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var username = request.Username.Trim(); + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(request.Password)) + { + throw new InvalidOperationException("用户名和密码不能为空。"); + } + + var user = await _userAccountRepository.GetByUsernameAsync(username, cancellationToken) + ?? throw new InvalidOperationException("用户名或密码错误。"); + + if (!user.IsActive || !PasswordHasher.Verify(request.Password, user.PasswordHash)) + { + throw new InvalidOperationException("用户名或密码错误。"); + } + + var now = DateTimeOffset.UtcNow; + var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); + var expiresAt = now.AddHours(12); + var session = new UserSession(user.Id, token, expiresAt, now); + + await _userSessionRepository.AddAsync(session, cancellationToken); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + var authenticatedUser = new AuthenticatedUser + { + UserId = user.Id, + Username = user.Username, + DisplayName = user.DisplayName, + Token = token, + ExpiresAt = expiresAt + }; + + return new LoginResponse + { + Token = token, + ExpiresAt = expiresAt, + User = authenticatedUser + }; + } + + public async Task LogoutAsync(string token, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(token)) + { + return; + } + + var session = await _userSessionRepository.GetByTokenAsync(token, cancellationToken); + if (session is null) + { + return; + } + + session.Revoke(DateTimeOffset.UtcNow); + _userSessionRepository.Update(session); + await _unitOfWork.SaveChangesAsync(cancellationToken); + } + + public async Task ValidateTokenAsync(string token, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(token)) + { + return null; + } + + var session = await _userSessionRepository.GetByTokenAsync(token, cancellationToken); + if (session is null || !session.IsValid(DateTimeOffset.UtcNow)) + { + return null; + } + + var user = await _userAccountRepository.GetByIdAsync(session.UserAccountId, cancellationToken); + if (user is null || !user.IsActive) + { + return null; + } + + return new AuthenticatedUser + { + UserId = user.Id, + Username = user.Username, + DisplayName = user.DisplayName, + Token = session.Token, + ExpiresAt = session.ExpiresAt + }; + } +} diff --git a/src/LiveRecorder.Application/Services/LiveDanmakuAdapterFactory.cs b/src/LiveRecorder.Application/Services/LiveDanmakuAdapterFactory.cs new file mode 100644 index 0000000..4d47326 --- /dev/null +++ b/src/LiveRecorder.Application/Services/LiveDanmakuAdapterFactory.cs @@ -0,0 +1,24 @@ +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Services; + +public sealed class LiveDanmakuAdapterFactory : ILiveDanmakuAdapterFactory +{ + private readonly IReadOnlyDictionary _adapterByPlatform; + + public LiveDanmakuAdapterFactory(IEnumerable adapters) + { + _adapterByPlatform = adapters.ToDictionary(static item => item.PlatformType); + } + + public ILiveDanmakuAdapter GetByPlatform(LivePlatformType platformType) + { + if (!_adapterByPlatform.TryGetValue(platformType, out var adapter)) + { + throw new NotSupportedException($"No danmaku adapter is registered for platform {platformType}."); + } + + return adapter; + } +} diff --git a/src/LiveRecorder.Application/Services/LivePlatformAdapterFactory.cs b/src/LiveRecorder.Application/Services/LivePlatformAdapterFactory.cs new file mode 100644 index 0000000..ed9b488 --- /dev/null +++ b/src/LiveRecorder.Application/Services/LivePlatformAdapterFactory.cs @@ -0,0 +1,37 @@ +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Services; + +public sealed class LivePlatformAdapterFactory : ILivePlatformAdapterFactory +{ + private readonly IReadOnlyDictionary _adapterByPlatform; + private readonly IReadOnlyList _adapters; + + public LivePlatformAdapterFactory(IEnumerable adapters) + { + _adapters = adapters.ToList(); + _adapterByPlatform = _adapters.ToDictionary(static item => item.PlatformType); + } + + public ILivePlatformAdapter GetByPlatform(LivePlatformType platformType) + { + if (!_adapterByPlatform.TryGetValue(platformType, out var adapter)) + { + throw new NotSupportedException($"未找到平台适配器: {platformType}。"); + } + + return adapter; + } + + public ILivePlatformAdapter GetByInput(string input) + { + var adapter = _adapters.FirstOrDefault(item => item.CanHandle(input)); + if (adapter is null) + { + throw new NotSupportedException("无法根据输入识别直播平台,请指定平台后重试。"); + } + + return adapter; + } +} diff --git a/src/LiveRecorder.Application/Services/LiveRoomService.cs b/src/LiveRecorder.Application/Services/LiveRoomService.cs new file mode 100644 index 0000000..ba2670f --- /dev/null +++ b/src/LiveRecorder.Application/Services/LiveRoomService.cs @@ -0,0 +1,175 @@ +using LiveRecorder.Application.Abstractions.Logging; +using LiveRecorder.Application.Abstractions.Persistence; +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Application.Models.LiveRooms; +using LiveRecorder.Domain.Entities; +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Services; + +public sealed class LiveRoomService +{ + private readonly ILiveRoomRepository _liveRoomRepository; + private readonly ILivePlatformAdapterFactory _livePlatformAdapterFactory; + private readonly LiveRoomStatusService _liveRoomStatusService; + private readonly StoppedOrphanRecordSessionCleanupService _stoppedOrphanRecordSessionCleanupService; + private readonly IUnitOfWork _unitOfWork; + private readonly ISystemLogService _systemLogService; + + public LiveRoomService( + ILiveRoomRepository liveRoomRepository, + ILivePlatformAdapterFactory livePlatformAdapterFactory, + LiveRoomStatusService liveRoomStatusService, + StoppedOrphanRecordSessionCleanupService stoppedOrphanRecordSessionCleanupService, + IUnitOfWork unitOfWork, + ISystemLogService systemLogService) + { + _liveRoomRepository = liveRoomRepository; + _livePlatformAdapterFactory = livePlatformAdapterFactory; + _liveRoomStatusService = liveRoomStatusService; + _stoppedOrphanRecordSessionCleanupService = stoppedOrphanRecordSessionCleanupService; + _unitOfWork = unitOfWork; + _systemLogService = systemLogService; + } + + public async Task> ListAsync(CancellationToken cancellationToken = default) + { + var rooms = await _liveRoomRepository.ListAsync(cancellationToken); + return rooms + .OrderByDescending(static item => item.UpdatedAt) + .Select(Map) + .ToList(); + } + + public async Task GetAsync(Guid id, CancellationToken cancellationToken = default) + { + var room = await _liveRoomRepository.GetByIdAsync(id, cancellationToken); + return room is null ? null : Map(room); + } + + public async Task CreateAsync(CreateLiveRoomRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var input = request.Url.Trim(); + if (string.IsNullOrWhiteSpace(input)) + { + throw new InvalidOperationException("Live room URL is required."); + } + + var adapter = request.PlatformOverride.HasValue && request.PlatformOverride.Value != LivePlatformType.Unknown + ? _livePlatformAdapterFactory.GetByPlatform(request.PlatformOverride.Value) + : _livePlatformAdapterFactory.GetByInput(input); + + var parsedRoom = await adapter.ParseRoomAsync(input, cancellationToken); + var liveStatus = await adapter.GetLiveStatusAsync(parsedRoom.RoomId, cancellationToken); + var now = DateTimeOffset.UtcNow; + + var liveRoom = await _liveRoomRepository.GetByPlatformRoomIdAsync(parsedRoom.PlatformType, parsedRoom.RoomId, cancellationToken); + if (liveRoom is null) + { + liveRoom = new LiveRoom(parsedRoom.PlatformType, parsedRoom.SourceUrl, parsedRoom.RoomId, parsedRoom.NormalizedUrl, now); + await _liveRoomStatusService.ApplySnapshotAsync(liveRoom, liveStatus, now, cancellationToken); + + await _liveRoomRepository.AddAsync(liveRoom, cancellationToken); + } + else + { + liveRoom.UpdateSource(parsedRoom.SourceUrl, parsedRoom.NormalizedUrl, now); + liveRoom.UpdateRoomId(parsedRoom.RoomId, now); + await _liveRoomStatusService.ApplySnapshotAsync(liveRoom, liveStatus, now, cancellationToken); + } + + await _unitOfWork.SaveChangesAsync(cancellationToken); + await _systemLogService.WriteAsync( + SystemLogLevel.Info, + "LiveRoom", + $"Live room resolved: {liveRoom.RoomId} ({liveRoom.Platform}).", + liveRoomId: liveRoom.Id, + cancellationToken: cancellationToken); + + return Map(liveRoom); + } + + public async Task RefreshStatusAsync(Guid id, CancellationToken cancellationToken = default) + { + var room = await _liveRoomRepository.GetByIdAsync(id, cancellationToken) + ?? throw new KeyNotFoundException("Live room was not found."); + + var adapter = _livePlatformAdapterFactory.GetByPlatform(room.Platform); + var liveStatus = await adapter.GetLiveStatusAsync(room.RoomId, cancellationToken); + var now = DateTimeOffset.UtcNow; + + await _liveRoomStatusService.ApplySnapshotAsync(room, liveStatus, now, cancellationToken); + + await _unitOfWork.SaveChangesAsync(cancellationToken); + await _systemLogService.WriteAsync( + SystemLogLevel.Info, + "LiveRoom", + $"Live status refreshed for room {room.RoomId}.", + detail: $"status={liveStatus.StatusCode}, rawStatus={liveStatus.RawStatus}", + liveRoomId: room.Id, + cancellationToken: cancellationToken); + + return Map(room); + } + + public async Task SetEnabledAsync(Guid id, bool isEnabled, CancellationToken cancellationToken = default) + { + var room = await _liveRoomRepository.GetByIdAsync(id, cancellationToken) + ?? throw new KeyNotFoundException("Live room was not found."); + + room.SetEnabled(isEnabled, DateTimeOffset.UtcNow); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + await _systemLogService.WriteAsync( + SystemLogLevel.Info, + "LiveRoom", + isEnabled + ? $"Live room {room.RoomId} was enabled." + : $"Live room {room.RoomId} was disabled.", + liveRoomId: room.Id, + cancellationToken: cancellationToken); + + return Map(room); + } + + public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) + { + var room = await _liveRoomRepository.GetByIdAsync(id, cancellationToken) + ?? throw new KeyNotFoundException("Live room was not found."); + + await _stoppedOrphanRecordSessionCleanupService.CleanupAsync( + liveRoomId: room.Id, + treatMatchingLiveRoomAsMissing: true, + cancellationToken: cancellationToken); + + _liveRoomRepository.Remove(room); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + await _systemLogService.WriteAsync( + SystemLogLevel.Warning, + "LiveRoom", + $"Live room {room.RoomId} was deleted.", + liveRoomId: room.Id, + cancellationToken: cancellationToken); + } + + private static LiveRoomDto Map(LiveRoom room) => new() + { + Id = room.Id, + Platform = room.Platform, + PlatformName = room.Platform.ToString(), + SourceUrl = room.SourceUrl, + RoomId = room.RoomId, + NormalizedUrl = room.NormalizedUrl, + Title = room.Title, + AnchorName = room.AnchorName, + CoverUrl = room.CoverUrl, + IsEnabled = room.IsEnabled, + AvailabilityStatus = room.AvailabilityStatus, + LastCheckedAt = room.LastCheckedAt, + CreatedAt = room.CreatedAt, + UpdatedAt = room.UpdatedAt + }; +} diff --git a/src/LiveRecorder.Application/Services/LiveRoomStatusService.cs b/src/LiveRecorder.Application/Services/LiveRoomStatusService.cs new file mode 100644 index 0000000..3a5cc51 --- /dev/null +++ b/src/LiveRecorder.Application/Services/LiveRoomStatusService.cs @@ -0,0 +1,39 @@ +using LiveRecorder.Application.Abstractions.Notifications; +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Domain.Entities; +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Services; + +public sealed class LiveRoomStatusService +{ + private readonly IEmailNotificationService _emailNotificationService; + + public LiveRoomStatusService(IEmailNotificationService emailNotificationService) + { + _emailNotificationService = emailNotificationService; + } + + public async Task ApplySnapshotAsync( + LiveRoom liveRoom, + LiveStatusSnapshot liveStatus, + DateTimeOffset observedAt, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(liveRoom); + ArgumentNullException.ThrowIfNull(liveStatus); + + liveRoom.UpdateMetadata(liveStatus.Title, liveStatus.AnchorName, liveStatus.CoverUrl, observedAt); + liveRoom.UpdateAvailability( + liveStatus.IsLive ? LiveRoomAvailabilityStatus.Live : LiveRoomAvailabilityStatus.Offline, + observedAt); + + if (!liveStatus.IsLive || liveRoom.HasSentLiveNotificationForCurrentSession) + { + return; + } + + await _emailNotificationService.SendLiveStartedAsync(liveRoom, cancellationToken); + liveRoom.MarkLiveNotificationSent(observedAt); + } +} diff --git a/src/LiveRecorder.Application/Services/RecordModelMapper.cs b/src/LiveRecorder.Application/Services/RecordModelMapper.cs new file mode 100644 index 0000000..d73e47c --- /dev/null +++ b/src/LiveRecorder.Application/Services/RecordModelMapper.cs @@ -0,0 +1,106 @@ +using LiveRecorder.Application.Models.RecordTasks; +using LiveRecorder.Domain.Entities; +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Services; + +internal static class RecordModelMapper +{ + public static RecordTaskDto MapTask(RecordTask recordTask) => new() + { + Id = recordTask.Id, + LiveRoomId = recordTask.LiveRoomId, + RecordSessionId = recordTask.RecordSessionId, + SegmentIndex = recordTask.SegmentIndex, + LiveRoomTitle = recordTask.LiveRoom?.Title ?? recordTask.LiveRoom?.AnchorName ?? recordTask.LiveRoom?.RoomId ?? "Unknown Room", + Platform = recordTask.LiveRoom?.Platform ?? LivePlatformType.Unknown, + RoomId = recordTask.LiveRoom?.RoomId ?? string.Empty, + Status = recordTask.Status, + PreferredQuality = recordTask.PreferredQuality, + OutputFormat = recordTask.OutputFormat, + StreamUrl = recordTask.StreamUrl, + OutputFilePath = recordTask.OutputFilePath, + RecorderProcessId = recordTask.RecorderProcessId, + ErrorMessage = recordTask.ErrorMessage, + CreatedAt = recordTask.CreatedAt, + StartedAt = recordTask.StartedAt, + EndedAt = recordTask.EndedAt, + DurationSeconds = recordTask.DurationSeconds + }; + + public static RecordResultDto MapResult(RecordResult recordResult) => new() + { + Id = recordResult.Id, + FilePath = recordResult.FilePath, + FileSizeBytes = recordResult.FileSizeBytes, + DurationSeconds = recordResult.DurationSeconds, + DanmakuFilePath = recordResult.DanmakuFilePath, + DanmakuMessageCount = recordResult.DanmakuMessageCount, + FinalStatus = recordResult.FinalStatus, + ErrorMessage = recordResult.ErrorMessage, + CreatedAt = recordResult.CreatedAt + }; + + public static RecordSessionDto MapSession(RecordSession recordSession) + { + var orderedTasks = recordSession.RecordTasks + .OrderBy(static item => item.SegmentIndex) + .ThenBy(static item => item.CreatedAt) + .ToList(); + var totalFileSizeBytes = orderedTasks + .Select(static item => item.Result?.FileSizeBytes ?? 0L) + .Sum(); + var totalDanmakuMessageCount = orderedTasks + .Select(static item => item.Result?.DanmakuMessageCount ?? 0) + .Sum(); + + return new RecordSessionDto + { + Id = recordSession.Id, + LiveRoomId = recordSession.LiveRoomId, + LiveRoomTitle = recordSession.LiveRoom?.Title ?? recordSession.LiveRoom?.AnchorName ?? recordSession.LiveRoom?.RoomId ?? "Unknown Room", + Platform = recordSession.LiveRoom?.Platform ?? LivePlatformType.Unknown, + RoomId = recordSession.LiveRoom?.RoomId ?? string.Empty, + Status = recordSession.Status, + PreferredQuality = recordSession.PreferredQuality, + OutputFormat = recordSession.OutputFormat, + SaveMode = recordSession.SaveMode, + ActiveSegmentIndex = recordSession.ActiveSegmentIndex, + SegmentCount = Math.Max(recordSession.SegmentCount, orderedTasks.Count), + RecorderProcessId = recordSession.RecorderProcessId, + ErrorMessage = recordSession.ErrorMessage, + CreatedAt = recordSession.CreatedAt, + StartedAt = recordSession.StartedAt, + EndedAt = recordSession.EndedAt, + TotalFileSizeBytes = totalFileSizeBytes, + TotalDanmakuMessageCount = totalDanmakuMessageCount, + Tasks = orderedTasks.Select(item => MapTaskWithFallback(item, recordSession)).ToList() + }; + } + + private static RecordTaskDto MapTaskWithFallback(RecordTask recordTask, RecordSession recordSession) + { + var liveRoom = recordTask.LiveRoom ?? recordSession.LiveRoom; + return new RecordTaskDto + { + Id = recordTask.Id, + LiveRoomId = recordTask.LiveRoomId, + RecordSessionId = recordTask.RecordSessionId, + SegmentIndex = recordTask.SegmentIndex, + LiveRoomTitle = liveRoom?.Title ?? liveRoom?.AnchorName ?? liveRoom?.RoomId ?? "Unknown Room", + Platform = liveRoom?.Platform ?? LivePlatformType.Unknown, + RoomId = liveRoom?.RoomId ?? string.Empty, + Status = recordTask.Status, + PreferredQuality = recordTask.PreferredQuality, + OutputFormat = recordTask.OutputFormat, + StreamUrl = recordTask.StreamUrl, + OutputFilePath = recordTask.OutputFilePath, + RecorderProcessId = recordTask.RecorderProcessId, + ErrorMessage = recordTask.ErrorMessage, + CreatedAt = recordTask.CreatedAt, + StartedAt = recordTask.StartedAt, + EndedAt = recordTask.EndedAt, + DurationSeconds = recordTask.DurationSeconds + }; + } +} diff --git a/src/LiveRecorder.Application/Services/RecordService.cs b/src/LiveRecorder.Application/Services/RecordService.cs new file mode 100644 index 0000000..17f5f50 --- /dev/null +++ b/src/LiveRecorder.Application/Services/RecordService.cs @@ -0,0 +1,670 @@ +using System.Text.RegularExpressions; +using LiveRecorder.Application.Abstractions.Logging; +using LiveRecorder.Application.Abstractions.Notifications; +using LiveRecorder.Application.Abstractions.Persistence; +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Application.Abstractions.Recording; +using LiveRecorder.Application.Abstractions.Settings; +using LiveRecorder.Application.Models.RecordTasks; +using LiveRecorder.Domain.Entities; +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Services; + +public sealed class RecordService +{ + private readonly ILiveRoomRepository _liveRoomRepository; + private readonly IRecordSessionRepository _recordSessionRepository; + private readonly IRecordTaskRepository _recordTaskRepository; + private readonly IRecordResultRepository _recordResultRepository; + private readonly ISystemLogRepository _systemLogRepository; + private readonly ILivePlatformAdapterFactory _livePlatformAdapterFactory; + private readonly IRecordMediaService _recordMediaService; + private readonly IFfmpegService _ffmpegService; + private readonly ISystemSettingsService _systemSettingsService; + private readonly ISystemLogService _systemLogService; + private readonly IEmailNotificationService _emailNotificationService; + private readonly LiveRoomStatusService _liveRoomStatusService; + private readonly IUnitOfWork _unitOfWork; + + public RecordService( + ILiveRoomRepository liveRoomRepository, + IRecordSessionRepository recordSessionRepository, + IRecordTaskRepository recordTaskRepository, + IRecordResultRepository recordResultRepository, + ISystemLogRepository systemLogRepository, + ILivePlatformAdapterFactory livePlatformAdapterFactory, + IRecordMediaService recordMediaService, + IFfmpegService ffmpegService, + ISystemSettingsService systemSettingsService, + ISystemLogService systemLogService, + IEmailNotificationService emailNotificationService, + LiveRoomStatusService liveRoomStatusService, + IUnitOfWork unitOfWork) + { + _liveRoomRepository = liveRoomRepository; + _recordSessionRepository = recordSessionRepository; + _recordTaskRepository = recordTaskRepository; + _recordResultRepository = recordResultRepository; + _systemLogRepository = systemLogRepository; + _livePlatformAdapterFactory = livePlatformAdapterFactory; + _recordMediaService = recordMediaService; + _ffmpegService = ffmpegService; + _systemSettingsService = systemSettingsService; + _systemLogService = systemLogService; + _emailNotificationService = emailNotificationService; + _liveRoomStatusService = liveRoomStatusService; + _unitOfWork = unitOfWork; + } + + public async Task> ListAsync(Guid? liveRoomId = null, CancellationToken cancellationToken = default) + { + await ReconcileActiveSessionsAsync(liveRoomId, cancellationToken); + + var tasks = await _recordTaskRepository.ListAsync(liveRoomId, cancellationToken); + return tasks + .OrderByDescending(static item => item.CreatedAt) + .Select(RecordModelMapper.MapTask) + .ToList(); + } + + public async Task GetDetailAsync(Guid id, CancellationToken cancellationToken = default) + { + var recordTask = await _recordTaskRepository.GetByIdAsync(id, cancellationToken); + if (recordTask is null) + { + return null; + } + + if (recordTask.RecordSessionId != Guid.Empty) + { + await _ffmpegService.TryReconcileInactiveSessionAsync(recordTask.RecordSessionId, cancellationToken); + recordTask = await _recordTaskRepository.GetByIdAsync(id, cancellationToken); + if (recordTask is null) + { + return null; + } + } + + var recordResult = await _recordResultRepository.GetByTaskIdAsync(id, cancellationToken); + var logs = await _systemLogService.ListAsync( + recordSessionId: recordTask.RecordSessionId, + recordTaskId: id, + take: 300, + cancellationToken: cancellationToken); + + return new RecordTaskDetailDto + { + Task = RecordModelMapper.MapTask(recordTask), + Result = recordResult is null ? null : RecordModelMapper.MapResult(recordResult), + Logs = logs + }; + } + + public async Task StartAsync(StartRecordTaskRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var liveRoom = await _liveRoomRepository.GetByIdAsync(request.LiveRoomId, cancellationToken) + ?? throw new KeyNotFoundException("Live room was not found."); + + if (!liveRoom.IsEnabled) + { + throw new InvalidOperationException("The live room is disabled. Enable it before starting a recording."); + } + + var activeSession = await _recordSessionRepository.GetActiveByLiveRoomIdAsync(liveRoom.Id, cancellationToken); + if (activeSession is not null) + { + throw new InvalidOperationException("An active recording session already exists for the live room."); + } + + var settings = await _systemSettingsService.GetAsync(cancellationToken); + var adapter = _livePlatformAdapterFactory.GetByPlatform(liveRoom.Platform); + var preferredQuality = string.IsNullOrWhiteSpace(request.PreferredQuality) ? settings.DefaultQuality : request.PreferredQuality.Trim(); + var outputFormat = request.OutputFormat ?? settings.DefaultOutputFormat; + var saveMode = settings.SaveMode; + var now = DateTimeOffset.UtcNow; + + var recordSession = new RecordSession(liveRoom.Id, preferredQuality, outputFormat, saveMode, now); + await _recordSessionRepository.AddAsync(recordSession, cancellationToken); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + var initialTask = new RecordTask(liveRoom.Id, recordSession.Id, 1, preferredQuality, outputFormat, now); + await _recordTaskRepository.AddAsync(initialTask, cancellationToken); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + try + { + var liveStatus = await adapter.GetLiveStatusAsync(liveRoom.RoomId, cancellationToken); + await _liveRoomStatusService.ApplySnapshotAsync(liveRoom, liveStatus, now, cancellationToken); + + if (!liveStatus.IsLive) + { + initialTask.MarkFailed("The live room is currently offline.", now); + recordSession.MarkFailed("The live room is currently offline.", now); + await _unitOfWork.SaveChangesAsync(cancellationToken); + await _systemLogService.WriteAsync( + SystemLogLevel.Warning, + "RecordSession", + "Recording start skipped because the live room is offline.", + liveRoomId: liveRoom.Id, + recordSessionId: recordSession.Id, + recordTaskId: initialTask.Id, + cancellationToken: cancellationToken); + + return RecordModelMapper.MapTask(initialTask); + } + + var streamResult = await adapter.GetStreamUrlAsync(liveRoom.RoomId, preferredQuality, cancellationToken); + var outputPattern = BuildOutputPathPattern( + settings.OutputRoot, + settings.OutputDirectoryTemplate, + settings.OutputFileNameTemplate, + liveRoom.Platform, + liveRoom.RoomId, + liveRoom.AnchorName, + liveRoom.Title, + outputFormat, + saveMode, + now); + var initialOutputPath = ResolveSegmentOutputPath(outputPattern, outputFormat, saveMode, 1); + + recordSession.MarkStarting(streamResult.SelectedUrl, outputPattern, now); + recordSession.ActivateSegment(1, now); + initialTask.MarkStarting(streamResult.SelectedUrl, initialOutputPath, now); + + await _unitOfWork.SaveChangesAsync(cancellationToken); + await _ffmpegService.StartAsync(recordSession, initialTask, streamResult, cancellationToken); + + recordSession.MarkRunning(DateTimeOffset.UtcNow); + initialTask.MarkRunning(DateTimeOffset.UtcNow); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + await _systemLogService.WriteAsync( + SystemLogLevel.Info, + "RecordSession", + $"Recording session started with quality={streamResult.SelectedQuality}, protocol={streamResult.SelectedProtocol}.", + detail: outputPattern, + liveRoomId: liveRoom.Id, + recordSessionId: recordSession.Id, + recordTaskId: initialTask.Id, + cancellationToken: cancellationToken); + } + catch (Exception ex) + { + initialTask.MarkFailed(ex.Message, DateTimeOffset.UtcNow); + recordSession.MarkFailed(ex.Message, DateTimeOffset.UtcNow); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + await _systemLogService.WriteAsync( + SystemLogLevel.Error, + "RecordSession", + "Recording session startup failed.", + ex.ToString(), + liveRoom.Id, + recordSession.Id, + initialTask.Id, + cancellationToken); + + await _emailNotificationService.SendExceptionAsync( + "RecordSession", + "Recording session startup failed.", + ex.ToString(), + liveRoom, + initialTask, + cancellationToken); + } + + return RecordModelMapper.MapTask(initialTask); + } + + public Task DeleteCompletedAsync( + DeleteCompletedRecordTasksRequest request, + CancellationToken cancellationToken = default) => + DeleteTasksAsync(request, cancellationToken); + + public async Task DeleteTasksAsync( + DeleteCompletedRecordTasksRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var taskIds = request.TaskIds + .Where(static item => item != Guid.Empty) + .Distinct() + .ToArray(); + + if (taskIds.Length == 0) + { + return CreateEmptyDeleteResult(); + } + + var recordTasks = await _recordTaskRepository.GetByIdsAsync(taskIds, cancellationToken); + var tasksById = recordTasks.ToDictionary(static item => item.Id); + var warnings = new List(); + var deletedTaskIds = new List(); + var deletedFilePaths = new List(); + var deletedDanmakuPaths = new List(); + var affectedSessionIds = new HashSet(); + + foreach (var taskId in taskIds) + { + if (!tasksById.TryGetValue(taskId, out var recordTask)) + { + warnings.Add($"Task {taskId} was not found."); + continue; + } + + if (IsActiveTaskStatus(recordTask.Status)) + { + warnings.Add($"Task {recordTask.Id} is active and cannot be deleted."); + continue; + } + + if (request.DeleteFiles) + { + TryDeleteRecordOutput(recordTask, warnings, deletedFilePaths, deletedDanmakuPaths); + } + + if (recordTask.Result is not null) + { + _recordResultRepository.Remove(recordTask.Result); + } + + var relatedLogs = await _systemLogRepository.ListByRecordTaskIdsAsync([recordTask.Id], cancellationToken); + if (relatedLogs.Count > 0) + { + _systemLogRepository.RemoveRange(relatedLogs); + } + + _recordTaskRepository.Remove(recordTask); + deletedTaskIds.Add(recordTask.Id); + affectedSessionIds.Add(recordTask.RecordSessionId); + } + + var deletedSessionIds = new List(); + if (deletedTaskIds.Count > 0) + { + await _unitOfWork.SaveChangesAsync(cancellationToken); + } + + if (affectedSessionIds.Count > 0) + { + var sessions = await _recordSessionRepository.ListAsync(cancellationToken: cancellationToken); + foreach (var session in sessions.Where(item => affectedSessionIds.Contains(item.Id))) + { + var remainingTasks = await _recordTaskRepository.ListBySessionIdAsync(session.Id, cancellationToken); + if (remainingTasks.Count != 0 || IsActiveSessionStatus(session.Status)) + { + continue; + } + + var sessionLogs = await _systemLogRepository.ListByRecordSessionIdsAsync([session.Id], cancellationToken); + if (sessionLogs.Count > 0) + { + _systemLogRepository.RemoveRange(sessionLogs); + } + + _recordSessionRepository.Remove(session); + deletedSessionIds.Add(session.Id); + } + } + + if (deletedSessionIds.Count > 0) + { + await _unitOfWork.SaveChangesAsync(cancellationToken); + } + + if (deletedTaskIds.Count > 0 || deletedSessionIds.Count > 0) + { + await _systemLogService.WriteAsync( + SystemLogLevel.Warning, + "RecordTask", + $"Deleted {deletedTaskIds.Count} recording task(s).", + detail: request.DeleteFiles + ? $"video-files={deletedFilePaths.Count}; danmaku-files={deletedDanmakuPaths.Count}; sessions={deletedSessionIds.Count}" + : $"video-files=0; danmaku-files=0; sessions={deletedSessionIds.Count}", + cancellationToken: cancellationToken); + } + + return new DeleteCompletedRecordTasksResultDto + { + DeletedTaskIds = deletedTaskIds, + DeletedFilePaths = deletedFilePaths, + DeletedDanmakuPaths = deletedDanmakuPaths, + DeletedSessionIds = deletedSessionIds, + Warnings = warnings + }; + } + + public async Task CreatePreviewTicketAsync(Guid id, string mediaBaseUrl, CancellationToken cancellationToken = default) + { + var previewTicket = await _recordMediaService.CreatePreviewTicketAsync(id, cancellationToken); + + return new RecordPreviewTicketDto + { + Url = $"{mediaBaseUrl.TrimEnd('/')}/{previewTicket.Ticket}", + ExpiresAt = previewTicket.ExpiresAt + }; + } + + public async Task StopAsync(Guid id, CancellationToken cancellationToken = default) + { + var recordTask = await _recordTaskRepository.GetByIdAsync(id, cancellationToken) + ?? throw new KeyNotFoundException("Recording task was not found."); + + if (!IsActiveTaskStatus(recordTask.Status)) + { + throw new InvalidOperationException("The recording task is not running."); + } + + var session = await _recordSessionRepository.GetByIdAsync(recordTask.RecordSessionId, cancellationToken) + ?? throw new KeyNotFoundException("Recording session was not found."); + + recordTask.MarkStopping(DateTimeOffset.UtcNow); + session.MarkStopping(DateTimeOffset.UtcNow); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + await _ffmpegService.StopAsync(session.Id, cancellationToken); + await _systemLogService.WriteAsync( + SystemLogLevel.Info, + "RecordSession", + "Stop signal sent to the active recording session.", + liveRoomId: recordTask.LiveRoomId, + recordSessionId: session.Id, + recordTaskId: recordTask.Id, + cancellationToken: cancellationToken); + + return RecordModelMapper.MapTask(recordTask); + } + + private async Task ReconcileActiveSessionsAsync(Guid? liveRoomId, CancellationToken cancellationToken) + { + var sessions = await _recordSessionRepository.ListAsync(liveRoomId, cancellationToken); + var activeSessionIds = sessions + .Where(item => IsActiveSessionStatus(item.Status)) + .Select(item => item.Id) + .Distinct() + .ToArray(); + + foreach (var activeSessionId in activeSessionIds) + { + await _ffmpegService.TryReconcileInactiveSessionAsync(activeSessionId, cancellationToken); + } + } + + internal static DeleteCompletedRecordTasksResultDto CreateEmptyDeleteResult() => new() + { + DeletedTaskIds = [], + DeletedFilePaths = [], + DeletedDanmakuPaths = [], + DeletedSessionIds = [], + Warnings = [] + }; + + private static bool IsActiveTaskStatus(RecordTaskStatus status) => + status is RecordTaskStatus.Starting or RecordTaskStatus.Running or RecordTaskStatus.Stopping; + + private static bool IsActiveSessionStatus(RecordSessionStatus status) => + status is RecordSessionStatus.Starting or RecordSessionStatus.Running or RecordSessionStatus.Stopping; + + private static string BuildOutputPathPattern( + string outputRoot, + string outputDirectoryTemplate, + string outputFileNameTemplate, + LivePlatformType platform, + string roomId, + string? anchorName, + string? title, + RecordOutputFormat outputFormat, + RecordSaveMode saveMode, + DateTimeOffset now) + { + var safeRoomId = SanitizeFileName(roomId, "room"); + var effectiveFileNameTemplate = EnsureSegmentSuffixTemplate(outputFileNameTemplate, saveMode); + var baseFileStem = BuildFileNameStem( + effectiveFileNameTemplate, + platform, + safeRoomId, + anchorName, + title, + now, + segmentSuffix: string.Empty); + var directoryPath = BuildDirectoryPath( + outputDirectoryTemplate, + platform, + safeRoomId, + anchorName, + title, + now, + baseFileStem); + var fileNameStem = BuildFileNameStem( + effectiveFileNameTemplate, + platform, + safeRoomId, + anchorName, + title, + now, + saveMode == RecordSaveMode.Segmented ? "_%05d" : string.Empty); + var folder = Path.Combine(outputRoot, directoryPath); + var extension = outputFormat == RecordOutputFormat.Ts ? "ts" : "mp4"; + return Path.Combine(folder, $"{fileNameStem}.{extension}"); + } + + internal static string ResolveSegmentOutputPath( + string outputPathPattern, + RecordOutputFormat outputFormat, + RecordSaveMode saveMode, + int segmentIndex) + { + if (saveMode != RecordSaveMode.Segmented) + { + return outputPathPattern; + } + + return outputPathPattern.Replace("%05d", $"{Math.Max(1, segmentIndex):D5}", StringComparison.OrdinalIgnoreCase); + } + + private static string BuildDirectoryPath( + string template, + LivePlatformType platform, + string roomId, + string? anchorName, + string? title, + DateTimeOffset now, + string fileStem) + { + var raw = ApplyOutputTemplate( + template, + platform, + roomId, + anchorName, + title, + now, + forPathSegment: true, + fileStem, + segmentSuffix: string.Empty); + var parts = raw + .Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(static part => SanitizeFileName(part, "untitled")) + .Where(static part => !string.IsNullOrWhiteSpace(part)) + .ToArray(); + + return parts.Length == 0 ? now.ToString("yyyy/MM/dd") : Path.Combine(parts); + } + + private static string BuildFileNameStem( + string template, + LivePlatformType platform, + string roomId, + string? anchorName, + string? title, + DateTimeOffset now, + string segmentSuffix) + { + var raw = ApplyOutputTemplate( + template, + platform, + roomId, + anchorName, + title, + now, + forPathSegment: false, + fileStem: string.Empty, + segmentSuffix); + return SanitizeFileName(raw, $"{now:yyyyMMdd_HHmmss}_{roomId}"); + } + + private static string EnsureSegmentSuffixTemplate(string template, RecordSaveMode saveMode) + { + var effectiveTemplate = string.IsNullOrWhiteSpace(template) + ? "{HHmmss}_{anchor}_{title}_{roomId}{segmentSuffix}" + : template.Trim(); + + if (saveMode != RecordSaveMode.Segmented) + { + return effectiveTemplate; + } + + if (effectiveTemplate.Contains("{segmentSuffix}", StringComparison.OrdinalIgnoreCase) || + effectiveTemplate.Contains("%05d", StringComparison.OrdinalIgnoreCase)) + { + return effectiveTemplate; + } + + return $"{effectiveTemplate}{{segmentSuffix}}"; + } + + private static string ApplyOutputTemplate( + string template, + LivePlatformType platform, + string roomId, + string? anchorName, + string? title, + DateTimeOffset now, + bool forPathSegment, + string fileStem, + string segmentSuffix) + { + var effectiveTemplate = string.IsNullOrWhiteSpace(template) + ? (forPathSegment ? "{platform}/{yyyy}/{MM}/{dd}/{anchor}" : "{HHmmss}_{anchor}_{title}_{roomId}{segmentSuffix}") + : template; + + var tokens = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["platform"] = platform.ToString(), + ["roomId"] = roomId, + ["anchor"] = NormalizeTokenValue(anchorName, "unknown-anchor"), + ["title"] = NormalizeTokenValue(title, "untitled"), + ["fileStem"] = fileStem, + ["segmentSuffix"] = segmentSuffix, + ["yyyy"] = now.ToString("yyyy"), + ["MM"] = now.ToString("MM"), + ["dd"] = now.ToString("dd"), + ["HH"] = now.ToString("HH"), + ["mm"] = now.ToString("mm"), + ["ss"] = now.ToString("ss"), + ["yyyyMMdd"] = now.ToString("yyyyMMdd"), + ["HHmmss"] = now.ToString("HHmmss"), + ["date"] = now.ToString("yyyyMMdd"), + ["time"] = now.ToString("HHmmss") + }; + + return Regex.Replace( + effectiveTemplate, + "{(?[a-zA-Z0-9]+)}", + match => + { + var name = match.Groups["name"].Value; + return tokens.TryGetValue(name, out var value) ? value : string.Empty; + }); + } + + private static string NormalizeTokenValue(string? value, string fallback) => + string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); + + private static string SanitizeFileName(string? value, string fallback) + { + var candidate = string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); + var invalidChars = Path.GetInvalidFileNameChars(); + var sanitized = new string(candidate.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray()); + sanitized = Regex.Replace(sanitized, @"\s+", " ").Trim(); + sanitized = sanitized.Trim('.', ' '); + + if (string.IsNullOrWhiteSpace(sanitized)) + { + sanitized = fallback; + } + + return sanitized.Length <= 96 ? sanitized : sanitized[..96].Trim(); + } + + internal static void TryDeleteRecordOutput( + RecordTask recordTask, + List warnings, + List deletedFilePaths, + List deletedDanmakuPaths) + { + var outputPath = recordTask.Result?.FilePath ?? recordTask.OutputFilePath; + if (!string.IsNullOrWhiteSpace(outputPath)) + { + TryDeletePath(outputPath, warnings, deletedFilePaths, $"output for task {recordTask.Id}"); + } + + var danmakuPath = recordTask.Result?.DanmakuFilePath; + if (!string.IsNullOrWhiteSpace(danmakuPath)) + { + TryDeletePath(danmakuPath, warnings, deletedDanmakuPaths, $"danmaku for task {recordTask.Id}"); + } + } + + private static void TryDeletePath( + string path, + List warnings, + List deletedPaths, + string label) + { + var absolutePath = Path.IsPathRooted(path) + ? path + : Path.GetFullPath(path, AppContext.BaseDirectory); + + try + { + if (IsUnsafeDeletionTarget(absolutePath)) + { + warnings.Add($"Skipped deleting suspicious path: {absolutePath}"); + return; + } + + if (File.Exists(absolutePath)) + { + File.Delete(absolutePath); + deletedPaths.Add(absolutePath); + return; + } + + if (Directory.Exists(absolutePath)) + { + Directory.Delete(absolutePath, true); + deletedPaths.Add(absolutePath); + return; + } + + warnings.Add($"Path not found for {label}: {absolutePath}"); + } + catch (Exception ex) + { + warnings.Add($"Failed to delete {label}: {ex.Message}"); + } + } + + private static bool IsUnsafeDeletionTarget(string absolutePath) + { + var normalized = absolutePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var root = Path.GetPathRoot(normalized)?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return string.IsNullOrWhiteSpace(normalized) || + normalized.Length < 4 || + string.Equals(normalized, root, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/LiveRecorder.Application/Services/RecordSessionService.cs b/src/LiveRecorder.Application/Services/RecordSessionService.cs new file mode 100644 index 0000000..f2b974e --- /dev/null +++ b/src/LiveRecorder.Application/Services/RecordSessionService.cs @@ -0,0 +1,285 @@ +using LiveRecorder.Application.Abstractions.Logging; +using LiveRecorder.Application.Abstractions.Persistence; +using LiveRecorder.Application.Abstractions.Recording; +using LiveRecorder.Application.Models.RecordTasks; +using LiveRecorder.Domain.Entities; +using LiveRecorder.Domain.Enums; +using Microsoft.Extensions.DependencyInjection; + +namespace LiveRecorder.Application.Services; + +public sealed class RecordSessionService +{ + private static readonly TimeSpan GracefulDeleteTimeout = TimeSpan.FromSeconds(15); + private static readonly TimeSpan ForcedKillTimeout = TimeSpan.FromSeconds(8); + + private readonly IRecordSessionRepository _recordSessionRepository; + private readonly IRecordTaskRepository _recordTaskRepository; + private readonly IRecordResultRepository _recordResultRepository; + private readonly ISystemLogRepository _systemLogRepository; + private readonly IFfmpegService _ffmpegService; + private readonly StoppedOrphanRecordSessionCleanupService _stoppedOrphanRecordSessionCleanupService; + private readonly ISystemLogService _systemLogService; + private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly IUnitOfWork _unitOfWork; + + public RecordSessionService( + IRecordSessionRepository recordSessionRepository, + IRecordTaskRepository recordTaskRepository, + IRecordResultRepository recordResultRepository, + ISystemLogRepository systemLogRepository, + IFfmpegService ffmpegService, + StoppedOrphanRecordSessionCleanupService stoppedOrphanRecordSessionCleanupService, + ISystemLogService systemLogService, + IServiceScopeFactory serviceScopeFactory, + IUnitOfWork unitOfWork) + { + _recordSessionRepository = recordSessionRepository; + _recordTaskRepository = recordTaskRepository; + _recordResultRepository = recordResultRepository; + _systemLogRepository = systemLogRepository; + _ffmpegService = ffmpegService; + _stoppedOrphanRecordSessionCleanupService = stoppedOrphanRecordSessionCleanupService; + _systemLogService = systemLogService; + _serviceScopeFactory = serviceScopeFactory; + _unitOfWork = unitOfWork; + } + + public async Task> ListAsync(Guid? liveRoomId = null, CancellationToken cancellationToken = default) + { + await _stoppedOrphanRecordSessionCleanupService.CleanupAsync(cancellationToken: cancellationToken); + await ReconcileActiveSessionsAsync(liveRoomId, cancellationToken); + + var sessions = await _recordSessionRepository.ListAsync(liveRoomId, cancellationToken); + return sessions + .OrderByDescending(static item => item.CreatedAt) + .Select(RecordModelMapper.MapSession) + .ToList(); + } + + public async Task GetDetailAsync(Guid id, CancellationToken cancellationToken = default) + { + var session = await _recordSessionRepository.GetByIdAsync(id, cancellationToken); + if (session is null) + { + return null; + } + + if (IsActiveStatus(session.Status)) + { + await _ffmpegService.TryReconcileInactiveSessionAsync(id, cancellationToken); + session = await LoadSessionSnapshotAsync(id, cancellationToken); + if (session is null) + { + return null; + } + } + + var logs = await _systemLogService.ListAsync(recordSessionId: id, take: 500, cancellationToken: cancellationToken); + return new RecordSessionDetailDto + { + Session = RecordModelMapper.MapSession(session), + Logs = logs + }; + } + + public async Task StopAsync(Guid id, CancellationToken cancellationToken = default) + { + var session = await _recordSessionRepository.GetByIdAsync(id, cancellationToken) + ?? throw new KeyNotFoundException("Recording session was not found."); + + if (!IsActiveStatus(session.Status)) + { + throw new InvalidOperationException("The recording session is not running."); + } + + session.MarkStopping(DateTimeOffset.UtcNow); + await _unitOfWork.SaveChangesAsync(cancellationToken); + await _ffmpegService.StopAsync(id, cancellationToken); + + await _systemLogService.WriteAsync( + SystemLogLevel.Info, + "RecordSession", + "Stop signal sent to the recording session.", + liveRoomId: session.LiveRoomId, + recordSessionId: session.Id, + cancellationToken: cancellationToken); + + return RecordModelMapper.MapSession(session); + } + + public async Task DeleteAsync( + DeleteRecordSessionsRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var sessionIds = request.SessionIds + .Where(static item => item != Guid.Empty) + .Distinct() + .ToArray(); + + if (sessionIds.Length == 0) + { + return RecordService.CreateEmptyDeleteResult(); + } + + var warnings = new List(); + var deletedSessionIds = new List(); + var deletedTaskIds = new List(); + var deletedFilePaths = new List(); + var deletedDanmakuPaths = new List(); + + foreach (var sessionId in sessionIds) + { + var session = await _recordSessionRepository.GetByIdAsync(sessionId, cancellationToken); + if (session is null) + { + warnings.Add($"Session {sessionId} was not found."); + continue; + } + + if (IsActiveStatus(session.Status)) + { + var stopped = await _ffmpegService.StopAndWaitAsync( + session.Id, + markAsCompletedOnExit: false, + GracefulDeleteTimeout, + cancellationToken); + + if (!stopped) + { + warnings.Add($"Session {session.Id} did not stop gracefully in time. Force killing the ffmpeg process."); + var killed = await _ffmpegService.KillAndWaitAsync(session.Id, ForcedKillTimeout, cancellationToken); + if (!killed) + { + warnings.Add($"Session {session.Id} is still shutting down. It was not deleted."); + continue; + } + } + + await _ffmpegService.TryReconcileInactiveSessionAsync(session.Id, cancellationToken); + var sessionSnapshot = await LoadSessionSnapshotAsync(sessionId, cancellationToken); + if (sessionSnapshot is null) + { + deletedSessionIds.Add(sessionId); + continue; + } + + session = sessionSnapshot; + } + + if (_ffmpegService.IsRunning(session.Id) || IsActiveStatus(session.Status)) + { + warnings.Add($"Session {session.Id} is still active and could not be deleted."); + continue; + } + + session = await _recordSessionRepository.GetByIdAsync(sessionId, cancellationToken); + if (session is null) + { + deletedSessionIds.Add(sessionId); + continue; + } + + var taskIds = session.RecordTasks + .Select(static item => item.Id) + .Distinct() + .ToArray(); + + if (request.DeleteFiles) + { + foreach (var recordTask in session.RecordTasks) + { + RecordService.TryDeleteRecordOutput(recordTask, warnings, deletedFilePaths, deletedDanmakuPaths); + } + } + + var relatedLogs = await ListRelatedLogsAsync(session.Id, taskIds, cancellationToken); + if (relatedLogs.Count > 0) + { + _systemLogRepository.RemoveRange(relatedLogs); + } + + var results = session.RecordTasks + .Select(static item => item.Result) + .OfType() + .ToArray(); + if (results.Length > 0) + { + _recordResultRepository.RemoveRange(results); + } + + if (session.RecordTasks.Count > 0) + { + _recordTaskRepository.RemoveRange(session.RecordTasks); + deletedTaskIds.AddRange(taskIds); + } + + _recordSessionRepository.Remove(session); + await _unitOfWork.SaveChangesAsync(cancellationToken); + deletedSessionIds.Add(session.Id); + } + + if (deletedSessionIds.Count > 0) + { + await _systemLogService.WriteAsync( + SystemLogLevel.Warning, + "RecordSession", + $"Deleted {deletedSessionIds.Count} recording session(s).", + detail: request.DeleteFiles + ? $"video-files={deletedFilePaths.Count}; danmaku-files={deletedDanmakuPaths.Count}; tasks={deletedTaskIds.Count}" + : $"video-files=0; danmaku-files=0; tasks={deletedTaskIds.Count}", + cancellationToken: cancellationToken); + } + + return new DeleteCompletedRecordTasksResultDto + { + DeletedTaskIds = deletedTaskIds, + DeletedFilePaths = deletedFilePaths, + DeletedDanmakuPaths = deletedDanmakuPaths, + DeletedSessionIds = deletedSessionIds, + Warnings = warnings + }; + } + + private async Task ReconcileActiveSessionsAsync(Guid? liveRoomId, CancellationToken cancellationToken) + { + var sessions = await _recordSessionRepository.ListAsync(liveRoomId, cancellationToken); + var activeIds = sessions + .Where(item => IsActiveStatus(item.Status)) + .Select(item => item.Id) + .Distinct() + .ToArray(); + + foreach (var activeId in activeIds) + { + await _ffmpegService.TryReconcileInactiveSessionAsync(activeId, cancellationToken); + } + } + + private static bool IsActiveStatus(RecordSessionStatus status) => + status is RecordSessionStatus.Starting or RecordSessionStatus.Running or RecordSessionStatus.Stopping; + + private async Task> ListRelatedLogsAsync( + Guid recordSessionId, + IReadOnlyCollection taskIds, + CancellationToken cancellationToken) + { + var bySession = await _systemLogRepository.ListByRecordSessionIdsAsync([recordSessionId], cancellationToken); + var byTask = await _systemLogRepository.ListByRecordTaskIdsAsync(taskIds, cancellationToken); + + return bySession + .Concat(byTask) + .GroupBy(static item => item.Id) + .Select(static group => group.First()) + .ToArray(); + } + + private async Task LoadSessionSnapshotAsync(Guid id, CancellationToken cancellationToken) + { + using var scope = _serviceScopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + return await repository.GetByIdAsync(id, cancellationToken); + } +} diff --git a/src/LiveRecorder.Application/Services/StoppedOrphanRecordSessionCleanupService.cs b/src/LiveRecorder.Application/Services/StoppedOrphanRecordSessionCleanupService.cs new file mode 100644 index 0000000..41adbcd --- /dev/null +++ b/src/LiveRecorder.Application/Services/StoppedOrphanRecordSessionCleanupService.cs @@ -0,0 +1,184 @@ +using LiveRecorder.Application.Abstractions.Logging; +using LiveRecorder.Application.Abstractions.Persistence; +using LiveRecorder.Domain.Entities; +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Services; + +public sealed class StoppedOrphanRecordSessionCleanupService +{ + private readonly IRecordSessionRepository _recordSessionRepository; + private readonly IRecordTaskRepository _recordTaskRepository; + private readonly IRecordResultRepository _recordResultRepository; + private readonly ISystemLogRepository _systemLogRepository; + private readonly ISystemLogService _systemLogService; + private readonly IUnitOfWork _unitOfWork; + + public StoppedOrphanRecordSessionCleanupService( + IRecordSessionRepository recordSessionRepository, + IRecordTaskRepository recordTaskRepository, + IRecordResultRepository recordResultRepository, + ISystemLogRepository systemLogRepository, + ISystemLogService systemLogService, + IUnitOfWork unitOfWork) + { + _recordSessionRepository = recordSessionRepository; + _recordTaskRepository = recordTaskRepository; + _recordResultRepository = recordResultRepository; + _systemLogRepository = systemLogRepository; + _systemLogService = systemLogService; + _unitOfWork = unitOfWork; + } + + public async Task> CleanupAsync( + Guid? liveRoomId = null, + bool treatMatchingLiveRoomAsMissing = false, + CancellationToken cancellationToken = default) + { + var sessions = await _recordSessionRepository.ListAsync(liveRoomId, cancellationToken); + var candidates = sessions + .Where(item => ShouldCleanup(item, liveRoomId, treatMatchingLiveRoomAsMissing)) + .Select(static item => item.Id) + .Distinct() + .ToArray(); + + if (candidates.Length == 0) + { + return []; + } + + var cleanedSessionIds = new List(); + foreach (var sessionId in candidates) + { + var trackedSession = await _recordSessionRepository.GetByIdAsync(sessionId, cancellationToken); + if (trackedSession is null || !ShouldCleanup(trackedSession, liveRoomId, treatMatchingLiveRoomAsMissing)) + { + continue; + } + + var taskIds = trackedSession.RecordTasks + .Select(static item => item.Id) + .Distinct() + .ToArray(); + var results = trackedSession.RecordTasks + .Select(static item => item.Result) + .OfType() + .ToArray(); + var relatedLogs = await ListRelatedLogsAsync(trackedSession.Id, taskIds, cancellationToken); + var taskCount = trackedSession.RecordTasks.Count; + var relatedLiveRoomId = trackedSession.LiveRoomId; + + if (relatedLogs.Count > 0) + { + _systemLogRepository.RemoveRange(relatedLogs); + } + + if (results.Length > 0) + { + _recordResultRepository.RemoveRange(results); + } + + if (trackedSession.RecordTasks.Count > 0) + { + _recordTaskRepository.RemoveRange(trackedSession.RecordTasks); + } + + _recordSessionRepository.Remove(trackedSession); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + cleanedSessionIds.Add(trackedSession.Id); + + await _systemLogService.WriteAsync( + SystemLogLevel.Warning, + "RecordSession", + "Automatically cleaned a stopped orphan session without valid video segments.", + detail: $"sessionId={trackedSession.Id}; taskCount={taskCount}; liveRoomId={relatedLiveRoomId}", + cancellationToken: cancellationToken); + } + + return cleanedSessionIds; + } + + private async Task> ListRelatedLogsAsync( + Guid recordSessionId, + IReadOnlyCollection taskIds, + CancellationToken cancellationToken) + { + var bySession = await _systemLogRepository.ListByRecordSessionIdsAsync([recordSessionId], cancellationToken); + var byTask = await _systemLogRepository.ListByRecordTaskIdsAsync(taskIds, cancellationToken); + + return bySession + .Concat(byTask) + .GroupBy(static item => item.Id) + .Select(static group => group.First()) + .ToArray(); + } + + private static bool ShouldCleanup( + RecordSession session, + Guid? liveRoomId, + bool treatMatchingLiveRoomAsMissing) + { + if (session.Status != RecordSessionStatus.Stopped) + { + return false; + } + + var liveRoomMissing = session.LiveRoom is null || + (treatMatchingLiveRoomAsMissing && + liveRoomId.HasValue && + session.LiveRoomId == liveRoomId.Value); + + if (!liveRoomMissing) + { + return false; + } + + return !HasAnyValidVideoSegments(session.RecordTasks); + } + + private static bool HasAnyValidVideoSegments(IEnumerable tasks) + { + foreach (var task in tasks) + { + if (!TryResolveVideoPath(task, out var absolutePath)) + { + continue; + } + + try + { + var fileInfo = new FileInfo(absolutePath); + if (fileInfo.Exists && fileInfo.Length > 0) + { + return true; + } + } + catch + { + // Ignore malformed or inaccessible paths and continue scanning the session. + } + } + + return false; + } + + private static bool TryResolveVideoPath(RecordTask task, out string absolutePath) + { + var candidatePath = !string.IsNullOrWhiteSpace(task.Result?.FilePath) + ? task.Result!.FilePath + : task.OutputFilePath; + + if (string.IsNullOrWhiteSpace(candidatePath) || candidatePath.Contains('%')) + { + absolutePath = string.Empty; + return false; + } + + absolutePath = Path.IsPathRooted(candidatePath) + ? candidatePath + : Path.GetFullPath(candidatePath, AppContext.BaseDirectory); + + return true; + } +} diff --git a/src/LiveRecorder.Application/Services/SystemLogService.cs b/src/LiveRecorder.Application/Services/SystemLogService.cs new file mode 100644 index 0000000..b9b210c --- /dev/null +++ b/src/LiveRecorder.Application/Services/SystemLogService.cs @@ -0,0 +1,65 @@ +using LiveRecorder.Application.Abstractions.Logging; +using LiveRecorder.Application.Abstractions.Persistence; +using LiveRecorder.Application.Models.Logs; +using LiveRecorder.Domain.Entities; +using LiveRecorder.Domain.Enums; +using Microsoft.Extensions.DependencyInjection; + +namespace LiveRecorder.Application.Services; + +public sealed class SystemLogService : ISystemLogService +{ + private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly ISystemLogRepository _systemLogRepository; + + public SystemLogService( + IServiceScopeFactory serviceScopeFactory, + ISystemLogRepository systemLogRepository) + { + _serviceScopeFactory = serviceScopeFactory; + _systemLogRepository = systemLogRepository; + } + + public async Task WriteAsync( + SystemLogLevel level, + string category, + string message, + string? detail = null, + Guid? liveRoomId = null, + Guid? recordSessionId = null, + Guid? recordTaskId = null, + CancellationToken cancellationToken = default) + { + var entry = new SystemLogEntry(level, category, message, detail, liveRoomId, recordSessionId, recordTaskId, DateTimeOffset.UtcNow); + + using var scope = _serviceScopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + await repository.AddAsync(entry, cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); + } + + public async Task> ListAsync( + Guid? liveRoomId = null, + Guid? recordSessionId = null, + Guid? recordTaskId = null, + int take = 200, + CancellationToken cancellationToken = default) + { + var entries = await _systemLogRepository.ListAsync(liveRoomId, recordSessionId, recordTaskId, take, cancellationToken); + return entries + .Select(static item => new SystemLogDto + { + Id = item.Id, + Level = item.Level, + Category = item.Category, + Message = item.Message, + Detail = item.Detail, + LiveRoomId = item.LiveRoomId, + RecordSessionId = item.RecordSessionId, + RecordTaskId = item.RecordTaskId, + CreatedAt = item.CreatedAt + }) + .ToList(); + } +} diff --git a/src/LiveRecorder.Application/Services/SystemSettingsService.cs b/src/LiveRecorder.Application/Services/SystemSettingsService.cs new file mode 100644 index 0000000..fad6bc2 --- /dev/null +++ b/src/LiveRecorder.Application/Services/SystemSettingsService.cs @@ -0,0 +1,226 @@ +using LiveRecorder.Application.Abstractions.Persistence; +using LiveRecorder.Application.Abstractions.Settings; +using LiveRecorder.Application.Models.Settings; +using LiveRecorder.Domain.Entities; +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Services; + +public sealed class SystemSettingsService : ISystemSettingsService +{ + private const string FfmpegPathKey = "ffmpeg.path"; + private const string OutputRootKey = "recording.output_root"; + private const string OutputDirectoryTemplateKey = "recording.output_directory_template"; + private const string OutputFileNameTemplateKey = "recording.output_file_name_template"; + private const string DefaultQualityKey = "recording.default_quality"; + private const string DefaultOutputFormatKey = "recording.default_output_format"; + private const string SaveModeKey = "recording.save_mode"; + private const string RecordingTemplateKey = "recording.template"; + private const string SegmentDurationMinutesKey = "recording.segment_duration_minutes"; + private const string EnableReconnectKey = "recording.enable_auto_reconnect"; + private const string ReconnectDelayMaxSecondsKey = "recording.reconnect_delay_max_seconds"; + private const string ReadWriteTimeoutMillisecondsKey = "recording.read_write_timeout_milliseconds"; + private const string EnableDanmakuRecordingKey = "recording.enable_danmaku_recording"; + private const string DanmakuIncludeNonChatEventsKey = "recording.danmaku_include_non_chat_events"; + private const string DanmakuMinPollIntervalMillisecondsKey = "recording.danmaku_min_poll_interval_milliseconds"; + private const string DanmakuRetryDelayMaxSecondsKey = "recording.danmaku_retry_delay_max_seconds"; + private const string EnableBackgroundPollingKey = "scheduler.enable_background_polling"; + private const string AutoStartRecordingOnLiveKey = "scheduler.auto_start_recording_on_live"; + private const string PollingIntervalSecondsKey = "scheduler.polling_interval_seconds"; + private const string EnableEmailNotificationKey = "notification.email.enabled"; + private const string EmailSmtpHostKey = "notification.email.smtp_host"; + private const string EmailSmtpPortKey = "notification.email.smtp_port"; + private const string EmailUseSslKey = "notification.email.use_ssl"; + private const string EmailUsernameKey = "notification.email.username"; + private const string EmailPasswordKey = "notification.email.password"; + private const string EmailFromAddressKey = "notification.email.from_address"; + private const string EmailFromDisplayNameKey = "notification.email.from_display_name"; + private const string EmailToAddressesKey = "notification.email.to_addresses"; + private const string NotifyOnLiveStartedKey = "notification.email.notify_live_started"; + private const string NotifyOnExceptionKey = "notification.email.notify_exception"; + private const string EmailLiveStartedSubjectTemplateKey = "notification.email.live_started.subject_template"; + private const string EmailLiveStartedBodyTemplateHtmlKey = "notification.email.live_started.body_template_html"; + private const string EmailExceptionSubjectTemplateKey = "notification.email.exception.subject_template"; + private const string EmailExceptionBodyTemplateHtmlKey = "notification.email.exception.body_template_html"; + private const string DouyinUserAgentKey = "douyin.user_agent"; + private const string DouyinRefererKey = "douyin.referer"; + private const string DouyinCookieKey = "douyin.cookie"; + + private readonly IAppSettingRepository _appSettingRepository; + private readonly IUnitOfWork _unitOfWork; + + public SystemSettingsService(IAppSettingRepository appSettingRepository, IUnitOfWork unitOfWork) + { + _appSettingRepository = appSettingRepository; + _unitOfWork = unitOfWork; + } + + public async Task GetAsync(CancellationToken cancellationToken = default) + { + var settings = await _appSettingRepository.ListAsync(cancellationToken); + var lookup = settings.ToDictionary(static item => item.Key, static item => item.Value, StringComparer.OrdinalIgnoreCase); + + return new SystemSettingsDto + { + FfmpegPath = GetValue(lookup, FfmpegPathKey, "ffmpeg"), + OutputRoot = GetValue(lookup, OutputRootKey, "records"), + OutputDirectoryTemplate = GetValue(lookup, OutputDirectoryTemplateKey, "{platform}/{yyyy}/{MM}/{dd}/{anchor}"), + OutputFileNameTemplate = GetValue(lookup, OutputFileNameTemplateKey, "{HHmmss}_{anchor}_{title}_{roomId}{segmentSuffix}"), + DefaultQuality = GetValue(lookup, DefaultQualityKey, "origin"), + DefaultOutputFormat = Enum.TryParse(GetValue(lookup, DefaultOutputFormatKey, "Mp4"), true, out RecordOutputFormat outputFormat) + ? outputFormat + : RecordOutputFormat.Mp4, + SaveMode = Enum.TryParse(GetValue(lookup, SaveModeKey, "SingleFile"), true, out RecordSaveMode saveMode) + ? saveMode + : RecordSaveMode.SingleFile, + RecordingTemplate = Enum.TryParse(GetValue(lookup, RecordingTemplateKey, "StreamCopy"), true, out RecordingTemplateType recordingTemplate) + ? recordingTemplate + : RecordingTemplateType.StreamCopy, + SegmentDurationMinutes = GetIntValue(lookup, SegmentDurationMinutesKey, 30, 1, 720), + EnableAutoReconnect = bool.TryParse(GetValue(lookup, EnableReconnectKey, "true"), out var enableReconnect) && enableReconnect, + ReconnectDelayMaxSeconds = GetIntValue(lookup, ReconnectDelayMaxSecondsKey, 5, 1, 300), + ReadWriteTimeoutMilliseconds = GetIntValue(lookup, ReadWriteTimeoutMillisecondsKey, 15000000, 1000, 60000000), + EnableDanmakuRecording = bool.TryParse(GetValue(lookup, EnableDanmakuRecordingKey, "true"), out var enableDanmakuRecording) && enableDanmakuRecording, + DanmakuIncludeNonChatEvents = bool.TryParse(GetValue(lookup, DanmakuIncludeNonChatEventsKey, "true"), out var danmakuIncludeNonChatEvents) && danmakuIncludeNonChatEvents, + DanmakuMinPollIntervalMilliseconds = GetIntValue(lookup, DanmakuMinPollIntervalMillisecondsKey, 1000, 100, 60000), + DanmakuRetryDelayMaxSeconds = GetIntValue(lookup, DanmakuRetryDelayMaxSecondsKey, 15, 1, 300), + EnableBackgroundPolling = bool.TryParse(GetValue(lookup, EnableBackgroundPollingKey, "true"), out var enableBackgroundPolling) && enableBackgroundPolling, + AutoStartRecordingOnLive = bool.TryParse(GetValue(lookup, AutoStartRecordingOnLiveKey, "true"), out var autoStartRecordingOnLive) && autoStartRecordingOnLive, + PollingIntervalSeconds = GetIntValue(lookup, PollingIntervalSecondsKey, 60, 10, 3600), + EnableEmailNotification = bool.TryParse(GetValue(lookup, EnableEmailNotificationKey, "false"), out var enableEmailNotification) && enableEmailNotification, + EmailSmtpHost = GetValue(lookup, EmailSmtpHostKey, string.Empty), + EmailSmtpPort = GetIntValue(lookup, EmailSmtpPortKey, 587, 1, 65535), + EmailUseSsl = bool.TryParse(GetValue(lookup, EmailUseSslKey, "true"), out var emailUseSsl) && emailUseSsl, + EmailUsername = GetValue(lookup, EmailUsernameKey, string.Empty), + EmailPassword = GetValue(lookup, EmailPasswordKey, string.Empty), + EmailFromAddress = GetValue(lookup, EmailFromAddressKey, string.Empty), + EmailFromDisplayName = GetValue(lookup, EmailFromDisplayNameKey, "Live Recorder"), + EmailToAddresses = GetValue(lookup, EmailToAddressesKey, string.Empty), + NotifyOnLiveStarted = bool.TryParse(GetValue(lookup, NotifyOnLiveStartedKey, "true"), out var notifyOnLiveStarted) && notifyOnLiveStarted, + NotifyOnException = bool.TryParse(GetValue(lookup, NotifyOnExceptionKey, "true"), out var notifyOnException) && notifyOnException, + EmailLiveStartedSubjectTemplate = GetValue(lookup, EmailLiveStartedSubjectTemplateKey, "[{{appName}}] Live started: {{anchor}} {{title}} ({{roomId}})"), + EmailLiveStartedBodyTemplateHtml = GetValue( + lookup, + EmailLiveStartedBodyTemplateHtmlKey, + """ +
+

Live started

+

The monitored live room is now online.

+
    +
  • Platform: {{platform}}
  • +
  • Room ID: {{roomId}}
  • +
  • Title: {{title}}
  • +
  • Anchor: {{anchor}}
  • +
  • Detected At (UTC): {{detectedAtUtc}}
  • +
+

Source URL: {{sourceUrl}}

+
+ """), + EmailExceptionSubjectTemplate = GetValue(lookup, EmailExceptionSubjectTemplateKey, "[{{appName}}] Exception: {{source}}"), + EmailExceptionBodyTemplateHtml = GetValue( + lookup, + EmailExceptionBodyTemplateHtmlKey, + """ +
+

Exception detected

+

{{summary}}

+
    +
  • Source: {{source}}
  • +
  • Live Room ID: {{liveRoomId}}
  • +
  • Room ID: {{roomId}}
  • +
  • Record Task ID: {{recordTaskId}}
  • +
  • Task Status: {{taskStatus}}
  • +
  • Occurred At (UTC): {{occurredAtUtc}}
  • +
+
{{detail}}
+
+ """), + 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) + }; + } + + public async Task UpdateAsync(UpdateSystemSettingsRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var now = DateTimeOffset.UtcNow; + + await UpsertAsync(FfmpegPathKey, request.FfmpegPath.Trim(), now, cancellationToken); + await UpsertAsync(OutputRootKey, request.OutputRoot.Trim(), now, cancellationToken); + await UpsertAsync(OutputDirectoryTemplateKey, request.OutputDirectoryTemplate.Trim(), now, cancellationToken); + await UpsertAsync(OutputFileNameTemplateKey, request.OutputFileNameTemplate.Trim(), now, cancellationToken); + await UpsertAsync(DefaultQualityKey, request.DefaultQuality.Trim(), now, cancellationToken); + await UpsertAsync(DefaultOutputFormatKey, request.DefaultOutputFormat.ToString(), now, cancellationToken); + await UpsertAsync(SaveModeKey, request.SaveMode.ToString(), now, cancellationToken); + await UpsertAsync(RecordingTemplateKey, request.RecordingTemplate.ToString(), now, cancellationToken); + await UpsertAsync(SegmentDurationMinutesKey, request.SegmentDurationMinutes.ToString(), now, cancellationToken); + await UpsertAsync(EnableReconnectKey, request.EnableAutoReconnect.ToString(), now, cancellationToken); + await UpsertAsync(ReconnectDelayMaxSecondsKey, request.ReconnectDelayMaxSeconds.ToString(), now, cancellationToken); + await UpsertAsync(ReadWriteTimeoutMillisecondsKey, request.ReadWriteTimeoutMilliseconds.ToString(), now, cancellationToken); + await UpsertAsync(EnableDanmakuRecordingKey, request.EnableDanmakuRecording.ToString(), now, cancellationToken); + await UpsertAsync(DanmakuIncludeNonChatEventsKey, request.DanmakuIncludeNonChatEvents.ToString(), now, cancellationToken); + await UpsertAsync(DanmakuMinPollIntervalMillisecondsKey, request.DanmakuMinPollIntervalMilliseconds.ToString(), now, cancellationToken); + await UpsertAsync(DanmakuRetryDelayMaxSecondsKey, request.DanmakuRetryDelayMaxSeconds.ToString(), now, cancellationToken); + await UpsertAsync(EnableBackgroundPollingKey, request.EnableBackgroundPolling.ToString(), now, cancellationToken); + await UpsertAsync(AutoStartRecordingOnLiveKey, request.AutoStartRecordingOnLive.ToString(), now, cancellationToken); + await UpsertAsync(PollingIntervalSecondsKey, request.PollingIntervalSeconds.ToString(), now, cancellationToken); + await UpsertAsync(EnableEmailNotificationKey, request.EnableEmailNotification.ToString(), now, cancellationToken); + await UpsertAsync(EmailSmtpHostKey, request.EmailSmtpHost.Trim(), now, cancellationToken); + await UpsertAsync(EmailSmtpPortKey, request.EmailSmtpPort.ToString(), now, cancellationToken); + await UpsertAsync(EmailUseSslKey, request.EmailUseSsl.ToString(), now, cancellationToken); + await UpsertAsync(EmailUsernameKey, request.EmailUsername.Trim(), now, cancellationToken); + await UpsertAsync(EmailPasswordKey, request.EmailPassword, now, cancellationToken); + await UpsertAsync(EmailFromAddressKey, request.EmailFromAddress.Trim(), now, cancellationToken); + await UpsertAsync(EmailFromDisplayNameKey, request.EmailFromDisplayName.Trim(), now, cancellationToken); + await UpsertAsync(EmailToAddressesKey, request.EmailToAddresses.Trim(), now, cancellationToken); + await UpsertAsync(NotifyOnLiveStartedKey, request.NotifyOnLiveStarted.ToString(), now, cancellationToken); + await UpsertAsync(NotifyOnExceptionKey, request.NotifyOnException.ToString(), now, cancellationToken); + await UpsertAsync(EmailLiveStartedSubjectTemplateKey, request.EmailLiveStartedSubjectTemplate.Trim(), now, cancellationToken); + await UpsertAsync(EmailLiveStartedBodyTemplateHtmlKey, request.EmailLiveStartedBodyTemplateHtml.Trim(), now, cancellationToken); + await UpsertAsync(EmailExceptionSubjectTemplateKey, request.EmailExceptionSubjectTemplate.Trim(), now, cancellationToken); + await UpsertAsync(EmailExceptionBodyTemplateHtmlKey, request.EmailExceptionBodyTemplateHtml.Trim(), now, cancellationToken); + await UpsertAsync(DouyinUserAgentKey, request.DouyinUserAgent.Trim(), now, cancellationToken); + await UpsertAsync(DouyinRefererKey, request.DouyinReferer.Trim(), now, cancellationToken); + await UpsertAsync(DouyinCookieKey, request.DouyinCookie.Trim(), now, cancellationToken); + + await _unitOfWork.SaveChangesAsync(cancellationToken); + return await GetAsync(cancellationToken); + } + + private static string GetValue(IReadOnlyDictionary lookup, string key, string fallback) => + lookup.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value) ? value : fallback; + + private static int GetIntValue( + IReadOnlyDictionary lookup, + string key, + int fallback, + int minimum, + int maximum) + { + var raw = GetValue(lookup, key, fallback.ToString()); + if (!int.TryParse(raw, out var parsedValue)) + { + return fallback; + } + + return Math.Clamp(parsedValue, minimum, maximum); + } + + private async Task UpsertAsync(string key, string value, DateTimeOffset updatedAt, CancellationToken cancellationToken) + { + var existing = await _appSettingRepository.GetByKeyAsync(key, cancellationToken); + if (existing is null) + { + await _appSettingRepository.AddAsync(new AppSetting(key, value, updatedAt), cancellationToken); + return; + } + + existing.Update(value, updatedAt); + _appSettingRepository.Update(existing); + } +} diff --git a/src/LiveRecorder.Domain/Entities/AppSetting.cs b/src/LiveRecorder.Domain/Entities/AppSetting.cs new file mode 100644 index 0000000..8db3539 --- /dev/null +++ b/src/LiveRecorder.Domain/Entities/AppSetting.cs @@ -0,0 +1,30 @@ +namespace LiveRecorder.Domain.Entities; + +public class AppSetting +{ + private AppSetting() + { + } + + public AppSetting(string key, string value, DateTimeOffset updatedAt) + { + Id = Guid.NewGuid(); + Key = key; + Value = value; + UpdatedAt = updatedAt; + } + + public Guid Id { get; private set; } + + public string Key { get; private set; } = string.Empty; + + public string Value { get; private set; } = string.Empty; + + public DateTimeOffset UpdatedAt { get; private set; } + + public void Update(string value, DateTimeOffset updatedAt) + { + Value = value; + UpdatedAt = updatedAt; + } +} diff --git a/src/LiveRecorder.Domain/Entities/LiveRoom.cs b/src/LiveRecorder.Domain/Entities/LiveRoom.cs new file mode 100644 index 0000000..77c7895 --- /dev/null +++ b/src/LiveRecorder.Domain/Entities/LiveRoom.cs @@ -0,0 +1,103 @@ +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Domain.Entities; + +public class LiveRoom +{ + private LiveRoom() + { + } + + public LiveRoom( + LivePlatformType platform, + string sourceUrl, + string roomId, + string normalizedUrl, + DateTimeOffset createdAt) + { + Id = Guid.NewGuid(); + Platform = platform; + SourceUrl = sourceUrl; + RoomId = roomId; + NormalizedUrl = normalizedUrl; + IsEnabled = true; + AvailabilityStatus = LiveRoomAvailabilityStatus.Unknown; + CreatedAt = createdAt; + UpdatedAt = createdAt; + } + + public Guid Id { get; private set; } + + public LivePlatformType Platform { get; private set; } + + public string SourceUrl { get; private set; } = string.Empty; + + public string RoomId { get; private set; } = string.Empty; + + public string NormalizedUrl { get; private set; } = string.Empty; + + public string? Title { get; private set; } + + public string? AnchorName { get; private set; } + + public string? CoverUrl { get; private set; } + + public bool IsEnabled { get; private set; } + + public bool HasSentLiveNotificationForCurrentSession { get; private set; } + + public LiveRoomAvailabilityStatus AvailabilityStatus { get; private set; } + + public DateTimeOffset CreatedAt { get; private set; } + + public DateTimeOffset UpdatedAt { get; private set; } + + public DateTimeOffset? LastCheckedAt { get; private set; } + + public ICollection RecordTasks { get; private set; } = new List(); + + public void UpdateSource(string sourceUrl, string normalizedUrl, DateTimeOffset updatedAt) + { + SourceUrl = sourceUrl; + NormalizedUrl = normalizedUrl; + UpdatedAt = updatedAt; + } + + public void UpdateRoomId(string roomId, DateTimeOffset updatedAt) + { + RoomId = roomId; + UpdatedAt = updatedAt; + } + + public void UpdateMetadata(string? title, string? anchorName, string? coverUrl, DateTimeOffset updatedAt) + { + Title = title; + AnchorName = anchorName; + CoverUrl = coverUrl; + UpdatedAt = updatedAt; + } + + public void UpdateAvailability(LiveRoomAvailabilityStatus availabilityStatus, DateTimeOffset checkedAt) + { + AvailabilityStatus = availabilityStatus; + if (availabilityStatus != LiveRoomAvailabilityStatus.Live) + { + HasSentLiveNotificationForCurrentSession = false; + } + + LastCheckedAt = checkedAt; + UpdatedAt = checkedAt; + } + + public void SetEnabled(bool isEnabled, DateTimeOffset updatedAt) + { + IsEnabled = isEnabled; + UpdatedAt = updatedAt; + } + + public void MarkLiveNotificationSent(DateTimeOffset updatedAt) + { + HasSentLiveNotificationForCurrentSession = true; + UpdatedAt = updatedAt; + } +} diff --git a/src/LiveRecorder.Domain/Entities/RecordResult.cs b/src/LiveRecorder.Domain/Entities/RecordResult.cs new file mode 100644 index 0000000..71c8f25 --- /dev/null +++ b/src/LiveRecorder.Domain/Entities/RecordResult.cs @@ -0,0 +1,73 @@ +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Domain.Entities; + +public class RecordResult +{ + private RecordResult() + { + } + + public RecordResult( + Guid recordTaskId, + string filePath, + long? fileSizeBytes, + double? durationSeconds, + string? danmakuFilePath, + int danmakuMessageCount, + RecordTaskStatus finalStatus, + string? errorMessage, + DateTimeOffset createdAt) + { + Id = Guid.NewGuid(); + RecordTaskId = recordTaskId; + FilePath = filePath; + FileSizeBytes = fileSizeBytes; + DurationSeconds = durationSeconds; + DanmakuFilePath = danmakuFilePath; + DanmakuMessageCount = Math.Max(0, danmakuMessageCount); + FinalStatus = finalStatus; + ErrorMessage = errorMessage; + CreatedAt = createdAt; + } + + public Guid Id { get; private set; } + + public Guid RecordTaskId { get; private set; } + + public RecordTask? RecordTask { get; private set; } + + public string FilePath { get; private set; } = string.Empty; + + public long? FileSizeBytes { get; private set; } + + public double? DurationSeconds { get; private set; } + + public string? DanmakuFilePath { get; private set; } + + public int DanmakuMessageCount { get; private set; } + + public RecordTaskStatus FinalStatus { get; private set; } + + public string? ErrorMessage { get; private set; } + + public DateTimeOffset CreatedAt { get; private set; } + + public void Update( + string filePath, + long? fileSizeBytes, + double? durationSeconds, + string? danmakuFilePath, + int danmakuMessageCount, + RecordTaskStatus finalStatus, + string? errorMessage) + { + FilePath = filePath; + FileSizeBytes = fileSizeBytes; + DurationSeconds = durationSeconds; + DanmakuFilePath = danmakuFilePath; + DanmakuMessageCount = Math.Max(0, danmakuMessageCount); + FinalStatus = finalStatus; + ErrorMessage = errorMessage; + } +} diff --git a/src/LiveRecorder.Domain/Entities/RecordSession.cs b/src/LiveRecorder.Domain/Entities/RecordSession.cs new file mode 100644 index 0000000..3d5ba2d --- /dev/null +++ b/src/LiveRecorder.Domain/Entities/RecordSession.cs @@ -0,0 +1,140 @@ +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Domain.Entities; + +public class RecordSession +{ + private RecordSession() + { + } + + public RecordSession( + Guid liveRoomId, + string preferredQuality, + RecordOutputFormat outputFormat, + RecordSaveMode saveMode, + DateTimeOffset createdAt) + { + Id = Guid.NewGuid(); + LiveRoomId = liveRoomId; + PreferredQuality = preferredQuality; + OutputFormat = outputFormat; + SaveMode = saveMode; + Status = RecordSessionStatus.Pending; + ActiveSegmentIndex = 0; + SegmentCount = 0; + CreatedAt = createdAt; + UpdatedAt = createdAt; + } + + public Guid Id { get; private set; } + + public Guid LiveRoomId { get; private set; } + + public LiveRoom? LiveRoom { get; private set; } + + public RecordSessionStatus Status { get; private set; } + + public string PreferredQuality { get; private set; } = "origin"; + + public RecordOutputFormat OutputFormat { get; private set; } = RecordOutputFormat.Mp4; + + public RecordSaveMode SaveMode { get; private set; } = RecordSaveMode.SingleFile; + + public string? StreamUrl { get; private set; } + + public string? OutputPathPattern { get; private set; } + + public int ActiveSegmentIndex { get; private set; } + + public int SegmentCount { get; private set; } + + public int? RecorderProcessId { get; private set; } + + public string? ErrorMessage { get; private set; } + + public DateTimeOffset CreatedAt { get; private set; } + + public DateTimeOffset UpdatedAt { get; private set; } + + public DateTimeOffset? StartedAt { get; private set; } + + public DateTimeOffset? EndedAt { get; private set; } + + public ICollection RecordTasks { get; private set; } = new List(); + + public void MarkStarting(string streamUrl, string outputPathPattern, DateTimeOffset startedAt) + { + StreamUrl = streamUrl; + OutputPathPattern = outputPathPattern; + StartedAt = startedAt; + Status = RecordSessionStatus.Starting; + ErrorMessage = null; + UpdatedAt = startedAt; + } + + public void MarkRunning(DateTimeOffset updatedAt) + { + Status = RecordSessionStatus.Running; + StartedAt ??= updatedAt; + ErrorMessage = null; + UpdatedAt = updatedAt; + } + + public void MarkStopping(DateTimeOffset updatedAt) + { + Status = RecordSessionStatus.Stopping; + UpdatedAt = updatedAt; + } + + public void MarkCompleted(DateTimeOffset endedAt) + { + Status = RecordSessionStatus.Completed; + EndedAt = endedAt; + RecorderProcessId = null; + ErrorMessage = null; + UpdatedAt = endedAt; + } + + public void MarkStopped(DateTimeOffset endedAt, string? errorMessage = null) + { + Status = RecordSessionStatus.Stopped; + EndedAt = endedAt; + RecorderProcessId = null; + ErrorMessage = errorMessage; + UpdatedAt = endedAt; + } + + public void MarkFailed(string errorMessage, DateTimeOffset endedAt) + { + Status = RecordSessionStatus.Failed; + ErrorMessage = errorMessage; + EndedAt = endedAt; + RecorderProcessId = null; + UpdatedAt = endedAt; + } + + public void AttachProcess(int processId, DateTimeOffset updatedAt) + { + RecorderProcessId = processId; + UpdatedAt = updatedAt; + } + + public void ActivateSegment(int segmentIndex, DateTimeOffset updatedAt) + { + ActiveSegmentIndex = Math.Max(1, segmentIndex); + SegmentCount = Math.Max(SegmentCount, ActiveSegmentIndex); + UpdatedAt = updatedAt; + } + + public void SyncSegmentCount(int segmentCount, DateTimeOffset updatedAt) + { + SegmentCount = Math.Max(0, segmentCount); + if (ActiveSegmentIndex > SegmentCount) + { + ActiveSegmentIndex = SegmentCount; + } + + UpdatedAt = updatedAt; + } +} diff --git a/src/LiveRecorder.Domain/Entities/RecordTask.cs b/src/LiveRecorder.Domain/Entities/RecordTask.cs new file mode 100644 index 0000000..f81798f --- /dev/null +++ b/src/LiveRecorder.Domain/Entities/RecordTask.cs @@ -0,0 +1,140 @@ +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Domain.Entities; + +public class RecordTask +{ + private RecordTask() + { + } + + public RecordTask( + Guid liveRoomId, + Guid recordSessionId, + int segmentIndex, + string preferredQuality, + RecordOutputFormat outputFormat, + DateTimeOffset createdAt) + { + Id = Guid.NewGuid(); + LiveRoomId = liveRoomId; + RecordSessionId = recordSessionId; + SegmentIndex = Math.Max(1, segmentIndex); + PreferredQuality = preferredQuality; + OutputFormat = outputFormat; + Status = RecordTaskStatus.Pending; + CreatedAt = createdAt; + UpdatedAt = createdAt; + } + + public Guid Id { get; private set; } + + public Guid LiveRoomId { get; private set; } + + public LiveRoom? LiveRoom { get; private set; } + + public Guid RecordSessionId { get; private set; } + + public RecordSession? RecordSession { get; private set; } + + public int SegmentIndex { get; private set; } + + public RecordTaskStatus Status { get; private set; } + + public string PreferredQuality { get; private set; } = "origin"; + + public RecordOutputFormat OutputFormat { get; private set; } = RecordOutputFormat.Mp4; + + public string? StreamUrl { get; private set; } + + public string? OutputFilePath { get; private set; } + + public int? RecorderProcessId { get; private set; } + + public string? ErrorMessage { get; private set; } + + public DateTimeOffset CreatedAt { get; private set; } + + public DateTimeOffset UpdatedAt { get; private set; } + + public DateTimeOffset? StartedAt { get; private set; } + + public DateTimeOffset? EndedAt { get; private set; } + + public double? DurationSeconds { get; private set; } + + public RecordResult? Result { get; private set; } + + public void AssignToSession(Guid recordSessionId, int segmentIndex, DateTimeOffset updatedAt) + { + RecordSessionId = recordSessionId; + SegmentIndex = Math.Max(1, segmentIndex); + UpdatedAt = updatedAt; + } + + public void MarkStarting(string streamUrl, string outputFilePath, DateTimeOffset startedAt) + { + StreamUrl = streamUrl; + OutputFilePath = outputFilePath; + StartedAt = startedAt; + Status = RecordTaskStatus.Starting; + ErrorMessage = null; + UpdatedAt = startedAt; + } + + public void AttachProcess(int processId, DateTimeOffset updatedAt) + { + RecorderProcessId = processId; + UpdatedAt = updatedAt; + } + + public void DetachProcess(DateTimeOffset updatedAt) + { + RecorderProcessId = null; + UpdatedAt = updatedAt; + } + + public void MarkRunning(DateTimeOffset updatedAt) + { + Status = RecordTaskStatus.Running; + StartedAt ??= updatedAt; + ErrorMessage = null; + UpdatedAt = updatedAt; + } + + public void MarkStopping(DateTimeOffset updatedAt) + { + Status = RecordTaskStatus.Stopping; + UpdatedAt = updatedAt; + } + + public void MarkCompleted(DateTimeOffset endedAt, double? durationSeconds) + { + Status = RecordTaskStatus.Completed; + EndedAt = endedAt; + DurationSeconds = durationSeconds; + ErrorMessage = null; + RecorderProcessId = null; + UpdatedAt = endedAt; + } + + public void MarkStopped(DateTimeOffset endedAt, double? durationSeconds, string? errorMessage = null) + { + Status = RecordTaskStatus.Stopped; + EndedAt = endedAt; + DurationSeconds = durationSeconds; + ErrorMessage = errorMessage; + RecorderProcessId = null; + UpdatedAt = endedAt; + } + + public void MarkFailed(string errorMessage, DateTimeOffset endedAt) + { + Status = RecordTaskStatus.Failed; + ErrorMessage = errorMessage; + EndedAt = endedAt; + DurationSeconds = StartedAt.HasValue ? Math.Max(0, (endedAt - StartedAt.Value).TotalSeconds) : null; + RecorderProcessId = null; + UpdatedAt = endedAt; + } +} diff --git a/src/LiveRecorder.Domain/Entities/SystemLogEntry.cs b/src/LiveRecorder.Domain/Entities/SystemLogEntry.cs new file mode 100644 index 0000000..fedfc3e --- /dev/null +++ b/src/LiveRecorder.Domain/Entities/SystemLogEntry.cs @@ -0,0 +1,49 @@ +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Domain.Entities; + +public class SystemLogEntry +{ + private SystemLogEntry() + { + } + + public SystemLogEntry( + SystemLogLevel level, + string category, + string message, + string? detail, + Guid? liveRoomId, + Guid? recordSessionId, + Guid? recordTaskId, + DateTimeOffset createdAt) + { + Id = Guid.NewGuid(); + Level = level; + Category = category; + Message = message; + Detail = detail; + LiveRoomId = liveRoomId; + RecordSessionId = recordSessionId; + RecordTaskId = recordTaskId; + CreatedAt = createdAt; + } + + public Guid Id { get; private set; } + + public SystemLogLevel Level { get; private set; } + + public string Category { get; private set; } = string.Empty; + + public string Message { get; private set; } = string.Empty; + + public string? Detail { get; private set; } + + public Guid? LiveRoomId { get; private set; } + + public Guid? RecordSessionId { get; private set; } + + public Guid? RecordTaskId { get; private set; } + + public DateTimeOffset CreatedAt { get; private set; } +} diff --git a/src/LiveRecorder.Domain/Entities/UserAccount.cs b/src/LiveRecorder.Domain/Entities/UserAccount.cs new file mode 100644 index 0000000..c15292a --- /dev/null +++ b/src/LiveRecorder.Domain/Entities/UserAccount.cs @@ -0,0 +1,35 @@ +namespace LiveRecorder.Domain.Entities; + +public class UserAccount +{ + private UserAccount() + { + } + + public UserAccount(string username, string displayName, string passwordHash, DateTimeOffset createdAt) + { + Id = Guid.NewGuid(); + Username = username; + DisplayName = displayName; + PasswordHash = passwordHash; + IsActive = true; + CreatedAt = createdAt; + } + + public Guid Id { get; private set; } + + public string Username { get; private set; } = string.Empty; + + public string DisplayName { get; private set; } = string.Empty; + + public string PasswordHash { get; private set; } = string.Empty; + + public bool IsActive { get; private set; } + + public DateTimeOffset CreatedAt { get; private set; } + + public void UpdatePassword(string passwordHash) + { + PasswordHash = passwordHash; + } +} diff --git a/src/LiveRecorder.Domain/Entities/UserSession.cs b/src/LiveRecorder.Domain/Entities/UserSession.cs new file mode 100644 index 0000000..7fb17b0 --- /dev/null +++ b/src/LiveRecorder.Domain/Entities/UserSession.cs @@ -0,0 +1,38 @@ +namespace LiveRecorder.Domain.Entities; + +public class UserSession +{ + private UserSession() + { + } + + public UserSession(Guid userAccountId, string token, DateTimeOffset expiresAt, DateTimeOffset createdAt) + { + Id = Guid.NewGuid(); + UserAccountId = userAccountId; + Token = token; + ExpiresAt = expiresAt; + CreatedAt = createdAt; + } + + public Guid Id { get; private set; } + + public Guid UserAccountId { get; private set; } + + public UserAccount? UserAccount { get; private set; } + + public string Token { get; private set; } = string.Empty; + + public DateTimeOffset ExpiresAt { get; private set; } + + public DateTimeOffset CreatedAt { get; private set; } + + public DateTimeOffset? RevokedAt { get; private set; } + + public bool IsValid(DateTimeOffset now) => RevokedAt is null && ExpiresAt > now; + + public void Revoke(DateTimeOffset revokedAt) + { + RevokedAt = revokedAt; + } +} diff --git a/src/LiveRecorder.Domain/Enums/LivePlatformType.cs b/src/LiveRecorder.Domain/Enums/LivePlatformType.cs new file mode 100644 index 0000000..62f9836 --- /dev/null +++ b/src/LiveRecorder.Domain/Enums/LivePlatformType.cs @@ -0,0 +1,11 @@ +namespace LiveRecorder.Domain.Enums; + +public enum LivePlatformType +{ + Unknown = 0, + Douyin = 1, + Bilibili = 2, + Huya = 3, + Douyu = 4, + Kuaishou = 5 +} diff --git a/src/LiveRecorder.Domain/Enums/LiveRoomAvailabilityStatus.cs b/src/LiveRecorder.Domain/Enums/LiveRoomAvailabilityStatus.cs new file mode 100644 index 0000000..b8fc80a --- /dev/null +++ b/src/LiveRecorder.Domain/Enums/LiveRoomAvailabilityStatus.cs @@ -0,0 +1,8 @@ +namespace LiveRecorder.Domain.Enums; + +public enum LiveRoomAvailabilityStatus +{ + Unknown = 0, + Offline = 1, + Live = 2 +} diff --git a/src/LiveRecorder.Domain/Enums/RecordOutputFormat.cs b/src/LiveRecorder.Domain/Enums/RecordOutputFormat.cs new file mode 100644 index 0000000..39201cc --- /dev/null +++ b/src/LiveRecorder.Domain/Enums/RecordOutputFormat.cs @@ -0,0 +1,7 @@ +namespace LiveRecorder.Domain.Enums; + +public enum RecordOutputFormat +{ + Mp4 = 0, + Ts = 1 +} diff --git a/src/LiveRecorder.Domain/Enums/RecordSaveMode.cs b/src/LiveRecorder.Domain/Enums/RecordSaveMode.cs new file mode 100644 index 0000000..158ba1d --- /dev/null +++ b/src/LiveRecorder.Domain/Enums/RecordSaveMode.cs @@ -0,0 +1,7 @@ +namespace LiveRecorder.Domain.Enums; + +public enum RecordSaveMode +{ + SingleFile = 0, + Segmented = 1 +} diff --git a/src/LiveRecorder.Domain/Enums/RecordSessionStatus.cs b/src/LiveRecorder.Domain/Enums/RecordSessionStatus.cs new file mode 100644 index 0000000..a49899c --- /dev/null +++ b/src/LiveRecorder.Domain/Enums/RecordSessionStatus.cs @@ -0,0 +1,12 @@ +namespace LiveRecorder.Domain.Enums; + +public enum RecordSessionStatus +{ + Pending = 0, + Starting = 1, + Running = 2, + Stopping = 3, + Completed = 4, + Failed = 5, + Stopped = 6 +} diff --git a/src/LiveRecorder.Domain/Enums/RecordTaskStatus.cs b/src/LiveRecorder.Domain/Enums/RecordTaskStatus.cs new file mode 100644 index 0000000..3860ff4 --- /dev/null +++ b/src/LiveRecorder.Domain/Enums/RecordTaskStatus.cs @@ -0,0 +1,12 @@ +namespace LiveRecorder.Domain.Enums; + +public enum RecordTaskStatus +{ + Pending = 0, + Starting = 1, + Running = 2, + Stopping = 3, + Completed = 4, + Failed = 5, + Stopped = 6 +} diff --git a/src/LiveRecorder.Domain/Enums/RecordingTemplateType.cs b/src/LiveRecorder.Domain/Enums/RecordingTemplateType.cs new file mode 100644 index 0000000..9d50485 --- /dev/null +++ b/src/LiveRecorder.Domain/Enums/RecordingTemplateType.cs @@ -0,0 +1,8 @@ +namespace LiveRecorder.Domain.Enums; + +public enum RecordingTemplateType +{ + StreamCopy = 0, + BalancedMp4 = 1, + ArchiveTs = 2 +} diff --git a/src/LiveRecorder.Domain/Enums/SystemLogLevel.cs b/src/LiveRecorder.Domain/Enums/SystemLogLevel.cs new file mode 100644 index 0000000..11941d4 --- /dev/null +++ b/src/LiveRecorder.Domain/Enums/SystemLogLevel.cs @@ -0,0 +1,9 @@ +namespace LiveRecorder.Domain.Enums; + +public enum SystemLogLevel +{ + Trace = 0, + Info = 1, + Warning = 2, + Error = 3 +} diff --git a/src/LiveRecorder.Domain/LiveRecorder.Domain.csproj b/src/LiveRecorder.Domain/LiveRecorder.Domain.csproj new file mode 100644 index 0000000..fa71b7a --- /dev/null +++ b/src/LiveRecorder.Domain/LiveRecorder.Domain.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/src/LiveRecorder.Infrastructure/LiveRecorder.Infrastructure.csproj b/src/LiveRecorder.Infrastructure/LiveRecorder.Infrastructure.csproj new file mode 100644 index 0000000..974f92c --- /dev/null +++ b/src/LiveRecorder.Infrastructure/LiveRecorder.Infrastructure.csproj @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + + net8.0 + enable + enable + + + diff --git a/src/LiveRecorder.Infrastructure/Persistence/DatabaseInitializer.cs b/src/LiveRecorder.Infrastructure/Persistence/DatabaseInitializer.cs new file mode 100644 index 0000000..645be40 --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Persistence/DatabaseInitializer.cs @@ -0,0 +1,312 @@ +using LiveRecorder.Application.Common; +using LiveRecorder.Domain.Entities; +using LiveRecorder.Domain.Enums; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace LiveRecorder.Infrastructure.Persistence; + +public sealed class DatabaseInitializer +{ + private readonly LiveRecorderDbContext _dbContext; + + public DatabaseInitializer(LiveRecorderDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task InitializeAsync(CancellationToken cancellationToken = default) + { + await _dbContext.Database.EnsureCreatedAsync(cancellationToken); + await EnsureSchemaAsync(cancellationToken); + await BackfillRecordSessionsAsync(cancellationToken); + + if (!await _dbContext.UserAccounts.AnyAsync(cancellationToken)) + { + var now = DateTimeOffset.UtcNow; + var admin = new UserAccount("admin", "Administrator", PasswordHasher.Hash("Admin@123"), now); + await _dbContext.UserAccounts.AddAsync(admin, cancellationToken); + } + + var defaults = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ffmpeg.path"] = "ffmpeg", + ["recording.output_root"] = "records", + ["recording.output_directory_template"] = "{platform}/{yyyy}/{MM}/{dd}/{anchor}", + ["recording.output_file_name_template"] = "{HHmmss}_{anchor}_{title}_{roomId}{segmentSuffix}", + ["recording.default_quality"] = "origin", + ["recording.default_output_format"] = "Mp4", + ["recording.save_mode"] = "SingleFile", + ["recording.template"] = "StreamCopy", + ["recording.segment_duration_minutes"] = "30", + ["recording.enable_auto_reconnect"] = "True", + ["recording.reconnect_delay_max_seconds"] = "5", + ["recording.read_write_timeout_milliseconds"] = "15000000", + ["recording.enable_danmaku_recording"] = "True", + ["recording.danmaku_include_non_chat_events"] = "True", + ["recording.danmaku_min_poll_interval_milliseconds"] = "1000", + ["recording.danmaku_retry_delay_max_seconds"] = "15", + ["scheduler.enable_background_polling"] = "True", + ["scheduler.auto_start_recording_on_live"] = "True", + ["scheduler.polling_interval_seconds"] = "60", + ["notification.email.enabled"] = "False", + ["notification.email.smtp_host"] = string.Empty, + ["notification.email.smtp_port"] = "587", + ["notification.email.use_ssl"] = "True", + ["notification.email.username"] = string.Empty, + ["notification.email.password"] = string.Empty, + ["notification.email.from_address"] = string.Empty, + ["notification.email.from_display_name"] = "Live Recorder", + ["notification.email.to_addresses"] = string.Empty, + ["notification.email.notify_live_started"] = "True", + ["notification.email.notify_exception"] = "True", + ["notification.email.live_started.subject_template"] = "[{{appName}}] Live started: {{anchor}} {{title}} ({{roomId}})", + ["notification.email.live_started.body_template_html"] = """ +
+

Live started

+

The monitored live room is now online.

+
    +
  • Platform: {{platform}}
  • +
  • Room ID: {{roomId}}
  • +
  • Title: {{title}}
  • +
  • Anchor: {{anchor}}
  • +
  • Detected At (UTC): {{detectedAtUtc}}
  • +
+

Source URL: {{sourceUrl}}

+
+""", + ["notification.email.exception.subject_template"] = "[{{appName}}] Exception: {{source}}", + ["notification.email.exception.body_template_html"] = """ +
+

Exception detected

+

{{summary}}

+
    +
  • Source: {{source}}
  • +
  • Live Room ID: {{liveRoomId}}
  • +
  • Room ID: {{roomId}}
  • +
  • Record Task ID: {{recordTaskId}}
  • +
  • Task Status: {{taskStatus}}
  • +
  • Occurred At (UTC): {{occurredAtUtc}}
  • +
+
{{detail}}
+
+""", + ["douyin.user_agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + ["douyin.referer"] = "https://live.douyin.com/", + ["douyin.cookie"] = string.Empty + }; + + var existingKeys = await _dbContext.AppSettings + .Select(static item => item.Key) + .ToListAsync(cancellationToken); + + foreach (var pair in defaults.Where(pair => !existingKeys.Contains(pair.Key, StringComparer.OrdinalIgnoreCase))) + { + await _dbContext.AppSettings.AddAsync(new AppSetting(pair.Key, pair.Value, DateTimeOffset.UtcNow), cancellationToken); + } + + await _dbContext.SaveChangesAsync(cancellationToken); + } + + private async Task EnsureSchemaAsync(CancellationToken cancellationToken) + { + try + { + await _dbContext.Database.ExecuteSqlRawAsync( + "ALTER TABLE LiveRooms ADD COLUMN IsEnabled INTEGER NOT NULL DEFAULT 1;", + cancellationToken); + } + catch (SqliteException ex) when (ex.SqliteErrorCode == 1 && + ex.Message.Contains("duplicate column name", StringComparison.OrdinalIgnoreCase)) + { + } + + try + { + await _dbContext.Database.ExecuteSqlRawAsync( + "ALTER TABLE LiveRooms ADD COLUMN HasSentLiveNotificationForCurrentSession INTEGER NOT NULL DEFAULT 0;", + cancellationToken); + } + catch (SqliteException ex) when (ex.SqliteErrorCode == 1 && + ex.Message.Contains("duplicate column name", StringComparison.OrdinalIgnoreCase)) + { + } + + await _dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS RecordSessions ( + Id TEXT NOT NULL CONSTRAINT PK_RecordSessions PRIMARY KEY, + LiveRoomId TEXT NOT NULL, + Status INTEGER NOT NULL, + PreferredQuality TEXT NOT NULL, + OutputFormat INTEGER NOT NULL, + SaveMode INTEGER NOT NULL, + StreamUrl TEXT NULL, + OutputPathPattern TEXT NULL, + ActiveSegmentIndex INTEGER NOT NULL, + SegmentCount INTEGER NOT NULL, + RecorderProcessId INTEGER NULL, + ErrorMessage TEXT NULL, + CreatedAt TEXT NOT NULL, + UpdatedAt TEXT NOT NULL, + StartedAt TEXT NULL, + EndedAt TEXT NULL + ); + """, + cancellationToken); + + await ExecuteAddColumnAsync("ALTER TABLE RecordTasks ADD COLUMN RecordSessionId TEXT NULL;", cancellationToken); + await ExecuteAddColumnAsync("ALTER TABLE RecordTasks ADD COLUMN SegmentIndex INTEGER NOT NULL DEFAULT 1;", cancellationToken); + await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN DanmakuFilePath TEXT NULL;", cancellationToken); + await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN DanmakuMessageCount INTEGER NOT NULL DEFAULT 0;", cancellationToken); + await ExecuteAddColumnAsync("ALTER TABLE SystemLogEntries ADD COLUMN RecordSessionId TEXT NULL;", cancellationToken); + + await _dbContext.Database.ExecuteSqlRawAsync( + "CREATE INDEX IF NOT EXISTS IX_RecordTasks_RecordSessionId_SegmentIndex ON RecordTasks (RecordSessionId, SegmentIndex);", + cancellationToken); + await _dbContext.Database.ExecuteSqlRawAsync( + "CREATE INDEX IF NOT EXISTS IX_RecordSessions_LiveRoomId_CreatedAt ON RecordSessions (LiveRoomId, CreatedAt);", + cancellationToken); + await _dbContext.Database.ExecuteSqlRawAsync( + "CREATE INDEX IF NOT EXISTS IX_SystemLogEntries_RecordSessionId ON SystemLogEntries (RecordSessionId);", + cancellationToken); + } + + private async Task ExecuteAddColumnAsync(string sql, CancellationToken cancellationToken) + { + try + { + await _dbContext.Database.ExecuteSqlRawAsync(sql, cancellationToken); + } + catch (SqliteException ex) when (ex.SqliteErrorCode == 1 && + ex.Message.Contains("duplicate column name", StringComparison.OrdinalIgnoreCase)) + { + } + } + + private async Task BackfillRecordSessionsAsync(CancellationToken cancellationToken) + { + await using var connection = (SqliteConnection)_dbContext.Database.GetDbConnection(); + if (connection.State != System.Data.ConnectionState.Open) + { + await connection.OpenAsync(cancellationToken); + } + + var selectCommand = connection.CreateCommand(); + selectCommand.CommandText = + """ + SELECT Id, LiveRoomId, Status, PreferredQuality, OutputFormat, StreamUrl, OutputFilePath, ErrorMessage, CreatedAt, UpdatedAt, StartedAt, EndedAt + FROM RecordTasks + WHERE RecordSessionId IS NULL OR RecordSessionId = ''; + """; + + var orphanTasks = new List(); + await using (var reader = await selectCommand.ExecuteReaderAsync(cancellationToken)) + { + while (await reader.ReadAsync(cancellationToken)) + { + orphanTasks.Add(new LegacyTaskRow( + reader.GetString(0), + reader.GetString(1), + reader.GetInt32(2), + reader.IsDBNull(3) ? "origin" : reader.GetString(3), + reader.GetInt32(4), + reader.IsDBNull(5) ? null : reader.GetString(5), + reader.IsDBNull(6) ? null : reader.GetString(6), + reader.IsDBNull(7) ? null : reader.GetString(7), + reader.GetString(8), + reader.GetString(9), + reader.IsDBNull(10) ? null : reader.GetString(10), + reader.IsDBNull(11) ? null : reader.GetString(11))); + } + } + + foreach (var orphanTask in orphanTasks) + { + var sessionId = Guid.NewGuid().ToString(); + var saveMode = !string.IsNullOrWhiteSpace(orphanTask.OutputFilePath) && orphanTask.OutputFilePath.Contains('%') + ? (int)RecordSaveMode.Segmented + : (int)RecordSaveMode.SingleFile; + var segmentCount = 1; + var activeSegmentIndex = orphanTask.Status is (int)RecordTaskStatus.Starting or (int)RecordTaskStatus.Running or (int)RecordTaskStatus.Stopping + ? 1 + : 0; + var insertSessionCommand = connection.CreateCommand(); + insertSessionCommand.CommandText = + """ + INSERT INTO RecordSessions ( + Id, LiveRoomId, Status, PreferredQuality, OutputFormat, SaveMode, StreamUrl, OutputPathPattern, + ActiveSegmentIndex, SegmentCount, RecorderProcessId, ErrorMessage, CreatedAt, UpdatedAt, StartedAt, EndedAt + ) VALUES ( + $id, $liveRoomId, $status, $preferredQuality, $outputFormat, $saveMode, $streamUrl, $outputPathPattern, + $activeSegmentIndex, $segmentCount, NULL, $errorMessage, $createdAt, $updatedAt, $startedAt, $endedAt + ); + """; + insertSessionCommand.Parameters.AddWithValue("$id", sessionId); + insertSessionCommand.Parameters.AddWithValue("$liveRoomId", orphanTask.LiveRoomId); + insertSessionCommand.Parameters.AddWithValue("$status", MapLegacyTaskStatusToSessionStatus(orphanTask.Status)); + insertSessionCommand.Parameters.AddWithValue("$preferredQuality", orphanTask.PreferredQuality); + insertSessionCommand.Parameters.AddWithValue("$outputFormat", orphanTask.OutputFormat); + insertSessionCommand.Parameters.AddWithValue("$saveMode", saveMode); + insertSessionCommand.Parameters.AddWithValue("$streamUrl", (object?)orphanTask.StreamUrl ?? DBNull.Value); + insertSessionCommand.Parameters.AddWithValue("$outputPathPattern", (object?)orphanTask.OutputFilePath ?? DBNull.Value); + insertSessionCommand.Parameters.AddWithValue("$activeSegmentIndex", activeSegmentIndex); + insertSessionCommand.Parameters.AddWithValue("$segmentCount", segmentCount); + insertSessionCommand.Parameters.AddWithValue("$errorMessage", (object?)orphanTask.ErrorMessage ?? DBNull.Value); + insertSessionCommand.Parameters.AddWithValue("$createdAt", orphanTask.CreatedAt); + insertSessionCommand.Parameters.AddWithValue("$updatedAt", orphanTask.UpdatedAt); + insertSessionCommand.Parameters.AddWithValue("$startedAt", (object?)orphanTask.StartedAt ?? DBNull.Value); + insertSessionCommand.Parameters.AddWithValue("$endedAt", (object?)orphanTask.EndedAt ?? DBNull.Value); + await insertSessionCommand.ExecuteNonQueryAsync(cancellationToken); + + var updateTaskCommand = connection.CreateCommand(); + updateTaskCommand.CommandText = + """ + UPDATE RecordTasks + SET RecordSessionId = $recordSessionId, SegmentIndex = COALESCE(SegmentIndex, 1) + WHERE Id = $taskId; + """; + updateTaskCommand.Parameters.AddWithValue("$recordSessionId", sessionId); + updateTaskCommand.Parameters.AddWithValue("$taskId", orphanTask.Id); + await updateTaskCommand.ExecuteNonQueryAsync(cancellationToken); + + var updateLogsCommand = connection.CreateCommand(); + updateLogsCommand.CommandText = + """ + UPDATE SystemLogEntries + SET RecordSessionId = $recordSessionId + WHERE RecordTaskId = $taskId AND (RecordSessionId IS NULL OR RecordSessionId = ''); + """; + updateLogsCommand.Parameters.AddWithValue("$recordSessionId", sessionId); + updateLogsCommand.Parameters.AddWithValue("$taskId", orphanTask.Id); + await updateLogsCommand.ExecuteNonQueryAsync(cancellationToken); + } + } + + private static int MapLegacyTaskStatusToSessionStatus(int taskStatus) => + taskStatus switch + { + (int)RecordTaskStatus.Pending => (int)RecordSessionStatus.Pending, + (int)RecordTaskStatus.Starting => (int)RecordSessionStatus.Starting, + (int)RecordTaskStatus.Running => (int)RecordSessionStatus.Running, + (int)RecordTaskStatus.Stopping => (int)RecordSessionStatus.Stopping, + (int)RecordTaskStatus.Completed => (int)RecordSessionStatus.Completed, + (int)RecordTaskStatus.Failed => (int)RecordSessionStatus.Failed, + (int)RecordTaskStatus.Stopped => (int)RecordSessionStatus.Stopped, + _ => (int)RecordSessionStatus.Stopped + }; + + private sealed record LegacyTaskRow( + string Id, + string LiveRoomId, + int Status, + string PreferredQuality, + int OutputFormat, + string? StreamUrl, + string? OutputFilePath, + string? ErrorMessage, + string CreatedAt, + string UpdatedAt, + string? StartedAt, + string? EndedAt); +} diff --git a/src/LiveRecorder.Infrastructure/Persistence/LiveRecorderDbContext.cs b/src/LiveRecorder.Infrastructure/Persistence/LiveRecorderDbContext.cs new file mode 100644 index 0000000..161dcfe --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Persistence/LiveRecorderDbContext.cs @@ -0,0 +1,145 @@ +using LiveRecorder.Application.Abstractions.Persistence; +using LiveRecorder.Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace LiveRecorder.Infrastructure.Persistence; + +public sealed class LiveRecorderDbContext : DbContext, IUnitOfWork +{ + public LiveRecorderDbContext(DbContextOptions options) + : base(options) + { + } + + public DbSet LiveRooms => Set(); + + public DbSet RecordSessions => Set(); + + public DbSet RecordTasks => Set(); + + public DbSet RecordResults => Set(); + + public DbSet SystemLogEntries => Set(); + + public DbSet AppSettings => Set(); + + public DbSet UserAccounts => Set(); + + public DbSet UserSessions => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(builder => + { + builder.ToTable("LiveRooms"); + builder.HasKey(static x => x.Id); + builder.Property(static x => x.Platform).HasConversion(); + builder.Property(static x => x.AvailabilityStatus).HasConversion(); + builder.HasIndex(static x => new { x.Platform, x.RoomId }).IsUnique(); + builder.Property(static x => x.SourceUrl).HasMaxLength(512); + builder.Property(static x => x.NormalizedUrl).HasMaxLength(512); + builder.Property(static x => x.RoomId).HasMaxLength(128); + builder.Property(static x => x.Title).HasMaxLength(256); + builder.Property(static x => x.AnchorName).HasMaxLength(128); + builder.Property(static x => x.CoverUrl).HasMaxLength(512); + builder.Property(static x => x.IsEnabled).HasDefaultValue(true); + builder.Property(static x => x.HasSentLiveNotificationForCurrentSession).HasDefaultValue(false); + }); + + modelBuilder.Entity(builder => + { + builder.ToTable("RecordTasks"); + builder.HasKey(static x => x.Id); + builder.Property(static x => x.Status).HasConversion(); + builder.Property(static x => x.OutputFormat).HasConversion(); + builder.Property(static x => x.PreferredQuality).HasMaxLength(64); + builder.Property(static x => x.StreamUrl).HasMaxLength(2048); + builder.Property(static x => x.OutputFilePath).HasMaxLength(2048); + builder.Property(static x => x.ErrorMessage).HasMaxLength(2048); + builder.HasOne(static x => x.LiveRoom) + .WithMany(static x => x.RecordTasks) + .HasForeignKey(static x => x.LiveRoomId) + .OnDelete(DeleteBehavior.Cascade); + builder.HasOne(static x => x.RecordSession) + .WithMany(static x => x.RecordTasks) + .HasForeignKey(static x => x.RecordSessionId) + .OnDelete(DeleteBehavior.Cascade); + builder.HasIndex(static x => new { x.RecordSessionId, x.SegmentIndex }); + }); + + modelBuilder.Entity(builder => + { + builder.ToTable("RecordSessions"); + builder.HasKey(static x => x.Id); + builder.Property(static x => x.Status).HasConversion(); + builder.Property(static x => x.OutputFormat).HasConversion(); + builder.Property(static x => x.SaveMode).HasConversion(); + builder.Property(static x => x.PreferredQuality).HasMaxLength(64); + builder.Property(static x => x.StreamUrl).HasMaxLength(2048); + builder.Property(static x => x.OutputPathPattern).HasMaxLength(2048); + builder.Property(static x => x.ErrorMessage).HasMaxLength(2048); + builder.HasOne(static x => x.LiveRoom) + .WithMany() + .HasForeignKey(static x => x.LiveRoomId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(builder => + { + builder.ToTable("RecordResults"); + builder.HasKey(static x => x.Id); + builder.Property(static x => x.FinalStatus).HasConversion(); + builder.Property(static x => x.FilePath).HasMaxLength(2048); + builder.Property(static x => x.DanmakuFilePath).HasMaxLength(2048); + builder.Property(static x => x.ErrorMessage).HasMaxLength(2048); + builder.HasIndex(static x => x.RecordTaskId).IsUnique(); + builder.HasOne(static x => x.RecordTask) + .WithOne(static x => x.Result) + .HasForeignKey(static x => x.RecordTaskId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(builder => + { + builder.ToTable("SystemLogEntries"); + builder.HasKey(static x => x.Id); + builder.Property(static x => x.Level).HasConversion(); + builder.Property(static x => x.Category).HasMaxLength(128); + builder.Property(static x => x.Message).HasMaxLength(512); + builder.Property(static x => x.Detail).HasMaxLength(4000); + builder.HasIndex(static x => x.CreatedAt); + builder.HasIndex(static x => x.RecordSessionId); + }); + + modelBuilder.Entity(builder => + { + builder.ToTable("AppSettings"); + builder.HasKey(static x => x.Id); + builder.Property(static x => x.Key).HasMaxLength(128); + builder.Property(static x => x.Value).HasMaxLength(4000); + builder.HasIndex(static x => x.Key).IsUnique(); + }); + + modelBuilder.Entity(builder => + { + builder.ToTable("UserAccounts"); + builder.HasKey(static x => x.Id); + builder.Property(static x => x.Username).HasMaxLength(64); + builder.Property(static x => x.DisplayName).HasMaxLength(64); + builder.Property(static x => x.PasswordHash).HasMaxLength(512); + builder.HasIndex(static x => x.Username).IsUnique(); + }); + + modelBuilder.Entity(builder => + { + builder.ToTable("UserSessions"); + builder.HasKey(static x => x.Id); + builder.Property(static x => x.Token).HasMaxLength(128); + builder.HasIndex(static x => x.Token).IsUnique(); + builder.HasOne(static x => x.UserAccount) + .WithMany() + .HasForeignKey(static x => x.UserAccountId) + .OnDelete(DeleteBehavior.Cascade); + }); + } +} diff --git a/src/LiveRecorder.Infrastructure/Persistence/Repositories/Repositories.cs b/src/LiveRecorder.Infrastructure/Persistence/Repositories/Repositories.cs new file mode 100644 index 0000000..2ed117f --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Persistence/Repositories/Repositories.cs @@ -0,0 +1,325 @@ +using LiveRecorder.Application.Abstractions.Persistence; +using LiveRecorder.Domain.Entities; +using LiveRecorder.Domain.Enums; +using Microsoft.EntityFrameworkCore; + +namespace LiveRecorder.Infrastructure.Persistence.Repositories; + +public sealed class AppSettingRepository : IAppSettingRepository +{ + private readonly LiveRecorderDbContext _dbContext; + + public AppSettingRepository(LiveRecorderDbContext dbContext) + { + _dbContext = dbContext; + } + + public Task GetByKeyAsync(string key, CancellationToken cancellationToken = default) => + _dbContext.AppSettings.FirstOrDefaultAsync(item => item.Key == key, cancellationToken); + + public async Task> ListAsync(CancellationToken cancellationToken = default) => + await _dbContext.AppSettings.OrderBy(static item => item.Key).ToListAsync(cancellationToken); + + public Task AddAsync(AppSetting setting, CancellationToken cancellationToken = default) => + _dbContext.AppSettings.AddAsync(setting, cancellationToken).AsTask(); + + public void Update(AppSetting setting) => _dbContext.AppSettings.Update(setting); +} + +public sealed class LiveRoomRepository : ILiveRoomRepository +{ + private readonly LiveRecorderDbContext _dbContext; + + public LiveRoomRepository(LiveRecorderDbContext dbContext) + { + _dbContext = dbContext; + } + + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) => + _dbContext.LiveRooms.FirstOrDefaultAsync(item => item.Id == id, cancellationToken); + + public Task GetByPlatformRoomIdAsync( + LivePlatformType platformType, + string roomId, + CancellationToken cancellationToken = default) => + _dbContext.LiveRooms.FirstOrDefaultAsync( + item => item.Platform == platformType && item.RoomId == roomId, + cancellationToken); + + public async Task> ListAsync(CancellationToken cancellationToken = default) => + (await _dbContext.LiveRooms.AsNoTracking().ToListAsync(cancellationToken)) + .OrderByDescending(static item => item.UpdatedAt) + .ToList(); + + public Task AddAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default) => + _dbContext.LiveRooms.AddAsync(liveRoom, cancellationToken).AsTask(); + + public void Remove(LiveRoom liveRoom) => _dbContext.LiveRooms.Remove(liveRoom); +} + +public sealed class RecordTaskRepository : IRecordTaskRepository +{ + private readonly LiveRecorderDbContext _dbContext; + + public RecordTaskRepository(LiveRecorderDbContext dbContext) + { + _dbContext = dbContext; + } + + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) => + _dbContext.RecordTasks + .Include(item => item.LiveRoom) + .Include(item => item.Result) + .FirstOrDefaultAsync(item => item.Id == id, cancellationToken); + + public async Task> GetByIdsAsync(IReadOnlyCollection ids, CancellationToken cancellationToken = default) + { + if (ids.Count == 0) + { + return []; + } + + return await _dbContext.RecordTasks + .Include(item => item.LiveRoom) + .Include(item => item.Result) + .Where(item => ids.Contains(item.Id)) + .ToListAsync(cancellationToken); + } + + public async Task> ListAsync(Guid? liveRoomId = null, CancellationToken cancellationToken = default) + { + IQueryable query = _dbContext.RecordTasks + .Include(item => item.LiveRoom) + .Include(item => item.RecordSession) + .Include(item => item.Result) + .AsNoTracking(); + + if (liveRoomId.HasValue) + { + query = query.Where(item => item.LiveRoomId == liveRoomId.Value); + } + + var items = await query.ToListAsync(cancellationToken); + return items + .OrderByDescending(static item => item.CreatedAt) + .ToList(); + } + + public async Task> ListBySessionIdAsync(Guid recordSessionId, CancellationToken cancellationToken = default) + { + var items = await _dbContext.RecordTasks + .Include(item => item.LiveRoom) + .Include(item => item.RecordSession) + .Include(item => item.Result) + .AsNoTracking() + .Where(item => item.RecordSessionId == recordSessionId) + .ToListAsync(cancellationToken); + + return items + .OrderBy(static item => item.SegmentIndex) + .ThenBy(static item => item.CreatedAt) + .ToList(); + } + + public Task GetRunningByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default) => + _dbContext.RecordTasks + .Include(item => item.LiveRoom) + .Include(item => item.RecordSession) + .FirstOrDefaultAsync( + item => item.LiveRoomId == liveRoomId && + (item.Status == RecordTaskStatus.Starting || item.Status == RecordTaskStatus.Running), + cancellationToken); + + public Task AddAsync(RecordTask recordTask, CancellationToken cancellationToken = default) => + _dbContext.RecordTasks.AddAsync(recordTask, cancellationToken).AsTask(); + + public void Remove(RecordTask recordTask) => _dbContext.RecordTasks.Remove(recordTask); + + public void RemoveRange(IEnumerable recordTasks) => _dbContext.RecordTasks.RemoveRange(recordTasks); +} + +public sealed class RecordSessionRepository : IRecordSessionRepository +{ + private readonly LiveRecorderDbContext _dbContext; + + public RecordSessionRepository(LiveRecorderDbContext dbContext) + { + _dbContext = dbContext; + } + + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) => + _dbContext.RecordSessions + .Include(item => item.LiveRoom) + .Include(item => item.RecordTasks.OrderBy(task => task.SegmentIndex)) + .ThenInclude(item => item.Result) + .FirstOrDefaultAsync(item => item.Id == id, cancellationToken); + + public async Task> ListAsync(Guid? liveRoomId = null, CancellationToken cancellationToken = default) + { + IQueryable query = _dbContext.RecordSessions + .Include(item => item.LiveRoom) + .Include(item => item.RecordTasks.OrderBy(task => task.SegmentIndex)) + .ThenInclude(item => item.Result) + .AsNoTracking(); + + if (liveRoomId.HasValue) + { + query = query.Where(item => item.LiveRoomId == liveRoomId.Value); + } + + var items = await query.ToListAsync(cancellationToken); + return items + .OrderByDescending(static item => item.CreatedAt) + .ToList(); + } + + public Task GetActiveByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default) => + _dbContext.RecordSessions + .Include(item => item.LiveRoom) + .Include(item => item.RecordTasks) + .ThenInclude(item => item.Result) + .FirstOrDefaultAsync( + item => item.LiveRoomId == liveRoomId && + (item.Status == RecordSessionStatus.Starting || + item.Status == RecordSessionStatus.Running || + item.Status == RecordSessionStatus.Stopping), + cancellationToken); + + public Task AddAsync(RecordSession recordSession, CancellationToken cancellationToken = default) => + _dbContext.RecordSessions.AddAsync(recordSession, cancellationToken).AsTask(); + + public void Remove(RecordSession recordSession) => _dbContext.RecordSessions.Remove(recordSession); +} + +public sealed class RecordResultRepository : IRecordResultRepository +{ + private readonly LiveRecorderDbContext _dbContext; + + public RecordResultRepository(LiveRecorderDbContext dbContext) + { + _dbContext = dbContext; + } + + public Task GetByTaskIdAsync(Guid recordTaskId, CancellationToken cancellationToken = default) => + _dbContext.RecordResults.FirstOrDefaultAsync(item => item.RecordTaskId == recordTaskId, cancellationToken); + + public Task AddAsync(RecordResult recordResult, CancellationToken cancellationToken = default) => + _dbContext.RecordResults.AddAsync(recordResult, cancellationToken).AsTask(); + + public void Update(RecordResult recordResult) => _dbContext.RecordResults.Update(recordResult); + + public void Remove(RecordResult recordResult) => _dbContext.RecordResults.Remove(recordResult); + + public void RemoveRange(IEnumerable recordResults) => _dbContext.RecordResults.RemoveRange(recordResults); +} + +public sealed class SystemLogRepository : ISystemLogRepository +{ + private readonly LiveRecorderDbContext _dbContext; + + public SystemLogRepository(LiveRecorderDbContext dbContext) + { + _dbContext = dbContext; + } + + public Task AddAsync(SystemLogEntry entry, CancellationToken cancellationToken = default) => + _dbContext.SystemLogEntries.AddAsync(entry, cancellationToken).AsTask(); + + public async Task> ListByRecordTaskIdsAsync( + IReadOnlyCollection recordTaskIds, + CancellationToken cancellationToken = default) + { + if (recordTaskIds.Count == 0) + { + return []; + } + + return await _dbContext.SystemLogEntries + .Where(item => item.RecordTaskId.HasValue && recordTaskIds.Contains(item.RecordTaskId.Value)) + .ToListAsync(cancellationToken); + } + + public async Task> ListByRecordSessionIdsAsync( + IReadOnlyCollection recordSessionIds, + CancellationToken cancellationToken = default) + { + if (recordSessionIds.Count == 0) + { + return []; + } + + return await _dbContext.SystemLogEntries + .Where(item => item.RecordSessionId.HasValue && recordSessionIds.Contains(item.RecordSessionId.Value)) + .ToListAsync(cancellationToken); + } + + public async Task> ListAsync( + Guid? liveRoomId = null, + Guid? recordSessionId = null, + Guid? recordTaskId = null, + int take = 200, + CancellationToken cancellationToken = default) + { + IQueryable query = _dbContext.SystemLogEntries.AsNoTracking(); + + if (liveRoomId.HasValue) + { + query = query.Where(item => item.LiveRoomId == liveRoomId.Value); + } + + if (recordTaskId.HasValue) + { + query = query.Where(item => item.RecordTaskId == recordTaskId.Value); + } + + if (recordSessionId.HasValue) + { + query = query.Where(item => item.RecordSessionId == recordSessionId.Value); + } + + var items = await query.ToListAsync(cancellationToken); + return items + .OrderByDescending(static item => item.CreatedAt) + .Take(Math.Clamp(take, 1, 500)) + .ToList(); + } + + public void RemoveRange(IEnumerable entries) => _dbContext.SystemLogEntries.RemoveRange(entries); +} + +public sealed class UserAccountRepository : IUserAccountRepository +{ + private readonly LiveRecorderDbContext _dbContext; + + public UserAccountRepository(LiveRecorderDbContext dbContext) + { + _dbContext = dbContext; + } + + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) => + _dbContext.UserAccounts.FirstOrDefaultAsync(item => item.Id == id, cancellationToken); + + public Task GetByUsernameAsync(string username, CancellationToken cancellationToken = default) => + _dbContext.UserAccounts.FirstOrDefaultAsync(item => item.Username == username, cancellationToken); + + public Task AddAsync(UserAccount userAccount, CancellationToken cancellationToken = default) => + _dbContext.UserAccounts.AddAsync(userAccount, cancellationToken).AsTask(); +} + +public sealed class UserSessionRepository : IUserSessionRepository +{ + private readonly LiveRecorderDbContext _dbContext; + + public UserSessionRepository(LiveRecorderDbContext dbContext) + { + _dbContext = dbContext; + } + + public Task GetByTokenAsync(string token, CancellationToken cancellationToken = default) => + _dbContext.UserSessions.FirstOrDefaultAsync(item => item.Token == token, cancellationToken); + + public Task AddAsync(UserSession session, CancellationToken cancellationToken = default) => + _dbContext.UserSessions.AddAsync(session, cancellationToken).AsTask(); + + public void Update(UserSession session) => _dbContext.UserSessions.Update(session); +} diff --git a/src/LiveRecorder.Infrastructure/Platforms/Bilibili/BilibiliLivePlatformAdapter.cs b/src/LiveRecorder.Infrastructure/Platforms/Bilibili/BilibiliLivePlatformAdapter.cs new file mode 100644 index 0000000..602a2da --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Platforms/Bilibili/BilibiliLivePlatformAdapter.cs @@ -0,0 +1,25 @@ +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Infrastructure.Platforms.Bilibili; + +public sealed class BilibiliLivePlatformAdapter : ILivePlatformAdapter +{ + public LivePlatformType PlatformType => LivePlatformType.Bilibili; + + public bool CanHandle(string input) => + !string.IsNullOrWhiteSpace(input) && + input.Contains("bilibili.com", StringComparison.OrdinalIgnoreCase); + + public Task ParseRoomAsync(string input, CancellationToken cancellationToken = default) => + throw new NotSupportedException("Bilibili 适配器尚未实现。"); + + public Task GetLiveStatusAsync(string roomId, CancellationToken cancellationToken = default) => + throw new NotSupportedException("Bilibili 适配器尚未实现。"); + + public Task GetStreamUrlAsync( + string roomId, + string? preferredQuality = null, + CancellationToken cancellationToken = default) => + throw new NotSupportedException("Bilibili 适配器尚未实现。"); +} diff --git a/src/LiveRecorder.Infrastructure/Platforms/Douyin/Danmaku/DouyinDanmakuAdapter.cs b/src/LiveRecorder.Infrastructure/Platforms/Douyin/Danmaku/DouyinDanmakuAdapter.cs new file mode 100644 index 0000000..ee7b727 --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Platforms/Douyin/Danmaku/DouyinDanmakuAdapter.cs @@ -0,0 +1,147 @@ +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Domain.Enums; +using Microsoft.Extensions.Logging; + +namespace LiveRecorder.Infrastructure.Platforms.Douyin.Danmaku; + +public sealed class DouyinDanmakuAdapter : ILiveDanmakuAdapter +{ + private readonly DouyinHttpClient _douyinHttpClient; + private readonly ILoggerFactory _loggerFactory; + + public DouyinDanmakuAdapter( + DouyinHttpClient douyinHttpClient, + ILoggerFactory loggerFactory) + { + _douyinHttpClient = douyinHttpClient; + _loggerFactory = loggerFactory; + } + + public LivePlatformType PlatformType => LivePlatformType.Douyin; + + public bool CanHandle(LivePlatformType platformType) => platformType == LivePlatformType.Douyin; + + public Task ConnectAsync(DanmakuConnectionContext context, CancellationToken cancellationToken = default) => + Task.FromResult(new DouyinDanmakuConnection(_douyinHttpClient, _loggerFactory.CreateLogger(), context)); +} + +internal sealed class DouyinDanmakuConnection : ILiveDanmakuConnection +{ + private readonly DouyinHttpClient _douyinHttpClient; + private readonly ILogger _logger; + private readonly DanmakuConnectionContext _context; + + public DouyinDanmakuConnection( + DouyinHttpClient douyinHttpClient, + ILogger logger, + DanmakuConnectionContext context) + { + _douyinHttpClient = douyinHttpClient; + _logger = logger; + _context = context; + } + + public async Task StartAsync(Func onEvent, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(onEvent); + + var bootstrap = await _douyinHttpClient.GetDanmakuBootstrapAsync(_context.RoomId, cancellationToken); + var danmakuRoomId = bootstrap.DanmakuRoomId; + var cursor = bootstrap.Cursor ?? string.Empty; + var internalExt = bootstrap.InternalExt ?? string.Empty; + var userUniqueId = bootstrap.UserUniqueId ?? throw new InvalidOperationException("Douyin danmaku bootstrap did not provide a user unique id."); + var backoff = TimeSpan.FromSeconds(1); + var minPollInterval = TimeSpan.FromMilliseconds(Math.Max(100, _context.MinPollIntervalMilliseconds)); + var maxBackoff = TimeSpan.FromSeconds(Math.Max(1, _context.RetryDelayMaxSeconds)); + var consecutiveEmptyPolls = 0; + + _logger.LogInformation( + "Douyin danmaku bootstrap resolved web room {WebRoomId} to im room {DanmakuRoomId}. CursorPresent={HasCursor}; InternalExtPresent={HasInternalExt}; UserUniqueId={UserUniqueId}", + _context.RoomId, + danmakuRoomId, + !string.IsNullOrWhiteSpace(cursor), + !string.IsNullOrWhiteSpace(internalExt), + userUniqueId); + + while (!cancellationToken.IsCancellationRequested) + { + try + { + var bytes = await _douyinHttpClient.GetLiveImAsync( + danmakuRoomId, + userUniqueId, + cursor, + internalExt, + cancellationToken); + var envelope = DouyinDanmakuProtocol.Parse(bytes); + + cursor = envelope.Cursor ?? cursor; + internalExt = envelope.InternalExt ?? internalExt; + + foreach (var danmakuEvent in envelope.Events) + { + await onEvent(danmakuEvent); + } + + if (envelope.Events.Count == 0) + { + consecutiveEmptyPolls++; + if (consecutiveEmptyPolls == 1 || consecutiveEmptyPolls % 30 == 0) + { + _logger.LogWarning( + "Douyin danmaku polling returned no events for web room {WebRoomId}. ImRoomId={DanmakuRoomId}; EmptyPolls={EmptyPolls}; CursorPresent={HasCursor}; InternalExtPresent={HasInternalExt}", + _context.RoomId, + danmakuRoomId, + consecutiveEmptyPolls, + !string.IsNullOrWhiteSpace(cursor), + !string.IsNullOrWhiteSpace(internalExt)); + } + + if (consecutiveEmptyPolls % 30 == 0) + { + var refreshedBootstrap = await _douyinHttpClient.GetDanmakuBootstrapAsync(_context.RoomId, cancellationToken); + if (!string.Equals(refreshedBootstrap.DanmakuRoomId, danmakuRoomId, StringComparison.Ordinal)) + { + _logger.LogInformation( + "Douyin danmaku bootstrap refreshed im room id from {OldDanmakuRoomId} to {NewDanmakuRoomId} for web room {WebRoomId}.", + danmakuRoomId, + refreshedBootstrap.DanmakuRoomId, + _context.RoomId); + danmakuRoomId = refreshedBootstrap.DanmakuRoomId; + } + + if (!string.IsNullOrWhiteSpace(refreshedBootstrap.Cursor)) + { + cursor = refreshedBootstrap.Cursor; + } + + if (!string.IsNullOrWhiteSpace(refreshedBootstrap.InternalExt)) + { + internalExt = refreshedBootstrap.InternalExt; + } + } + } + else + { + consecutiveEmptyPolls = 0; + } + + backoff = TimeSpan.FromSeconds(1); + var pollDelay = envelope.PollInterval < minPollInterval ? minPollInterval : envelope.PollInterval; + await Task.Delay(pollDelay, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Douyin danmaku polling failed for room {RoomId}. Retrying.", _context.RoomId); + await Task.Delay(backoff, cancellationToken); + backoff = TimeSpan.FromSeconds(Math.Min(maxBackoff.TotalSeconds, backoff.TotalSeconds * 2)); + } + } + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} diff --git a/src/LiveRecorder.Infrastructure/Platforms/Douyin/Danmaku/DouyinDanmakuProtocol.cs b/src/LiveRecorder.Infrastructure/Platforms/Douyin/Danmaku/DouyinDanmakuProtocol.cs new file mode 100644 index 0000000..d469828 --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Platforms/Douyin/Danmaku/DouyinDanmakuProtocol.cs @@ -0,0 +1,480 @@ +using System.IO.Compression; +using System.Text; +using LiveRecorder.Application.Abstractions.Platforms; + +namespace LiveRecorder.Infrastructure.Platforms.Douyin.Danmaku; + +internal static class DouyinDanmakuProtocol +{ + public static DouyinDanmakuEnvelope Parse(byte[] payload) + { + if (payload.Length == 0) + { + return new DouyinDanmakuEnvelope([], null, null, TimeSpan.FromSeconds(1)); + } + + var responseBytes = payload.AsSpan(); + var pushFrame = TryParsePushFrame(responseBytes); + if (pushFrame.Payload.Length > 0) + { + responseBytes = pushFrame.Payload; + } + + if (LooksLikeGzip(responseBytes)) + { + responseBytes = Decompress(responseBytes); + } + + var response = ParseResponse(responseBytes); + return response; + } + + private static DouyinPushFrame ParsePushFrame(ReadOnlySpan data) + { + var payload = ReadOnlySpan.Empty; + var payloadEncoding = string.Empty; + var payloadType = string.Empty; + + var index = 0; + while (index < data.Length) + { + var tag = ReadVarint(data, ref index); + var fieldNumber = (int)(tag >> 3); + var wireType = (int)(tag & 0x07); + + switch (fieldNumber) + { + case 5 when wireType == 2: + var headerBytes = ReadLengthDelimited(data, ref index); + var (key, value) = ParseHeader(headerBytes); + if (string.Equals(key, "compress_type", StringComparison.OrdinalIgnoreCase)) + { + payloadEncoding = value; + } + break; + case 7 when wireType == 2: + payloadType = ReadString(data, ref index); + break; + case 8 when wireType == 2: + payload = ReadLengthDelimited(data, ref index).ToArray(); + break; + default: + SkipField(data, ref index, wireType); + break; + } + } + + if (payload.Length > 0 && + (LooksLikeGzip(payload) || + payloadEncoding.Contains("gzip", StringComparison.OrdinalIgnoreCase) || + payloadType.Contains("response", StringComparison.OrdinalIgnoreCase))) + { + payload = Decompress(payload); + } + + return new DouyinPushFrame(payload.ToArray()); + } + + private static DouyinPushFrame TryParsePushFrame(ReadOnlySpan data) + { + try + { + return ParsePushFrame(data); + } + catch + { + return new DouyinPushFrame(Array.Empty()); + } + } + + private static DouyinDanmakuEnvelope ParseResponse(ReadOnlySpan data) + { + var events = new List(); + string? cursor = null; + string? internalExt = null; + TimeSpan pollInterval = TimeSpan.FromSeconds(1); + + var index = 0; + while (index < data.Length) + { + var tag = ReadVarint(data, ref index); + var fieldNumber = (int)(tag >> 3); + var wireType = (int)(tag & 0x07); + + switch (fieldNumber) + { + case 1 when wireType == 2: + var messageBytes = ReadLengthDelimited(data, ref index); + var danmakuEvent = ParseEnvelopeMessage(messageBytes); + if (danmakuEvent is not null) + { + events.Add(danmakuEvent); + } + break; + case 2 when wireType == 2: + cursor = Encoding.UTF8.GetString(ReadLengthDelimited(data, ref index)); + break; + case 5 when wireType == 2: + internalExt = Encoding.UTF8.GetString(ReadLengthDelimited(data, ref index)); + break; + case 3 when wireType == 0: + case 8 when wireType == 0: + pollInterval = TimeSpan.FromMilliseconds(Math.Max(500, (long)ReadVarint(data, ref index))); + break; + default: + SkipField(data, ref index, wireType); + break; + } + } + + return new DouyinDanmakuEnvelope(events, cursor, internalExt, pollInterval); + } + + private static DanmakuEvent? ParseEnvelopeMessage(ReadOnlySpan data) + { + string? method = null; + ReadOnlySpan payload = []; + + var index = 0; + while (index < data.Length) + { + var tag = ReadVarint(data, ref index); + var fieldNumber = (int)(tag >> 3); + var wireType = (int)(tag & 0x07); + + switch (fieldNumber) + { + case 1 when wireType == 2: + method = Encoding.UTF8.GetString(ReadLengthDelimited(data, ref index)); + break; + case 2 when wireType == 2: + payload = ReadLengthDelimited(data, ref index).ToArray(); + break; + default: + SkipField(data, ref index, wireType); + break; + } + } + + if (string.IsNullOrWhiteSpace(method) || payload.Length == 0) + { + return null; + } + + var occurredAt = DateTimeOffset.UtcNow; + return method switch + { + "WebcastChatMessage" => ParseChat(payload, occurredAt), + "WebcastGiftMessage" => ParseGift(payload, occurredAt), + "WebcastLikeMessage" => ParseLike(payload, occurredAt), + "WebcastMemberMessage" => ParseMember(payload, occurredAt), + "WebcastSocialMessage" => ParseSocial(payload, occurredAt), + _ => new DanmakuEvent("other", null, null, method, occurredAt, Convert.ToBase64String(payload.ToArray())) + }; + } + + private static DanmakuEvent ParseChat(ReadOnlySpan data, DateTimeOffset occurredAt) + { + string? user = null; + string? userId = null; + string? content = null; + + var index = 0; + while (index < data.Length) + { + var tag = ReadVarint(data, ref index); + var fieldNumber = (int)(tag >> 3); + var wireType = (int)(tag & 0x07); + + switch (fieldNumber) + { + case 2 when wireType == 2: + (userId, user) = ParseUser(ReadLengthDelimited(data, ref index)); + break; + case 3 when wireType == 2: + content = Encoding.UTF8.GetString(ReadLengthDelimited(data, ref index)); + break; + default: + SkipField(data, ref index, wireType); + break; + } + } + + return new DanmakuEvent("chat", user, userId, content, occurredAt, Convert.ToBase64String(data.ToArray())); + } + + private static DanmakuEvent ParseGift(ReadOnlySpan data, DateTimeOffset occurredAt) + { + string? user = null; + string? userId = null; + long giftId = 0; + long repeatCount = 0; + + var index = 0; + while (index < data.Length) + { + var tag = ReadVarint(data, ref index); + var fieldNumber = (int)(tag >> 3); + var wireType = (int)(tag & 0x07); + + switch (fieldNumber) + { + case 2 when wireType == 0: + giftId = (long)ReadVarint(data, ref index); + break; + case 5 when wireType == 0: + repeatCount = (long)ReadVarint(data, ref index); + break; + case 7 when wireType == 2: + (userId, user) = ParseUser(ReadLengthDelimited(data, ref index)); + break; + default: + SkipField(data, ref index, wireType); + break; + } + } + + var extra = new Dictionary + { + ["giftId"] = giftId.ToString(), + ["repeatCount"] = repeatCount.ToString() + }; + return new DanmakuEvent("gift", user, userId, $"gift:{giftId} x{Math.Max(1, repeatCount)}", occurredAt, Convert.ToBase64String(data.ToArray()), extra); + } + + private static DanmakuEvent ParseLike(ReadOnlySpan data, DateTimeOffset occurredAt) + { + string? user = null; + string? userId = null; + long count = 0; + + var index = 0; + while (index < data.Length) + { + var tag = ReadVarint(data, ref index); + var fieldNumber = (int)(tag >> 3); + var wireType = (int)(tag & 0x07); + + switch (fieldNumber) + { + case 2 when wireType == 0: + count = (long)ReadVarint(data, ref index); + break; + case 5 when wireType == 2: + (userId, user) = ParseUser(ReadLengthDelimited(data, ref index)); + break; + default: + SkipField(data, ref index, wireType); + break; + } + } + + return new DanmakuEvent( + "like", + user, + userId, + $"like x{Math.Max(1, count)}", + occurredAt, + Convert.ToBase64String(data.ToArray()), + new Dictionary { ["count"] = count.ToString() }); + } + + private static DanmakuEvent ParseMember(ReadOnlySpan data, DateTimeOffset occurredAt) + { + string? user = null; + string? userId = null; + + var index = 0; + while (index < data.Length) + { + var tag = ReadVarint(data, ref index); + var fieldNumber = (int)(tag >> 3); + var wireType = (int)(tag & 0x07); + + switch (fieldNumber) + { + case 2 when wireType == 2: + (userId, user) = ParseUser(ReadLengthDelimited(data, ref index)); + break; + default: + SkipField(data, ref index, wireType); + break; + } + } + + return new DanmakuEvent("member", user, userId, "member joined", occurredAt, Convert.ToBase64String(data.ToArray())); + } + + private static DanmakuEvent ParseSocial(ReadOnlySpan data, DateTimeOffset occurredAt) + { + string? user = null; + string? userId = null; + long action = 0; + + var index = 0; + while (index < data.Length) + { + var tag = ReadVarint(data, ref index); + var fieldNumber = (int)(tag >> 3); + var wireType = (int)(tag & 0x07); + + switch (fieldNumber) + { + case 2 when wireType == 2: + (userId, user) = ParseUser(ReadLengthDelimited(data, ref index)); + break; + case 4 when wireType == 0: + action = (long)ReadVarint(data, ref index); + break; + default: + SkipField(data, ref index, wireType); + break; + } + } + + return new DanmakuEvent( + "enter", + user, + userId, + $"action:{action}", + occurredAt, + Convert.ToBase64String(data.ToArray()), + new Dictionary { ["action"] = action.ToString() }); + } + + private static (string? UserId, string? UserName) ParseUser(ReadOnlySpan data) + { + string? userId = null; + string? userName = null; + var index = 0; + while (index < data.Length) + { + var tag = ReadVarint(data, ref index); + var fieldNumber = (int)(tag >> 3); + var wireType = (int)(tag & 0x07); + + switch (fieldNumber) + { + case 1 when wireType == 0: + userId = ReadVarint(data, ref index).ToString(); + break; + case 3 when wireType == 2: + userName = Encoding.UTF8.GetString(ReadLengthDelimited(data, ref index)); + break; + default: + SkipField(data, ref index, wireType); + break; + } + } + + return (userId, userName); + } + + private static (string Key, string Value) ParseHeader(ReadOnlySpan data) + { + string key = string.Empty; + string value = string.Empty; + var index = 0; + while (index < data.Length) + { + var tag = ReadVarint(data, ref index); + var fieldNumber = (int)(tag >> 3); + var wireType = (int)(tag & 0x07); + + switch (fieldNumber) + { + case 1 when wireType == 2: + key = Encoding.UTF8.GetString(ReadLengthDelimited(data, ref index)); + break; + case 2 when wireType == 2: + value = Encoding.UTF8.GetString(ReadLengthDelimited(data, ref index)); + break; + default: + SkipField(data, ref index, wireType); + break; + } + } + + return (key, value); + } + + private static ulong ReadVarint(ReadOnlySpan data, ref int index) + { + ulong result = 0; + var shift = 0; + while (index < data.Length) + { + var value = data[index++]; + result |= (ulong)(value & 0x7F) << shift; + if ((value & 0x80) == 0) + { + return result; + } + + shift += 7; + if (shift > 63) + { + throw new InvalidDataException("Invalid protobuf varint."); + } + } + + throw new EndOfStreamException(); + } + + private static ReadOnlySpan ReadLengthDelimited(ReadOnlySpan data, ref int index) + { + var length = checked((int)ReadVarint(data, ref index)); + if (length < 0 || index + length > data.Length) + { + throw new InvalidDataException("Invalid protobuf length-delimited field."); + } + + var result = data.Slice(index, length); + index += length; + return result; + } + + private static string ReadString(ReadOnlySpan data, ref int index) => + Encoding.UTF8.GetString(ReadLengthDelimited(data, ref index)); + + private static void SkipField(ReadOnlySpan data, ref int index, int wireType) + { + switch (wireType) + { + case 0: + ReadVarint(data, ref index); + return; + case 1: + index += 8; + return; + case 2: + _ = ReadLengthDelimited(data, ref index); + return; + case 5: + index += 4; + return; + default: + throw new InvalidDataException($"Unsupported protobuf wire type: {wireType}"); + } + } + + private static bool LooksLikeGzip(ReadOnlySpan data) => + data.Length >= 2 && data[0] == 0x1F && data[1] == 0x8B; + + private static byte[] Decompress(ReadOnlySpan data) + { + using var input = new MemoryStream(data.ToArray()); + using var gzip = new GZipStream(input, CompressionMode.Decompress); + using var output = new MemoryStream(); + gzip.CopyTo(output); + return output.ToArray(); + } + + internal sealed record DouyinDanmakuEnvelope( + IReadOnlyList Events, + string? Cursor, + string? InternalExt, + TimeSpan PollInterval); + + private readonly record struct DouyinPushFrame(byte[] Payload); +} diff --git a/src/LiveRecorder.Infrastructure/Platforms/Douyin/DouyinHttpClient.cs b/src/LiveRecorder.Infrastructure/Platforms/Douyin/DouyinHttpClient.cs new file mode 100644 index 0000000..9ae6aff --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Platforms/Douyin/DouyinHttpClient.cs @@ -0,0 +1,681 @@ +using System.Net; +using System.Text.Json; +using System.Text.RegularExpressions; +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Application.Abstractions.Settings; +using LiveRecorder.Application.Models.Settings; +using LiveRecorder.Infrastructure.Platforms.Douyin.Signing; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Logging; + +namespace LiveRecorder.Infrastructure.Platforms.Douyin; + +public sealed class DouyinHttpClient +{ + private const int MaxAttempts = 3; + private const int MsTokenLength = 184; + private static readonly Regex PaceStateRegex = new( + "self\\.__pace_f\\.push\\(\\[1,\"[a-z]?:\\[\\\\\"\\$\\\\\",\\\\\"\\$L\\d+\\\\\",null,(?[\\s\\S]+?state[\\s\\S]+?)\\]\\\\n\"\\]\\)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex EscapedQuoteRegex = new( + "\\\\{1,7}\"", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex HtmlRoomIdRegex = new( + "\"roomId\":\"(?\\d{8,})\"", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex HtmlRoomIdNewRegex = new( + "\"roomStore\":{[\\s\\S]*?\"roomInfo\":{[\\s\\S]*?\"roomId\":\"(?\\d{8,})\"", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex HtmlTitleRegex = new( + "\"roomStore\":{[\\s\\S]*?\"roomInfo\":{[\\s\\S]*?\"room\":{[\\s\\S]*?\"title\":\"(?[\\s\\S]*?)\"", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex HtmlAnchorRegex = new( + "\"roomStore\":{[\\s\\S]*?\"roomInfo\":{[\\s\\S]*?\"anchor\":{[\\s\\S]*?\"nickname\":\"(?<nickname>[\\s\\S]*?)\"", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private readonly IHttpClientFactory _httpClientFactory; + private readonly ISystemSettingsService _systemSettingsService; + private readonly DouyinXBogusSigner _xBogusSigner; + private readonly ILogger<DouyinHttpClient> _logger; + + public DouyinHttpClient( + IHttpClientFactory httpClientFactory, + ISystemSettingsService systemSettingsService, + DouyinXBogusSigner xBogusSigner, + ILogger<DouyinHttpClient> logger) + { + _httpClientFactory = httpClientFactory; + _systemSettingsService = systemSettingsService; + _xBogusSigner = xBogusSigner; + _logger = logger; + } + + public async Task<(Uri? FinalUri, string Body)> ResolvePageAsync(string input, CancellationToken cancellationToken = default) + { + var settings = await _systemSettingsService.GetAsync(cancellationToken); + var cookieHeader = await BuildCookieHeaderAsync(settings, cancellationToken); + using var response = await SendWithRetryAsync( + () => + { + var request = new HttpRequestMessage(HttpMethod.Get, input); + ApplyDefaultHeaders(request, settings, refererRoomId: null); + + if (!string.IsNullOrWhiteSpace(cookieHeader)) + { + request.Headers.TryAddWithoutValidation("Cookie", cookieHeader); + } + + return request; + }, + HttpCompletionOption.ResponseContentRead, + cancellationToken); + + var body = await response.Content.ReadAsStringAsync(cancellationToken); + return (response.RequestMessage?.RequestUri, body); + } + + public async Task<JsonDocument> GetRoomEnterAsync(string roomId, CancellationToken cancellationToken = default) + { + var settings = await _systemSettingsService.GetAsync(cancellationToken); + var cookieHeader = await BuildCookieHeaderAsync(settings, cancellationToken); + using var response = await SendWithRetryAsync( + () => + { + var request = new HttpRequestMessage(HttpMethod.Get, BuildRoomEnterUri(roomId)); + ApplyDefaultHeaders(request, settings, roomId); + + if (!string.IsNullOrWhiteSpace(cookieHeader)) + { + request.Headers.TryAddWithoutValidation("Cookie", cookieHeader); + } + + return request; + }, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + return await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + } + + public async Task<StreamInputHeaders> GetStreamInputHeadersAsync(string roomId, CancellationToken cancellationToken = default) + { + var settings = await _systemSettingsService.GetAsync(cancellationToken); + var cookieHeader = await BuildCookieHeaderAsync(settings, cancellationToken); + var additionalHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) + { + ["Origin"] = "https://live.douyin.com" + }; + + return new StreamInputHeaders( + settings.DouyinUserAgent, + string.IsNullOrWhiteSpace(roomId) ? settings.DouyinReferer : $"https://live.douyin.com/{roomId}", + string.IsNullOrWhiteSpace(cookieHeader) ? null : cookieHeader, + additionalHeaders); + } + + public async Task<byte[]> GetLiveImAsync( + string roomId, + string userUniqueId, + string? cursor, + string? internalExt, + CancellationToken cancellationToken = default) + { + var settings = await _systemSettingsService.GetAsync(cancellationToken); + var cookieHeader = await BuildCookieHeaderAsync(settings, cancellationToken); + var requestUri = await BuildSignedLiveImUriAsync( + roomId, + userUniqueId, + cursor, + internalExt, + settings.DouyinUserAgent, + cancellationToken); + using var response = await SendWithRetryAsync( + () => + { + var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + ApplyLiveImHeaders(request, settings, roomId); + request.Headers.Accept.ParseAdd("application/protobuf, application/octet-stream, */*"); + + if (!string.IsNullOrWhiteSpace(cookieHeader)) + { + request.Headers.TryAddWithoutValidation("Cookie", cookieHeader); + } + + return request; + }, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + + var body = await response.Content.ReadAsByteArrayAsync(cancellationToken); + if (body.Length == 0) + { + throw new InvalidOperationException($"Douyin live im returned an empty body for room {roomId}."); + } + + return body; + } + + public async Task<DouyinDanmakuBootstrap> GetDanmakuBootstrapAsync( + string webRoomId, + CancellationToken cancellationToken = default) + { + var (_, pageHtml) = await ResolvePageAsync($"https://live.douyin.com/{webRoomId}", cancellationToken); + using var document = await GetRoomEnterAsync(webRoomId, cancellationToken); + var root = document.RootElement; + var room = GetRoomNode(root); + + var danmakuRoomId = + TryExtractDanmakuRoomIdFromHtml(pageHtml) ?? + ReadNumericLikeString(room, "id_str") ?? + ReadNumericLikeString(room, "id") ?? + ReadNumericLikeString(room, "room_id") ?? + ReadNumericLikeString(room, "roomId") ?? + FindNumericLikeValue(room, "id_str", "room_id", "roomId", "id") ?? + webRoomId; + + var (anchor, title) = TryExtractAnchorAndTitleFromHtml(pageHtml); + + return new DouyinDanmakuBootstrap( + webRoomId, + danmakuRoomId, + null, + null, + CreateAnonymousUserUniqueId(), + anchor, + title); + } + + private async Task<string> BuildCookieHeaderAsync(SystemSettingsDto settings, CancellationToken cancellationToken) + { + var configuredCookie = settings.DouyinCookie?.Trim() ?? string.Empty; + if (!string.IsNullOrWhiteSpace(configuredCookie) && + configuredCookie.Contains("ttwid=", StringComparison.OrdinalIgnoreCase)) + { + return configuredCookie; + } + + var ttwidCookie = await FetchTtwidCookieAsync(settings, cancellationToken); + if (string.IsNullOrWhiteSpace(ttwidCookie)) + { + return configuredCookie; + } + + if (string.IsNullOrWhiteSpace(configuredCookie)) + { + return ttwidCookie; + } + + return $"{configuredCookie.TrimEnd(';')}; {ttwidCookie}"; + } + + private async Task<string?> FetchTtwidCookieAsync(SystemSettingsDto settings, CancellationToken cancellationToken) + { + try + { + using var response = await SendWithRetryAsync( + () => + { + var request = new HttpRequestMessage(HttpMethod.Get, settings.DouyinReferer); + ApplyDefaultHeaders(request, settings, refererRoomId: null); + + if (!string.IsNullOrWhiteSpace(settings.DouyinCookie)) + { + request.Headers.TryAddWithoutValidation("Cookie", settings.DouyinCookie.Trim()); + } + + return request; + }, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + + if (!response.Headers.TryGetValues("Set-Cookie", out var setCookieHeaders)) + { + return null; + } + + foreach (var cookieLine in setCookieHeaders) + { + var part = cookieLine + .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .FirstOrDefault(item => item.StartsWith("ttwid=", StringComparison.OrdinalIgnoreCase)); + + if (!string.IsNullOrWhiteSpace(part)) + { + return part; + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Fetch ttwid cookie failed."); + } + + return null; + } + + private async Task<HttpResponseMessage> SendWithRetryAsync( + Func<HttpRequestMessage> requestFactory, + HttpCompletionOption completionOption, + CancellationToken cancellationToken) + { + var client = _httpClientFactory.CreateClient("douyin"); + + for (var attempt = 1; attempt <= MaxAttempts; attempt++) + { + using var request = requestFactory(); + + try + { + var response = await client.SendAsync(request, completionOption, cancellationToken); + if (response.IsSuccessStatusCode) + { + return response; + } + + if (attempt < MaxAttempts && IsTransientStatusCode(response.StatusCode)) + { + response.Dispose(); + await DelayForRetryAsync(attempt, cancellationToken); + continue; + } + + response.EnsureSuccessStatusCode(); + return response; + } + catch (Exception ex) when (attempt < MaxAttempts && IsTransientTransportException(ex, cancellationToken)) + { + _logger.LogWarning(ex, "Douyin request transient failure on attempt {Attempt}. Retrying.", attempt); + await DelayForRetryAsync(attempt, cancellationToken); + } + } + + using var lastRequest = requestFactory(); + return await client.SendAsync(lastRequest, completionOption, cancellationToken); + } + + private static void ApplyDefaultHeaders(HttpRequestMessage request, SystemSettingsDto settings, string? refererRoomId) + { + request.Version = HttpVersion.Version11; + request.VersionPolicy = HttpVersionPolicy.RequestVersionOrLower; + request.Headers.Accept.ParseAdd("application/json, text/plain, */*"); + request.Headers.ConnectionClose = true; + request.Headers.UserAgent.ParseAdd(settings.DouyinUserAgent); + request.Headers.TryAddWithoutValidation("Origin", "https://live.douyin.com"); + request.Headers.Referrer = new Uri( + string.IsNullOrWhiteSpace(refererRoomId) + ? settings.DouyinReferer + : $"https://live.douyin.com/{refererRoomId}"); + } + + private static void ApplyLiveImHeaders(HttpRequestMessage request, SystemSettingsDto settings, string roomId) + { + ApplyDefaultHeaders(request, settings, roomId); + request.Headers.TryAddWithoutValidation("Host", "live.douyin.com"); + } + + private static string BuildRoomEnterUri(string roomId) + { + var parameters = new Dictionary<string, string?> + { + ["aid"] = "6383", + ["app_name"] = "douyin_web", + ["device_platform"] = "web", + ["language"] = "zh-CN", + ["browser_language"] = "zh-CN", + ["browser_platform"] = "Win32", + ["browser_name"] = "Mozilla", + ["browser_version"] = "5.0", + ["enter_from"] = "web_live", + ["cookie_enabled"] = "true", + ["screen_width"] = "1920", + ["screen_height"] = "1080", + ["is_need_double_stream"] = "true", + ["web_rid"] = roomId + }; + + return QueryHelpers.AddQueryString("https://live.douyin.com/webcast/room/web/enter/", parameters); + } + + private async Task<string> BuildSignedLiveImUriAsync( + string roomId, + string userUniqueId, + string? cursor, + string? internalExt, + string userAgent, + CancellationToken cancellationToken) + { + var parameters = BuildLiveImParameters(roomId, userUniqueId, cursor, internalExt, userAgent); + var unencodedQuery = string.Join("&", parameters.Select(p => $"{p.Key}={p.Value}")); + var signedValue = await _xBogusSigner.SignAsync(unencodedQuery, userAgent, cancellationToken); + parameters["a_bogus"] = signedValue; + return QueryHelpers.AddQueryString("https://live.douyin.com/webcast/im/fetch/", parameters); + } + + private static Dictionary<string, string?> BuildLiveImParameters( + string roomId, + string userUniqueId, + string? cursor, + string? internalExt, + string userAgent) + { + return new Dictionary<string, string?> + { + ["aid"] = "6383", + ["app_name"] = "douyin_web", + ["browser_language"] = "zh-CN", + ["browser_name"] = "Mozilla", + ["browser_online"] = "true", + ["browser_platform"] = "Win32", + ["browser_version"] = string.IsNullOrWhiteSpace(userAgent) ? "5.0" : userAgent, + ["compress"] = "gzip", + ["cookie_enabled"] = "true", + ["cursor"] = cursor ?? string.Empty, + ["device_id"] = string.Empty, + ["device_platform"] = "web", + ["did_rule"] = "3", + ["endpoint"] = "live_pc", + ["fetch_rule"] = "1", + ["host"] = "https://live.douyin.com", + ["identity"] = "audience", + ["insert_task_id"] = string.Empty, + ["internal_ext"] = internalExt ?? string.Empty, + ["last_rtt"] = "0", + ["live_id"] = "1", + ["live_pc"] = roomId, + ["live_reason"] = string.Empty, + ["msToken"] = CreateMsToken(), + ["need_persist_msg_count"] = "15", + ["room_id"] = roomId, + ["room_id_str"] = roomId, + ["resp_content_type"] = "protobuf", + ["screen_height"] = "1080", + ["screen_width"] = "1920", + ["support_wrds"] = "1", + ["tz_name"] = "Asia/Shanghai", + ["user_unique_id"] = userUniqueId, + ["version_code"] = "180800", + ["webcast_sdk_version"] = "1.0.15" + }; + } + + private static bool IsTransientStatusCode(HttpStatusCode statusCode) => + statusCode == HttpStatusCode.RequestTimeout || + statusCode == (HttpStatusCode)429 || + (int)statusCode >= 500; + + private static bool IsTransientTransportException(Exception exception, CancellationToken cancellationToken) + { + if (exception is OperationCanceledException && cancellationToken.IsCancellationRequested) + { + return false; + } + + if (exception is HttpRequestException or IOException or TimeoutException) + { + return true; + } + + return exception.InnerException is not null && + IsTransientTransportException(exception.InnerException, cancellationToken); + } + + private static Task DelayForRetryAsync(int attempt, CancellationToken cancellationToken) => + Task.Delay(TimeSpan.FromMilliseconds(250 * attempt), cancellationToken); + + private static JsonElement GetRoomNode(JsonElement root) + { + if (!TryGetProperty(root, "data", out var data)) + { + throw new InvalidOperationException("Douyin response does not contain a data node."); + } + + if (data.ValueKind == JsonValueKind.Array && data.GetArrayLength() > 0) + { + return data[0]; + } + + if (data.ValueKind == JsonValueKind.Object) + { + if (TryGetProperty(data, "data", out var innerData)) + { + if (innerData.ValueKind == JsonValueKind.Array && innerData.GetArrayLength() > 0) + { + return innerData[0]; + } + + if (innerData.ValueKind == JsonValueKind.Object) + { + return innerData; + } + } + + if (TryGetProperty(data, "room", out var room)) + { + return room; + } + + return data; + } + + throw new InvalidOperationException("Douyin response data node format is not supported."); + } + + private static bool TryGetProperty(JsonElement element, string name, out JsonElement value) + { + if (element.ValueKind == JsonValueKind.Object) + { + if (element.TryGetProperty(name, out value)) + { + return true; + } + + foreach (var property in element.EnumerateObject()) + { + if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase)) + { + value = property.Value; + return true; + } + } + } + + value = default; + return false; + } + + private static string? ReadNumericLikeString(JsonElement element, string propertyName) + { + if (!TryGetProperty(element, propertyName, out var value)) + { + return null; + } + + return NormalizeNumericLikeString(ReadScalarString(value)); + } + + private static string? FindNumericLikeValue(JsonElement element, params string[] propertyNames) + { + foreach (var propertyName in propertyNames) + { + var value = FindScalarStringByPropertyName(element, propertyName); + var normalized = NormalizeNumericLikeString(value); + if (!string.IsNullOrWhiteSpace(normalized)) + { + return normalized; + } + } + + return null; + } + + private static string? FindScalarString(JsonElement element, params string[] propertyNames) + { + foreach (var propertyName in propertyNames) + { + var value = FindScalarStringByPropertyName(element, propertyName); + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + + return null; + } + + private static string? FindScalarStringByPropertyName(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 = FindScalarStringByPropertyName(property.Value, propertyName); + if (!string.IsNullOrWhiteSpace(nested)) + { + return nested; + } + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + foreach (var item in element.EnumerateArray()) + { + var nested = FindScalarStringByPropertyName(item, propertyName); + if (!string.IsNullOrWhiteSpace(nested)) + { + return nested; + } + } + } + + return null; + } + + private static string? ReadScalarString(JsonElement value) + { + return value.ValueKind switch + { + JsonValueKind.String => value.GetString(), + JsonValueKind.Number => value.GetRawText(), + JsonValueKind.True => bool.TrueString.ToLowerInvariant(), + JsonValueKind.False => bool.FalseString.ToLowerInvariant(), + _ => null + }; + } + + private static string? NormalizeOptional(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static string? NormalizeNumericLikeString(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var trimmed = value.Trim(); + return trimmed.All(char.IsDigit) ? trimmed : null; + } + + private static string CreateMsToken() + { + const string characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_="; + Span<char> buffer = stackalloc char[MsTokenLength]; + for (var index = 0; index < buffer.Length; index++) + { + buffer[index] = characters[Random.Shared.Next(0, characters.Length)]; + } + + return new string(buffer); + } + + private static string CreateAnonymousUserUniqueId() + { + Span<char> buffer = stackalloc char[19]; + buffer[0] = '7'; + for (var index = 1; index < buffer.Length; index++) + { + buffer[index] = (char)('0' + Random.Shared.Next(0, 10)); + } + + return new string(buffer); + } + + private static string? TryExtractDanmakuRoomIdFromHtml(string html) + { + if (string.IsNullOrWhiteSpace(html)) + { + return null; + } + + var stateMatch = PaceStateRegex.Match(html); + if (!stateMatch.Success) + { + return null; + } + + var statePayload = stateMatch.Groups["state"].Value; + if (string.IsNullOrWhiteSpace(statePayload)) + { + return null; + } + + var normalized = Regex.Unescape(statePayload); + var roomIdMatch = HtmlRoomIdNewRegex.Match(normalized); + if (!roomIdMatch.Success) + { + roomIdMatch = HtmlRoomIdRegex.Match(normalized); + } + return roomIdMatch.Success ? roomIdMatch.Groups["id"].Value : null; + } + private static (string? Anchor, string? Title) TryExtractAnchorAndTitleFromHtml(string? html) + { + if (string.IsNullOrWhiteSpace(html)) + { + return (null, null); + } + + var stateMatch = PaceStateRegex.Match(html); + if (!stateMatch.Success) + { + return (null, null); + } + + var statePayload = stateMatch.Groups["state"].Value; + if (string.IsNullOrWhiteSpace(statePayload)) + { + return (null, null); + } + + var normalized = Regex.Unescape(statePayload); + + var anchorMatch = HtmlAnchorRegex.Match(normalized); + var titleMatch = HtmlTitleRegex.Match(normalized); + + return ( + anchorMatch.Success ? Regex.Unescape(anchorMatch.Groups["nickname"].Value) : null, + titleMatch.Success ? Regex.Unescape(titleMatch.Groups["title"].Value) : null + ); + } +} + +public sealed record DouyinDanmakuBootstrap( + string WebRoomId, + string DanmakuRoomId, + string? Cursor, + string? InternalExt, + string? UserUniqueId, + string? Anchor, + string? Title); diff --git a/src/LiveRecorder.Infrastructure/Platforms/Douyin/DouyinLivePlatformAdapter.cs b/src/LiveRecorder.Infrastructure/Platforms/Douyin/DouyinLivePlatformAdapter.cs new file mode 100644 index 0000000..1aae5bd --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Platforms/Douyin/DouyinLivePlatformAdapter.cs @@ -0,0 +1,424 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Infrastructure.Platforms.Douyin; + +public sealed class DouyinLivePlatformAdapter : ILivePlatformAdapter +{ + private static readonly Regex RoomIdRegex = new( + @"(?<id>\d{8,})", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex HtmlTitleRegex = new( + "\"roomStore\":{[\\s\\S]*?\"roomInfo\":{[\\s\\S]*?\"room\":{[\\s\\S]*?\"title\":\"(?<title>[\\s\\S]*?)\"", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex HtmlAnchorRegex = new( + "\"roomStore\":{[\\s\\S]*?\"roomInfo\":{[\\s\\S]*?\"anchor\":{[\\s\\S]*?\"nickname\":\"(?<nickname>[\\s\\S]*?)\"", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private readonly DouyinHttpClient _douyinHttpClient; + + public DouyinLivePlatformAdapter(DouyinHttpClient douyinHttpClient) + { + _douyinHttpClient = douyinHttpClient; + } + + public LivePlatformType PlatformType => LivePlatformType.Douyin; + + public bool CanHandle(string input) + { + if (string.IsNullOrWhiteSpace(input)) + { + return false; + } + + return input.Contains("douyin.com", StringComparison.OrdinalIgnoreCase) || + input.Contains("iesdouyin.com", StringComparison.OrdinalIgnoreCase); + } + + public async Task<ParsedLiveRoom> ParseRoomAsync(string input, CancellationToken cancellationToken = default) + { + var trimmedInput = input.Trim(); + if (RoomIdRegex.FullMatch(trimmedInput)) + { + return new ParsedLiveRoom(PlatformType, trimmedInput, trimmedInput, $"https://live.douyin.com/{trimmedInput}"); + } + + var (finalUri, body) = await _douyinHttpClient.ResolvePageAsync(trimmedInput, cancellationToken); + var roomId = ExtractRoomId(finalUri?.AbsoluteUri) ?? ExtractRoomId(body); + if (string.IsNullOrWhiteSpace(roomId)) + { + throw new InvalidOperationException("Unable to extract the Douyin live room id from the input."); + } + + var normalizedUrl = finalUri?.AbsoluteUri ?? $"https://live.douyin.com/{roomId}"; + return new ParsedLiveRoom(PlatformType, roomId, trimmedInput, normalizedUrl); + } + + private static (string? Anchor, string? Title) TryExtractAnchorAndTitleFromHtml(JsonElement root) + { + if (root.ValueKind != JsonValueKind.Object || !TryGetProperty(root, "data", out var data)) + { + return (null, null); + } + + if (data.ValueKind == JsonValueKind.Array && data.GetArrayLength() > 0) + { + data = data[0]; + } + + var json = data.ValueKind == JsonValueKind.String ? data.GetString() : data.GetRawText(); + if (string.IsNullOrWhiteSpace(json)) + { + return (null, null); + } + + var normalized = Regex.Replace(json, "\\\\{1,7}\"", "\""); + + var anchorMatch = HtmlAnchorRegex.Match(normalized); + var titleMatch = HtmlTitleRegex.Match(normalized); + + return ( + anchorMatch.Success ? anchorMatch.Groups["nickname"].Value : null, + titleMatch.Success ? titleMatch.Groups["title"].Value : null + ); + } + + public async Task<LiveStatusSnapshot> GetLiveStatusAsync(string roomId, CancellationToken cancellationToken = default) + { + using var document = await _douyinHttpClient.GetRoomEnterAsync(roomId, cancellationToken); + var room = GetRoomNode(document.RootElement); + + var statusCode = GetInt(room, "status") ?? GetInt(room, "live_status"); + var (anchorMatch, titleMatch) = TryExtractAnchorAndTitleFromHtml(document.RootElement); + var title = titleMatch ?? GetString(room, "title"); + var anchorName = anchorMatch ?? GetNestedString(room, "owner", "nickname") + ?? GetNestedString(room, "anchor", "nickname") + ?? GetNestedString(room, "user", "nickname"); + var coverUrl = GetCoverUrl(room); + + var isLive = statusCode == 2 || GetInt(room, "live_status") is 1 or 2; + return new LiveStatusSnapshot(isLive, title, anchorName, coverUrl, statusCode, statusCode?.ToString()); + } + + public async Task<StreamUrlResult> GetStreamUrlAsync( + string roomId, + string? preferredQuality = null, + CancellationToken cancellationToken = default) + { + using var document = await _douyinHttpClient.GetRoomEnterAsync(roomId, cancellationToken); + var room = GetRoomNode(document.RootElement); + var streamRoot = TryGetProperty(room, "stream_url", out var streamUrl) ? streamUrl : room; + + var options = ParseStreamDataOptions(streamRoot); + if (options.Count == 0) + { + options = ParseLegacyStreamOptions(streamRoot); + } + + if (options.Count == 0) + { + throw new InvalidOperationException("Douyin did not return any playable stream URL."); + } + + var ordered = options + .DistinctBy(static item => $"{item.QualityKey}|{item.Protocol}|{item.Url}") + .OrderByDescending(static item => item.Rank) + .ThenBy(static item => item.Protocol.Equals("flv", StringComparison.OrdinalIgnoreCase) ? 0 : 1) + .ToList(); + + var selected = SelectOption(ordered, preferredQuality); + var inputHeaders = await _douyinHttpClient.GetStreamInputHeadersAsync(roomId, cancellationToken); + return new StreamUrlResult( + selected.QualityKey, + selected.Protocol, + selected.Url, + inputHeaders, + ordered); + } + + private static string? ExtractRoomId(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + var liveUrlMatch = Regex.Match(text, @"live\.douyin\.com/(?<id>\d{8,})", RegexOptions.IgnoreCase); + if (liveUrlMatch.Success) + { + return liveUrlMatch.Groups["id"].Value; + } + + var roomIdFieldMatch = Regex.Match(text, @"room(?:_id|Id)\\?[""=: ]+\\?[""]?(?<id>\d{8,})", RegexOptions.IgnoreCase); + if (roomIdFieldMatch.Success) + { + return roomIdFieldMatch.Groups["id"].Value; + } + + var genericMatch = RoomIdRegex.Match(text); + return genericMatch.Success ? genericMatch.Groups["id"].Value : null; + } + + private static JsonElement GetRoomNode(JsonElement root) + { + if (!TryGetProperty(root, "data", out var data)) + { + throw new InvalidOperationException("Douyin response does not contain a data node."); + } + + if (data.ValueKind == JsonValueKind.Array && data.GetArrayLength() > 0) + { + return data[0]; + } + + if (data.ValueKind == JsonValueKind.Object) + { + if (TryGetProperty(data, "data", out var innerData)) + { + if (innerData.ValueKind == JsonValueKind.Array && innerData.GetArrayLength() > 0) + { + return innerData[0]; + } + + if (innerData.ValueKind == JsonValueKind.Object) + { + return innerData; + } + } + + if (TryGetProperty(data, "room", out var room)) + { + return room; + } + + return data; + } + + throw new InvalidOperationException("Douyin response data node format is not supported."); + } + + private static List<StreamQualityOption> ParseStreamDataOptions(JsonElement streamRoot) + { + if (!TryGetNested(streamRoot, out var streamDataElement, "live_core_sdk_data", "pull_data", "stream_data")) + { + return []; + } + + var rawJson = streamDataElement.ValueKind == JsonValueKind.String + ? streamDataElement.GetString() + : streamDataElement.GetRawText(); + + if (string.IsNullOrWhiteSpace(rawJson)) + { + return []; + } + + using var document = JsonDocument.Parse(rawJson); + if (!TryGetProperty(document.RootElement, "data", out var data) || data.ValueKind != JsonValueKind.Object) + { + return []; + } + + var result = new List<StreamQualityOption>(); + foreach (var qualityNode in data.EnumerateObject()) + { + var qualityKey = qualityNode.Name; + var rank = GetQualityRank(qualityKey); + var flvUrl = GetNestedString(qualityNode.Value, "main", "flv"); + var hlsUrl = GetNestedString(qualityNode.Value, "main", "hls"); + + if (!string.IsNullOrWhiteSpace(flvUrl)) + { + result.Add(new StreamQualityOption(qualityKey, qualityKey, flvUrl, "flv", rank)); + } + + if (!string.IsNullOrWhiteSpace(hlsUrl)) + { + result.Add(new StreamQualityOption(qualityKey, qualityKey, hlsUrl, "hls", rank)); + } + } + + return result; + } + + private static List<StreamQualityOption> ParseLegacyStreamOptions(JsonElement streamRoot) + { + var result = new List<StreamQualityOption>(); + + if (TryGetProperty(streamRoot, "flv_pull_url", out var flvMap) && flvMap.ValueKind == JsonValueKind.Object) + { + foreach (var item in flvMap.EnumerateObject()) + { + var url = item.Value.GetString(); + if (!string.IsNullOrWhiteSpace(url)) + { + result.Add(new StreamQualityOption(item.Name, item.Name, url, "flv", GetQualityRank(item.Name))); + } + } + } + + if (TryGetProperty(streamRoot, "hls_pull_url_map", out var hlsMap) && hlsMap.ValueKind == JsonValueKind.Object) + { + foreach (var item in hlsMap.EnumerateObject()) + { + var url = item.Value.GetString(); + if (!string.IsNullOrWhiteSpace(url)) + { + result.Add(new StreamQualityOption(item.Name, item.Name, url, "hls", GetQualityRank(item.Name))); + } + } + } + + return result; + } + + private static StreamQualityOption SelectOption(IReadOnlyList<StreamQualityOption> options, string? preferredQuality) + { + if (!string.IsNullOrWhiteSpace(preferredQuality)) + { + var normalized = preferredQuality.Trim(); + var matched = options + .Where(item => + item.QualityKey.Equals(normalized, StringComparison.OrdinalIgnoreCase) || + item.QualityName.Equals(normalized, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(static item => item.Rank) + .ThenBy(static item => item.Protocol.Equals("flv", StringComparison.OrdinalIgnoreCase) ? 0 : 1) + .FirstOrDefault(); + + if (matched is not null) + { + return matched; + } + } + + return options[0]; + } + + private static int GetQualityRank(string quality) + { + if (quality.Equals("origin", StringComparison.OrdinalIgnoreCase)) + { + return 100; + } + + if (quality.Contains("full", StringComparison.OrdinalIgnoreCase) || + quality.Contains("uhd", StringComparison.OrdinalIgnoreCase)) + { + return 90; + } + + if (quality.Contains("hd", StringComparison.OrdinalIgnoreCase)) + { + return 80; + } + + if (quality.Contains("sd", StringComparison.OrdinalIgnoreCase)) + { + return 70; + } + + return 60; + } + + private static bool TryGetProperty(JsonElement element, string name, out JsonElement value) + { + if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out value)) + { + return true; + } + + value = default; + return false; + } + + private static bool TryGetNested(JsonElement element, out JsonElement value, params string[] path) + { + value = element; + foreach (var segment in path) + { + if (!TryGetProperty(value, segment, out value)) + { + return false; + } + } + + return true; + } + + private static int? GetInt(JsonElement element, string name) + { + if (!TryGetProperty(element, name, out var value)) + { + return null; + } + + if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var intValue)) + { + return intValue; + } + + if (value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), out intValue)) + { + return intValue; + } + + return null; + } + + private static string? GetString(JsonElement element, string name) + { + if (!TryGetProperty(element, name, out var value)) + { + return null; + } + + return value.ValueKind == JsonValueKind.String ? value.GetString() : null; + } + + private static string? GetNestedString(JsonElement element, params string[] path) + { + if (!TryGetNested(element, out var value, path)) + { + return null; + } + + return value.ValueKind == JsonValueKind.String ? value.GetString() : null; + } + + private static string? GetCoverUrl(JsonElement room) + { + if (!TryGetProperty(room, "cover", out var cover)) + { + return null; + } + + if (cover.ValueKind == JsonValueKind.String) + { + return cover.GetString(); + } + + if (cover.ValueKind == JsonValueKind.Object && + TryGetProperty(cover, "url_list", out var urlList) && + urlList.ValueKind == JsonValueKind.Array && + urlList.GetArrayLength() > 0 && + urlList[0].ValueKind == JsonValueKind.String) + { + return urlList[0].GetString(); + } + + return null; + } +} + +file static class RegexExtensions +{ + public static bool FullMatch(this Regex regex, string input) + { + var match = regex.Match(input); + return match.Success && match.Index == 0 && match.Length == input.Length; + } +} diff --git a/src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/DouyinXBogusSigner.cs b/src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/DouyinXBogusSigner.cs new file mode 100644 index 0000000..555df53 --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/DouyinXBogusSigner.cs @@ -0,0 +1,65 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace LiveRecorder.Infrastructure.Platforms.Douyin.Signing; + +public sealed class DouyinXBogusSigner +{ + private readonly ILogger<DouyinXBogusSigner> _logger; + private readonly string _signerScriptPath; + + public DouyinXBogusSigner(ILogger<DouyinXBogusSigner> logger) + { + _logger = logger; + _signerScriptPath = Path.Combine(AppContext.BaseDirectory, "Platforms", "Douyin", "Signing", "sign-xbogus.js"); + } + + public async Task<string> SignAsync(string requestUrl, string userAgent, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(requestUrl)) + { + throw new ArgumentException("Request URL cannot be empty.", nameof(requestUrl)); + } + + if (!File.Exists(_signerScriptPath)) + { + throw new FileNotFoundException("Douyin X-Bogus signer script is missing.", _signerScriptPath); + } + + var startInfo = new ProcessStartInfo + { + FileName = "node", + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + startInfo.ArgumentList.Add(_signerScriptPath); + startInfo.ArgumentList.Add(requestUrl); + startInfo.ArgumentList.Add(userAgent ?? string.Empty); + + using var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true }; + process.Start(); + + var standardOutputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + var standardErrorTask = process.StandardError.ReadToEndAsync(cancellationToken); + await process.WaitForExitAsync(cancellationToken); + + var standardOutput = (await standardOutputTask).Trim(); + var standardError = (await standardErrorTask).Trim(); + + if (process.ExitCode != 0 || string.IsNullOrWhiteSpace(standardOutput)) + { + _logger.LogWarning( + "Douyin X-Bogus signing failed. ExitCode={ExitCode}; Error={Error}", + process.ExitCode, + standardError); + throw new InvalidOperationException( + string.IsNullOrWhiteSpace(standardError) + ? $"Douyin X-Bogus signer exited with code {process.ExitCode}." + : $"Douyin X-Bogus signer failed: {standardError}"); + } + + return standardOutput; + } +} diff --git a/src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/abogus.js b/src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/abogus.js new file mode 100644 index 0000000..802bfd8 --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/abogus.js @@ -0,0 +1,551 @@ +/** + * 生成 a_bougs 参数值 + * @param {string} query 请求的 query 参数 + * @param {string} ua 浏览器 User-Agent + * @returns + */ +module.exports = function (query, ua) { + function enc_sum(n_str) { + function ir(t) { + return ( + (ir = + 'function' == typeof Symbol && 'symbol' == typeof Symbol.iterator + ? function (t) { + return typeof t; + } + : function (t) { + return t && 'function' == typeof Symbol && t.constructor === Symbol && t !== Symbol.prototype + ? 'symbol' + : typeof t; + }), + ir(t) + ); + } + function ur(t, r) { + for (let e = 0; e < r.length; e++) { + const n = r[e]; + ((n.enumerable = n.enumerable || !1), + (n.configurable = !0), + 'value' in n && (n.writable = !0), + Object.defineProperty(t, sr(n.key), n)); + } + } + function sr(t) { + const r = (function (t, r) { + if ('object' != ir(t) || !t) return t; + const e = t[Symbol.toPrimitive]; + if (void 0 !== e) { + const n = e.call(t, r || 'default'); + if ('object' != ir(n)) return n; + throw new TypeError('@@toPrimitive must return a primitive value.'); + } + return ('string' === r ? String : Number)(t); + })(t, 'string'); + return 'symbol' == ir(r) ? r : r + ''; + } + const gr = (function () { + function t() { + if ( + ((function (t, r) { + if (!(t instanceof r)) throw new TypeError('Cannot call a class as a function'); + })(this, t), + !(this instanceof t)) + ) + return new t(); + ((this.reg = new Array(8)), (this.chunk = []), (this.size = 0), this.reset()); + } + return ( + (function (t, r, e) { + (r && ur(t.prototype, r), + e && ur(t, e), + Object.defineProperty(t, 'prototype', { + writable: !1 + })); + })(t, [ + { + key: 'reset', + value: function () { + ((this.reg[0] = 1937774191), + (this.reg[1] = 1226093241), + (this.reg[2] = 388252375), + (this.reg[3] = 3666478592), + (this.reg[4] = 2842636476), + (this.reg[5] = 372324522), + (this.reg[6] = 3817729613), + (this.reg[7] = 2969243214), + (this.chunk = []), + (this.size = 0)); + } + }, + { + key: 'write', + value: function (t) { + const r = + 'string' == typeof t + ? (function (t) { + const r = encodeURIComponent(t).replace(/%([0-9A-F]{2})/g, function (t, r) { + return String.fromCharCode('0x' + r); + }), + e = new Array(r.length); + return ( + Array.prototype.forEach.call(r, function (t, r) { + e[r] = t.charCodeAt(0); + }), + e + ); + })(t) + : t; + this.size += r.length; + let e = 64 - this.chunk.length; + if (r.length < e) this.chunk = this.chunk.concat(r); + else + for (this.chunk = this.chunk.concat(r.slice(0, e)); this.chunk.length >= 64; ) + (this._compress(this.chunk), + e < r.length ? (this.chunk = r.slice(e, Math.min(e + 64, r.length))) : (this.chunk = []), + (e += 64)); + } + }, + { + key: 'sum', + value: function (t, r) { + (t && (this.reset(), this.write(t)), this._fill()); + let e = 0; + for (; e < this.chunk.length; e += 64) this._compress(this.chunk.slice(e, e + 64)); + let n, + o, + i, + u = null; + if ('hex' == r) { + u = ''; + for (e = 0; e < 8; e++) + u += + ((n = this.reg[e].toString(16)), + (o = 8), + (i = '0'), + n.length >= o ? n : i.repeat(o - n.length) + n); + } else + for (u = new Array(32), e = 0; e < 8; e++) { + let s = this.reg[e]; + ((u[4 * e + 3] = (255 & s) >>> 0), + (s >>>= 8), + (u[4 * e + 2] = (255 & s) >>> 0), + (s >>>= 8), + (u[4 * e + 1] = (255 & s) >>> 0), + (s >>>= 8), + (u[4 * e] = (255 & s) >>> 0)); + } + return (this.reset(), u); + } + }, + { + key: '_compress', + value: function (t) { + if (t < 64) console.error('compress error: not enough data'); + else { + let r = (function (t) { + const r = new Array(132); + for (let e = 0; e < 16; e++) { + r[e] = t[4 * e] << 24; + r[e] |= t[4 * e + 1] << 16; + r[e] |= t[4 * e + 2] << 8; + r[e] |= t[4 * e + 3]; + r[e] >>>= 0; + } + + for (let n = 16; n < 68; n++) { + let o = r[n - 16] ^ r[n - 9] ^ dr(r[n - 3], 15); + o = o ^ dr(o, 15) ^ dr(o, 23); + r[n] = (o ^ dr(r[n - 13], 7) ^ r[n - 6]) >>> 0; + } + for (let n = 0; n < 64; n++) r[n + 68] = (r[n] ^ r[n + 4]) >>> 0; + return r; + })(t); + let e = this.reg.slice(0); + for (let n = 0; n < 64; n++) { + let o = dr(e[0], 12) + e[4] + dr(yr(n), n), + i = ((o = dr((o = (4294967295 & o) >>> 0), 7)) ^ dr(e[0], 12)) >>> 0, + u = br(n, e[0], e[1], e[2]); + u = (4294967295 & (u = u + e[3] + i + r[n + 68])) >>> 0; + let s = mr(n, e[4], e[5], e[6]); + s = (4294967295 & (s = s + e[7] + o + r[n])) >>> 0; + e[3] = e[2]; + e[2] = dr(e[1], 9); + e[1] = e[0]; + e[0] = u; + e[7] = e[6]; + e[6] = dr(e[5], 19); + e[5] = e[4]; + e[4] = (s ^ dr(s, 9) ^ dr(s, 17)) >>> 0; + } + for (let c = 0; c < 8; c++) this.reg[c] = (this.reg[c] ^ e[c]) >>> 0; + } + } + }, + { + key: '_fill', + value: function () { + let t = 8 * this.size, + r = this.chunk.push(128) % 64; + for (64 - r < 8 && (r -= 64); r < 56; r++) this.chunk.push(0); + for (let e = 0; e < 4; e++) { + let n = Math.floor(t / 4294967296); + this.chunk.push((n >>> (8 * (3 - e))) & 255); + } + for (let e = 0; e < 4; e++) this.chunk.push((t >>> (8 * (3 - e))) & 255); + } + } + ]), + t + ); + })(); + function dr(t, r) { + return ((t << (r %= 32)) | (t >>> (32 - r))) >>> 0; + } + function yr(t) { + return 0 <= t && t < 16 + ? 2043430169 + : 16 <= t && t < 64 + ? 2055708042 + : void console.error('invalid j for constant Tj'); + } + function br(t, r, e, n) { + return 0 <= t && t < 16 + ? (r ^ e ^ n) >>> 0 + : 16 <= t && t < 64 + ? ((r & e) | (r & n) | (e & n)) >>> 0 + : (console.error('invalid j for bool function FF'), 0); + } + function mr(t, r, e, n) { + return 0 <= t && t < 16 + ? (r ^ e ^ n) >>> 0 + : 16 <= t && t < 64 + ? ((r & e) | (~r & n)) >>> 0 + : (console.error('invalid j for bool function GG'), 0); + } + const enc_ = new gr(); + return enc_.sum(n_str); + } + function generate_lm_g_EP(uat = ua) { + function get_sz256f() { + let r = [], + k = 0, + y = [0, 1, 0]; + for (let i = 255; i >= 0; i--) { + r.push(i); + } + for (let i = 0; i < r.length; i++) { + let a = r[i]; + k = (k * a + k + y[i % 3]) % 256; + let b = r[k]; + ((r[i] = b), (r[k] = a)); + } + return r; + } + const sz256f = [ + 233, 5, 1, 249, 162, 140, 57, 143, 19, 203, 254, 236, 99, 248, 93, 213, 79, 149, 216, 50, 145, 123, 240, 92, 23, + 113, 130, 53, 235, 220, 201, 136, 223, 155, 190, 242, 243, 42, 52, 214, 151, 232, 97, 187, 163, 222, 30, 78, 47, + 71, 49, 170, 247, 196, 25, 156, 183, 182, 217, 180, 147, 124, 208, 69, 215, 200, 161, 154, 91, 60, 133, 224, 119, + 164, 221, 45, 98, 40, 186, 120, 51, 167, 38, 90, 194, 212, 129, 56, 87, 195, 144, 44, 75, 84, 81, 13, 197, 245, + 36, 250, 115, 100, 105, 252, 206, 103, 112, 202, 114, 138, 192, 21, 116, 173, 181, 29, 82, 125, 141, 16, 211, 131, + 225, 118, 31, 101, 77, 146, 135, 150, 62, 66, 67, 176, 0, 41, 46, 59, 107, 178, 43, 26, 189, 128, 8, 207, 166, + 110, 3, 229, 85, 54, 63, 11, 32, 4, 234, 142, 72, 58, 33, 231, 12, 230, 102, 86, 70, 159, 226, 65, 237, 34, 244, + 76, 132, 122, 111, 95, 179, 152, 175, 18, 177, 6, 126, 193, 219, 74, 134, 2, 61, 251, 191, 168, 209, 241, 137, + 165, 88, 238, 160, 174, 153, 157, 199, 48, 22, 64, 246, 7, 139, 55, 27, 188, 148, 204, 127, 171, 89, 37, 172, 205, + 121, 20, 28, 17, 169, 15, 227, 117, 80, 218, 198, 10, 106, 9, 39, 210, 104, 83, 109, 24, 108, 228, 184, 96, 185, + 158, 14, 255, 239, 68, 94, 35, 73, 253 + ]; + let k = 0, + s = ''; + for (let i = 0; i < uat.length; i++) { + let t = (i + 1) % 256; + let a = sz256f[t]; + k = (k + a) % 256; + let c = sz256f[k]; + sz256f[t] = c; + sz256f[k] = a; + s += String.fromCharCode(uat.charCodeAt(i) ^ sz256f[(a + c) % 256]); + } + return s; + } + function get_str_chr_list(one_str) { + const r = []; + for (let i = 0; i < one_str.length; i++) { + r.push(one_str.charCodeAt(i)); + } + return r; + } + function generate_szenc_head8p1() { + let z = Math.random() * 65535; + let a = z & 255; + let b = (z >> 8) & 255, + d = []; + d.push((a & 170) | 1); + d.push((a & 85) | 0); + d.push((b & 170) | 0); + d.push((b & 85) | 0); + return d; + } + function generate_szenc_head8p2() { + let a = ((Math.random() * 240) >> 0) + 1; + let b = ((Math.random() * 255) >> 0) & 77, + c = [1, 4, 5, 7], + d = []; + for (let i = 0; i < c.length; i++) { + b = b | (1 << c[i]); + } + d.push((a & 170) | 1); + d.push((a & 85) | 0); + d.push((b & 170) | 0); + d.push((b & 85) | 0); + return d; + } + function get_szenc_tail(sz96) { + const zKeys = [145, 110, 66, 189, 44, 211]; + const a = []; + for (let i = 0; i < 94; i += 3) { + let b = sz96[i]; + let c = sz96[i + 1]; + let d = sz96[i + 2]; + let e = (Math.random() * 1000) & 255; + a.push((e & zKeys[0]) | (b & zKeys[1])); + a.push((e & zKeys[2]) | (c & zKeys[3])); + a.push((e & zKeys[4]) | (d & zKeys[5])); + a.push((b & zKeys[0]) | (c & zKeys[2]) | (d & zKeys[4])); + } + return a; + } + function generate_lm_g_ab_head4() { + let s = ''; + const a = (Math.random() * 65535) & 255, + b = (Math.random() * 40) >> 0; + s += String.fromCharCode((a & 170) | 1); + s += String.fromCharCode((a & 85) | 2); + s += String.fromCharCode((b & 170) | 80); + s += String.fromCharCode((b & 85) | 2); + return s; + } + function get_list_str(one_list) { + let s = ''; + for (let i = 0; i < one_list.length; i++) { + s += String.fromCharCode(one_list[i]); + } + return s; + } + function get_lm_g_ab(lm_g_lm_n) { + function getSZ256() { + const raw = []; + let z = 0; + for (let i = 255; i >= 0; i--) { + raw.push(i); + } + for (let i = 0; i < raw.length; i++) { + z += 211; + let a = z % 256; + let b = raw[i]; + let c = raw[a]; + raw[a] = b; + raw[i] = c; + z = raw[i + 1] * a + a; + } + return raw; + } + const fixedSZ256 = [ + 194, 249, 255, 165, 114, 67, 251, 187, 174, 231, 164, 237, 124, 235, 68, 83, 206, 79, 142, 167, 30, 77, 0, 93, + 118, 29, 32, 161, 2, 171, 243, 179, 42, 170, 223, 119, 98, 222, 219, 57, 245, 135, 197, 13, 186, 202, 88, 184, + 214, 12, 76, 185, 116, 74, 54, 53, 104, 208, 158, 163, 82, 173, 253, 240, 172, 63, 191, 207, 25, 15, 201, 203, + 215, 236, 183, 233, 145, 127, 72, 6, 16, 10, 228, 35, 232, 159, 66, 168, 108, 71, 217, 75, 33, 155, 112, 128, 36, + 24, 138, 50, 211, 23, 107, 14, 247, 137, 175, 242, 234, 157, 199, 49, 139, 85, 81, 17, 180, 86, 120, 78, 51, 205, + 169, 148, 181, 3, 94, 106, 252, 220, 150, 47, 151, 84, 212, 18, 149, 182, 100, 123, 121, 156, 154, 152, 126, 204, + 60, 133, 132, 248, 7, 91, 58, 59, 20, 97, 113, 117, 131, 46, 250, 224, 21, 73, 146, 31, 193, 69, 140, 125, 9, 39, + 89, 5, 65, 141, 218, 80, 1, 70, 64, 166, 87, 189, 55, 147, 22, 26, 143, 61, 144, 99, 92, 44, 129, 130, 227, 103, + 90, 192, 198, 244, 136, 101, 246, 153, 56, 38, 4, 178, 221, 162, 134, 37, 111, 28, 216, 96, 102, 210, 254, 196, + 195, 230, 241, 62, 11, 122, 52, 40, 41, 229, 226, 225, 48, 45, 160, 105, 8, 115, 34, 43, 209, 95, 239, 190, 188, + 109, 27, 19, 176, 213, 200, 238, 177, 110 + ]; + // const fixedSZ256 = getSZ256(); + let z = 0; + let st = ''; + for (let i = 0; i < lm_g_lm_n.length; i++) { + let a = (i + 1) % 256; + let c = fixedSZ256[a]; + z = (z + c) % 256; + let e = fixedSZ256[z]; + fixedSZ256[a] = e; + fixedSZ256[z] = c; + let g = (e + c) % 256; + let h = lm_g_lm_n.charCodeAt(i); + let j = fixedSZ256[g]; + let k = h ^ j; + let l = String.fromCharCode(k); + st += l; + } + return st; + } + function get_raw_ab(lm_get_ab_n, key_str = info_dic.s4) { + let s = '', + bw = 0; + for (let i = 0; i < lm_get_ab_n.length; i += 3) { + let cl = 16; + let tcz = 0; + let sof = 16515072; + for (let j = i; j < i + 3; j++) { + if (j < lm_get_ab_n.length) { + let tlcy = lm_get_ab_n.charCodeAt(j) & 255; + tcz = tcz | (tlcy << cl); + cl -= 8; + } else { + bw += 1; + } + } + for (let h = 18; h >= 6 * bw; h -= 6) { + let tsz = tcz & sof; + s += key_str[tsz >> h]; + sof = sof / 64; + } + s += '='.repeat(bw); + } + return s; + } + function get_random_number(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; + } + const info_dic = { + s0: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=', + s1: 'Dkdpgh4ZKsQB80/Mfvw36XI1R25+WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=', + s2: 'Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=', + s3: 'ckdp1h4ZKsUB80/Mfvw36XIgR25+WQAlEi7NLboqYTOPuzmFjJnryx9HVGDaStCe', + s4: 'Dkdpgh2ZmsQB80/MfvV36XI1R45-WUAlEixNLwoqYTOPuzKFjJnry79HbGcaStCe' + }; + const t1 = Date.now(); + const s = []; + + const t2 = Date.now() - 1 + get_random_number(1, 3); + + const EP = get_raw_ab(generate_lm_g_EP(ua), info_dic.s3); + const eEP = enc_sum(EP); + + s.push('env_fx_list', 'dpf_ua_dic', 1, 0, 8, 'dpf', '', 'ua', 6241, 6383, '1.0.1.19-fix.01', 'ink', 3, '0X21_dic'); // 固定即可 + + const t3 = Date.now() + get_random_number(4, 15); + const eedp = enc_sum(enc_sum(query + 'dhzx')); + + s.push(t3, 'reg_dic', 1, 0, eedp, 'eedh', EP, eEP, t2, [3, 82], 41, [1, 0, 1, 0, 1]); + + const t4 = Date.now() + get_random_number(100, 1000); + + const s1 = ((t4 - 1721836800000) / 1000 / 60 / 60 / 24 / 14) >> 0, + szenc_o95_tail41 = [ + 49, 52, 52, 49, 124, 56, 51, 56, 124, 49, 52, 52, 49, 124, 57, 49, 51, 124, 49, 52, 52, 49, 124, 57, 49, 51, 124, + 49, 52, 52, 49, 124, 57, 54, 49, 124, 87, 105, 110, 51, 50 + ]; + + s.push( + s1, + 6, + (t3 - t1 + 3) & 255, + t3 & 255, + (t3 >> 8) & 255, + (t3 >> 16) & 255, + (t3 >> 24) & 255, + (t3 / 256 / 256 / 256 / 256) & 255 + ); + + const s2 = (t3 / 256 / 256 / 256 / 256 / 256) & 255; + + s.push( + s2, + (s2 % 256) & 255, + (s2 / 256) & 255, + [211, 2, 5, 1, 129], + 129, + 0, + 211, + 2, + 5, + 1, + 0, + 0, + 0, + 0, + eedp[9], + eedp[18], + 3, + eedp[3], + 82, + 177, + 4, + 44, + eEP[11], + eEP[21], + 5, + eEP[5], + t2 & 255, + (t2 >> 8) & 255, + (t2 >> 16) & 255, + (t2 >> 24) & 255, + (t2 / 256 / 256 / 256 / 256) & 255, + (t2 / 256 / 256 / 256 / 256 / 256) & 255, + 3, + 97, + 24, + 0, + 0, + 239, + 24, + 0, + 0, + 'screec_dic', + 'screen_str', + szenc_o95_tail41, + 41, + 41, + 0 + ); + + const s3 = ((t3 + 3) & 255) + ',', + s4 = get_str_chr_list(s3); + + s.push(s3, s4, s4.length, s4.length & 255, (s4.length >> 8) & 255); + + const szenc_head8_p1 = generate_szenc_head8p1(), + szenc_head8_p2 = generate_szenc_head8p2(), + szenc_head8 = szenc_head8_p1.concat(szenc_head8_p2), + s5 = [], + s6 = [ + 24, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 51, 52, 53, 55, + 56, 57, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 79, 80, 84, 85 + ]; + for (let i = 0; i < s6.length; i++) { + s5.push(s[s6[i]]); + } + s.push(szenc_head8); + const s7 = szenc_head8.concat(s5); + + let s8 = s7[0]; + for (let i = 1; i < s7.length; i++) { + s8 = s8 ^ s7[i]; + } + s.push(s8); + + const enc_s_i = [ + 34, 44, 56, 61, 73, 29, 70, 45, 35, 49, 38, 66, 51, 68, 28, 48, 64, 47, 30, 71, 26, 55, 31, 69, 59, 40, 62, 63, + 27, 72, 41, 74, 57, 52, 42, 39, 33, 67, 53, 43, 65, 46, 36, 24, 60, 32, 79, 80, 84, 85 + ], + szenc_o95_head50 = []; + for (let i = 0; i < enc_s_i.length; i++) { + szenc_o95_head50.push(s[enc_s_i[i]]); + } + let szenc_o95 = []; + szenc_o95 = szenc_o95.concat(szenc_o95_head50, szenc_o95_tail41, s4, [s8]); + + const szenc_tail = get_szenc_tail(szenc_o95), + szenc = szenc_head8.concat(szenc_tail), + lm_get_ab_head4 = generate_lm_g_ab_head4(); + + const lm_get_lm = get_list_str(szenc); + const lm_get_ab_tail = get_lm_g_ab(lm_get_lm); + const lm_get_ab = lm_get_ab_head4 + lm_get_ab_tail; + const ab = get_raw_ab(lm_get_ab); + + return ab; +}; diff --git a/src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/index.js b/src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/index.js new file mode 100644 index 0000000..84a49c8 --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/index.js @@ -0,0 +1,7 @@ +// index.js +const sign = require('./abogus'); + +module.exports = function (query, userAgent) { + return sign(query, userAgent); +}; + diff --git a/src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/sign-xbogus.js b/src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/sign-xbogus.js new file mode 100644 index 0000000..b0c2c7e --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/sign-xbogus.js @@ -0,0 +1,8 @@ +const path = require("path"); + +const sign = require(path.join(__dirname, "index.js")); + +const query = process.argv[2] || ""; +const userAgent = process.argv[3] || ""; + +process.stdout.write(sign(query, userAgent)); diff --git a/src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/xbogus.js b/src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/xbogus.js new file mode 100644 index 0000000..044cab5 --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Platforms/Douyin/Signing/xbogus.js @@ -0,0 +1,563 @@ +var window = null; + +function _0x5cd844(e) { + var b = { + exports: {} + }; + return e(b, b.exports), b.exports +} +jsvmp = function(e, b, a) { + function f(e, b, a) { + return (f = function() { + if ("undefined" == typeof Reflect || !Reflect.construct || Reflect.construct.sham) return !1; + if ("function" == typeof Proxy) return !0; + try { + return Date.prototype.toString.call(Reflect.construct(Date, [], function() {})), !0 + } catch (e) { + return !1 + } + }() ? Reflect.construct : function(e, b, a) { + var f = [null]; + f.push.apply(f, b); + var c = new(Function.bind.apply(e, f)); + return a && function(e, b) { + (Object.setPrototypeOf || function(e, b) { + return e.__proto__ = b, e + })(e, b) + }(c, a.prototype), c + }).apply(null, arguments) + } + + function c(e) { + return function(e) { + if (Array.isArray(e)) { + for (var b = 0, a = new Array(e.length); b < e.length; b++) a[b] = e[b]; + return a + } + }(e) || function(e) { + if (Symbol.iterator in Object(e) || "[object Arguments]" === Object.prototype.toString.call(e)) return Array.from(e) + }(e) || function() { + throw new TypeError("Invalid attempt to spread non-iterable instance") + }() + } + for (var r = [], t = 0, d = [], i = 0, n = function(e, b) { + var a = e[b++], + f = e[b], + c = parseInt("" + a + f, 16); + if (c >> 7 == 0) return [1, c]; + if (c >> 6 == 2) { + var r = parseInt("" + e[++b] + e[++b], 16); + return c &= 63, [2, r = (c <<= 8) + r] + } + if (c >> 6 == 3) { + var t = parseInt("" + e[++b] + e[++b], 16), + d = parseInt("" + e[++b] + e[++b], 16); + return c &= 63, [3, d = (c <<= 16) + (t <<= 8) + d] + } + }, s = function(e, b) { + var a = parseInt("" + e[b] + e[b + 1], 16); + return a > 127 ? -256 + a : a + }, o = function(e, b) { + var a = parseInt("" + e[b] + e[b + 1] + e[b + 2] + e[b + 3], 16); + return a > 32767 ? -65536 + a : a + }, l = function(e, b) { + var a = parseInt("" + e[b] + e[b + 1] + e[b + 2] + e[b + 3] + e[b + 4] + e[b + 5] + e[b + 6] + e[b + 7], 16); + return a > 2147483647 ? 0 + a : a + }, _ = function(e, b) { + return parseInt("" + e[b] + e[b + 1], 16) + }, x = function(e, b) { + return parseInt("" + e[b] + e[b + 1] + e[b + 2] + e[b + 3], 16) + }, u = u || this || window, h = (e.length, 0), p = "", y = h; y < h + 16; y++) { + var v = "" + e[y++] + e[y]; + v = parseInt(v, 16), p += String.fromCharCode(v) + } + if ("HNOJ@?RC" != p) throw new Error("error magic number " + p); + parseInt("" + e[h += 16] + e[h + 1], 16), h += 8, t = 0; + for (var g = 0; g < 4; g++) { + var w = h + 2 * g, + A = parseInt("" + e[w++] + e[w], 16); + t += (3 & A) << 2 * g + } + h += 16; + var C = parseInt("" + e[h += 8] + e[h + 1] + e[h + 2] + e[h + 3] + e[h + 4] + e[h + 5] + e[h + 6] + e[h + 7], 16), + m = C, + S = h += 8, + z = x(e, h += C); + z[1], h += 4, r = { + p: [], + q: [] + }; + for (var B = 0; B < z; B++) { + for (var R = n(e, h), q = h += 2 * R[0], I = r.p.length, k = 0; k < R[1]; k++) { + var j = n(e, q); + r.p.push(j[1]), q += 2 * j[0] + } + h = q, r.q.push([I, r.p.length]) + } + var O = { + 5: 1, + 6: 1, + 70: 1, + 22: 1, + 23: 1, + 37: 1, + 73: 1 + }, + U = { + 72: 1 + }, + D = { + 74: 1 + }, + N = { + 11: 1, + 12: 1, + 24: 1, + 26: 1, + 27: 1, + 31: 1 + }, + J = { + 10: 1 + }, + L = { + 2: 1, + 29: 1, + 30: 1, + 20: 1 + }, + T = [], + E = []; + + function M(e, b, a) { + for (var f = b; f < b + a;) { + var c = _(e, f); + T[f] = c, f += 2, U[c] ? (E[f] = s(e, f), f += 2) : O[c] ? (E[f] = o(e, f), f += 4) : D[c] ? (E[f] = l(e, f), f += 8) : N[c] ? (E[f] = _(e, f), f += 2) : J[c] ? (E[f] = x(e, f), f += 4) : L[c] && (E[f] = x(e, f), f += 4) + } + } + return F(e, S, m / 2, [], b, a); + + function P(e, b, a, n, h, p, y, v) { + null == p && (p = this); + var g, w, A, C, m = [], + S = 0; + y && (w = y); + var z, B, R = b, + q = R + 2 * a; + if (!v) + for (; R < q;) { + var I = parseInt("" + e[R] + e[R + 1], 16); + R += 2; + var j = 3 & (z = 13 * I % 241); + if (z >>= 2, j < 1) + if (j = 3 & z, z >>= 2, j < 1) { + if ((j = z) < 1) return [1, m[S--]]; + j < 5 ? (w = m[S--], m[S] = m[S] * w) : j < 7 ? (w = m[S--], m[S] = m[S] != w) : j < 14 ? (A = m[S--], C = m[S--], (j = m[S--]).x === P ? j.y >= 1 ? m[++S] = F(e, j.c, j.l, A, j.z, C, null, 1) : (m[++S] = F(e, j.c, j.l, A, j.z, C, null, 0), j.y++) : m[++S] = j.apply(C, A)) : j < 16 && (B = o(e, R), (g = function b() { + var a = arguments; + return b.y > 0 || b.y++, F(e, b.c, b.l, a, b.z, this, null, 0) + }).c = R + 4, g.l = B - 2, g.x = P, g.y = 0, g.z = h, m[S] = g, R += 2 * B - 2) + } else if (j < 2)(j = z) > 8 ? (w = m[S--], m[S] = typeof w) : j > 4 ? m[S -= 1] = m[S][m[S + 1]] : j > 2 && (A = m[S--], (j = m[S]).x === P ? j.y >= 1 ? m[S] = F(e, j.c, j.l, [A], j.z, C, null, 1) : (m[S] = F(e, j.c, j.l, [A], j.z, C, null, 0), j.y++) : m[S] = j(A)); + else if (j < 3) { + if ((j = z) < 9) { + for (w = m[S--], B = x(e, R), j = "", k = r.q[B][0]; k < r.q[B][1]; k++) j += String.fromCharCode(t ^ r.p[k]); + R += 4, m[S--][j] = w + } else if (j < 13) throw m[S--] + } else(j = z) < 1 ? m[++S] = null : j < 3 ? (w = m[S--], m[S] = m[S] >= w) : j < 12 && (m[++S] = void 0); + else if (j < 2) + if (j = 3 & z, z >>= 2, j < 1) + if ((j = z) < 5) { + B = o(e, R); + try { + if (d[i][2] = 1, 1 == (w = P(e, R + 4, B - 3, [], h, p, null, 0))[0]) return w + } catch (b) { + if (d[i] && d[i][1] && 1 == (w = P(e, d[i][1][0], d[i][1][1], [], h, p, b, 0))[0]) return w + } finally { + if (d[i] && d[i][0] && 1 == (w = P(e, d[i][0][0], d[i][0][1], [], h, p, null, 0))[0]) return w; + d[i] = 0, i-- + } + R += 2 * B - 2 + } else j < 7 ? (B = _(e, R), R += 2, m[S -= B] = 0 === B ? new m[S] : f(m[S], c(m.slice(S + 1, S + B + 1)))) : j < 9 && (w = m[S--], m[S] = m[S] & w); + else if (j < 2) + if ((j = z) > 12) m[++S] = s(e, R), R += 2; + else if (j > 10) w = m[S--], m[S] = m[S] << w; + else if (j > 8) { + for (B = x(e, R), j = "", k = r.q[B][0]; k < r.q[B][1]; k++) j += String.fromCharCode(t ^ r.p[k]); + R += 4, m[S] = m[S][j] + } else j > 6 && (A = m[S--], w = delete m[S--][A]); + else if (j < 3)(j = z) < 2 ? m[++S] = w : j < 11 ? (w = m[S -= 2][m[S + 1]] = m[S + 2], S--) : j < 13 && (w = m[S], m[++S] = w); + else if ((j = z) > 12) m[++S] = p; + else if (j > 5) w = m[S--], m[S] = m[S] !== w; + else if (j > 3) w = m[S--], m[S] = m[S] / w; + else if (j > 1) { + if ((B = o(e, R)) < 0) { + v = 1, M(e, b, 2 * a), R += 2 * B - 2; + break + } + R += 2 * B - 2 + } else j > -1 && (m[S] = !m[S]); + else if (j < 3) + if (j = 3 & z, z >>= 2, j < 1)(j = z) > 13 ? (m[++S] = o(e, R), R += 4) : j > 11 ? (w = m[S--], m[S] = m[S] >> w) : j > 9 ? (B = _(e, R), R += 2, w = m[S--], h[B] = w) : j > 7 ? (B = x(e, R), R += 4, A = S + 1, m[S -= B - 1] = B ? m.slice(S, A) : []) : j > 0 && (w = m[S--], m[S] = m[S] > w); + else if (j < 2)(j = z) > 12 ? (w = m[S - 1], A = m[S], m[++S] = w, m[++S] = A) : j > 3 ? (w = m[S--], m[S] = m[S] == w) : j > 1 ? (w = m[S--], m[S] = m[S] + w) : j > -1 && (m[++S] = u); + else if (j < 3) { + if ((j = z) > 13) m[++S] = !1; + else if (j > 6) w = m[S--], m[S] = m[S] instanceof w; + else if (j > 4) w = m[S--], m[S] = m[S] % w; + else if (j > 2) + if (m[S--]) R += 4; + else { + if ((B = o(e, R)) < 0) { + v = 1, M(e, b, 2 * a), R += 2 * B - 2; + break + } + R += 2 * B - 2 + } + else if (j > 0) { + for (B = x(e, R), w = "", k = r.q[B][0]; k < r.q[B][1]; k++) w += String.fromCharCode(t ^ r.p[k]); + m[++S] = w, R += 4 + } + } else(j = z) > 7 ? (w = m[S--], m[S] = m[S] | w) : j > 5 ? (B = _(e, R), R += 2, m[++S] = h["$" + B]) : j > 3 && (B = o(e, R), d[i][0] && !d[i][2] ? d[i][1] = [R + 4, B - 3] : d[i++] = [0, [R + 4, B - 3], 0], R += 2 * B - 2); + else if (j = 3 & z, z >>= 2, j > 2)(j = z) > 13 ? (m[++S] = l(e, R), R += 8) : j > 11 ? (w = m[S--], m[S] = m[S] >>> w) : j > 9 ? m[++S] = !0 : j > 7 ? (B = _(e, R), R += 2, m[S] = m[S][B]) : j > 0 && (w = m[S--], m[S] = m[S] < w); + else if (j > 1)(j = z) > 10 ? (B = o(e, R), d[++i] = [ + [R + 4, B - 3], 0, 0 + ], R += 2 * B - 2) : j > 8 ? (w = m[S--], m[S] = m[S] ^ w) : j > 6 && (w = m[S--]); + else if (j > 0) { + if ((j = z) > 7) w = m[S--], m[S] = m[S] in w; + else if (j > 5) m[S] = ++m[S]; + else if (j > 3) B = _(e, R), R += 2, w = h[B], m[++S] = w; + else if (j > 1) { + var O = 0, + U = m[S].length, + D = m[S]; + m[++S] = function() { + var e = O < U; + if (e) { + var b = D[O++]; + m[++S] = b + } + m[++S] = e + } + } + } else if ((j = z) > 13) w = m[S], m[S] = m[S - 1], m[S - 1] = w; + else if (j > 4) w = m[S--], m[S] = m[S] === w; + else if (j > 2) w = m[S--], m[S] = m[S] - w; + else if (j > 0) { + for (B = x(e, R), j = "", k = r.q[B][0]; k < r.q[B][1]; k++) j += String.fromCharCode(t ^ r.p[k]); + j = +j, R += 4, m[++S] = j + } + } + if (v) + for (; R < q;) + if (I = T[R], R += 2, j = 3 & (z = 13 * I % 241), z >>= 2, j > 2) + if (j = 3 & z, z >>= 2, j > 2)(j = z) < 2 ? (w = m[S--], m[S] = m[S] < w) : j < 9 ? (B = E[R], R += 2, m[S] = m[S][B]) : j < 11 ? m[++S] = !0 : j < 13 ? (w = m[S--], m[S] = m[S] >>> w) : j < 15 && (m[++S] = E[R], R += 8); + else if (j > 1)(j = z) < 6 || (j < 8 ? w = m[S--] : j < 10 ? (w = m[S--], m[S] = m[S] ^ w) : j < 12 && (B = E[R], d[++i] = [ + [R + 4, B - 3], 0, 0 + ], R += 2 * B - 2)); + else if (j > 0)(j = z) > 7 ? (w = m[S--], m[S] = m[S] in w) : j > 5 ? m[S] = ++m[S] : j > 3 ? (B = E[R], R += 2, w = h[B], m[++S] = w) : j > 1 && (O = 0, U = m[S].length, D = m[S], m[++S] = function() { + var e = O < U; + if (e) { + var b = D[O++]; + m[++S] = b + } + m[++S] = e + }); + else if ((j = z) < 2) { + for (B = E[R], j = "", k = r.q[B][0]; k < r.q[B][1]; k++) j += String.fromCharCode(t ^ r.p[k]); + j = +j, R += 4, m[++S] = j + } else j < 4 ? (w = m[S--], m[S] = m[S] - w) : j < 6 ? (w = m[S--], m[S] = m[S] === w) : j < 15 && (w = m[S], m[S] = m[S - 1], m[S - 1] = w); + else if (j > 1) + if (j = 3 & z, z >>= 2, j < 1)(j = z) > 13 ? (m[++S] = E[R], R += 4) : j > 11 ? (w = m[S--], m[S] = m[S] >> w) : j > 9 ? (B = E[R], R += 2, w = m[S--], h[B] = w) : j > 7 ? (B = E[R], R += 4, A = S + 1, m[S -= B - 1] = B ? m.slice(S, A) : []) : j > 0 && (w = m[S--], m[S] = m[S] > w); + else if (j < 2)(j = z) < 1 ? m[++S] = u : j < 3 ? (w = m[S--], m[S] = m[S] + w) : j < 5 ? (w = m[S--], m[S] = m[S] == w) : j < 14 && (w = m[S - 1], A = m[S], m[++S] = w, m[++S] = A); + else if (j < 3) { + if ((j = z) > 13) m[++S] = !1; + else if (j > 6) w = m[S--], m[S] = m[S] instanceof w; + else if (j > 4) w = m[S--], m[S] = m[S] % w; + else if (j > 2) m[S--] ? R += 4 : R += 2 * (B = E[R]) - 2; + else if (j > 0) { + for (B = E[R], w = "", k = r.q[B][0]; k < r.q[B][1]; k++) w += String.fromCharCode(t ^ r.p[k]); + m[++S] = w, R += 4 + } + } else(j = z) > 7 ? (w = m[S--], m[S] = m[S] | w) : j > 5 ? (B = E[R], R += 2, m[++S] = h["$" + B]) : j > 3 && (B = E[R], d[i][0] && !d[i][2] ? d[i][1] = [R + 4, B - 3] : d[i++] = [0, [R + 4, B - 3], 0], R += 2 * B - 2); + else if (j > 0) + if (j = 3 & z, z >>= 2, j < 1) { + if ((j = z) > 9); + else if (j > 7) w = m[S--], m[S] = m[S] & w; + else if (j > 5) B = E[R], R += 2, m[S -= B] = 0 === B ? new m[S] : f(m[S], c(m.slice(S + 1, S + B + 1))); + else if (j > 3) { + B = E[R]; + try { + if (d[i][2] = 1, 1 == (w = P(e, R + 4, B - 3, [], h, p, null, 0))[0]) return w + } catch (b) { + if (d[i] && d[i][1] && 1 == (w = P(e, d[i][1][0], d[i][1][1], [], h, p, b, 0))[0]) return w + } finally { + if (d[i] && d[i][0] && 1 == (w = P(e, d[i][0][0], d[i][0][1], [], h, p, null, 0))[0]) return w; + d[i] = 0, i-- + } + R += 2 * B - 2 + } + } else if (j < 2) + if ((j = z) < 8) A = m[S--], w = delete m[S--][A]; + else if (j < 10) { + for (B = E[R], j = "", k = r.q[B][0]; k < r.q[B][1]; k++) j += String.fromCharCode(t ^ r.p[k]); + R += 4, m[S] = m[S][j] + } else j < 12 ? (w = m[S--], m[S] = m[S] << w) : j < 14 && (m[++S] = E[R], R += 2); + else j < 3 ? (j = z) < 2 ? m[++S] = w : j < 11 ? (w = m[S -= 2][m[S + 1]] = m[S + 2], S--) : j < 13 && (w = m[S], m[++S] = w) : (j = z) > 12 ? m[++S] = p : j > 5 ? (w = m[S--], m[S] = m[S] !== w) : j > 3 ? (w = m[S--], m[S] = m[S] / w) : j > 1 ? R += 2 * (B = E[R]) - 2 : j > -1 && (m[S] = !m[S]); + else if (j = 3 & z, z >>= 2, j < 1) { + if ((j = z) < 1) return [1, m[S--]]; + j < 5 ? (w = m[S--], m[S] = m[S] * w) : j < 7 ? (w = m[S--], m[S] = m[S] != w) : j < 14 ? (A = m[S--], C = m[S--], (j = m[S--]).x === P ? j.y >= 1 ? m[++S] = F(e, j.c, j.l, A, j.z, C, null, 1) : (m[++S] = F(e, j.c, j.l, A, j.z, C, null, 0), j.y++) : m[++S] = j.apply(C, A)) : j < 16 && (B = E[R], (g = function b() { + var a = arguments; + return b.y > 0 || b.y++, F(e, b.c, b.l, a, b.z, this, null, 0) + }).c = R + 4, g.l = B - 2, g.x = P, g.y = 0, g.z = h, m[S] = g, R += 2 * B - 2) + } else if (j < 2)(j = z) > 8 ? (w = m[S--], m[S] = typeof w) : j > 4 ? m[S -= 1] = m[S][m[S + 1]] : j > 2 && (A = m[S--], (j = m[S]).x === P ? j.y >= 1 ? m[S] = F(e, j.c, j.l, [A], j.z, C, null, 1) : (m[S] = F(e, j.c, j.l, [A], j.z, C, null, 0), j.y++) : m[S] = j(A)); + else if (j < 3) { + if ((j = z) < 9) { + for (w = m[S--], B = E[R], j = "", k = r.q[B][0]; k < r.q[B][1]; k++) j += String.fromCharCode(t ^ r.p[k]); + R += 4, m[S--][j] = w + } else if (j < 13) throw m[S--] + } else(j = z) < 1 ? m[++S] = null : j < 3 ? (w = m[S--], m[S] = m[S] >= w) : j < 12 && (m[++S] = void 0); + return [0, null] + } + + function F(e, b, a, f, c, r, t, d) { + null == r && (r = this), c && !c.d && (c.d = 0, c.$0 = c, c[1] = {}); + var i, n, s = {}, + o = s.d = c ? c.d + 1 : 0; + for (s["$" + o] = s, n = 0; n < o; n++) s[i = "$" + n] = c[i]; + for (n = 0, o = s.length = f.length; n < o; n++) s[n] = f[n]; + return d && !T[b] && M(e, b, 2 * a), T[b] ? P(e, b, a, 0, s, r, null, 1)[1] : P(e, b, a, 0, s, r, null, 0)[1] + } +}; +var _0x397dc7 = "undefined" != typeof globalThis ? globalThis : void 0 !== window ? window : "undefined" != typeof global ? global : "undefined" != typeof self ? self : {}, + _0x124d1a = _0x5cd844(function(_0x770f81) { + ! function() { + var _0x250d36 = "input is invalid type", + _0x4cfaee = !1, + _0x1702f9 = {}, + _0x5ccbb3 = !_0x4cfaee && "object" == typeof self, + _0x54d876 = !_0x1702f9.JS_MD5_NO_NODE_JS && "object" == typeof process && process.versions && process.versions.node, + _0x185caf; + _0x54d876 ? _0x1702f9 = _0x397dc7 : _0x5ccbb3 && (_0x1702f9 = self); + var _0x17dcbf = !_0x1702f9.JS_MD5_NO_COMMON_JS && _0x770f81.exports, + _0x554fed = !1, + _0x2de28f = !_0x1702f9.JS_MD5_NO_ARRAY_BUFFER && "undefined" != typeof ArrayBuffer, + _0x3a9a1b = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"], + _0x465562 = [128, 32768, 8388608, -2147483648], + _0x20b37e = [0, 8, 16, 24], + _0x323604 = ["hex", "array", "digest", "buffer", "arrayBuffer", "base64"], + _0x2c185e = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "+", "/"], + _0x4b59e0 = []; + if (_0x2de28f) { + var _0x395837 = new ArrayBuffer(68); + _0x185caf = new Uint8Array(_0x395837), _0x4b59e0 = new Uint32Array(_0x395837) + }!_0x1702f9.JS_MD5_NO_NODE_JS && Array.isArray || (Array.isArray = function(e) { + return "[object Array]" === Object.prototype.toString.call(e) + }), _0x2de28f && (_0x1702f9.JS_MD5_NO_ARRAY_BUFFER_IS_VIEW || !ArrayBuffer.isView) && (ArrayBuffer.isView = function(e) { + return "object" == typeof e && e.buffer && e.buffer.constructor === ArrayBuffer + }); + var _0x4e9930 = function(e) { + return function(b) { + return new _0x5887c8(!0).update(b)[e]() + } + }, + _0x38ba77 = function() { + var e = _0x4e9930("hex"); + _0x54d876 && (e = _0x474989(e)), e.create = function() { + return new _0x5887c8 + }, e.update = function(b) { + return e.create().update(b) + }; + for (var b = 0; b < _0x323604.length; ++b) { + var a = _0x323604[b]; + e[a] = _0x4e9930(a) + } + return e + }, + _0x474989 = function(_0x57eeaa) { + var _0x114910, _0x226465 = eval("require('crypto');"), + _0x1f6ae0 = eval("require('buffer')['Buffer'];"); + return function(e) { + if ("string" == typeof e) return _0x226465.createHash("md5").update(e, "utf8").digest("hex"); + if (null == e) throw _0x250d36; + return e.constructor === ArrayBuffer && (e = new Uint8Array(e)), Array.isArray(e) || ArrayBuffer.isView(e) || e.constructor === _0x1f6ae0 ? _0x226465.createHash("md5").update(new _0x1f6ae0.from(e)).digest("hex") : _0x57eeaa(e) + } + }; + + function _0x5887c8(e) { + if (e) _0x4b59e0[0] = _0x4b59e0[16] = _0x4b59e0[1] = _0x4b59e0[2] = _0x4b59e0[3] = _0x4b59e0[4] = _0x4b59e0[5] = _0x4b59e0[6] = _0x4b59e0[7] = _0x4b59e0[8] = _0x4b59e0[9] = _0x4b59e0[10] = _0x4b59e0[11] = _0x4b59e0[12] = _0x4b59e0[13] = _0x4b59e0[14] = _0x4b59e0[15] = 0, this.blocks = _0x4b59e0, this.buffer8 = _0x185caf; + else if (_0x2de28f) { + var b = new ArrayBuffer(68); + this.buffer8 = new Uint8Array(b), this.blocks = new Uint32Array(b) + } else this.blocks = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + this.h0 = this.h1 = this.h2 = this.h3 = this.start = this.bytes = this.hBytes = 0, this.finalized = this.hashed = !1, this.first = !0 + } + _0x5887c8.prototype.update = function(e) { + if (!this.finalized) { + var b, a = typeof e; + if ("string" !== a) { + if ("object" !== a || null === e) throw _0x250d36; + if (_0x2de28f && e.constructor === ArrayBuffer) e = new Uint8Array(e); + else if (!(Array.isArray(e) || _0x2de28f && ArrayBuffer.isView(e))) throw _0x250d36; + b = !0 + } + for (var f, c, r = 0, t = e.length, d = this.blocks, i = this.buffer8; r < t;) { + if (this.hashed && (this.hashed = !1, d[0] = d[16], d[16] = d[1] = d[2] = d[3] = d[4] = d[5] = d[6] = d[7] = d[8] = d[9] = d[10] = d[11] = d[12] = d[13] = d[14] = d[15] = 0), b) + if (_0x2de28f) + for (c = this.start; r < t && c < 64; ++r) i[c++] = e[r]; + else + for (c = this.start; r < t && c < 64; ++r) d[c >> 2] |= e[r] << _0x20b37e[3 & c++]; + else if (_0x2de28f) + for (c = this.start; r < t && c < 64; ++r)(f = e.charCodeAt(r)) < 128 ? i[c++] = f : f < 2048 ? (i[c++] = 192 | f >> 6, i[c++] = 128 | 63 & f) : f < 55296 || f >= 57344 ? (i[c++] = 224 | f >> 12, i[c++] = 128 | f >> 6 & 63, i[c++] = 128 | 63 & f) : (f = 65536 + ((1023 & f) << 10 | 1023 & e.charCodeAt(++r)), i[c++] = 240 | f >> 18, i[c++] = 128 | f >> 12 & 63, i[c++] = 128 | f >> 6 & 63, i[c++] = 128 | 63 & f); + else + for (c = this.start; r < t && c < 64; ++r)(f = e.charCodeAt(r)) < 128 ? d[c >> 2] |= f << _0x20b37e[3 & c++] : f < 2048 ? (d[c >> 2] |= (192 | f >> 6) << _0x20b37e[3 & c++], d[c >> 2] |= (128 | 63 & f) << _0x20b37e[3 & c++]) : f < 55296 || f >= 57344 ? (d[c >> 2] |= (224 | f >> 12) << _0x20b37e[3 & c++], d[c >> 2] |= (128 | f >> 6 & 63) << _0x20b37e[3 & c++], d[c >> 2] |= (128 | 63 & f) << _0x20b37e[3 & c++]) : (f = 65536 + ((1023 & f) << 10 | 1023 & e.charCodeAt(++r)), d[c >> 2] |= (240 | f >> 18) << _0x20b37e[3 & c++], d[c >> 2] |= (128 | f >> 12 & 63) << _0x20b37e[3 & c++], d[c >> 2] |= (128 | f >> 6 & 63) << _0x20b37e[3 & c++], d[c >> 2] |= (128 | 63 & f) << _0x20b37e[3 & c++]); + this.lastByteIndex = c, this.bytes += c - this.start, c >= 64 ? (this.start = c - 64, this.hash(), this.hashed = !0) : this.start = c + } + return this.bytes > 4294967295 && (this.hBytes += this.bytes / 4294967296 << 0, this.bytes = this.bytes % 4294967296), this + } + }, _0x5887c8.prototype.finalize = function() { + if (!this.finalized) { + this.finalized = !0; + var e = this.blocks, + b = this.lastByteIndex; + e[b >> 2] |= _0x465562[3 & b], b >= 56 && (this.hashed || this.hash(), e[0] = e[16], e[16] = e[1] = e[2] = e[3] = e[4] = e[5] = e[6] = e[7] = e[8] = e[9] = e[10] = e[11] = e[12] = e[13] = e[14] = e[15] = 0), e[14] = this.bytes << 3, e[15] = this.hBytes << 3 | this.bytes >>> 29, this.hash() + } + }, _0x5887c8.prototype.hash = function() { + var e, b, a, f, c, r, t = this.blocks; + this.first ? b = ((b = ((e = ((e = t[0] - 680876937) << 7 | e >>> 25) - 271733879 << 0) ^ (a = ((a = (-271733879 ^ (f = ((f = (-1732584194 ^ 2004318071 & e) + t[1] - 117830708) << 12 | f >>> 20) + e << 0) & (-271733879 ^ e)) + t[2] - 1126478375) << 17 | a >>> 15) + f << 0) & (f ^ e)) + t[3] - 1316259209) << 22 | b >>> 10) + a << 0 : (e = this.h0, b = this.h1, a = this.h2, b = ((b += ((e = ((e += ((f = this.h3) ^ b & (a ^ f)) + t[0] - 680876936) << 7 | e >>> 25) + b << 0) ^ (a = ((a += (b ^ (f = ((f += (a ^ e & (b ^ a)) + t[1] - 389564586) << 12 | f >>> 20) + e << 0) & (e ^ b)) + t[2] + 606105819) << 17 | a >>> 15) + f << 0) & (f ^ e)) + t[3] - 1044525330) << 22 | b >>> 10) + a << 0), b = ((b += ((e = ((e += (f ^ b & (a ^ f)) + t[4] - 176418897) << 7 | e >>> 25) + b << 0) ^ (a = ((a += (b ^ (f = ((f += (a ^ e & (b ^ a)) + t[5] + 1200080426) << 12 | f >>> 20) + e << 0) & (e ^ b)) + t[6] - 1473231341) << 17 | a >>> 15) + f << 0) & (f ^ e)) + t[7] - 45705983) << 22 | b >>> 10) + a << 0, b = ((b += ((e = ((e += (f ^ b & (a ^ f)) + t[8] + 1770035416) << 7 | e >>> 25) + b << 0) ^ (a = ((a += (b ^ (f = ((f += (a ^ e & (b ^ a)) + t[9] - 1958414417) << 12 | f >>> 20) + e << 0) & (e ^ b)) + t[10] - 42063) << 17 | a >>> 15) + f << 0) & (f ^ e)) + t[11] - 1990404162) << 22 | b >>> 10) + a << 0, b = ((b += ((e = ((e += (f ^ b & (a ^ f)) + t[12] + 1804603682) << 7 | e >>> 25) + b << 0) ^ (a = ((a += (b ^ (f = ((f += (a ^ e & (b ^ a)) + t[13] - 40341101) << 12 | f >>> 20) + e << 0) & (e ^ b)) + t[14] - 1502002290) << 17 | a >>> 15) + f << 0) & (f ^ e)) + t[15] + 1236535329) << 22 | b >>> 10) + a << 0, b = ((b += ((f = ((f += (b ^ a & ((e = ((e += (a ^ f & (b ^ a)) + t[1] - 165796510) << 5 | e >>> 27) + b << 0) ^ b)) + t[6] - 1069501632) << 9 | f >>> 23) + e << 0) ^ e & ((a = ((a += (e ^ b & (f ^ e)) + t[11] + 643717713) << 14 | a >>> 18) + f << 0) ^ f)) + t[0] - 373897302) << 20 | b >>> 12) + a << 0, b = ((b += ((f = ((f += (b ^ a & ((e = ((e += (a ^ f & (b ^ a)) + t[5] - 701558691) << 5 | e >>> 27) + b << 0) ^ b)) + t[10] + 38016083) << 9 | f >>> 23) + e << 0) ^ e & ((a = ((a += (e ^ b & (f ^ e)) + t[15] - 660478335) << 14 | a >>> 18) + f << 0) ^ f)) + t[4] - 405537848) << 20 | b >>> 12) + a << 0, b = ((b += ((f = ((f += (b ^ a & ((e = ((e += (a ^ f & (b ^ a)) + t[9] + 568446438) << 5 | e >>> 27) + b << 0) ^ b)) + t[14] - 1019803690) << 9 | f >>> 23) + e << 0) ^ e & ((a = ((a += (e ^ b & (f ^ e)) + t[3] - 187363961) << 14 | a >>> 18) + f << 0) ^ f)) + t[8] + 1163531501) << 20 | b >>> 12) + a << 0, b = ((b += ((f = ((f += (b ^ a & ((e = ((e += (a ^ f & (b ^ a)) + t[13] - 1444681467) << 5 | e >>> 27) + b << 0) ^ b)) + t[2] - 51403784) << 9 | f >>> 23) + e << 0) ^ e & ((a = ((a += (e ^ b & (f ^ e)) + t[7] + 1735328473) << 14 | a >>> 18) + f << 0) ^ f)) + t[12] - 1926607734) << 20 | b >>> 12) + a << 0, b = ((b += ((r = (f = ((f += ((c = b ^ a) ^ (e = ((e += (c ^ f) + t[5] - 378558) << 4 | e >>> 28) + b << 0)) + t[8] - 2022574463) << 11 | f >>> 21) + e << 0) ^ e) ^ (a = ((a += (r ^ b) + t[11] + 1839030562) << 16 | a >>> 16) + f << 0)) + t[14] - 35309556) << 23 | b >>> 9) + a << 0, b = ((b += ((r = (f = ((f += ((c = b ^ a) ^ (e = ((e += (c ^ f) + t[1] - 1530992060) << 4 | e >>> 28) + b << 0)) + t[4] + 1272893353) << 11 | f >>> 21) + e << 0) ^ e) ^ (a = ((a += (r ^ b) + t[7] - 155497632) << 16 | a >>> 16) + f << 0)) + t[10] - 1094730640) << 23 | b >>> 9) + a << 0, b = ((b += ((r = (f = ((f += ((c = b ^ a) ^ (e = ((e += (c ^ f) + t[13] + 681279174) << 4 | e >>> 28) + b << 0)) + t[0] - 358537222) << 11 | f >>> 21) + e << 0) ^ e) ^ (a = ((a += (r ^ b) + t[3] - 722521979) << 16 | a >>> 16) + f << 0)) + t[6] + 76029189) << 23 | b >>> 9) + a << 0, b = ((b += ((r = (f = ((f += ((c = b ^ a) ^ (e = ((e += (c ^ f) + t[9] - 640364487) << 4 | e >>> 28) + b << 0)) + t[12] - 421815835) << 11 | f >>> 21) + e << 0) ^ e) ^ (a = ((a += (r ^ b) + t[15] + 530742520) << 16 | a >>> 16) + f << 0)) + t[2] - 995338651) << 23 | b >>> 9) + a << 0, b = ((b += ((f = ((f += (b ^ ((e = ((e += (a ^ (b | ~f)) + t[0] - 198630844) << 6 | e >>> 26) + b << 0) | ~a)) + t[7] + 1126891415) << 10 | f >>> 22) + e << 0) ^ ((a = ((a += (e ^ (f | ~b)) + t[14] - 1416354905) << 15 | a >>> 17) + f << 0) | ~e)) + t[5] - 57434055) << 21 | b >>> 11) + a << 0, b = ((b += ((f = ((f += (b ^ ((e = ((e += (a ^ (b | ~f)) + t[12] + 1700485571) << 6 | e >>> 26) + b << 0) | ~a)) + t[3] - 1894986606) << 10 | f >>> 22) + e << 0) ^ ((a = ((a += (e ^ (f | ~b)) + t[10] - 1051523) << 15 | a >>> 17) + f << 0) | ~e)) + t[1] - 2054922799) << 21 | b >>> 11) + a << 0, b = ((b += ((f = ((f += (b ^ ((e = ((e += (a ^ (b | ~f)) + t[8] + 1873313359) << 6 | e >>> 26) + b << 0) | ~a)) + t[15] - 30611744) << 10 | f >>> 22) + e << 0) ^ ((a = ((a += (e ^ (f | ~b)) + t[6] - 1560198380) << 15 | a >>> 17) + f << 0) | ~e)) + t[13] + 1309151649) << 21 | b >>> 11) + a << 0, b = ((b += ((f = ((f += (b ^ ((e = ((e += (a ^ (b | ~f)) + t[4] - 145523070) << 6 | e >>> 26) + b << 0) | ~a)) + t[11] - 1120210379) << 10 | f >>> 22) + e << 0) ^ ((a = ((a += (e ^ (f | ~b)) + t[2] + 718787259) << 15 | a >>> 17) + f << 0) | ~e)) + t[9] - 343485551) << 21 | b >>> 11) + a << 0, this.first ? (this.h0 = e + 1732584193 << 0, this.h1 = b - 271733879 << 0, this.h2 = a - 1732584194 << 0, this.h3 = f + 271733878 << 0, this.first = !1) : (this.h0 = this.h0 + e << 0, this.h1 = this.h1 + b << 0, this.h2 = this.h2 + a << 0, this.h3 = this.h3 + f << 0) + }, _0x5887c8.prototype.hex = function() { + this.finalize(); + var e = this.h0, + b = this.h1, + a = this.h2, + f = this.h3; + return _0x3a9a1b[e >> 4 & 15] + _0x3a9a1b[15 & e] + _0x3a9a1b[e >> 12 & 15] + _0x3a9a1b[e >> 8 & 15] + _0x3a9a1b[e >> 20 & 15] + _0x3a9a1b[e >> 16 & 15] + _0x3a9a1b[e >> 28 & 15] + _0x3a9a1b[e >> 24 & 15] + _0x3a9a1b[b >> 4 & 15] + _0x3a9a1b[15 & b] + _0x3a9a1b[b >> 12 & 15] + _0x3a9a1b[b >> 8 & 15] + _0x3a9a1b[b >> 20 & 15] + _0x3a9a1b[b >> 16 & 15] + _0x3a9a1b[b >> 28 & 15] + _0x3a9a1b[b >> 24 & 15] + _0x3a9a1b[a >> 4 & 15] + _0x3a9a1b[15 & a] + _0x3a9a1b[a >> 12 & 15] + _0x3a9a1b[a >> 8 & 15] + _0x3a9a1b[a >> 20 & 15] + _0x3a9a1b[a >> 16 & 15] + _0x3a9a1b[a >> 28 & 15] + _0x3a9a1b[a >> 24 & 15] + _0x3a9a1b[f >> 4 & 15] + _0x3a9a1b[15 & f] + _0x3a9a1b[f >> 12 & 15] + _0x3a9a1b[f >> 8 & 15] + _0x3a9a1b[f >> 20 & 15] + _0x3a9a1b[f >> 16 & 15] + _0x3a9a1b[f >> 28 & 15] + _0x3a9a1b[f >> 24 & 15] + }, _0x5887c8.prototype.toString = _0x5887c8.prototype.hex, _0x5887c8.prototype.digest = function() { + this.finalize(); + var e = this.h0, + b = this.h1, + a = this.h2, + f = this.h3; + return [255 & e, e >> 8 & 255, e >> 16 & 255, e >> 24 & 255, 255 & b, b >> 8 & 255, b >> 16 & 255, b >> 24 & 255, 255 & a, a >> 8 & 255, a >> 16 & 255, a >> 24 & 255, 255 & f, f >> 8 & 255, f >> 16 & 255, f >> 24 & 255] + }, _0x5887c8.prototype.array = _0x5887c8.prototype.digest, _0x5887c8.prototype.arrayBuffer = function() { + this.finalize(); + var e = new ArrayBuffer(16), + b = new Uint32Array(e); + return b[0] = this.h0, b[1] = this.h1, b[2] = this.h2, b[3] = this.h3, e + }, _0x5887c8.prototype.buffer = _0x5887c8.prototype.arrayBuffer, _0x5887c8.prototype.base64 = function() { + for (var e, b, a, f = "", c = this.array(), r = 0; r < 15;) e = c[r++], b = c[r++], a = c[r++], f += _0x2c185e[e >>> 2] + _0x2c185e[63 & (e << 4 | b >>> 4)] + _0x2c185e[63 & (b << 2 | a >>> 6)] + _0x2c185e[63 & a]; + return f + (_0x2c185e[(e = c[r]) >>> 2] + _0x2c185e[e << 4 & 63] + "==") + }; + var _0x4dd781 = _0x38ba77(); + _0x17dcbf ? _0x770f81.exports = _0x4dd781 : (_0x1702f9.md5 = _0x4dd781, _0x554fed && (void 0)(function() { + return _0x4dd781 + })) + }() + }); + +function _0x178cef(e) { + return jsvmp("484e4f4a403f52430038001eab0015840e8ee21a00000000000000621b000200001d000146000306000e271f001b000200021d00010500121b001b000b021b000b04041d0001071b000b0500000003000126207575757575757575757575757575757575757575757575757575757575757575", [, , void 0 !== _0x124d1a ? _0x124d1a : void 0, _0x178cef, e]) +} +for (var _0xb55f3e = { + boe: !1, + aid: 0, + dfp: !1, + sdi: !1, + enablePathList: [], + _enablePathListRegex: [], + urlRewriteRules: [], + _urlRewriteRules: [], + initialized: !1, + enableTrack: !1, + track: { + unitTime: 0, + unitAmount: 0, + fre: 0 + }, + triggerUnload: !1, + region: "", + regionConf: {}, + umode: 0, + v: !1, + perf: !1, + xxbg: !0 + }, _0x3eaf64 = { + debug: function(e, b) { + let a = !1; + a = !1 + } + }, _0x233455 = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"], _0x2e9f6d = [], _0x511f86 = [], _0x3d35de = 0; _0x3d35de < 256; _0x3d35de++) _0x2e9f6d[_0x3d35de] = _0x233455[_0x3d35de >> 4 & 15] + _0x233455[15 & _0x3d35de], _0x3d35de < 16 && (_0x3d35de < 10 ? _0x511f86[48 + _0x3d35de] = _0x3d35de : _0x511f86[87 + _0x3d35de] = _0x3d35de); +var _0x2ce54d = function(e) { + for (var b = e.length, a = "", f = 0; f < b;) a += _0x2e9f6d[e[f++]]; + return a + }, + _0x5960a2 = function(e) { + for (var b = e.length >> 1, a = b << 1, f = new Uint8Array(b), c = 0, r = 0; r < a;) f[c++] = _0x511f86[e.charCodeAt(r++)] << 4 | _0x511f86[e.charCodeAt(r++)]; + return f + }, + _0x4e46b6 = { + encode: _0x2ce54d, + decode: _0x5960a2 + }; + +function sign(e, b) { + return jsvmp("484e4f4a403f5243001f240fbf2031ccf317480300000000000007181b0002012e1d00921b000b171b000b02402217000a1c1b000b1726402217000c1c1b000b170200004017002646000306000e271f001b000200021d00920500121b001b000b031b000b17041d0092071b000b041e012f17000d1b000b05260a0000101c1b000b06260a0000101c1b001b000b071e01301d00931b001b000b081e00081d00941b0048021d00951b001b000b1b1d00961b0048401d009e1b001b000b031b000b16041d009f1b001b000b09221e0131241b000b031b000b09221e0131241b000b1e0a000110040a0001101d00d51b001b000b09221e0131241b000b031b000b09221e0131241b000b180a000110040a0001101d00d71b001b000b0a1e00101d00d91b001b000b0b261b000b1a1b000b190a0002101d00db1b001b000b0c261b000b221b000b210a0002101d00dc1b001b000b0d261b000b230200200a0002101d00dd1b001b000b09221e0131241b000b031b000b24040a0001101d00df1b001b000b0e1a00221e00de240a0000104903e82b1d00e31b001b000b0f260a0000101d00e41b001b000b1d1d00e71b001b000b1a4901002b1d00e81b001b000b1a4901002c1d00ea1b001b000b191d00f21b001b000b1f480e191d00f81b001b000b1f480f191d00f91b001b000b20480e191d00fb1b001b000b20480f191d00fe1b001b000b25480e191d01001b001b000b25480f191d01011b001b000b264818344900ff2f1d01031b001b000b264810344900ff2f1d01321b001b000b264808344900ff2f1d01331b001b000b264800344900ff2f1d01341b001b000b274818344900ff2f1d01351b001b000b274810344900ff2f1d01361b001b000b274808344900ff2f1d01371b001b000b274800344900ff2f1d01381b001b000b281b000b29311b000b2a311b000b2b311b000b2c311b000b2d311b000b2e311b000b2f311b000b30311b000b31311b000b32311b000b33311b000b34311b000b35311b000b36311b000b37311b000b38311b000b39311d01391b004900ff1d013a1b001b000b10261b000b281b000b2a1b000b2c1b000b2e1b000b301b000b321b000b341b000b361b000b381b000b3a1b000b291b000b2b1b000b2d1b000b2f1b000b311b000b331b000b351b000b371b000b390a0013101d013b1b001b000b0c261b000b111b000b3b041b000b3c0a0002101d013c1b001b000b12261b000b1c1b000b3b1b000b3d0a0003101d013d1b001b000b13261b000b3e0200240a0002101d013e1b000b3f0000013f000126207575757575757575757575757575757575757575757575757575757575757575012b0e7776757a7d7643617c637661676a027a77065c717976706708777671667474766107767d65707c77760374766707707c7d607c7f7607757a61767166740a7c66677661447a77677b0a7a7d7d7661447a77677b0b7c666776615b767a747b670b7a7d7d76615b767a747b6709666076615274767d670b677c5f7c64766150726076077a7d77766b5c7508767f767067617c7d09667d7776757a7d76770963617c677c676a637608677c4067617a7d740470727f7f0763617c7076606010487c71797670673363617c707660604e067c717976706705677a677f76047d7c7776012e0125012402602341525150575655545b5a59585f5e5d5c43424140474645444b4a49727170777675747b7a79787f7e7d7c63626160676665646b6a6923222120272625242b2a383c2e0260224157787763747b2749586042512b233c5e75656420254b5a22412126384446527f567a245d5f717c624a475c4366697e5579597d616a6b2a5b45547072406750762e0260214157787763747b2749586042512b233c5e75656420254b5a224121263e4446527f567a245d5f717c624a475c4366697e5579597d616a6b2a5b45547072406750762e02602041525150575655545b5a59585f5e5d5c43424140474645444b4a49727170777675747b7a79787f7e7d7c63626160676665646b6a6923222120272625242b2a3e4c2e012a022222067f767d74677b0a707b7261507c7776526702222306707b726152670f487c717976706733447a7d777c644e08577c70667e767d6712487c7179767067335d72657a7472677c614e057960777c7e10487c7179767067335b7a60677c616a4e07637f66747a7d60084c637b727d677c7e0b70727f7f437b727d677c7e0b4c4c7d7a747b677e726176055266777a7c1850727d65726041767d7776617a7d74507c7d67766b6721570964767177617a657661137476675c647d43617c637661676a5d727e7660097f727d74667274766006707b617c7e760761667d677a7e7607707c7d7d767067144c4c64767177617a6576614c7665727f66726776134c4c60767f767d7a667e4c7665727f667267761b4c4c64767177617a6576614c6070617a63674c75667d70677a7c7d174c4c64767177617a6576614c6070617a63674c75667d70154c4c64767177617a6576614c6070617a63674c757d134c4c756b77617a6576614c7665727f66726776124c4c77617a6576614c667d64617263637677154c4c64767177617a6576614c667d64617263637677114c4c77617a6576614c7665727f66726776144c4c60767f767d7a667e4c667d64617263637677144c4c756b77617a6576614c667d64617263637677094c60767f767d7a667e0c70727f7f40767f767d7a667e164c40767f767d7a667e4c5a57564c4176707c6177766108777c70667e767d670478766a60057e7267707b06417674566b630a4f3748723e694e77704c067072707b764c04607c7e7608707675407b72616308507675407b72616305767c72637a16767c44767151617c64607661577a60637267707b76610f717a7d775c717976706752606a7d700e7a60565c44767151617c646076610120047c63767d0467766067097a7d707c747d7a677c077c7d7661617c6104707c77761242465c47524c564b5056565756574c5641410e607660607a7c7d40677c61727476076076675a67767e10607c7e7658766a5b766176516a6776770a61767e7c65765a67767e097a7d77766b767757510c437c7a7d6776615665767d670e5e40437c7a7d6776615665767d670d706176726776567f767e767d670670727d65726009677c5772677246415f076176637f727076034f603901740a7d72677a6576707c777614487c717976706733437f66747a7d526161726a4e4a4d7b676763602c294f3c4f3c3b48233e2a4e68223f206e3b4f3d48233e2a4e68223f206e3a68206e6f48723e75233e2a4e68223f276e3b2948723e75233e2a4e68223f276e3a68246e3a0127087f7c7072677a7c7d047b61767504757a7f76107b676763293c3c7f7c70727f7b7c606708637f7267757c617e02222102222007647a7d777c646002222703647a7d02222607727d77617c7a77022225057f7a7d666b022224067a637b7c7d7602222b047a63727702222a047a637c77022123037e7270022122097e72707a7d677c607b0c7e72704c637c64766163703a0470617c60036b22220570617a7c6005756b7a7c6004637a787602212102212002212702212602212502212402212b08757a6176757c6b3c067c637661723c05337c63613c05337c63673c07707b617c7e763c0867617a77767d673c047e607a7602212a0220230665767d777c6106547c7c747f760e4c637261727e40647a67707b5c7d0a777a61767067407a747d0a707c7d607a6067767d670660647a67707b03777c7e07637b727d677c7e047b7c7c7840525150575655545b5a59585f5e5d5c43424140474645444b4a49727170777675747b7a79787f7e7d7c63626160676665646b6a6923222120272625242b2a3e3d03727a77017d01750161096067726167477a7e7601670972717a7f7a677a76600a677a7e766067727e6322137b72617764726176507c7d70666161767d706a0c7776657a70765e767e7c616a087f727d74667274760a6176607c7f66677a7c7d0f7265727a7f4176607c7f66677a7c7d0960706176767d477c630a60706176767d5f767567107776657a7076437a6b767f4172677a7c0a63617c77667067406671077172676776616a016309677c66707b5a7d757c08677a7e76697c7d760a677a7e766067727e6321077463665a7d757c0b7960557c7d67605f7a60670b637f66747a7d605f7a60670a677a7e766067727e63200a76657661507c7c787a760767674c60707a77017e0b606a7d67726b5661617c610c7d72677a65765f767d74677b056167705a43097563457661607a7c7d0b4c4c657661607a7c7d4c4c08707f7a767d675a770a677a7e766067727e63270b766b67767d77557a767f77046366607b03727f7f04677b767d097172607625274c707b0c75617c7e507b7261507c7776067125274c2023022022087172607625274c23022021087172607625274c22022020087172607625274c2102202702202602202507747667477a7e760220240b777c7e5d7c6745727f7a77096066716067617a7d740863617c677c707c7f02202b02202a01230e222323232323232322222323232302272302272207757c616176727f02272104717c776a096067617a7d747a756a02686e0b717c776a45727f216067610a717c776a4c7b72607b2e01350366617f02272005626676616a0a72607c7f774c607a747d096372677b7d727e762e0967674c6476717a772e063566667a772e0227270227260e4c716a6776774c6076704c777a770227250a27212a272a2524212a25097576457661607a7c7d0227240e4c232151274925647c232323232202272b02272a05607f7a7076022623074056505a5d555c037d7c6409677a7e766067727e6305757f7c7c610661727d777c7e0f7476674747447671507c7c787a7660056767647a770867674c6476717a770767674476715a770b67674c6476717a774c65210967674476717a7745210761667d7d7a7d7405757f66607b087e7c65765f7a60670660637f7a70760671765e7c657609707f7a70785f7a6067077176507f7a70780c78766a717c7261775f7a60670a717658766a717c7261770b7270677a657640677267760b647a7d777c6440677267760360477e05676172707808667d7a67477a7e76037270700a667d7a67527e7c667d670871767b72657a7c61077e6074476a637603645a5707727a775f7a60670b63617a6572706a5e7c777606706660677c7e067260607a747d0f4456514c5756455a50564c5a5d555c0479607c7d0a6176747a7c7d507c7d75096176637c616746617f04766b7a67094b3e5e403e404746510c4b3e5e403e43524a5f5c525720232323232323232323232323232323232323232323232323232323232323232320772722772b70772a2b75232371212327762a2b23232a2a2b7670752b272124760165066671707c7776067776707c777602262202262102262002262702262602262502262402262b02262a022523022522022521022520", [, , void 0, void 0 !== _0x178cef ? _0x178cef : void 0, { + boe: !1, + aid: 0, + dfp: !1, + sdi: !1, + enablePathList: [], + _enablePathListRegex: [/\/web\/report/], + urlRewriteRules: [], + _urlRewriteRules: [], + initialized: !1, + enableTrack: !1, + track: { + unitTime: 0, + unitAmount: 0, + fre: 0 + }, + triggerUnload: !1, + region: "", + regionConf: {}, + umode: 0, + v: !1, + perf: !1, + xxbg: !0 + }, () => 0, () => "03v", { + ubcode: 0 + }, { + bogusIndex: 0, + msNewTokenList: [], + moveList: [], + clickList: [], + keyboardList: [], + activeState: [], + aidList: [], + envcode: 0, + msToken: "", + msStatus: 0, + __ac_testid: "", + ttwid: "", + tt_webid: "", + tt_webid_v2: "" + }, void 0 !== _0x4e46b6 ? _0x4e46b6 : void 0, { + userAgent: b + }, (e, b) => { + let a = new Uint8Array(3); + return a[0] = e / 256, a[1] = e % 256, a[2] = b % 256, String.fromCharCode.apply(null, a) + }, (e, b) => { + let a, f = [], + c = 0, + r = ""; + for (let e = 0; e < 256; e++) f[e] = e; + for (let b = 0; b < 256; b++) c = (c + f[b] + e.charCodeAt(b % e.length)) % 256, a = f[b], f[b] = f[c], f[c] = a; + let t = 0; + c = 0; + for (let e = 0; e < b.length; e++) c = (c + f[t = (t + 1) % 256]) % 256, a = f[t], f[t] = f[c], f[c] = a, r += String.fromCharCode(b.charCodeAt(e) ^ f[(f[t] + f[c]) % 256]); + return r + }, (e, b) => jsvmp("484e4f4a403f524300281018f7b851f02d296e5b00000000000004a21b0002001d1d001e1b00131e00061a001d001f1b000b070200200200210d1b000b070200220200230d1b000b070200240200250d1b000b070200260200270d1b001b000b071b000b05191d00031b000200001d00281b0048001d00291b000b041e002a1b000b0b4803283b1700f11b001b000b04221e002b241b001e0029222d1b00241d00290a0001104900ff2f4810331b000b04221e002b241b001e0029222d1b00241d00290a0001104900ff2f480833301b000b04221e002b241b001e0029222d1b00241d00290a0001104900ff2f301d002c1b00220b091b000b08221e002d241b000b0a4a00fc00002f4812340a000110281d00281b00220b091b000b08221e002d241b000b0a4a0003f0002f480c340a000110281d00281b00220b091b000b08221e002d241b000b0a490fc02f4806340a000110281d00281b00220b091b000b08221e002d241b000b0a483f2f0a000110281d002816ff031b000b041e002a1b000b0b294800391700e01b001b000b04221e002b241b001e0029222d1b00241d00290a0001104900ff2f4810331b000b041e002a1b000b0b3917001e1b000b04221e002b241b000b0b0a0001104900ff2f4808331600054800301d002c1b00220b091b000b08221e002d241b000b0a4a00fc00002f4812340a000110281d00281b00220b091b000b08221e002d241b000b0a4a0003f0002f480c340a000110281d00281b00220b091b000b041e002a1b000b0b3917001e1b000b08221e002d241b000b0a490fc02f4806340a0001101600071b000b06281d00281b00220b091b000b06281d00281b000b090000002e000126207575757575757575757575757575757575757575757575757575757575757575012b0e7776757a7d7643617c637661676a027a77065c717976706708777671667474766107767d65707c77760374766707707c7d607c7f7607757a61767166740a7c66677661447a77677b0a7a7d7d7661447a77677b0b7c666776615b767a747b670b7a7d7d76615b767a747b6709666076615274767d670b677c5f7c64766150726076077a7d77766b5c7508767f767067617c7d09667d7776757a7d76770963617c677c676a637608677c4067617a7d740470727f7f0763617c7076606010487c71797670673363617c707660604e067c717976706705677a677f76047d7c7776012e0125012402602341525150575655545b5a59585f5e5d5c43424140474645444b4a49727170777675747b7a79787f7e7d7c63626160676665646b6a6923222120272625242b2a383c2e0260224157787763747b2749586042512b233c5e75656420254b5a22412126384446527f567a245d5f717c624a475c4366697e5579597d616a6b2a5b45547072406750762e0260214157787763747b2749586042512b233c5e75656420254b5a224121263e4446527f567a245d5f717c624a475c4366697e5579597d616a6b2a5b45547072406750762e02602041525150575655545b5a59585f5e5d5c43424140474645444b4a49727170777675747b7a79787f7e7d7c63626160676665646b6a6923222120272625242b2a3e4c2e012a022222067f767d74677b0a707b7261507c7776526702222306707b72615267", [, , , , e, b]), "undefined" != typeof Date ? Date : void 0, () => 0, (e, b, a, f, c, r, t, d, i, n, s, o, l, _, x, u, h, p, y) => { + let v = new Uint8Array(19); + return v[0] = e, v[1] = s, v[2] = b, v[3] = o, v[4] = a, v[5] = l, v[6] = f, v[7] = _, v[8] = c, v[9] = x, v[10] = r, v[11] = u, v[12] = t, v[13] = h, v[14] = d, v[15] = p, v[16] = i, v[17] = y, v[18] = n, String.fromCharCode.apply(null, v) + }, e => String.fromCharCode(e), (e, b, a) => String.fromCharCode(e) + String.fromCharCode(b) + a, (e, b) => jsvmp("484e4f4a403f524300281018f7b851f02d296e5b00000000000004a21b0002001d1d001e1b00131e00061a001d001f1b000b070200200200210d1b000b070200220200230d1b000b070200240200250d1b000b070200260200270d1b001b000b071b000b05191d00031b000200001d00281b0048001d00291b000b041e002a1b000b0b4803283b1700f11b001b000b04221e002b241b001e0029222d1b00241d00290a0001104900ff2f4810331b000b04221e002b241b001e0029222d1b00241d00290a0001104900ff2f480833301b000b04221e002b241b001e0029222d1b00241d00290a0001104900ff2f301d002c1b00220b091b000b08221e002d241b000b0a4a00fc00002f4812340a000110281d00281b00220b091b000b08221e002d241b000b0a4a0003f0002f480c340a000110281d00281b00220b091b000b08221e002d241b000b0a490fc02f4806340a000110281d00281b00220b091b000b08221e002d241b000b0a483f2f0a000110281d002816ff031b000b041e002a1b000b0b294800391700e01b001b000b04221e002b241b001e0029222d1b00241d00290a0001104900ff2f4810331b000b041e002a1b000b0b3917001e1b000b04221e002b241b000b0b0a0001104900ff2f4808331600054800301d002c1b00220b091b000b08221e002d241b000b0a4a00fc00002f4812340a000110281d00281b00220b091b000b08221e002d241b000b0a4a0003f0002f480c340a000110281d00281b00220b091b000b041e002a1b000b0b3917001e1b000b08221e002d241b000b0a490fc02f4806340a0001101600071b000b06281d00281b00220b091b000b06281d00281b000b090000002e000126207575757575757575757575757575757575757575757575757575757575757575012b0e7776757a7d7643617c637661676a027a77065c717976706708777671667474766107767d65707c77760374766707707c7d607c7f7607757a61767166740a7c66677661447a77677b0a7a7d7d7661447a77677b0b7c666776615b767a747b670b7a7d7d76615b767a747b6709666076615274767d670b677c5f7c64766150726076077a7d77766b5c7508767f767067617c7d09667d7776757a7d76770963617c677c676a637608677c4067617a7d740470727f7f0763617c7076606010487c71797670673363617c707660604e067c717976706705677a677f76047d7c7776012e0125012402602341525150575655545b5a59585f5e5d5c43424140474645444b4a49727170777675747b7a79787f7e7d7c63626160676665646b6a6923222120272625242b2a383c2e0260224157787763747b2749586042512b233c5e75656420254b5a22412126384446527f567a245d5f717c624a475c4366697e5579597d616a6b2a5b45547072406750762e0260214157787763747b2749586042512b233c5e75656420254b5a224121263e4446527f567a245d5f717c624a475c4366697e5579597d616a6b2a5b45547072406750762e02602041525150575655545b5a59585f5e5d5c43424140474645444b4a49727170777675747b7a79787f7e7d7c63626160676665646b6a6923222120272625242b2a3e4c2e012a022222067f767d74677b0a707b7261507c7776526702222306707b72615267", [, , , , e, b]), , sign, e, void 0]) +} + +//export text function +module.exports = sign; diff --git a/src/LiveRecorder.Infrastructure/Platforms/Huya/HuyaLivePlatformAdapter.cs b/src/LiveRecorder.Infrastructure/Platforms/Huya/HuyaLivePlatformAdapter.cs new file mode 100644 index 0000000..19e9b61 --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Platforms/Huya/HuyaLivePlatformAdapter.cs @@ -0,0 +1,25 @@ +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Infrastructure.Platforms.Huya; + +public sealed class HuyaLivePlatformAdapter : ILivePlatformAdapter +{ + public LivePlatformType PlatformType => LivePlatformType.Huya; + + public bool CanHandle(string input) => + !string.IsNullOrWhiteSpace(input) && + input.Contains("huya.com", StringComparison.OrdinalIgnoreCase); + + public Task<ParsedLiveRoom> ParseRoomAsync(string input, CancellationToken cancellationToken = default) => + throw new NotSupportedException("Huya 适配器尚未实现。"); + + public Task<LiveStatusSnapshot> GetLiveStatusAsync(string roomId, CancellationToken cancellationToken = default) => + throw new NotSupportedException("Huya 适配器尚未实现。"); + + public Task<StreamUrlResult> GetStreamUrlAsync( + string roomId, + string? preferredQuality = null, + CancellationToken cancellationToken = default) => + throw new NotSupportedException("Huya 适配器尚未实现。"); +} diff --git a/src/LiveRecorder.Infrastructure/Services/EmailNotificationService.cs b/src/LiveRecorder.Infrastructure/Services/EmailNotificationService.cs new file mode 100644 index 0000000..88f2419 --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Services/EmailNotificationService.cs @@ -0,0 +1,265 @@ +using System.Net; +using System.Net.Mail; +using System.Text; +using System.Text.RegularExpressions; +using LiveRecorder.Application.Abstractions.Notifications; +using LiveRecorder.Application.Abstractions.Settings; +using LiveRecorder.Application.Models.Settings; +using LiveRecorder.Domain.Entities; +using Microsoft.Extensions.Logging; + +namespace LiveRecorder.Infrastructure.Services; + +public sealed class EmailNotificationService : IEmailNotificationService +{ + private const string AppName = "LiveRecorder"; + private static readonly Regex TemplateTokenRegex = new("""\{\{\s*(?<name>[a-zA-Z0-9_]+)\s*\}\}""", RegexOptions.Compiled); + + private readonly ISystemSettingsService _systemSettingsService; + private readonly ILogger<EmailNotificationService> _logger; + + public EmailNotificationService( + ISystemSettingsService systemSettingsService, + ILogger<EmailNotificationService> logger) + { + _systemSettingsService = systemSettingsService; + _logger = logger; + } + + public async Task SendLiveStartedAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default) + { + var settings = await _systemSettingsService.GetAsync(cancellationToken); + if (!settings.EnableEmailNotification || !settings.NotifyOnLiveStarted) + { + return; + } + + var tokens = CreateTokenMap(new Dictionary<string, string?> + { + ["platform"] = liveRoom.Platform.ToString(), + ["roomId"] = liveRoom.RoomId, + ["title"] = liveRoom.Title, + ["anchor"] = liveRoom.AnchorName, + ["sourceUrl"] = liveRoom.SourceUrl, + ["detectedAtUtc"] = DateTimeOffset.UtcNow.ToString("O") + }); + + var subject = RenderSubject(settings.EmailLiveStartedSubjectTemplate, tokens); + var body = RenderHtml(settings.EmailLiveStartedBodyTemplateHtml, tokens); + + await SendAsync(settings, subject, body, cancellationToken); + } + + public async Task SendExceptionAsync( + string source, + string summary, + string? detail = null, + LiveRoom? liveRoom = null, + RecordTask? recordTask = null, + CancellationToken cancellationToken = default) + { + var settings = await _systemSettingsService.GetAsync(cancellationToken); + if (!settings.EnableEmailNotification || !settings.NotifyOnException) + { + return; + } + + var tokens = CreateTokenMap(new Dictionary<string, string?> + { + ["source"] = source, + ["summary"] = summary, + ["detail"] = detail, + ["liveRoomId"] = liveRoom?.Id.ToString(), + ["roomId"] = liveRoom?.RoomId, + ["recordTaskId"] = recordTask?.Id.ToString(), + ["taskStatus"] = recordTask?.Status.ToString(), + ["occurredAtUtc"] = DateTimeOffset.UtcNow.ToString("O") + }); + + var subject = RenderSubject(settings.EmailExceptionSubjectTemplate, tokens); + var body = RenderHtml(settings.EmailExceptionBodyTemplateHtml, tokens); + + await SendAsync(settings, subject, body, cancellationToken); + } + + public async Task SendTestAsync(SendTestEmailRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var settings = new SystemSettingsDto + { + EnableEmailNotification = true, + NotifyOnLiveStarted = true, + NotifyOnException = true, + EmailSmtpHost = request.EmailSmtpHost.Trim(), + EmailSmtpPort = request.EmailSmtpPort, + EmailUseSsl = request.EmailUseSsl, + EmailUsername = request.EmailUsername.Trim(), + EmailPassword = request.EmailPassword, + EmailFromAddress = request.EmailFromAddress.Trim(), + EmailFromDisplayName = request.EmailFromDisplayName.Trim(), + EmailToAddresses = request.EmailToAddresses.Trim(), + EmailLiveStartedSubjectTemplate = request.EmailLiveStartedSubjectTemplate, + EmailLiveStartedBodyTemplateHtml = request.EmailLiveStartedBodyTemplateHtml, + EmailExceptionSubjectTemplate = request.EmailExceptionSubjectTemplate, + EmailExceptionBodyTemplateHtml = request.EmailExceptionBodyTemplateHtml + }; + + var sampleLiveTokens = CreateTokenMap(new Dictionary<string, string?> + { + ["platform"] = "Douyin", + ["roomId"] = "123456789", + ["title"] = "Sample Live Title", + ["anchor"] = "Sample Anchor", + ["sourceUrl"] = "https://live.douyin.com/123456789", + ["detectedAtUtc"] = DateTimeOffset.UtcNow.ToString("O") + }); + var sampleExceptionTokens = CreateTokenMap(new Dictionary<string, string?> + { + ["source"] = "Scheduler", + ["summary"] = "Background polling failed for a live room.", + ["detail"] = "Sample stack trace or diagnostic detail goes here.", + ["liveRoomId"] = Guid.NewGuid().ToString(), + ["roomId"] = "123456789", + ["recordTaskId"] = Guid.NewGuid().ToString(), + ["taskStatus"] = "Running", + ["occurredAtUtc"] = DateTimeOffset.UtcNow.ToString("O") + }); + + var body = $$""" +<!DOCTYPE html> +<html lang="en"> + <body style="margin: 0; padding: 24px; background: #f5f7fb; font-family: 'Segoe UI', 'PingFang SC', sans-serif; color: #1f2937;"> + <div style="max-width: 880px; margin: 0 auto;"> + <h1 style="margin: 0 0 20px; color: #2f4c66;">LiveRecorder SMTP template test</h1> + <div style="margin-bottom: 18px; padding: 18px 20px; border-radius: 12px; background: #ffffff; border: 1px solid #d9e1ea;"> + <h2 style="margin: 0 0 14px; color: #3e5f7c;">开播提醒示例</h2> + <div style="margin-bottom: 10px; font-size: 13px; color: #6b7280;"><strong>Subject:</strong> {{WebUtility.HtmlEncode(RenderSubject(settings.EmailLiveStartedSubjectTemplate, sampleLiveTokens))}}</div> + {{RenderHtml(settings.EmailLiveStartedBodyTemplateHtml, sampleLiveTokens)}} + </div> + <div style="padding: 18px 20px; border-radius: 12px; background: #ffffff; border: 1px solid #d9e1ea;"> + <h2 style="margin: 0 0 14px; color: #8b5e3c;">异常提醒示例</h2> + <div style="margin-bottom: 10px; font-size: 13px; color: #6b7280;"><strong>Subject:</strong> {{WebUtility.HtmlEncode(RenderSubject(settings.EmailExceptionSubjectTemplate, sampleExceptionTokens))}}</div> + {{RenderHtml(settings.EmailExceptionBodyTemplateHtml, sampleExceptionTokens)}} + </div> + </div> + </body> +</html> +"""; + + await SendAsync(settings, "[LiveRecorder] SMTP template test", body, cancellationToken, swallowErrors: false); + } + + private async Task SendAsync( + SystemSettingsDto settings, + string subject, + string body, + CancellationToken cancellationToken, + bool swallowErrors = true) + { + var recipients = ParseRecipients(settings.EmailToAddresses); + if (string.IsNullOrWhiteSpace(settings.EmailSmtpHost) || + string.IsNullOrWhiteSpace(settings.EmailFromAddress) || + recipients.Count == 0) + { + if (swallowErrors) + { + return; + } + + throw new InvalidOperationException("SMTP host, sender address or recipient list is missing."); + } + + try + { + using var message = new MailMessage + { + From = new MailAddress(settings.EmailFromAddress, settings.EmailFromDisplayName), + Subject = subject, + Body = body, + BodyEncoding = Encoding.UTF8, + SubjectEncoding = Encoding.UTF8, + IsBodyHtml = true + }; + + foreach (var recipient in recipients) + { + message.To.Add(recipient); + } + + using var client = new SmtpClient(settings.EmailSmtpHost, settings.EmailSmtpPort) + { + EnableSsl = settings.EmailUseSsl, + DeliveryMethod = SmtpDeliveryMethod.Network + }; + + if (!string.IsNullOrWhiteSpace(settings.EmailUsername)) + { + client.Credentials = new NetworkCredential(settings.EmailUsername, settings.EmailPassword); + } + + using var registration = cancellationToken.Register(client.SendAsyncCancel); + await client.SendMailAsync(message); + } + catch (Exception ex) + { + if (!swallowErrors) + { + throw new InvalidOperationException($"SMTP test email send failed: {ex.Message}", ex); + } + + _logger.LogWarning(ex, "Email notification send failed"); + } + } + + private static List<string> ParseRecipients(string rawAddresses) => + rawAddresses + .Split([',', ';', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(static item => !string.IsNullOrWhiteSpace(item)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + private static Dictionary<string, string> CreateTokenMap(IReadOnlyDictionary<string, string?> rawTokens) + { + var tokens = rawTokens.ToDictionary( + static pair => pair.Key, + static pair => pair.Value?.Trim() ?? string.Empty, + StringComparer.OrdinalIgnoreCase); + tokens["appName"] = AppName; + return tokens; + } + + private static string RenderSubject(string template, IReadOnlyDictionary<string, string> tokens) + { + var rendered = RenderTemplate(template, tokens, htmlEncodeValues: false); + rendered = rendered.Replace("\r", " ").Replace("\n", " ").Trim(); + return string.IsNullOrWhiteSpace(rendered) ? $"[{AppName}] Notification" : rendered; + } + + private static string RenderHtml(string template, IReadOnlyDictionary<string, string> tokens) + { + var rendered = RenderTemplate(template, tokens, htmlEncodeValues: true).Trim(); + return string.IsNullOrWhiteSpace(rendered) ? "<div></div>" : rendered; + } + + private static string RenderTemplate(string template, IReadOnlyDictionary<string, string> tokens, bool htmlEncodeValues) + { + if (string.IsNullOrWhiteSpace(template)) + { + return string.Empty; + } + + return TemplateTokenRegex.Replace( + template, + match => + { + var tokenName = match.Groups["name"].Value; + if (!tokens.TryGetValue(tokenName, out var value)) + { + return string.Empty; + } + + return htmlEncodeValues ? WebUtility.HtmlEncode(value) : value; + }); + } +} diff --git a/src/LiveRecorder.Infrastructure/Services/FfmpegService.Runtime.cs b/src/LiveRecorder.Infrastructure/Services/FfmpegService.Runtime.cs new file mode 100644 index 0000000..c1f6e54 --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Services/FfmpegService.Runtime.cs @@ -0,0 +1,767 @@ +using System.Diagnostics; +using LiveRecorder.Application.Abstractions.Logging; +using LiveRecorder.Application.Abstractions.Notifications; +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Application.Abstractions.Settings; +using LiveRecorder.Application.Services; +using LiveRecorder.Domain.Entities; +using LiveRecorder.Domain.Enums; +using LiveRecorder.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace LiveRecorder.Infrastructure.Services; + +public sealed partial class FfmpegService +{ + private async Task HandleProcessOutputAsync(SessionProcessRuntime runtime, string? line, bool isError) + { + if (string.IsNullOrWhiteSpace(line)) + { + return; + } + + _logger.LogDebug("ffmpeg[{SessionId}] {Line}", runtime.RecordSessionId, line); + if (TryParseSegmentOpenPath(line, out var openedPath)) + { + await HandleSegmentOpenedAsync(runtime, openedPath); + return; + } + + if (!runtime.HasOpenedFirstSegment && IsOptionCompatibilityFailureLine(line)) + { + runtime.MarkStartupFailure(StartupFailureKind.InputOptionCompatibility, line); + } + else if (!runtime.HasOpenedFirstSegment && IsRetryableStartupFailureLine(line)) + { + runtime.MarkStartupFailure(StartupFailureKind.StreamHandshake, line); + } + + if (line.Contains("error", StringComparison.OrdinalIgnoreCase) || + line.Contains("fail", StringComparison.OrdinalIgnoreCase) || + line.Contains("timed out", StringComparison.OrdinalIgnoreCase)) + { + await PersistFfmpegLineAsync(runtime, line, isError ? SystemLogLevel.Error : SystemLogLevel.Warning); + } + } + + private async Task PersistFfmpegLineAsync(SessionProcessRuntime runtime, string line, SystemLogLevel level) + { + try + { + using var scope = _serviceScopeFactory.CreateScope(); + var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>(); + await logService.WriteAsync( + level, + "FFmpeg", + "ffmpeg reported a warning or error line.", + line, + runtime.LiveRoomId, + runtime.RecordSessionId, + runtime.CurrentTaskId); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Persist ffmpeg output failed for session {RecordSessionId}", runtime.RecordSessionId); + } + } + + private async Task HandleSegmentOpenedAsync(SessionProcessRuntime runtime, string openedPath) + { + await runtime.Gate.WaitAsync(); + try + { + using var scope = _serviceScopeFactory.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>(); + var session = await dbContext.RecordSessions + .Include(item => item.RecordTasks) + .ThenInclude(item => item.Result) + .FirstOrDefaultAsync(item => item.Id == runtime.RecordSessionId); + + if (session is null) + { + return; + } + + var now = DateTimeOffset.UtcNow; + var currentTask = session.RecordTasks.FirstOrDefault(item => item.Id == runtime.CurrentTaskId); + if (currentTask is null) + { + return; + } + + if (runtime.SaveMode == RecordSaveMode.SingleFile) + { + runtime.HasOpenedFirstSegment = true; + currentTask.AttachProcess(runtime.ProcessId, now); + currentTask.MarkRunning(now); + session.AttachProcess(runtime.ProcessId, now); + session.MarkRunning(now); + session.ActivateSegment(1, now); + if (!runtime.HasInitializedDanmaku) + { + await EnsureDanmakuSegmentAsync(runtime, currentTask, now); + runtime.HasInitializedDanmaku = true; + } + + await dbContext.SaveChangesAsync(); + return; + } + + var segmentIndex = string.Equals(openedPath, runtime.CurrentOutputFilePath, StringComparison.OrdinalIgnoreCase) + ? Math.Max(1, runtime.CurrentSegmentIndex) + : Math.Max(1, runtime.CurrentSegmentIndex + 1); + if (!runtime.HasInitializedDanmaku) + { + await EnsureDanmakuSegmentAsync(runtime, currentTask, now); + runtime.HasInitializedDanmaku = true; + } + + if (segmentIndex == runtime.CurrentSegmentIndex) + { + runtime.HasOpenedFirstSegment = true; + currentTask.MarkStarting(runtime.StreamUrl, openedPath, currentTask.StartedAt ?? now); + currentTask.AttachProcess(runtime.ProcessId, now); + currentTask.MarkRunning(now); + session.AttachProcess(runtime.ProcessId, now); + session.MarkRunning(now); + session.ActivateSegment(segmentIndex, now); + runtime.CurrentOutputFilePath = openedPath; + await dbContext.SaveChangesAsync(); + return; + } + + var previousTask = currentTask; + var previousTaskId = previousTask.Id; + var previousOutputPath = runtime.CurrentOutputFilePath; + + var newTask = new RecordTask( + session.LiveRoomId, + session.Id, + segmentIndex, + session.PreferredQuality, + session.OutputFormat, + now); + newTask.MarkStarting(runtime.StreamUrl, openedPath, now); + newTask.AttachProcess(runtime.ProcessId, now); + newTask.MarkRunning(now); + await dbContext.RecordTasks.AddAsync(newTask); + + previousTask.MarkCompleted( + now, + previousTask.StartedAt.HasValue ? Math.Max(0, (now - previousTask.StartedAt.Value).TotalSeconds) : null); + previousTask.DetachProcess(now); + + session.AttachProcess(runtime.ProcessId, now); + session.MarkRunning(now); + session.ActivateSegment(segmentIndex, now); + + await dbContext.SaveChangesAsync(); + + if (runtime.DanmakuRecorder is not null) + { + await runtime.DanmakuRecorder.StartSegmentAsync(newTask.Id, segmentIndex, openedPath, now); + } + + var previousDanmakuSummary = runtime.DanmakuRecorder?.TakeSummary(previousTaskId); + UpsertRecordResult( + previousTask, + dbContext, + previousOutputPath, + CalculateFileSize(previousOutputPath), + previousTask.DurationSeconds, + previousDanmakuSummary?.FilePath, + previousDanmakuSummary?.MessageCount ?? 0, + now); + + await dbContext.SaveChangesAsync(); + + runtime.CurrentTaskId = newTask.Id; + runtime.CurrentSegmentIndex = segmentIndex; + runtime.CurrentOutputFilePath = openedPath; + runtime.HasOpenedFirstSegment = true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Handle segment open failed for session {RecordSessionId}", runtime.RecordSessionId); + } + finally + { + runtime.Gate.Release(); + } + } + + private async Task HandleProcessExitedAsync(SessionProcessRuntime runtime, Process process) + { + _processes.TryRemove(runtime.RecordSessionId, out _); + + try + { + runtime.DanmakuCancellation.Cancel(); + if (runtime.DanmakuPumpTask is not null) + { + try + { + await runtime.DanmakuPumpTask; + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Danmaku pump ended with error for session {RecordSessionId}", runtime.RecordSessionId); + } + } + + SessionDanmakuXmlRecorder.DanmakuSegmentSummary? activeDanmakuSummary = null; + if (runtime.DanmakuRecorder is not null) + { + activeDanmakuSummary = await runtime.DanmakuRecorder.CompleteActiveSegmentAsync(); + await runtime.DanmakuRecorder.DisposeAsync(); + } + + if (runtime.DanmakuConnection is not null) + { + await runtime.DanmakuConnection.DisposeAsync(); + } + + if (await TryRecoverStartupFailureAsync(runtime)) + { + return; + } + + await FinalizeExitedSessionAsync(runtime, process, activeDanmakuSummary); + } + catch (Exception ex) + { + _logger.LogError(ex, "Handle ffmpeg exit failed for session {RecordSessionId}", runtime.RecordSessionId); + } + finally + { + runtime.ExitCompletion.TrySetResult(true); + runtime.Dispose(); + process.Dispose(); + } + } + + private async Task<bool> TryRecoverStartupFailureAsync(SessionProcessRuntime runtime) + { + if (runtime.HasOpenedFirstSegment || + runtime.StartupFailureKind == StartupFailureKind.None || + string.IsNullOrWhiteSpace(runtime.LastStartupFailureLine)) + { + return false; + } + + try + { + using var scope = _serviceScopeFactory.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>(); + var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>(); + + var session = await dbContext.RecordSessions + .Include(item => item.LiveRoom) + .Include(item => item.RecordTasks) + .ThenInclude(item => item.Result) + .FirstOrDefaultAsync(item => item.Id == runtime.RecordSessionId); + + if (session?.LiveRoom is null || !IsActiveSessionStatus(session.Status)) + { + return false; + } + + var currentTask = session.RecordTasks + .OrderBy(item => item.SegmentIndex) + .ThenBy(item => item.CreatedAt) + .FirstOrDefault(item => item.Id == runtime.CurrentTaskId); + + if (currentTask is null || !IsActiveTaskStatus(currentTask.Status)) + { + return false; + } + + var observedAt = DateTimeOffset.UtcNow; + if (runtime.StartupFailureKind == StartupFailureKind.InputOptionCompatibility) + { + if (runtime.HasRetriedWithCompatibilityProfile) + { + return false; + } + + await logService.WriteAsync( + SystemLogLevel.Warning, + "FFmpeg", + "Initial ffmpeg input profile was rejected. Retrying once with minimal compatibility options.", + runtime.LastStartupFailureLine, + session.LiveRoomId, + session.Id, + currentTask.Id); + + var retryStream = new StreamUrlResult( + runtime.SelectedQuality, + runtime.SelectedProtocol, + runtime.StreamUrl, + runtime.InputHeaders, + Array.Empty<StreamQualityOption>()); + + session.MarkStarting(retryStream.SelectedUrl, session.OutputPathPattern ?? runtime.OutputPathPattern, observedAt); + session.ActivateSegment(Math.Max(1, runtime.CurrentSegmentIndex), observedAt); + currentTask.MarkStarting(retryStream.SelectedUrl, currentTask.OutputFilePath ?? runtime.CurrentOutputFilePath, observedAt); + await dbContext.SaveChangesAsync(); + + await StartInternalAsync( + session, + currentTask, + retryStream, + FfmpegInputOptionProfile.Minimal, + hasRetriedWithCompatibilityProfile: true, + hasRetriedWithRefreshedStream: runtime.HasRetriedWithRefreshedStream, + runtime.RetryAttemptCount + 1); + + var restartedAt = DateTimeOffset.UtcNow; + session.MarkRunning(restartedAt); + currentTask.MarkRunning(restartedAt); + await dbContext.SaveChangesAsync(); + + await logService.WriteAsync( + SystemLogLevel.Info, + "FFmpeg", + "ffmpeg startup retry succeeded with minimal compatibility options.", + liveRoomId: session.LiveRoomId, + recordSessionId: session.Id, + recordTaskId: currentTask.Id); + + return true; + } + + if (runtime.StartupFailureKind != StartupFailureKind.StreamHandshake || runtime.HasRetriedWithRefreshedStream) + { + return false; + } + + var adapterFactory = scope.ServiceProvider.GetRequiredService<ILivePlatformAdapterFactory>(); + var liveRoomStatusService = scope.ServiceProvider.GetRequiredService<LiveRoomStatusService>(); + var adapter = adapterFactory.GetByPlatform(session.LiveRoom.Platform); + var liveStatus = await adapter.GetLiveStatusAsync(session.LiveRoom.RoomId); + await liveRoomStatusService.ApplySnapshotAsync(session.LiveRoom, liveStatus, observedAt); + await dbContext.SaveChangesAsync(); + + if (!liveStatus.IsLive) + { + return false; + } + + await logService.WriteAsync( + SystemLogLevel.Warning, + "FFmpeg", + "Initial ffmpeg input handshake failed. Refreshing stream URL and retrying once.", + runtime.LastStartupFailureLine, + session.LiveRoomId, + session.Id, + currentTask.Id); + + var refreshedStream = await adapter.GetStreamUrlAsync(session.LiveRoom.RoomId, session.PreferredQuality); + session.MarkStarting(refreshedStream.SelectedUrl, session.OutputPathPattern ?? runtime.OutputPathPattern, observedAt); + session.ActivateSegment(Math.Max(1, runtime.CurrentSegmentIndex), observedAt); + currentTask.MarkStarting(refreshedStream.SelectedUrl, currentTask.OutputFilePath ?? runtime.CurrentOutputFilePath, observedAt); + await dbContext.SaveChangesAsync(); + + await StartInternalAsync( + session, + currentTask, + refreshedStream, + runtime.InputOptionProfile, + hasRetriedWithCompatibilityProfile: runtime.HasRetriedWithCompatibilityProfile, + hasRetriedWithRefreshedStream: true, + runtime.RetryAttemptCount + 1); + + var refreshedRetryStartedAt = DateTimeOffset.UtcNow; + session.MarkRunning(refreshedRetryStartedAt); + currentTask.MarkRunning(refreshedRetryStartedAt); + await dbContext.SaveChangesAsync(); + + await logService.WriteAsync( + SystemLogLevel.Info, + "FFmpeg", + "ffmpeg startup retry succeeded with a refreshed stream URL.", + liveRoomId: session.LiveRoomId, + recordSessionId: session.Id, + recordTaskId: currentTask.Id); + + return true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Recover ffmpeg startup failure failed for session {RecordSessionId}", runtime.RecordSessionId); + return false; + } + } + + private async Task FinalizeExitedSessionAsync( + SessionProcessRuntime runtime, + Process process, + SessionDanmakuXmlRecorder.DanmakuSegmentSummary? activeDanmakuSummary) + { + using var scope = _serviceScopeFactory.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>(); + var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>(); + var settingsService = scope.ServiceProvider.GetRequiredService<ISystemSettingsService>(); + var emailNotificationService = scope.ServiceProvider.GetRequiredService<IEmailNotificationService>(); + var settings = await settingsService.GetAsync(); + + var session = await dbContext.RecordSessions + .Include(item => item.LiveRoom) + .Include(item => item.RecordTasks) + .ThenInclude(item => item.Result) + .FirstOrDefaultAsync(item => item.Id == runtime.RecordSessionId); + + if (session is null) + { + return; + } + + var currentTask = session.RecordTasks.FirstOrDefault(item => item.Id == runtime.CurrentTaskId) + ?? session.RecordTasks.OrderByDescending(static item => item.SegmentIndex).FirstOrDefault(); + if (currentTask is null) + { + return; + } + + var endedAt = DateTimeOffset.UtcNow; + string? finalizationError = null; + var effectiveOutputPath = runtime.CurrentOutputFilePath; + + if (session.SaveMode == RecordSaveMode.SingleFile && + session.OutputFormat == RecordOutputFormat.Mp4 && + !string.IsNullOrWhiteSpace(session.OutputPathPattern)) + { + var finalOutputPath = Path.IsPathRooted(session.OutputPathPattern) + ? session.OutputPathPattern + : Path.GetFullPath(session.OutputPathPattern, AppContext.BaseDirectory); + var finalizationResult = await TryFinalizeMp4Async(settings.FfmpegPath, runtime.RecorderOutputPath, finalOutputPath); + effectiveOutputPath = finalizationResult.OutputPath; + finalizationError = finalizationResult.ErrorMessage; + } + + var fileSize = CalculateFileSize(effectiveOutputPath); + var durationSeconds = currentTask.StartedAt.HasValue + ? (double?)Math.Max(0, (endedAt - currentTask.StartedAt.Value).TotalSeconds) + : null; + var danmakuPath = activeDanmakuSummary?.FilePath ?? currentTask.Result?.DanmakuFilePath ?? GuessDanmakuPath(currentTask.OutputFilePath); + var danmakuMessageCount = activeDanmakuSummary?.MessageCount ?? CountDanmakuMessages(danmakuPath); + + if (!string.IsNullOrWhiteSpace(finalizationError)) + { + currentTask.MarkFailed(finalizationError, endedAt); + session.MarkFailed(finalizationError, endedAt); + } + else if (runtime.StopRequested) + { + currentTask.MarkStopped(endedAt, durationSeconds); + session.MarkStopped(endedAt); + } + else if (process.ExitCode == 0 && (runtime.CompletionRequested || !runtime.StopRequested)) + { + currentTask.MarkCompleted(endedAt, durationSeconds); + session.MarkCompleted(endedAt); + } + else + { + var errorMessage = $"ffmpeg exit code: {process.ExitCode}"; + currentTask.MarkFailed(errorMessage, endedAt); + session.MarkFailed(errorMessage, endedAt); + } + + currentTask.DetachProcess(endedAt); + session.SyncSegmentCount(session.RecordTasks.Count, endedAt); + UpsertRecordResult(currentTask, dbContext, effectiveOutputPath, fileSize, durationSeconds, danmakuPath, danmakuMessageCount, endedAt); + + await dbContext.SaveChangesAsync(); + + await logService.WriteAsync( + session.Status == RecordSessionStatus.Completed ? SystemLogLevel.Info : + session.Status == RecordSessionStatus.Stopped ? SystemLogLevel.Warning : + SystemLogLevel.Error, + "FFmpeg", + $"Recording session exited with status={session.Status}.", + $"exitCode={process.ExitCode}; output={effectiveOutputPath}; recorderOutput={runtime.RecorderOutputPath}", + session.LiveRoomId, + session.Id, + currentTask.Id); + + if (session.Status == RecordSessionStatus.Failed && session.LiveRoom is not null) + { + await emailNotificationService.SendExceptionAsync( + "FFmpeg", + "Recording session exited abnormally.", + $"exitCode={process.ExitCode}; output={effectiveOutputPath}", + session.LiveRoom, + currentTask); + } + } + + private async Task PersistProcessBindingAsync(Guid recordSessionId, Guid recordTaskId, int processId, CancellationToken cancellationToken) + { + using var scope = _serviceScopeFactory.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>(); + var session = await dbContext.RecordSessions.FirstOrDefaultAsync(item => item.Id == recordSessionId, cancellationToken); + var task = await dbContext.RecordTasks.FirstOrDefaultAsync(item => item.Id == recordTaskId, cancellationToken); + + var now = DateTimeOffset.UtcNow; + session?.AttachProcess(processId, now); + task?.AttachProcess(processId, now); + if (session is not null || task is not null) + { + await dbContext.SaveChangesAsync(cancellationToken); + } + } + + private async Task StartDanmakuAsync(SessionProcessRuntime runtime, RecordTask initialTask, CancellationToken cancellationToken) + { + try + { + LiveRoom? liveRoom = null; + var includeNonChatEvents = true; + + using (var readScope = _serviceScopeFactory.CreateScope()) + { + var dbContext = readScope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>(); + var adapterFactory = readScope.ServiceProvider.GetRequiredService<ILiveDanmakuAdapterFactory>(); + var settingsService = readScope.ServiceProvider.GetRequiredService<ISystemSettingsService>(); + var settings = await settingsService.GetAsync(cancellationToken); + + if (!settings.EnableDanmakuRecording) + { + return; + } + + liveRoom = await dbContext.LiveRooms + .AsNoTracking() + .FirstOrDefaultAsync(item => item.Id == runtime.LiveRoomId, cancellationToken); + if (liveRoom is null) + { + return; + } + + includeNonChatEvents = settings.DanmakuIncludeNonChatEvents; + var adapter = adapterFactory.GetByPlatform(liveRoom.Platform); + runtime.DanmakuConnection = await adapter.ConnectAsync( + new DanmakuConnectionContext( + liveRoom.Id, + runtime.RecordSessionId, + liveRoom.Platform, + liveRoom.RoomId, + liveRoom.AnchorName, + liveRoom.Title, + liveRoom.SourceUrl, + settings.DanmakuMinPollIntervalMilliseconds, + settings.DanmakuRetryDelayMaxSeconds), + cancellationToken); + } + + runtime.DanmakuRecorder = new SessionDanmakuXmlRecorder( + liveRoom.Platform, + liveRoom.Id, + runtime.RecordSessionId, + liveRoom.RoomId); + await runtime.DanmakuRecorder.StartSegmentAsync( + initialTask.Id, + initialTask.SegmentIndex, + initialTask.OutputFilePath ?? runtime.OutputPathPattern, + initialTask.StartedAt ?? DateTimeOffset.UtcNow); + runtime.HasInitializedDanmaku = true; + + runtime.DanmakuPumpTask = Task.Run( + () => runtime.DanmakuConnection.StartAsync( + danmakuEvent => + { + if (runtime.DanmakuRecorder is null) + { + return Task.CompletedTask; + } + + if (!includeNonChatEvents && + !string.Equals(danmakuEvent.Type, "chat", StringComparison.OrdinalIgnoreCase)) + { + return Task.CompletedTask; + } + + return runtime.DanmakuRecorder.AppendAsync(danmakuEvent); + }, + runtime.DanmakuCancellation.Token), + runtime.DanmakuCancellation.Token); + + using var logScope = _serviceScopeFactory.CreateScope(); + var logService = logScope.ServiceProvider.GetRequiredService<ISystemLogService>(); + await logService.WriteAsync( + SystemLogLevel.Info, + "Danmaku", + "Danmaku capture started for the recording session.", + liveRoomId: liveRoom.Id, + recordSessionId: runtime.RecordSessionId, + recordTaskId: initialTask.Id, + cancellationToken: cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Start danmaku capture failed for session {RecordSessionId}", runtime.RecordSessionId); + + try + { + using var scope = _serviceScopeFactory.CreateScope(); + var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>(); + await logService.WriteAsync( + SystemLogLevel.Warning, + "Danmaku", + "Danmaku capture startup failed. Recording will continue without live comments until retry succeeds.", + ex.ToString(), + runtime.LiveRoomId, + runtime.RecordSessionId, + runtime.CurrentTaskId, + cancellationToken); + } + catch (Exception logEx) + { + _logger.LogWarning(logEx, "Persist danmaku startup failure log failed for session {RecordSessionId}", runtime.RecordSessionId); + } + } + } + + private static bool IsRetryableStartupFailureLine(string line) => + line.Contains("Error reading HTTP response", StringComparison.OrdinalIgnoreCase) || + line.Contains("unexpected EOF", StringComparison.OrdinalIgnoreCase) || + line.Contains("Connection reset", StringComparison.OrdinalIgnoreCase) || + line.Contains("Connection refused", StringComparison.OrdinalIgnoreCase) || + line.Contains("I/O error", StringComparison.OrdinalIgnoreCase) || + line.Contains("Invalid data found when processing input", StringComparison.OrdinalIgnoreCase) || + line.Contains("Server returned 4", StringComparison.OrdinalIgnoreCase) || + line.Contains("Server returned 5", StringComparison.OrdinalIgnoreCase) || + line.Contains("HTTP error 4", StringComparison.OrdinalIgnoreCase) || + line.Contains("HTTP error 5", StringComparison.OrdinalIgnoreCase); + + private static bool IsOptionCompatibilityFailureLine(string line) => + line.Contains("Option not found", StringComparison.OrdinalIgnoreCase) || + line.Contains("Unrecognized option", StringComparison.OrdinalIgnoreCase); + + private async Task EnsureDanmakuSegmentAsync(SessionProcessRuntime runtime, RecordTask recordTask, DateTimeOffset startedAt) + { + if (runtime.DanmakuRecorder is null) + { + return; + } + + await runtime.DanmakuRecorder.StartSegmentAsync( + recordTask.Id, + recordTask.SegmentIndex, + recordTask.OutputFilePath ?? runtime.CurrentOutputFilePath, + startedAt); + } + + private sealed class SessionProcessRuntime : IDisposable + { + public SessionProcessRuntime( + Guid recordSessionId, + Guid liveRoomId, + string streamUrl, + string outputPathPattern, + string recorderOutputPath, + RecordOutputFormat outputFormat, + RecordSaveMode saveMode, + Guid currentTaskId, + int currentSegmentIndex, + string currentOutputFilePath, + string selectedQuality, + string selectedProtocol, + StreamInputHeaders? inputHeaders, + FfmpegInputOptionProfile inputOptionProfile, + bool hasRetriedWithCompatibilityProfile, + bool hasRetriedWithRefreshedStream, + int retryAttemptCount) + { + RecordSessionId = recordSessionId; + LiveRoomId = liveRoomId; + StreamUrl = streamUrl; + OutputPathPattern = outputPathPattern; + RecorderOutputPath = recorderOutputPath; + OutputFormat = outputFormat; + SaveMode = saveMode; + CurrentTaskId = currentTaskId; + CurrentSegmentIndex = currentSegmentIndex; + CurrentOutputFilePath = currentOutputFilePath; + SelectedQuality = selectedQuality; + SelectedProtocol = selectedProtocol; + InputHeaders = inputHeaders; + InputOptionProfile = inputOptionProfile; + HasRetriedWithCompatibilityProfile = hasRetriedWithCompatibilityProfile; + HasRetriedWithRefreshedStream = hasRetriedWithRefreshedStream; + RetryAttemptCount = Math.Max(0, retryAttemptCount); + } + + public Guid RecordSessionId { get; } + public Guid LiveRoomId { get; } + public string StreamUrl { get; } + public string OutputPathPattern { get; } + public string RecorderOutputPath { get; } + public RecordOutputFormat OutputFormat { get; } + public RecordSaveMode SaveMode { get; } + public Guid CurrentTaskId { get; set; } + public int CurrentSegmentIndex { get; set; } + public string CurrentOutputFilePath { get; set; } + public string SelectedQuality { get; } + public string SelectedProtocol { get; } + public StreamInputHeaders? InputHeaders { get; } + public FfmpegInputOptionProfile InputOptionProfile { get; } + public bool HasRetriedWithCompatibilityProfile { get; } + public bool HasRetriedWithRefreshedStream { get; } + public Process? Process { get; private set; } + public int ProcessId => Process?.Id ?? 0; + public bool CompletionRequested { get; private set; } + public bool StopRequested { get; private set; } + public bool HasInitializedDanmaku { get; set; } + public bool HasOpenedFirstSegment { get; set; } + public int RetryAttemptCount { get; } + public StartupFailureKind StartupFailureKind { get; private set; } + public string? LastStartupFailureLine { get; private set; } + public SemaphoreSlim Gate { get; } = new(1, 1); + public CancellationTokenSource DanmakuCancellation { get; } = new(); + public TaskCompletionSource<bool> ExitCompletion { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public SessionDanmakuXmlRecorder? DanmakuRecorder { get; set; } + public ILiveDanmakuConnection? DanmakuConnection { get; set; } + public Task? DanmakuPumpTask { get; set; } + + public void AttachProcess(Process process) => Process = process; + + public void MarkStopRequested(bool markAsCompletedOnExit) + { + if (markAsCompletedOnExit) + { + CompletionRequested = true; + StopRequested = false; + } + else + { + CompletionRequested = false; + StopRequested = true; + } + } + + public void MarkStartupFailure(StartupFailureKind kind, string line) + { + if (StartupFailureKind == StartupFailureKind.InputOptionCompatibility && + kind != StartupFailureKind.InputOptionCompatibility) + { + return; + } + + StartupFailureKind = kind; + LastStartupFailureLine = line; + } + + public void Dispose() + { + DanmakuCancellation.Dispose(); + Gate.Dispose(); + } + } +} diff --git a/src/LiveRecorder.Infrastructure/Services/FfmpegService.Utils.cs b/src/LiveRecorder.Infrastructure/Services/FfmpegService.Utils.cs new file mode 100644 index 0000000..3266e6a --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Services/FfmpegService.Utils.cs @@ -0,0 +1,399 @@ +using System.Diagnostics; +using System.Text; +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Domain.Entities; +using LiveRecorder.Domain.Enums; +using LiveRecorder.Infrastructure.Persistence; + +namespace LiveRecorder.Infrastructure.Services; + +public sealed partial class FfmpegService +{ + private static async Task<(string OutputPath, string? ErrorMessage)> TryFinalizeMp4Async( + string ffmpegPath, + string sourcePath, + string targetPath) + { + if (!File.Exists(sourcePath)) + { + return (File.Exists(targetPath) ? targetPath : sourcePath, "The intermediate recording file was not found for MP4 finalization."); + } + + var tempPath = Path.Combine( + Path.GetDirectoryName(targetPath)!, + $"{Path.GetFileNameWithoutExtension(targetPath)}.remux{Path.GetExtension(targetPath)}"); + + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + + var remuxProcess = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = ffmpegPath, + Arguments = $"-hide_banner -y -i {Quote(sourcePath)} -c copy -movflags +faststart {Quote(tempPath)}", + UseShellExecute = false, + RedirectStandardError = true, + RedirectStandardOutput = true, + CreateNoWindow = true + } + }; + + remuxProcess.Start(); + await remuxProcess.WaitForExitAsync(); + + if (remuxProcess.ExitCode == 0 && File.Exists(tempPath)) + { + if (File.Exists(targetPath)) + { + File.Replace(tempPath, targetPath, null, ignoreMetadataErrors: true); + } + else + { + File.Move(tempPath, targetPath); + } + + if (!string.Equals(sourcePath, targetPath, StringComparison.OrdinalIgnoreCase) && File.Exists(sourcePath)) + { + File.Delete(sourcePath); + } + + return (targetPath, null); + } + + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + + var fallbackPath = File.Exists(targetPath) ? targetPath : sourcePath; + return (fallbackPath, "The MP4 file could not be finalized into a seekable output."); + } + + private static IReadOnlyList<string> BuildArgumentList( + string streamUrl, + string outputFilePath, + RecordOutputFormat outputFormat, + RecordSaveMode saveMode, + RecordingTemplateType recordingTemplate, + bool enableReconnect, + int reconnectDelayMaxSeconds, + int readWriteTimeoutMilliseconds, + int segmentDurationMinutes, + StreamInputHeaders? inputHeaders, + FfmpegInputOptionProfile inputOptionProfile) + { + var arguments = new List<string> { "-hide_banner", "-y" }; + var useIntermediateTransportStream = ShouldUseIntermediateTransportStream(outputFilePath, outputFormat, saveMode); + + if (IsHttpInput(streamUrl)) + { + if (!string.IsNullOrWhiteSpace(inputHeaders?.UserAgent)) + { + arguments.AddRange(["-user_agent", inputHeaders.UserAgent]); + } + + if (!string.IsNullOrWhiteSpace(inputHeaders?.Referer)) + { + arguments.AddRange(["-referer", inputHeaders.Referer]); + } + + var customHeaders = BuildCustomHeaderArgument(inputHeaders); + if (!string.IsNullOrWhiteSpace(customHeaders)) + { + arguments.AddRange(["-headers", customHeaders]); + } + } + + if (enableReconnect && inputOptionProfile == FfmpegInputOptionProfile.Baseline) + { + arguments.AddRange( + [ + "-reconnect", "1", + "-reconnect_streamed", "1", + "-reconnect_at_eof", "1", + "-reconnect_delay_max", reconnectDelayMaxSeconds.ToString() + ]); + } + + arguments.AddRange(["-rw_timeout", readWriteTimeoutMilliseconds.ToString(), "-fflags", "+discardcorrupt+genpts", "-i", streamUrl]); + arguments.AddRange(BuildCodecArguments(recordingTemplate)); + + if (saveMode == RecordSaveMode.Segmented) + { + arguments.AddRange( + [ + "-f", "segment", + "-segment_start_number", "1", + "-segment_time", Math.Max(60, segmentDurationMinutes * 60).ToString(), + "-reset_timestamps", "1", + "-strftime", "0", + "-segment_format", outputFormat == RecordOutputFormat.Ts ? "mpegts" : "mp4" + ]); + + if (outputFormat == RecordOutputFormat.Mp4) + { + arguments.AddRange(["-segment_format_options", $"movflags={BuildSegmentedMp4MovFlags()}"]); + } + } + else if (useIntermediateTransportStream) + { + arguments.AddRange(["-f", "mpegts"]); + } + else if (outputFormat == RecordOutputFormat.Mp4) + { + arguments.AddRange(["-movflags", BuildSingleFileMp4MovFlags()]); + } + + arguments.Add(outputFilePath); + return arguments; + } + + private static IReadOnlyList<string> BuildCodecArguments(RecordingTemplateType recordingTemplate) => + recordingTemplate switch + { + RecordingTemplateType.BalancedMp4 => + [ + "-c:v", "libx264", + "-preset", "veryfast", + "-crf", "23", + "-c:a", "aac", + "-b:a", "128k" + ], + RecordingTemplateType.ArchiveTs => + [ + "-map", "0", + "-c", "copy" + ], + _ => + [ + "-c", "copy" + ] + }; + + private static long? CalculateFileSize(string? outputPath) + { + if (string.IsNullOrWhiteSpace(outputPath)) + { + return null; + } + + if (File.Exists(outputPath)) + { + return new FileInfo(outputPath).Length; + } + + if (Directory.Exists(outputPath)) + { + return new DirectoryInfo(outputPath) + .EnumerateFiles("*", SearchOption.TopDirectoryOnly) + .Sum(static file => file.Length); + } + + return null; + } + + private static bool HasUsableOutput(string? outputPath, long? fileSize) + { + if (string.IsNullOrWhiteSpace(outputPath)) + { + return false; + } + + if (File.Exists(outputPath)) + { + return fileSize.GetValueOrDefault() > 0; + } + + if (Directory.Exists(outputPath)) + { + return Directory.EnumerateFiles(outputPath, "*", SearchOption.TopDirectoryOnly).Any(); + } + + return false; + } + + private static void UpsertRecordResult( + RecordTask recordTask, + LiveRecorderDbContext dbContext, + string? effectiveOutputPath, + long? fileSize, + double? durationSeconds, + string? danmakuFilePath, + int danmakuMessageCount, + DateTimeOffset endedAt) + { + if (string.IsNullOrWhiteSpace(effectiveOutputPath)) + { + effectiveOutputPath = recordTask.OutputFilePath ?? string.Empty; + } + + if (recordTask.Result is null) + { + dbContext.RecordResults.Add(new RecordResult( + recordTask.Id, + effectiveOutputPath, + fileSize, + durationSeconds, + danmakuFilePath, + danmakuMessageCount, + recordTask.Status, + recordTask.ErrorMessage, + endedAt)); + return; + } + + recordTask.Result.Update( + effectiveOutputPath, + fileSize, + durationSeconds, + danmakuFilePath, + danmakuMessageCount, + recordTask.Status, + recordTask.ErrorMessage); + } + + private static string BuildSingleFileMp4MovFlags() => + "+faststart+frag_keyframe+empty_moov+default_base_moof"; + + private static string BuildSegmentedMp4MovFlags() => + "+faststart+frag_keyframe+empty_moov+default_base_moof"; + + private static string GetRecorderOutputPath( + string finalOutputPath, + RecordOutputFormat outputFormat, + RecordSaveMode saveMode) + { + if (saveMode == RecordSaveMode.SingleFile && outputFormat == RecordOutputFormat.Mp4) + { + return Path.Combine( + Path.GetDirectoryName(finalOutputPath)!, + $"{Path.GetFileNameWithoutExtension(finalOutputPath)}.recording.ts"); + } + + return finalOutputPath; + } + + private static bool ShouldUseIntermediateTransportStream( + string outputPath, + RecordOutputFormat outputFormat, + RecordSaveMode saveMode) => + saveMode == RecordSaveMode.SingleFile && + outputFormat == RecordOutputFormat.Mp4 && + outputPath.EndsWith(".ts", StringComparison.OrdinalIgnoreCase); + + private static bool IsHttpInput(string streamUrl) => + streamUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + streamUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase); + + private static string? BuildCustomHeaderArgument(StreamInputHeaders? inputHeaders) + { + if (inputHeaders is null) + { + return null; + } + + var builder = new StringBuilder(); + if (!string.IsNullOrWhiteSpace(inputHeaders.Cookie)) + { + builder.Append("Cookie: "); + builder.Append(inputHeaders.Cookie.Trim()); + builder.Append("\r\n"); + } + + if (inputHeaders.AdditionalHeaders is not null) + { + foreach (var pair in inputHeaders.AdditionalHeaders.Where(static pair => !string.IsNullOrWhiteSpace(pair.Key))) + { + builder.Append(pair.Key.Trim()); + builder.Append(": "); + builder.Append(pair.Value?.Trim() ?? string.Empty); + builder.Append("\r\n"); + } + } + + return builder.Length == 0 ? null : builder.ToString(); + } + + private static bool TryParseSegmentOpenPath(string line, out string openedPath) + { + var match = SegmentOpeningRegex.Match(line); + if (match.Success) + { + openedPath = match.Groups[1].Value; + return true; + } + + openedPath = string.Empty; + return false; + } + + private static int? ExtractSegmentIndex(string openedPath) + { + var fileName = Path.GetFileNameWithoutExtension(openedPath); + var lastUnderscore = fileName.LastIndexOf('_'); + if (lastUnderscore < 0 || lastUnderscore == fileName.Length - 1) + { + return null; + } + + var suffix = fileName[(lastUnderscore + 1)..]; + return int.TryParse(suffix, out var value) ? value : null; + } + + private static string? GuessDanmakuPath(string? outputFilePath) + { + if (string.IsNullOrWhiteSpace(outputFilePath)) + { + return null; + } + + var fullPath = Path.IsPathRooted(outputFilePath) + ? outputFilePath + : Path.GetFullPath(outputFilePath, AppContext.BaseDirectory); + return SessionDanmakuXmlRecorder.GetDanmakuFilePath(fullPath); + } + + private static int CountDanmakuMessages(string? danmakuPath) + { + if (string.IsNullOrWhiteSpace(danmakuPath) || !File.Exists(danmakuPath)) + { + return 0; + } + + var count = 0; + foreach (var line in File.ReadLines(danmakuPath)) + { + if (line.Contains("<d ", StringComparison.OrdinalIgnoreCase) || + line.Contains("<event ", StringComparison.OrdinalIgnoreCase)) + { + count++; + } + } + + return count; + } + + private static bool IsActiveTaskStatus(RecordTaskStatus status) => + status is RecordTaskStatus.Starting or RecordTaskStatus.Running or RecordTaskStatus.Stopping; + + private static bool IsActiveSessionStatus(RecordSessionStatus status) => + status is RecordSessionStatus.Starting or RecordSessionStatus.Running or RecordSessionStatus.Stopping; + + private static string Quote(string value) => $"\"{value.Replace("\"", "\\\"", StringComparison.Ordinal)}\""; + + private enum FfmpegInputOptionProfile + { + Baseline = 0, + Minimal = 1 + } + + private enum StartupFailureKind + { + None = 0, + InputOptionCompatibility = 1, + StreamHandshake = 2 + } +} diff --git a/src/LiveRecorder.Infrastructure/Services/FfmpegService.cs b/src/LiveRecorder.Infrastructure/Services/FfmpegService.cs new file mode 100644 index 0000000..38923d1 --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Services/FfmpegService.cs @@ -0,0 +1,380 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Text.RegularExpressions; +using LiveRecorder.Application.Abstractions.Logging; +using LiveRecorder.Application.Abstractions.Notifications; +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Application.Abstractions.Recording; +using LiveRecorder.Application.Abstractions.Settings; +using LiveRecorder.Domain.Entities; +using LiveRecorder.Domain.Enums; +using LiveRecorder.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace LiveRecorder.Infrastructure.Services; + +public sealed partial class FfmpegService : IFfmpegService +{ + private static readonly Regex SegmentOpeningRegex = new( + """Opening '([^']+)' for writing""", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + + private readonly ConcurrentDictionary<Guid, SessionProcessRuntime> _processes = new(); + private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly ILogger<FfmpegService> _logger; + + public FfmpegService( + IServiceScopeFactory serviceScopeFactory, + ILogger<FfmpegService> logger) + { + _serviceScopeFactory = serviceScopeFactory; + _logger = logger; + } + + public bool IsRunning(Guid recordSessionId) => _processes.ContainsKey(recordSessionId); + + public Task CompleteAsync(Guid recordSessionId, CancellationToken cancellationToken = default) => + RequestStopAsync(recordSessionId, markAsCompletedOnExit: true, cancellationToken); + + public Task StartAsync( + RecordSession recordSession, + RecordTask initialTask, + StreamUrlResult streamUrlResult, + CancellationToken cancellationToken = default) => + StartInternalAsync( + recordSession, + initialTask, + streamUrlResult, + inputOptionProfile: FfmpegInputOptionProfile.Baseline, + hasRetriedWithCompatibilityProfile: false, + hasRetriedWithRefreshedStream: false, + retryAttemptCount: 0, + cancellationToken); + + private async Task StartInternalAsync( + RecordSession recordSession, + RecordTask initialTask, + StreamUrlResult streamUrlResult, + FfmpegInputOptionProfile inputOptionProfile, + bool hasRetriedWithCompatibilityProfile, + bool hasRetriedWithRefreshedStream, + int retryAttemptCount, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(recordSession); + ArgumentNullException.ThrowIfNull(initialTask); + ArgumentNullException.ThrowIfNull(streamUrlResult); + + if (string.IsNullOrWhiteSpace(streamUrlResult.SelectedUrl) || string.IsNullOrWhiteSpace(recordSession.OutputPathPattern)) + { + throw new InvalidOperationException("Recording session is missing stream URL or output path."); + } + + using var settingsScope = _serviceScopeFactory.CreateScope(); + var settingsService = settingsScope.ServiceProvider.GetRequiredService<ISystemSettingsService>(); + var settings = await settingsService.GetAsync(cancellationToken); + + var outputPathPattern = Path.IsPathRooted(recordSession.OutputPathPattern) + ? recordSession.OutputPathPattern + : Path.GetFullPath(recordSession.OutputPathPattern, AppContext.BaseDirectory); + var recorderOutputPath = GetRecorderOutputPath(outputPathPattern, recordSession.OutputFormat, recordSession.SaveMode); + Directory.CreateDirectory(Path.GetDirectoryName(recorderOutputPath)!); + + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = settings.FfmpegPath, + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }, + EnableRaisingEvents = true + }; + + var runtime = new SessionProcessRuntime( + recordSession.Id, + recordSession.LiveRoomId, + streamUrlResult.SelectedUrl, + outputPathPattern, + recorderOutputPath, + recordSession.OutputFormat, + recordSession.SaveMode, + initialTask.Id, + Math.Max(1, initialTask.SegmentIndex), + initialTask.OutputFilePath ?? outputPathPattern, + streamUrlResult.SelectedQuality, + streamUrlResult.SelectedProtocol, + streamUrlResult.InputHeaders, + inputOptionProfile, + hasRetriedWithCompatibilityProfile, + hasRetriedWithRefreshedStream, + retryAttemptCount); + + foreach (var argument in BuildArgumentList( + streamUrlResult.SelectedUrl, + recorderOutputPath, + recordSession.OutputFormat, + recordSession.SaveMode, + settings.RecordingTemplate, + settings.EnableAutoReconnect, + settings.ReconnectDelayMaxSeconds, + settings.ReadWriteTimeoutMilliseconds, + settings.SegmentDurationMinutes, + streamUrlResult.InputHeaders, + inputOptionProfile)) + { + process.StartInfo.ArgumentList.Add(argument); + } + + process.OutputDataReceived += (_, args) => _ = HandleProcessOutputAsync(runtime, args.Data, isError: false); + process.ErrorDataReceived += (_, args) => _ = HandleProcessOutputAsync(runtime, args.Data, isError: true); + process.Exited += (_, _) => _ = HandleProcessExitedAsync(runtime, process); + + if (!process.Start()) + { + throw new InvalidOperationException("ffmpeg failed to start."); + } + + runtime.AttachProcess(process); + if (!_processes.TryAdd(recordSession.Id, runtime)) + { + process.Kill(true); + process.Dispose(); + throw new InvalidOperationException("A running ffmpeg process already exists for the recording session."); + } + + await PersistProcessBindingAsync(recordSession.Id, initialTask.Id, process.Id, cancellationToken); + await PersistStartupProfileAsync(runtime, cancellationToken); + await StartDanmakuAsync(runtime, initialTask, cancellationToken); + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + } + + public Task StopAsync(Guid recordSessionId, CancellationToken cancellationToken = default) => + RequestStopAsync(recordSessionId, markAsCompletedOnExit: false, cancellationToken); + + public async Task<bool> StopAndWaitAsync( + Guid recordSessionId, + bool markAsCompletedOnExit, + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + if (!_processes.TryGetValue(recordSessionId, out var runtime)) + { + return true; + } + + await RequestStopAsync(recordSessionId, markAsCompletedOnExit, cancellationToken); + return await WaitForExitAsync(runtime, timeout, cancellationToken); + } + + public async Task<bool> KillAndWaitAsync( + Guid recordSessionId, + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + if (!_processes.TryGetValue(recordSessionId, out var runtime)) + { + return true; + } + + var process = runtime.Process; + if (process is not null) + { + try + { + if (!process.HasExited) + { + process.Kill(true); + } + } + catch (InvalidOperationException) + { + } + } + + return await WaitForExitAsync(runtime, timeout, cancellationToken); + } + + public async Task<bool> TryReconcileInactiveSessionAsync(Guid recordSessionId, CancellationToken cancellationToken = default) + { + if (IsRunning(recordSessionId)) + { + return false; + } + + using var scope = _serviceScopeFactory.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>(); + var settingsService = scope.ServiceProvider.GetRequiredService<ISystemSettingsService>(); + + var recordSession = await dbContext.RecordSessions + .Include(item => item.LiveRoom) + .Include(item => item.RecordTasks) + .ThenInclude(item => item.Result) + .FirstOrDefaultAsync(item => item.Id == recordSessionId, cancellationToken); + + if (recordSession is null || !IsActiveSessionStatus(recordSession.Status)) + { + return false; + } + + var settings = await settingsService.GetAsync(cancellationToken); + var endedAt = DateTimeOffset.UtcNow; + var tasks = recordSession.RecordTasks + .OrderBy(static item => item.SegmentIndex) + .ThenBy(static item => item.CreatedAt) + .ToList(); + + var anyUsableOutput = false; + string? finalizationError = null; + + foreach (var task in tasks.Where(item => IsActiveTaskStatus(item.Status))) + { + var effectiveOutputPath = task.OutputFilePath ?? string.Empty; + if (recordSession.SaveMode == RecordSaveMode.SingleFile && + recordSession.OutputFormat == RecordOutputFormat.Mp4 && + !string.IsNullOrWhiteSpace(recordSession.OutputPathPattern)) + { + var finalOutputPath = Path.IsPathRooted(recordSession.OutputPathPattern) + ? recordSession.OutputPathPattern + : Path.GetFullPath(recordSession.OutputPathPattern, AppContext.BaseDirectory); + var recorderOutputPath = GetRecorderOutputPath(finalOutputPath, recordSession.OutputFormat, recordSession.SaveMode); + if (File.Exists(recorderOutputPath)) + { + var finalizationResult = await TryFinalizeMp4Async(settings.FfmpegPath, recorderOutputPath, finalOutputPath); + effectiveOutputPath = finalizationResult.OutputPath; + finalizationError ??= finalizationResult.ErrorMessage; + } + } + + var fileSize = CalculateFileSize(effectiveOutputPath); + var danmakuPath = task.Result?.DanmakuFilePath ?? GuessDanmakuPath(task.OutputFilePath); + var danmakuCount = CountDanmakuMessages(danmakuPath); + var durationSeconds = task.StartedAt.HasValue + ? (double?)Math.Max(0, (endedAt - task.StartedAt.Value).TotalSeconds) + : null; + + if (!string.IsNullOrWhiteSpace(finalizationError)) + { + task.MarkFailed(finalizationError, endedAt); + } + else if (HasUsableOutput(effectiveOutputPath, fileSize)) + { + task.MarkCompleted(endedAt, durationSeconds); + anyUsableOutput = true; + } + else + { + task.MarkStopped(endedAt, durationSeconds, "Recording process was no longer running when the session was reconciled."); + } + + UpsertRecordResult(task, dbContext, effectiveOutputPath, fileSize, durationSeconds, danmakuPath, danmakuCount, endedAt); + } + + recordSession.SyncSegmentCount(tasks.Count, endedAt); + if (!string.IsNullOrWhiteSpace(finalizationError)) + { + recordSession.MarkFailed(finalizationError, endedAt); + } + else if (anyUsableOutput) + { + recordSession.MarkCompleted(endedAt); + } + else + { + recordSession.MarkStopped(endedAt, "Recording process was no longer running when the session was reconciled."); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return true; + } + + private async Task PersistStartupProfileAsync(SessionProcessRuntime runtime, CancellationToken cancellationToken) + { + using var scope = _serviceScopeFactory.CreateScope(); + var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>(); + await logService.WriteAsync( + SystemLogLevel.Info, + "FFmpeg", + $"ffmpeg input profile={runtime.InputOptionProfile}.", + detail: $"stream={runtime.SelectedProtocol}:{runtime.SelectedQuality}; compatibilityRetry={runtime.HasRetriedWithCompatibilityProfile}; refreshRetry={runtime.HasRetriedWithRefreshedStream}", + liveRoomId: runtime.LiveRoomId, + recordSessionId: runtime.RecordSessionId, + recordTaskId: runtime.CurrentTaskId, + cancellationToken: cancellationToken); + } + + private static async Task<bool> WaitForExitAsync( + SessionProcessRuntime runtime, + TimeSpan timeout, + CancellationToken cancellationToken) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var delayTask = Task.Delay(timeout, timeoutCts.Token); + var completed = await Task.WhenAny(runtime.ExitCompletion.Task, delayTask); + if (completed == runtime.ExitCompletion.Task) + { + timeoutCts.Cancel(); + await runtime.ExitCompletion.Task; + return true; + } + + return false; + } + + private async Task RequestStopAsync(Guid recordSessionId, bool markAsCompletedOnExit, CancellationToken cancellationToken) + { + if (!_processes.TryGetValue(recordSessionId, out var runtime)) + { + return; + } + + runtime.MarkStopRequested(markAsCompletedOnExit); + var process = runtime.Process; + if (process is null) + { + return; + } + + try + { + if (process.HasExited) + { + return; + } + + await process.StandardInput.WriteLineAsync("q"); + await process.StandardInput.FlushAsync(); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(12)); + + try + { + await process.WaitForExitAsync(timeoutCts.Token); + } + catch (OperationCanceledException) + { + if (!process.HasExited) + { + process.Kill(true); + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Stop ffmpeg process failed for session {RecordSessionId}", recordSessionId); + + if (!process.HasExited) + { + process.Kill(true); + } + } + } +} diff --git a/src/LiveRecorder.Infrastructure/Services/LiveRoomPollingBackgroundService.cs b/src/LiveRecorder.Infrastructure/Services/LiveRoomPollingBackgroundService.cs new file mode 100644 index 0000000..485582d --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Services/LiveRoomPollingBackgroundService.cs @@ -0,0 +1,243 @@ +using LiveRecorder.Application.Abstractions.Logging; +using LiveRecorder.Application.Abstractions.Notifications; +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Application.Abstractions.Recording; +using LiveRecorder.Application.Abstractions.Settings; +using LiveRecorder.Application.Models.RecordTasks; +using LiveRecorder.Application.Services; +using LiveRecorder.Domain.Enums; +using LiveRecorder.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace LiveRecorder.Infrastructure.Services; + +public sealed class LiveRoomPollingBackgroundService : BackgroundService +{ + private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly ILogger<LiveRoomPollingBackgroundService> _logger; + + public LiveRoomPollingBackgroundService( + IServiceScopeFactory serviceScopeFactory, + ILogger<LiveRoomPollingBackgroundService> logger) + { + _serviceScopeFactory = serviceScopeFactory; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + var delaySeconds = 60; + + try + { + using var scope = _serviceScopeFactory.CreateScope(); + var settingsService = scope.ServiceProvider.GetRequiredService<ISystemSettingsService>(); + var settings = await settingsService.GetAsync(stoppingToken); + var emailNotificationService = scope.ServiceProvider.GetRequiredService<IEmailNotificationService>(); + + delaySeconds = settings.PollingIntervalSeconds; + if (!settings.EnableBackgroundPolling) + { + await DelayAsync(delaySeconds, stoppingToken); + continue; + } + + var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>(); + var adapterFactory = scope.ServiceProvider.GetRequiredService<ILivePlatformAdapterFactory>(); + var ffmpegService = scope.ServiceProvider.GetRequiredService<IFfmpegService>(); + var recordService = scope.ServiceProvider.GetRequiredService<RecordService>(); + var liveRoomStatusService = scope.ServiceProvider.GetRequiredService<LiveRoomStatusService>(); + var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>(); + + var liveRooms = (await dbContext.LiveRooms.ToListAsync(stoppingToken)) + .Where(static item => item.IsEnabled) + .OrderBy(static item => item.UpdatedAt) + .ToList(); + + foreach (var liveRoom in liveRooms) + { + if (stoppingToken.IsCancellationRequested) + { + break; + } + + try + { + var adapter = adapterFactory.GetByPlatform(liveRoom.Platform); + var liveStatus = await adapter.GetLiveStatusAsync(liveRoom.RoomId, stoppingToken); + var now = DateTimeOffset.UtcNow; + + await liveRoomStatusService.ApplySnapshotAsync(liveRoom, liveStatus, now, stoppingToken); + + await dbContext.SaveChangesAsync(stoppingToken); + + if (!liveStatus.IsLive) + { + await CompleteActiveSessionsForOfflineRoomAsync( + dbContext, + ffmpegService, + logService, + liveRoom.Id, + stoppingToken); + continue; + } + + if (!settings.AutoStartRecordingOnLive) + { + continue; + } + + var hasRunningSession = await dbContext.RecordSessions.AnyAsync( + item => item.LiveRoomId == liveRoom.Id && + (item.Status == RecordSessionStatus.Starting || + item.Status == RecordSessionStatus.Running || + item.Status == RecordSessionStatus.Stopping), + stoppingToken); + + if (hasRunningSession) + { + continue; + } + + _logger.LogInformation("Auto-start recording for live room {RoomId}", liveRoom.RoomId); + await logService.WriteAsync( + SystemLogLevel.Info, + "Scheduler", + "Live detected by background poller. Auto-starting recording task.", + liveRoomId: liveRoom.Id, + cancellationToken: stoppingToken); + + await recordService.StartAsync( + new StartRecordTaskRequest + { + LiveRoomId = liveRoom.Id, + PreferredQuality = settings.DefaultQuality, + OutputFormat = settings.DefaultOutputFormat + }, + stoppingToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Polling room {RoomId} failed", liveRoom.RoomId); + + var isTransient = IsTransientPollingException(ex, stoppingToken); + await logService.WriteAsync( + isTransient ? SystemLogLevel.Warning : SystemLogLevel.Error, + "Scheduler", + isTransient + ? "Transient background polling failure. The room will be retried on the next cycle." + : "Background polling failed for a live room.", + ex.ToString(), + liveRoomId: liveRoom.Id, + cancellationToken: stoppingToken); + + if (!isTransient) + { + await emailNotificationService.SendExceptionAsync( + "Scheduler", + "Background polling failed for a live room.", + ex.ToString(), + liveRoom, + cancellationToken: stoppingToken); + } + } + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Background live room polling failed"); + + try + { + using var notificationScope = _serviceScopeFactory.CreateScope(); + var emailNotificationService = notificationScope.ServiceProvider.GetRequiredService<IEmailNotificationService>(); + await emailNotificationService.SendExceptionAsync( + "Scheduler", + "Background live room polling failed.", + ex.ToString(), + cancellationToken: stoppingToken); + } + catch (Exception notificationEx) + { + _logger.LogWarning(notificationEx, "Scheduler failure notification send failed"); + } + } + + await DelayAsync(delaySeconds, stoppingToken); + } + } + + private static Task DelayAsync(int delaySeconds, CancellationToken cancellationToken) => + Task.Delay(TimeSpan.FromSeconds(Math.Clamp(delaySeconds, 10, 3600)), cancellationToken); + + private static async Task CompleteActiveSessionsForOfflineRoomAsync( + LiveRecorderDbContext dbContext, + IFfmpegService ffmpegService, + ISystemLogService logService, + Guid liveRoomId, + CancellationToken cancellationToken) + { + var activeSessions = await dbContext.RecordSessions + .Where(item => item.LiveRoomId == liveRoomId && + (item.Status == RecordSessionStatus.Starting || + item.Status == RecordSessionStatus.Running || + item.Status == RecordSessionStatus.Stopping)) + .ToListAsync(cancellationToken); + + activeSessions = activeSessions + .OrderBy(item => item.CreatedAt) + .ToList(); + + foreach (var activeSession in activeSessions) + { + if (ffmpegService.IsRunning(activeSession.Id)) + { + await ffmpegService.CompleteAsync(activeSession.Id, cancellationToken); + await logService.WriteAsync( + SystemLogLevel.Info, + "Scheduler", + "Live room is offline. Completing the active recording session.", + liveRoomId: liveRoomId, + recordSessionId: activeSession.Id, + cancellationToken: cancellationToken); + continue; + } + + if (await ffmpegService.TryReconcileInactiveSessionAsync(activeSession.Id, cancellationToken)) + { + await logService.WriteAsync( + SystemLogLevel.Warning, + "Scheduler", + "Recovered a stale active recording session after the room was detected offline.", + liveRoomId: liveRoomId, + recordSessionId: activeSession.Id, + cancellationToken: cancellationToken); + } + } + } + + private static bool IsTransientPollingException(Exception exception, CancellationToken cancellationToken) + { + if (exception is OperationCanceledException && cancellationToken.IsCancellationRequested) + { + return false; + } + + if (exception is HttpRequestException or IOException or TimeoutException) + { + return true; + } + + return exception.InnerException is not null && + IsTransientPollingException(exception.InnerException, cancellationToken); + } +} diff --git a/src/LiveRecorder.Infrastructure/Services/RecordMediaService.cs b/src/LiveRecorder.Infrastructure/Services/RecordMediaService.cs new file mode 100644 index 0000000..63996c4 --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Services/RecordMediaService.cs @@ -0,0 +1,102 @@ +using System.Security.Cryptography; +using LiveRecorder.Application.Abstractions.Persistence; +using LiveRecorder.Application.Abstractions.Recording; +using LiveRecorder.Domain.Enums; +using Microsoft.Extensions.Caching.Memory; + +namespace LiveRecorder.Infrastructure.Services; + +public sealed class RecordMediaService : IRecordMediaService +{ + private static readonly TimeSpan PreviewTicketLifetime = TimeSpan.FromMinutes(5); + + private readonly IMemoryCache _memoryCache; + private readonly IRecordTaskRepository _recordTaskRepository; + private readonly IRecordResultRepository _recordResultRepository; + + public RecordMediaService( + IMemoryCache memoryCache, + IRecordTaskRepository recordTaskRepository, + IRecordResultRepository recordResultRepository) + { + _memoryCache = memoryCache; + _recordTaskRepository = recordTaskRepository; + _recordResultRepository = recordResultRepository; + } + + public async Task<RecordPreviewTicketGrant> CreatePreviewTicketAsync(Guid recordTaskId, CancellationToken cancellationToken = default) + { + var recordTask = await _recordTaskRepository.GetByIdAsync(recordTaskId, cancellationToken) + ?? throw new KeyNotFoundException("Recording task was not found."); + + if (recordTask.Status != RecordTaskStatus.Completed) + { + throw new InvalidOperationException("Preview is only available for completed tasks."); + } + + if (recordTask.OutputFormat != RecordOutputFormat.Mp4) + { + throw new NotSupportedException("Only MP4 recordings are supported for preview."); + } + + var recordResult = await _recordResultRepository.GetByTaskIdAsync(recordTaskId, cancellationToken); + var filePath = recordResult?.FilePath ?? recordTask.OutputFilePath; + if (string.IsNullOrWhiteSpace(filePath)) + { + throw new InvalidOperationException("Preview file path is missing."); + } + + var absoluteFilePath = Path.IsPathRooted(filePath) + ? filePath + : Path.GetFullPath(filePath, AppContext.BaseDirectory); + + if (Directory.Exists(absoluteFilePath)) + { + throw new NotSupportedException("Segmented recordings are not supported for preview."); + } + + if (!absoluteFilePath.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase)) + { + throw new NotSupportedException("Only MP4 recordings are supported for preview."); + } + + if (!File.Exists(absoluteFilePath)) + { + throw new InvalidOperationException("Preview file does not exist on disk."); + } + + var ticket = Convert.ToHexString(RandomNumberGenerator.GetBytes(24)); + var expiresAt = DateTimeOffset.UtcNow.Add(PreviewTicketLifetime); + + _memoryCache.Set( + GetCacheKey(ticket), + new PreviewTicketPayload(recordTaskId, absoluteFilePath, "video/mp4"), + expiresAt); + + return new RecordPreviewTicketGrant(ticket, expiresAt); + } + + public Task<RecordMediaFile?> ResolvePreviewAsync(string ticket, CancellationToken cancellationToken = default) + { + if (!_memoryCache.TryGetValue(GetCacheKey(ticket), out PreviewTicketPayload? payload) || payload is null) + { + return Task.FromResult<RecordMediaFile?>(null); + } + + if (!File.Exists(payload.FilePath)) + { + _memoryCache.Remove(GetCacheKey(ticket)); + return Task.FromResult<RecordMediaFile?>(null); + } + + return Task.FromResult<RecordMediaFile?>( + new RecordMediaFile(payload.RecordTaskId, payload.FilePath, payload.ContentType)); + } + + private static string GetCacheKey(string ticket) => $"record-preview:{ticket}"; + + private sealed record PreviewTicketPayload( + Guid RecordTaskId, + string FilePath, + string ContentType); +} diff --git a/src/LiveRecorder.Infrastructure/Services/SessionDanmakuXmlRecorder.cs b/src/LiveRecorder.Infrastructure/Services/SessionDanmakuXmlRecorder.cs new file mode 100644 index 0000000..945b1f4 --- /dev/null +++ b/src/LiveRecorder.Infrastructure/Services/SessionDanmakuXmlRecorder.cs @@ -0,0 +1,236 @@ +using System.Collections.Concurrent; +using System.Security; +using System.Text; +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Infrastructure.Services; + +internal sealed class SessionDanmakuXmlRecorder : IAsyncDisposable +{ + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly LivePlatformType _platformType; + private readonly Guid _liveRoomId; + private readonly Guid _recordSessionId; + private readonly string _roomId; + + private DanmakuSegmentWriter? _currentWriter; + private readonly ConcurrentDictionary<Guid, DanmakuSegmentSummary> _completedSummaries = new(); + + public SessionDanmakuXmlRecorder( + LivePlatformType platformType, + Guid liveRoomId, + Guid recordSessionId, + string roomId) + { + _platformType = platformType; + _liveRoomId = liveRoomId; + _recordSessionId = recordSessionId; + _roomId = roomId; + } + + public async Task StartSegmentAsync(Guid recordTaskId, int segmentIndex, string videoFilePath, DateTimeOffset startedAt) + { + await _gate.WaitAsync(); + try + { + if (_currentWriter is not null) + { + var summary = await _currentWriter.CloseAsync(); + _completedSummaries[_currentWriter.RecordTaskId] = summary; + } + + _currentWriter = await DanmakuSegmentWriter.CreateAsync( + _platformType, + _liveRoomId, + _recordSessionId, + recordTaskId, + _roomId, + Math.Max(1, segmentIndex), + GetDanmakuFilePath(videoFilePath), + startedAt); + } + finally + { + _gate.Release(); + } + } + + public async Task AppendAsync(DanmakuEvent danmakuEvent) + { + await _gate.WaitAsync(); + try + { + if (_currentWriter is null) + { + return; + } + + await _currentWriter.AppendAsync(danmakuEvent); + } + finally + { + _gate.Release(); + } + } + + public async Task<DanmakuSegmentSummary?> CompleteActiveSegmentAsync() + { + await _gate.WaitAsync(); + try + { + if (_currentWriter is null) + { + return null; + } + + var summary = await _currentWriter.CloseAsync(); + _completedSummaries[_currentWriter.RecordTaskId] = summary; + _currentWriter = null; + return summary; + } + finally + { + _gate.Release(); + } + } + + public DanmakuSegmentSummary? TakeSummary(Guid recordTaskId) + { + if (_completedSummaries.TryRemove(recordTaskId, out var summary)) + { + return summary; + } + + if (_currentWriter is not null && _currentWriter.RecordTaskId == recordTaskId) + { + return _currentWriter.ToSummary(); + } + + return null; + } + + public async ValueTask DisposeAsync() + { + await CompleteActiveSegmentAsync(); + _gate.Dispose(); + } + + public static string GetDanmakuFilePath(string videoFilePath) => + Path.ChangeExtension(videoFilePath, ".xml"); + + internal sealed record DanmakuSegmentSummary(Guid RecordTaskId, string FilePath, int MessageCount); + + private sealed class DanmakuSegmentWriter + { + private readonly StreamWriter _writer; + private readonly DateTimeOffset _startedAt; + private bool _closed; + + private DanmakuSegmentWriter( + Guid recordTaskId, + int segmentIndex, + string filePath, + DateTimeOffset startedAt, + StreamWriter writer) + { + RecordTaskId = recordTaskId; + SegmentIndex = segmentIndex; + FilePath = filePath; + _startedAt = startedAt; + _writer = writer; + } + + public Guid RecordTaskId { get; } + + public int SegmentIndex { get; } + + public string FilePath { get; } + + public int MessageCount { get; private set; } + + public static async Task<DanmakuSegmentWriter> CreateAsync( + LivePlatformType platformType, + Guid liveRoomId, + Guid recordSessionId, + Guid recordTaskId, + string roomId, + int segmentIndex, + string filePath, + DateTimeOffset startedAt) + { + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + var writer = new StreamWriter(filePath, false, new UTF8Encoding(false)); + await writer.WriteLineAsync("""<?xml version="1.0" encoding="UTF-8"?>"""); + await writer.WriteLineAsync( + $"""<i platform="{Escape(platformType.ToString())}" roomId="{Escape(roomId)}" liveRoomId="{liveRoomId}" recordSessionId="{recordSessionId}" recordTaskId="{recordTaskId}" segmentIndex="{segmentIndex}" startedAt="{startedAt:O}">"""); + await writer.FlushAsync(); + + return new DanmakuSegmentWriter(recordTaskId, segmentIndex, filePath, startedAt, writer); + } + + public async Task AppendAsync(DanmakuEvent danmakuEvent) + { + if (_closed) + { + return; + } + + var offsetSeconds = Math.Max(0, (danmakuEvent.OccurredAt - _startedAt).TotalSeconds); + var timestamp = danmakuEvent.OccurredAt.ToUnixTimeMilliseconds(); + if (string.Equals(danmakuEvent.Type, "chat", StringComparison.OrdinalIgnoreCase)) + { + var content = Escape(danmakuEvent.Content ?? string.Empty); + var user = Escape(danmakuEvent.User ?? string.Empty); + var userId = Escape(danmakuEvent.UserId ?? "0"); + var raw = Escape(CompactRawPayload(danmakuEvent.RawPayload)); + await _writer.WriteLineAsync( + $""" <d p="{offsetSeconds:F1},1,25,16777215,{timestamp},0,{userId},0" user="{user}" type="chat" raw="{raw}">{content}</d>"""); + } + else + { + var extraAttributes = string.Empty; + if (danmakuEvent.Extra is not null) + { + extraAttributes = string.Join( + string.Empty, + danmakuEvent.Extra.Select(pair => $" {Escape(pair.Key)}=\"{Escape(pair.Value)}\"")); + } + + await _writer.WriteLineAsync( + $""" <event type="{Escape(danmakuEvent.Type)}" ts="{timestamp}" offset="{offsetSeconds:F1}" user="{Escape(danmakuEvent.User ?? string.Empty)}" userId="{Escape(danmakuEvent.UserId ?? string.Empty)}" content="{Escape(danmakuEvent.Content ?? string.Empty)}" raw="{Escape(CompactRawPayload(danmakuEvent.RawPayload))}"{extraAttributes} />"""); + } + + MessageCount++; + await _writer.FlushAsync(); + } + + public async Task<DanmakuSegmentSummary> CloseAsync() + { + if (!_closed) + { + await _writer.WriteLineAsync("</i>"); + await _writer.FlushAsync(); + await _writer.DisposeAsync(); + _closed = true; + } + + return ToSummary(); + } + + public DanmakuSegmentSummary ToSummary() => new(RecordTaskId, FilePath, MessageCount); + + private static string Escape(string input) => SecurityElement.Escape(input) ?? string.Empty; + + private static string CompactRawPayload(string rawPayload) + { + if (string.IsNullOrWhiteSpace(rawPayload)) + { + return string.Empty; + } + + var compact = rawPayload.Trim(); + return compact.Length <= 512 ? compact : compact[..512]; + } + } +} diff --git a/src/LiveRecorder.WebApi/Controllers/AuthController.cs b/src/LiveRecorder.WebApi/Controllers/AuthController.cs new file mode 100644 index 0000000..bbd4c4b --- /dev/null +++ b/src/LiveRecorder.WebApi/Controllers/AuthController.cs @@ -0,0 +1,36 @@ +using LiveRecorder.Application.Abstractions.Auth; +using LiveRecorder.Application.Models.Auth; +using Microsoft.AspNetCore.Mvc; + +namespace LiveRecorder.WebApi.Controllers; + +[ApiController] +[Route("api/auth")] +public sealed class AuthController : ControllerBase +{ + private readonly IAuthService _authService; + + public AuthController(IAuthService authService) + { + _authService = authService; + } + + [HttpPost("login")] + public async Task<ActionResult<LoginResponse>> Login([FromBody] LoginRequest request, CancellationToken cancellationToken) + { + var result = await _authService.LoginAsync(request, cancellationToken); + return Ok(result); + } + + [HttpPost("logout")] + public async Task<IActionResult> Logout(CancellationToken cancellationToken) + { + var authorization = Request.Headers.Authorization.ToString(); + var token = authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) + ? authorization["Bearer ".Length..].Trim() + : string.Empty; + + await _authService.LogoutAsync(token, cancellationToken); + return NoContent(); + } +} diff --git a/src/LiveRecorder.WebApi/Controllers/LiveRoomsController.cs b/src/LiveRecorder.WebApi/Controllers/LiveRoomsController.cs new file mode 100644 index 0000000..4a0ea5d --- /dev/null +++ b/src/LiveRecorder.WebApi/Controllers/LiveRoomsController.cs @@ -0,0 +1,50 @@ +using LiveRecorder.Application.Models.LiveRooms; +using LiveRecorder.Application.Services; +using Microsoft.AspNetCore.Mvc; + +namespace LiveRecorder.WebApi.Controllers; + +[ApiController] +[Route("api/live-rooms")] +public sealed class LiveRoomsController : ControllerBase +{ + private readonly LiveRoomService _liveRoomService; + + public LiveRoomsController(LiveRoomService liveRoomService) + { + _liveRoomService = liveRoomService; + } + + [HttpGet] + public async Task<ActionResult<IReadOnlyList<LiveRoomDto>>> List(CancellationToken cancellationToken) => + Ok(await _liveRoomService.ListAsync(cancellationToken)); + + [HttpGet("{id:guid}")] + public async Task<ActionResult<LiveRoomDto>> Get(Guid id, CancellationToken cancellationToken) + { + var result = await _liveRoomService.GetAsync(id, cancellationToken); + return result is null ? NotFound() : Ok(result); + } + + [HttpPost] + public async Task<ActionResult<LiveRoomDto>> Create([FromBody] CreateLiveRoomRequest request, CancellationToken cancellationToken) => + Ok(await _liveRoomService.CreateAsync(request, cancellationToken)); + + [HttpPost("{id:guid}/refresh")] + public async Task<ActionResult<LiveRoomDto>> Refresh(Guid id, CancellationToken cancellationToken) => + Ok(await _liveRoomService.RefreshStatusAsync(id, cancellationToken)); + + [HttpPut("{id:guid}/enabled")] + public async Task<ActionResult<LiveRoomDto>> SetEnabled( + Guid id, + [FromBody] SetLiveRoomEnabledRequest request, + CancellationToken cancellationToken) => + Ok(await _liveRoomService.SetEnabledAsync(id, request.IsEnabled, cancellationToken)); + + [HttpDelete("{id:guid}")] + public async Task<IActionResult> Delete(Guid id, CancellationToken cancellationToken) + { + await _liveRoomService.DeleteAsync(id, cancellationToken); + return NoContent(); + } +} diff --git a/src/LiveRecorder.WebApi/Controllers/LogsController.cs b/src/LiveRecorder.WebApi/Controllers/LogsController.cs new file mode 100644 index 0000000..2ebbc72 --- /dev/null +++ b/src/LiveRecorder.WebApi/Controllers/LogsController.cs @@ -0,0 +1,26 @@ +using LiveRecorder.Application.Abstractions.Logging; +using LiveRecorder.Application.Models.Logs; +using Microsoft.AspNetCore.Mvc; + +namespace LiveRecorder.WebApi.Controllers; + +[ApiController] +[Route("api/logs")] +public sealed class LogsController : ControllerBase +{ + private readonly ISystemLogService _systemLogService; + + public LogsController(ISystemLogService systemLogService) + { + _systemLogService = systemLogService; + } + + [HttpGet] + public async Task<ActionResult<IReadOnlyList<SystemLogDto>>> List( + [FromQuery] Guid? liveRoomId, + [FromQuery] Guid? recordSessionId, + [FromQuery] Guid? recordTaskId, + [FromQuery] int take = 200, + CancellationToken cancellationToken = default) => + Ok(await _systemLogService.ListAsync(liveRoomId, recordSessionId, recordTaskId, take, cancellationToken)); +} diff --git a/src/LiveRecorder.WebApi/Controllers/MediaController.cs b/src/LiveRecorder.WebApi/Controllers/MediaController.cs new file mode 100644 index 0000000..3f47af9 --- /dev/null +++ b/src/LiveRecorder.WebApi/Controllers/MediaController.cs @@ -0,0 +1,32 @@ +using LiveRecorder.Application.Abstractions.Recording; +using Microsoft.AspNetCore.Mvc; + +namespace LiveRecorder.WebApi.Controllers; + +[ApiController] +[Route("media/record-tasks")] +public sealed class MediaController : ControllerBase +{ + private readonly IRecordMediaService _recordMediaService; + + public MediaController(IRecordMediaService recordMediaService) + { + _recordMediaService = recordMediaService; + } + + [HttpGet("{ticket}")] + public async Task<IActionResult> GetRecordTaskMedia(string ticket, CancellationToken cancellationToken) + { + var mediaFile = await _recordMediaService.ResolvePreviewAsync(ticket, cancellationToken); + if (mediaFile is null) + { + return NotFound(); + } + + var stream = System.IO.File.OpenRead(mediaFile.FilePath); + return new FileStreamResult(stream, mediaFile.ContentType) + { + EnableRangeProcessing = true + }; + } +} diff --git a/src/LiveRecorder.WebApi/Controllers/RecordSessionsController.cs b/src/LiveRecorder.WebApi/Controllers/RecordSessionsController.cs new file mode 100644 index 0000000..3ef7cff --- /dev/null +++ b/src/LiveRecorder.WebApi/Controllers/RecordSessionsController.cs @@ -0,0 +1,38 @@ +using LiveRecorder.Application.Models.RecordTasks; +using LiveRecorder.Application.Services; +using Microsoft.AspNetCore.Mvc; + +namespace LiveRecorder.WebApi.Controllers; + +[ApiController] +[Route("api/record-sessions")] +public sealed class RecordSessionsController : ControllerBase +{ + private readonly RecordSessionService _recordSessionService; + + public RecordSessionsController(RecordSessionService recordSessionService) + { + _recordSessionService = recordSessionService; + } + + [HttpGet] + public async Task<ActionResult<IReadOnlyList<RecordSessionDto>>> List([FromQuery] Guid? liveRoomId, CancellationToken cancellationToken) => + Ok(await _recordSessionService.ListAsync(liveRoomId, cancellationToken)); + + [HttpGet("{id:guid}")] + public async Task<ActionResult<RecordSessionDetailDto>> Get(Guid id, CancellationToken cancellationToken) + { + var result = await _recordSessionService.GetDetailAsync(id, cancellationToken); + return result is null ? NotFound() : Ok(result); + } + + [HttpPost("{id:guid}/stop")] + public async Task<ActionResult<RecordSessionDto>> Stop(Guid id, CancellationToken cancellationToken) => + Ok(await _recordSessionService.StopAsync(id, cancellationToken)); + + [HttpPost("delete")] + public async Task<ActionResult<DeleteCompletedRecordTasksResultDto>> Delete( + [FromBody] DeleteRecordSessionsRequest request, + CancellationToken cancellationToken) => + Ok(await _recordSessionService.DeleteAsync(request, cancellationToken)); +} diff --git a/src/LiveRecorder.WebApi/Controllers/RecordTasksController.cs b/src/LiveRecorder.WebApi/Controllers/RecordTasksController.cs new file mode 100644 index 0000000..3f4dfd1 --- /dev/null +++ b/src/LiveRecorder.WebApi/Controllers/RecordTasksController.cs @@ -0,0 +1,64 @@ +using LiveRecorder.Application.Models.RecordTasks; +using LiveRecorder.Application.Services; +using Microsoft.AspNetCore.Mvc; + +namespace LiveRecorder.WebApi.Controllers; + +[ApiController] +[Route("api/record-tasks")] +public sealed class RecordTasksController : ControllerBase +{ + private readonly RecordService _recordService; + private readonly LinkGenerator _linkGenerator; + + public RecordTasksController(RecordService recordService, LinkGenerator linkGenerator) + { + _recordService = recordService; + _linkGenerator = linkGenerator; + } + + [HttpGet] + public async Task<ActionResult<IReadOnlyList<RecordTaskDto>>> List([FromQuery] Guid? liveRoomId, CancellationToken cancellationToken) => + Ok(await _recordService.ListAsync(liveRoomId, cancellationToken)); + + [HttpGet("{id:guid}")] + public async Task<ActionResult<RecordTaskDetailDto>> Get(Guid id, CancellationToken cancellationToken) + { + var result = await _recordService.GetDetailAsync(id, cancellationToken); + return result is null ? NotFound() : Ok(result); + } + + [HttpPost("start")] + public async Task<ActionResult<RecordTaskDto>> Start([FromBody] StartRecordTaskRequest request, CancellationToken cancellationToken) => + Ok(await _recordService.StartAsync(request, cancellationToken)); + + [HttpPost("{id:guid}/stop")] + public async Task<ActionResult<RecordTaskDto>> Stop(Guid id, CancellationToken cancellationToken) => + Ok(await _recordService.StopAsync(id, cancellationToken)); + + [HttpPost("delete")] + public async Task<ActionResult<DeleteCompletedRecordTasksResultDto>> Delete( + [FromBody] DeleteCompletedRecordTasksRequest request, + CancellationToken cancellationToken) => + Ok(await _recordService.DeleteTasksAsync(request, cancellationToken)); + + [HttpPost("delete-completed")] + public async Task<ActionResult<DeleteCompletedRecordTasksResultDto>> DeleteCompleted( + [FromBody] DeleteCompletedRecordTasksRequest request, + CancellationToken cancellationToken) => + Ok(await _recordService.DeleteTasksAsync(request, cancellationToken)); + + [HttpPost("{id:guid}/preview-ticket")] + public async Task<ActionResult<RecordPreviewTicketDto>> CreatePreviewTicket(Guid id, CancellationToken cancellationToken) + { + var baseUrl = _linkGenerator.GetUriByAction( + HttpContext, + action: nameof(MediaController.GetRecordTaskMedia), + controller: "Media", + values: new { ticket = "placeholder" }) + ?? $"{Request.Scheme}://{Request.Host}/media/record-tasks/placeholder"; + + var mediaBaseUrl = baseUrl[..baseUrl.LastIndexOf('/')]; + return Ok(await _recordService.CreatePreviewTicketAsync(id, mediaBaseUrl, cancellationToken)); + } +} diff --git a/src/LiveRecorder.WebApi/Controllers/SettingsController.cs b/src/LiveRecorder.WebApi/Controllers/SettingsController.cs new file mode 100644 index 0000000..01c5578 --- /dev/null +++ b/src/LiveRecorder.WebApi/Controllers/SettingsController.cs @@ -0,0 +1,41 @@ +using LiveRecorder.Application.Abstractions.Settings; +using LiveRecorder.Application.Abstractions.Notifications; +using LiveRecorder.Application.Models.Settings; +using Microsoft.AspNetCore.Mvc; + +namespace LiveRecorder.WebApi.Controllers; + +[ApiController] +[Route("api/settings")] +public sealed class SettingsController : ControllerBase +{ + private readonly ISystemSettingsService _systemSettingsService; + private readonly IEmailNotificationService _emailNotificationService; + + public SettingsController( + ISystemSettingsService systemSettingsService, + IEmailNotificationService emailNotificationService) + { + _systemSettingsService = systemSettingsService; + _emailNotificationService = emailNotificationService; + } + + [HttpGet] + public async Task<ActionResult<SystemSettingsDto>> Get(CancellationToken cancellationToken) => + Ok(await _systemSettingsService.GetAsync(cancellationToken)); + + [HttpPut] + public async Task<ActionResult<SystemSettingsDto>> Update( + [FromBody] UpdateSystemSettingsRequest request, + CancellationToken cancellationToken) => + Ok(await _systemSettingsService.UpdateAsync(request, cancellationToken)); + + [HttpPost("test-email")] + public async Task<IActionResult> SendTestEmail( + [FromBody] SendTestEmailRequest request, + CancellationToken cancellationToken) + { + await _emailNotificationService.SendTestAsync(request, cancellationToken); + return NoContent(); + } +} diff --git a/src/LiveRecorder.WebApi/LiveRecorder.WebApi.csproj b/src/LiveRecorder.WebApi/LiveRecorder.WebApi.csproj new file mode 100644 index 0000000..b001221 --- /dev/null +++ b/src/LiveRecorder.WebApi/LiveRecorder.WebApi.csproj @@ -0,0 +1,18 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + + <ItemGroup> + <ProjectReference Include="..\LiveRecorder.Application\LiveRecorder.Application.csproj" /> + <ProjectReference Include="..\LiveRecorder.Infrastructure\LiveRecorder.Infrastructure.csproj" /> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" /> + </ItemGroup> + + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <Nullable>enable</Nullable> + <ImplicitUsings>enable</ImplicitUsings> + </PropertyGroup> + +</Project> diff --git a/src/LiveRecorder.WebApi/LiveRecorder.WebApi.http b/src/LiveRecorder.WebApi/LiveRecorder.WebApi.http new file mode 100644 index 0000000..75abe69 --- /dev/null +++ b/src/LiveRecorder.WebApi/LiveRecorder.WebApi.http @@ -0,0 +1,6 @@ +@LiveRecorder.WebApi_HostAddress = http://localhost:5135 + +GET {{LiveRecorder.WebApi_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/src/LiveRecorder.WebApi/Middleware/ApiTokenAuthenticationMiddleware.cs b/src/LiveRecorder.WebApi/Middleware/ApiTokenAuthenticationMiddleware.cs new file mode 100644 index 0000000..6b6aa58 --- /dev/null +++ b/src/LiveRecorder.WebApi/Middleware/ApiTokenAuthenticationMiddleware.cs @@ -0,0 +1,49 @@ +using LiveRecorder.Application.Abstractions.Auth; + +namespace LiveRecorder.WebApi.Middleware; + +public sealed class ApiTokenAuthenticationMiddleware +{ + private readonly RequestDelegate _next; + + public ApiTokenAuthenticationMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context, IAuthService authService) + { + if (HttpMethods.IsOptions(context.Request.Method)) + { + await _next(context); + return; + } + + var path = context.Request.Path; + if (!path.StartsWithSegments("/api") || + path.StartsWithSegments("/api/auth/login")) + { + await _next(context); + return; + } + + var authorization = context.Request.Headers.Authorization.ToString(); + var token = authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) + ? authorization["Bearer ".Length..].Trim() + : string.Empty; + + var user = await authService.ValidateTokenAsync(token, context.RequestAborted); + if (user is null) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + await context.Response.WriteAsJsonAsync(new + { + message = "Unauthorized" + }); + return; + } + + context.Items["CurrentUser"] = user; + await _next(context); + } +} diff --git a/src/LiveRecorder.WebApi/Middleware/ExceptionHandlingMiddleware.cs b/src/LiveRecorder.WebApi/Middleware/ExceptionHandlingMiddleware.cs new file mode 100644 index 0000000..d50fd07 --- /dev/null +++ b/src/LiveRecorder.WebApi/Middleware/ExceptionHandlingMiddleware.cs @@ -0,0 +1,38 @@ +namespace LiveRecorder.WebApi.Middleware; + +public sealed class ExceptionHandlingMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger<ExceptionHandlingMiddleware> _logger; + + public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + try + { + await _next(context); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unhandled exception"); + context.Response.StatusCode = ex switch + { + KeyNotFoundException => StatusCodes.Status404NotFound, + InvalidOperationException => StatusCodes.Status400BadRequest, + NotSupportedException => StatusCodes.Status400BadRequest, + _ => StatusCodes.Status500InternalServerError + }; + + await context.Response.WriteAsJsonAsync(new + { + message = ex.Message, + detail = context.Response.StatusCode == StatusCodes.Status500InternalServerError ? "Internal Server Error" : null + }); + } + } +} diff --git a/src/LiveRecorder.WebApi/Program.cs b/src/LiveRecorder.WebApi/Program.cs new file mode 100644 index 0000000..18d5dee --- /dev/null +++ b/src/LiveRecorder.WebApi/Program.cs @@ -0,0 +1,181 @@ +using System.Net; +using LiveRecorder.Application.Abstractions.Auth; +using LiveRecorder.Application.Abstractions.Logging; +using LiveRecorder.Application.Abstractions.Notifications; +using LiveRecorder.Application.Abstractions.Persistence; +using LiveRecorder.Application.Abstractions.Platforms; +using LiveRecorder.Application.Abstractions.Recording; +using LiveRecorder.Application.Abstractions.Settings; +using LiveRecorder.Application.Services; +using LiveRecorder.Infrastructure.Persistence; +using LiveRecorder.Infrastructure.Persistence.Repositories; +using LiveRecorder.Infrastructure.Platforms.Bilibili; +using LiveRecorder.Infrastructure.Platforms.Douyin; +using LiveRecorder.Infrastructure.Platforms.Douyin.Danmaku; +using LiveRecorder.Infrastructure.Platforms.Douyin.Signing; +using LiveRecorder.Infrastructure.Platforms.Huya; +using LiveRecorder.Infrastructure.Services; +using LiveRecorder.WebApi.Middleware; +using Microsoft.EntityFrameworkCore; +using Microsoft.OpenApi.Models; + +var builder = WebApplication.CreateBuilder(args); +var resetRecordingData = args.Contains("--reset-recording-data", StringComparer.OrdinalIgnoreCase); +var corsOrigins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? ["http://localhost:5173"]; + +builder.Services.AddControllers(); +builder.Services.AddMemoryCache(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc("v1", new OpenApiInfo + { + Title = "Live Recorder API", + Version = "v1", + Description = "Multi-platform live recording service" + }); + + options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + In = ParameterLocation.Header, + Description = "Input token as: Bearer {token}", + Name = "Authorization", + Type = SecuritySchemeType.ApiKey + }); + + options.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }, + Array.Empty<string>() + } + }); +}); + +builder.Services.AddCors(options => +{ + options.AddPolicy("frontend", policy => + { + policy.WithOrigins(corsOrigins) + .AllowAnyHeader() + .AllowAnyMethod(); + }); +}); + +builder.Services.AddHttpClient("douyin", client => +{ + client.Timeout = TimeSpan.FromSeconds(20); + client.DefaultRequestVersion = HttpVersion.Version11; + client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower; +}) +.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler +{ + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli, + PooledConnectionLifetime = TimeSpan.FromMinutes(2), + PooledConnectionIdleTimeout = TimeSpan.FromSeconds(30), + MaxConnectionsPerServer = 8 +}); + +builder.Services.AddDbContext<LiveRecorderDbContext>(options => + options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection"))); + +builder.Services.AddScoped<IUnitOfWork>(provider => provider.GetRequiredService<LiveRecorderDbContext>()); +builder.Services.AddScoped<IAppSettingRepository, AppSettingRepository>(); +builder.Services.AddScoped<ILiveRoomRepository, LiveRoomRepository>(); +builder.Services.AddScoped<IRecordSessionRepository, RecordSessionRepository>(); +builder.Services.AddScoped<IRecordTaskRepository, RecordTaskRepository>(); +builder.Services.AddScoped<IRecordResultRepository, RecordResultRepository>(); +builder.Services.AddScoped<ISystemLogRepository, SystemLogRepository>(); +builder.Services.AddScoped<IUserAccountRepository, UserAccountRepository>(); +builder.Services.AddScoped<IUserSessionRepository, UserSessionRepository>(); + +builder.Services.AddScoped<ISystemSettingsService, SystemSettingsService>(); +builder.Services.AddScoped<ISystemLogService, SystemLogService>(); +builder.Services.AddScoped<IEmailNotificationService, EmailNotificationService>(); +builder.Services.AddScoped<IAuthService, AuthService>(); +builder.Services.AddScoped<LiveRoomService>(); +builder.Services.AddScoped<LiveRoomStatusService>(); +builder.Services.AddScoped<RecordService>(); +builder.Services.AddScoped<RecordSessionService>(); +builder.Services.AddScoped<StoppedOrphanRecordSessionCleanupService>(); +builder.Services.AddScoped<DatabaseInitializer>(); + +builder.Services.AddScoped<DouyinHttpClient>(); +builder.Services.AddSingleton<DouyinXBogusSigner>(); +builder.Services.AddScoped<ILivePlatformAdapter, DouyinLivePlatformAdapter>(); +builder.Services.AddScoped<ILivePlatformAdapter, BilibiliLivePlatformAdapter>(); +builder.Services.AddScoped<ILivePlatformAdapter, HuyaLivePlatformAdapter>(); +builder.Services.AddScoped<ILivePlatformAdapterFactory, LivePlatformAdapterFactory>(); +builder.Services.AddScoped<ILiveDanmakuAdapter, DouyinDanmakuAdapter>(); +builder.Services.AddScoped<ILiveDanmakuAdapterFactory, LiveDanmakuAdapterFactory>(); + +builder.Services.AddSingleton<IFfmpegService, FfmpegService>(); +builder.Services.AddScoped<IRecordMediaService, RecordMediaService>(); +builder.Services.AddHostedService<LiveRoomPollingBackgroundService>(); + +var app = builder.Build(); + +app.UseMiddleware<ExceptionHandlingMiddleware>(); +app.UseSwagger(); +app.UseSwaggerUI(); +app.UseCors("frontend"); +app.UseMiddleware<ApiTokenAuthenticationMiddleware>(); + +app.MapGet("/", () => Results.Redirect("/swagger")); +app.MapControllers(); + +if (!resetRecordingData) +{ + using var scope = app.Services.CreateScope(); + var initializer = scope.ServiceProvider.GetRequiredService<DatabaseInitializer>(); + await initializer.InitializeAsync(); +} + +if (resetRecordingData) +{ + using var scope = app.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>(); + var before = await ReadRecordingDataCountsAsync(dbContext); + + await using (var transaction = await dbContext.Database.BeginTransactionAsync()) + { + await dbContext.Database.ExecuteSqlRawAsync( + "DELETE FROM SystemLogEntries WHERE LiveRoomId IS NOT NULL OR RecordSessionId IS NOT NULL OR RecordTaskId IS NOT NULL;"); + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM RecordResults;"); + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM RecordTasks;"); + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM RecordSessions;"); + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM LiveRooms;"); + await transaction.CommitAsync(); + } + + var after = await ReadRecordingDataCountsAsync(dbContext); + Console.WriteLine("Recording data reset complete."); + foreach (var key in before.Keys) + { + Console.WriteLine($"{key}: {before[key]} -> {after[key]}"); + } + + return; +} + +app.Run(); + +static async Task<Dictionary<string, long>> ReadRecordingDataCountsAsync(LiveRecorderDbContext dbContext) +{ + return new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase) + { + ["LiveRooms"] = await dbContext.LiveRooms.LongCountAsync(), + ["RecordSessions"] = await dbContext.RecordSessions.LongCountAsync(), + ["RecordTasks"] = await dbContext.RecordTasks.LongCountAsync(), + ["RecordResults"] = await dbContext.RecordResults.LongCountAsync(), + ["SystemLogEntries(Related)"] = await dbContext.SystemLogEntries.LongCountAsync( + item => item.LiveRoomId != null || item.RecordSessionId != null || item.RecordTaskId != null) + }; +} diff --git a/src/LiveRecorder.WebApi/Properties/launchSettings.json b/src/LiveRecorder.WebApi/Properties/launchSettings.json new file mode 100644 index 0000000..0d07ae4 --- /dev/null +++ b/src/LiveRecorder.WebApi/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:45325", + "sslPort": 44312 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "weatherforecast", + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "weatherforecast", + "applicationUrl": "https://localhost:7033;http://localhost:5135", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "weatherforecast", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/LiveRecorder.WebApi/appsettings.Development.json b/src/LiveRecorder.WebApi/appsettings.Development.json new file mode 100644 index 0000000..34f00ef --- /dev/null +++ b/src/LiveRecorder.WebApi/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information" + } + } +} diff --git a/src/LiveRecorder.WebApi/appsettings.json b/src/LiveRecorder.WebApi/appsettings.json new file mode 100644 index 0000000..deab313 --- /dev/null +++ b/src/LiveRecorder.WebApi/appsettings.json @@ -0,0 +1,18 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Data Source=live-recorder.db" + }, + "Cors": { + "Origins": [ + "http://localhost:5173" + ] + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore.Database.Command": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/LiveRecorder.WebApi/live-recorder.db b/src/LiveRecorder.WebApi/live-recorder.db new file mode 100644 index 0000000000000000000000000000000000000000..f0e4ac592d4b0f4707df3e3591f842a2182512d6 GIT binary patch literal 352256 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 literal 0 HcmV?d00001 diff --git a/src/LiveRecorder.WebApi/live-recorder.db-shm b/src/LiveRecorder.WebApi/live-recorder.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..eba5a664f838d8fe6663428f3f1ff40211b9fb22 GIT binary patch literal 32768 zcmeI*d6-UR7{~E1nIYSlB~4ihp#_Cji56v)Y-1FmMT9Bshzt!ama;Z2BorYkie!zJ zkv+?_Z&Qe3kbnA%`*F=X*L$6rX{KRj&Uk<4y6$<;Iq&n__x*g%dH;PTHoYP4sAy3N zsvNb?E*7afHvC&-W!m=Sr2Z+(x+Wy1W*kh9>o;#e?Ef5ZsZJb+?eDRmBl9+!Gf&Fc zXP8B#MOlj&i_0xKS%h1-d*v-ESX8!%u!ypVws2!FvuJG5#G<K1U~XqSPp}Bi>8^9D zm!I#vxdOFa-9V47FV~B!<L<dJflu#z?{#kDf^)lkE`P9vtCdr@dBNk{TyITx9mpR% z_P=~{bM@xV%Q~;`b9yfV&&gY>u%3neHnQGB_rBG%aPL^WMZnEj<@H>G`*m~O+P%*s zkTdu><TS?3_2xU7^}D*>d~R(y)y`|pHcf{FBp?9^NI(J-kbndvAOQ(TKmrnwfCMBU z0SQPz0uqpb1SB8<2}nQ!5|DrdBp?9^NI(J-kbndvAOQ(TKmrnwfCMBU0SQPz0uqpb z1SB8<2}mHH0`u}YPQxT10SQPz0uqow$rmU^4KCmkuCg7r;^}OAWOd;V9%nGa8Nu_s z#Oo~M6PB}vEqq_{7pxUXAWNXM?cY_Gr+9`JvhMlwB+_=@x`u1HncL}3KPEGc>CELr zKIJQR@FPF-Cx`smyt?wGKso07vajq-McJN?{h7kf>|g1Q1PUZjhBG;b^Qlh*nsYt3 z(1Uw<m?s#@NX9UpH+hS9S;1O1u$5o<oqYwWbh4A8xsrAy@){rU9c~9z9TEr?ff(E0 z_Ca1|SEwww!0TLC;F+4}SAlTOq8hcih(@&FM&h`ehj^5M4C7hGF_D?f<~>&N8JqZ; z-`LGxeqD_E@`XTo+Zp|8n$e0|xs&@yVJdI%4j=FZY5c%X?B#$jI#m-AIMo6bY`^x~ z=*256;u{X0>h#Jd0SQPz0uqpb1bie=k@j@uewOehM|{+mT9H8E3sj;5-RR9yQu(Lw z%c+6{Bp?9^_(0$c&Z8bfNM^JTx={<EB~X^C#k%0ibmT7j@G<K-TCAO@c<2ed&uTum z|L?n<boTIfkpo0<1#NkNS6R%rWE6RQ6%1v8vx|B0VN~H<YH=YAxsDrXO;7sr2v71f zqZrEsX7Dx(SjjpzvW;K)gZ;(av&x??0<l!54i|GNExCy{+`|AKV-QI^$BRs27IRLQ JrIzq@90&Xa!n6PY literal 0 HcmV?d00001 diff --git a/src/LiveRecorder.WebApi/live-recorder.db-wal b/src/LiveRecorder.WebApi/live-recorder.db-wal new file mode 100644 index 0000000000000000000000000000000000000000..067e6a63202f6187c88d612b23a11f9d5f5e564f GIT binary patch literal 1005312 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%0D=V7S8g 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 literal 0 HcmV?d00001 diff --git a/src/LiveRecorder.WebApi/records/Douyin/2026-21-15/第五人格赛事/092132_第五人格赛事_COA9小组赛Day6_24482384478_00001.xml b/src/LiveRecorder.WebApi/records/Douyin/2026-21-15/第五人格赛事/092132_第五人格赛事_COA9小组赛Day6_24482384478_00001.xml new file mode 100644 index 0000000..4a34e89 --- /dev/null +++ b/src/LiveRecorder.WebApi/records/Douyin/2026-21-15/第五人格赛事/092132_第五人格赛事_COA9小组赛Day6_24482384478_00001.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="UTF-8"?> +<i platform="Douyin" roomId="24482384478" liveRoomId="758496da-3485-4b50-8f36-f622a04cccaa" recordSessionId="96541586-d6a4-492f-b77e-8351f907f6b2" recordTaskId="16e3ee7b-775a-4306-af9f-030d3fc16c59" segmentIndex="1" startedAt="2026-04-15T09:21:32.9557018+00:00"> +</i> diff --git a/src/LiveRecorder.WebApi/records/Douyin/2026-40-15/第五人格赛事/084002_第五人格赛事_COA9小组赛Day6_24482384478/084002_第五人格赛事_COA9小组赛Day6_24482384478_00001.xml b/src/LiveRecorder.WebApi/records/Douyin/2026-40-15/第五人格赛事/084002_第五人格赛事_COA9小组赛Day6_24482384478/084002_第五人格赛事_COA9小组赛Day6_24482384478_00001.xml new file mode 100644 index 0000000..dc2bef4 --- /dev/null +++ b/src/LiveRecorder.WebApi/records/Douyin/2026-40-15/第五人格赛事/084002_第五人格赛事_COA9小组赛Day6_24482384478/084002_第五人格赛事_COA9小组赛Day6_24482384478_00001.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="UTF-8"?> +<i platform="Douyin" roomId="24482384478" liveRoomId="758496da-3485-4b50-8f36-f622a04cccaa" recordSessionId="a6b972a1-0fa4-4cb0-a4f4-49c1886f68b9" recordTaskId="16c513ce-20a4-4b68-b781-cbe0a3866bd3" segmentIndex="1" startedAt="2026-04-15T08:40:02.3186341+00:00"> +</i> diff --git a/src/LiveRecorder.WebApi/records/Douyin/2026-47-15/第五人格赛事/084717_第五人格赛事_COA9小组赛Day6_24482384478/084717_第五人格赛事_COA9小组赛Day6_24482384478_00001.xml b/src/LiveRecorder.WebApi/records/Douyin/2026-47-15/第五人格赛事/084717_第五人格赛事_COA9小组赛Day6_24482384478/084717_第五人格赛事_COA9小组赛Day6_24482384478_00001.xml new file mode 100644 index 0000000..056e71b --- /dev/null +++ b/src/LiveRecorder.WebApi/records/Douyin/2026-47-15/第五人格赛事/084717_第五人格赛事_COA9小组赛Day6_24482384478/084717_第五人格赛事_COA9小组赛Day6_24482384478_00001.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="UTF-8"?> +<i platform="Douyin" roomId="24482384478" liveRoomId="758496da-3485-4b50-8f36-f622a04cccaa" recordSessionId="8066da9f-7234-4bb9-b5a6-dbda093ec805" recordTaskId="275d15d5-06a3-4592-a2e4-213f2d33a05e" segmentIndex="1" startedAt="2026-04-15T08:47:17.0035883+00:00"> +</i> diff --git a/src/LiveRecorder.WebApi/records/Douyin/2026-49-15/第五人格赛事/084924_第五人格赛事_COA9小组赛Day6_24482384478/084924_第五人格赛事_COA9小组赛Day6_24482384478_00001.xml b/src/LiveRecorder.WebApi/records/Douyin/2026-49-15/第五人格赛事/084924_第五人格赛事_COA9小组赛Day6_24482384478/084924_第五人格赛事_COA9小组赛Day6_24482384478_00001.xml new file mode 100644 index 0000000..1215b47 --- /dev/null +++ b/src/LiveRecorder.WebApi/records/Douyin/2026-49-15/第五人格赛事/084924_第五人格赛事_COA9小组赛Day6_24482384478/084924_第五人格赛事_COA9小组赛Day6_24482384478_00001.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="UTF-8"?> +<i platform="Douyin" roomId="24482384478" liveRoomId="758496da-3485-4b50-8f36-f622a04cccaa" recordSessionId="b1f625ae-9630-40cb-b332-0911cbf9495a" recordTaskId="31681d5f-32f1-4d3b-b67c-9c76bb262ae6" segmentIndex="1" startedAt="2026-04-15T08:49:24.7664120+00:00"> +</i> diff --git a/src/LiveRecorder.WebApi/records/Douyin/Wd/2026-28-15/132847_Wd_Wd正在直播__00001.xml b/src/LiveRecorder.WebApi/records/Douyin/Wd/2026-28-15/132847_Wd_Wd正在直播__00001.xml new file mode 100644 index 0000000..8fe6407 --- /dev/null +++ b/src/LiveRecorder.WebApi/records/Douyin/Wd/2026-28-15/132847_Wd_Wd正在直播__00001.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="UTF-8"?> +<i platform="Douyin" roomId="410202829936" liveRoomId="723ddadf-f211-488a-b3c8-af8ca26ce501" recordSessionId="6edb0432-0aad-42ca-913f-f754fe1c74a2" recordTaskId="b363a8c2-4b2f-4a66-b178-7015f2ba42bf" segmentIndex="1" startedAt="2026-04-15T13:28:47.9706852+00:00"> +</i> diff --git a/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-05-15/100501_第五人格赛事_COA9小组赛Day6__00001.xml b/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-05-15/100501_第五人格赛事_COA9小组赛Day6__00001.xml new file mode 100644 index 0000000..44a6dd7 --- /dev/null +++ b/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-05-15/100501_第五人格赛事_COA9小组赛Day6__00001.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="UTF-8"?> +<i platform="Douyin" roomId="24482384478" liveRoomId="758496da-3485-4b50-8f36-f622a04cccaa" recordSessionId="710beae4-aaab-49b3-b290-cb92e8cfbf73" recordTaskId="662d9dd1-a52c-49d3-939f-acb9047118ba" segmentIndex="1" startedAt="2026-04-15T10:05:01.8366164+00:00"> +</i> diff --git a/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-19-16/061914_第五人格赛事_COA9小组赛Day7__00001.xml b/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-19-16/061914_第五人格赛事_COA9小组赛Day7__00001.xml new file mode 100644 index 0000000..ca53097 --- /dev/null +++ b/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-19-16/061914_第五人格赛事_COA9小组赛Day7__00001.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="UTF-8"?> +<i platform="Douyin" roomId="24482384478" liveRoomId="5fe39aa7-adc1-4f3b-8f79-a88a8a78575a" recordSessionId="a332ebc7-1ec8-479d-be4e-ae9bd0e3b492" recordTaskId="f809528f-50f0-4294-9898-2c78077e89c6" segmentIndex="1" startedAt="2026-04-16T06:19:14.1248014+00:00"> +</i> diff --git a/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-19-16/071935_第五人格赛事_COA9小组赛Day7__00001.xml b/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-19-16/071935_第五人格赛事_COA9小组赛Day7__00001.xml new file mode 100644 index 0000000..4068c43 --- /dev/null +++ b/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-19-16/071935_第五人格赛事_COA9小组赛Day7__00001.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="UTF-8"?> +<i platform="Douyin" roomId="24482384478" liveRoomId="5fe39aa7-adc1-4f3b-8f79-a88a8a78575a" recordSessionId="adb12db2-cb13-4220-b674-066034168179" recordTaskId="234cabf8-2b21-44e3-90e4-3eb3f972f84b" segmentIndex="1" startedAt="2026-04-16T07:19:35.5837509+00:00"> +</i> diff --git a/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-22-15/092236_第五人格赛事_COA9小组赛Day6__00001.xml b/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-22-15/092236_第五人格赛事_COA9小组赛Day6__00001.xml new file mode 100644 index 0000000..53cc141 --- /dev/null +++ b/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-22-15/092236_第五人格赛事_COA9小组赛Day6__00001.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="UTF-8"?> +<i platform="Douyin" roomId="24482384478" liveRoomId="758496da-3485-4b50-8f36-f622a04cccaa" recordSessionId="4339a2f9-7971-49c3-a862-51c40b5f863a" recordTaskId="e13a9515-a3b2-4926-91e8-ab5c950451f6" segmentIndex="1" startedAt="2026-04-15T09:22:36.1844012+00:00"> +</i> diff --git a/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-23-15/092339_第五人格赛事_COA9小组赛Day6__00001.xml b/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-23-15/092339_第五人格赛事_COA9小组赛Day6__00001.xml new file mode 100644 index 0000000..c78cd97 --- /dev/null +++ b/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-23-15/092339_第五人格赛事_COA9小组赛Day6__00001.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="UTF-8"?> +<i platform="Douyin" roomId="24482384478" liveRoomId="758496da-3485-4b50-8f36-f622a04cccaa" recordSessionId="a6b25554-b0c4-401c-b609-154900b3e7ae" recordTaskId="10b39d85-ff00-411b-8aa3-1eebf8f211b2" segmentIndex="1" startedAt="2026-04-15T09:23:39.5759339+00:00"> +</i> diff --git a/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-49-15/114940_第五人格赛事_COA9小组赛Day6__00001.xml b/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-49-15/114940_第五人格赛事_COA9小组赛Day6__00001.xml new file mode 100644 index 0000000..6e2309b --- /dev/null +++ b/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-49-15/114940_第五人格赛事_COA9小组赛Day6__00001.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="UTF-8"?> +<i platform="Douyin" roomId="24482384478" liveRoomId="758496da-3485-4b50-8f36-f622a04cccaa" recordSessionId="bcde0a6f-36f5-49e6-9793-123771a6a073" recordTaskId="782b119c-ea9e-4854-a19e-178699feb5ca" segmentIndex="1" startedAt="2026-04-15T11:49:40.6314631+00:00"> +</i> diff --git a/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-58-15/115804_第五人格赛事_COA9小组赛Day6__00001.xml b/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-58-15/115804_第五人格赛事_COA9小组赛Day6__00001.xml new file mode 100644 index 0000000..1a42839 --- /dev/null +++ b/src/LiveRecorder.WebApi/records/Douyin/第五人格赛事/2026-58-15/115804_第五人格赛事_COA9小组赛Day6__00001.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="UTF-8"?> +<i platform="Douyin" roomId="24482384478" liveRoomId="bbcc8489-a89b-43e9-8cef-36d8a2aa25de" recordSessionId="f0ffa9e6-8200-4605-8005-badc2a025939" recordTaskId="d8131983-8ea8-484e-b306-f4086861154b" segmentIndex="1" startedAt="2026-04-15T11:58:04.5702834+00:00"> +</i> diff --git a/src/LiveRecorder.WebApi/records/Douyin/豆浆(S邦邦)/2026-00-16/030005_豆浆(S邦邦)_百星S邦邦 娱乐主播!一把__00001.xml b/src/LiveRecorder.WebApi/records/Douyin/豆浆(S邦邦)/2026-00-16/030005_豆浆(S邦邦)_百星S邦邦 娱乐主播!一把__00001.xml new file mode 100644 index 0000000..b21a14e --- /dev/null +++ b/src/LiveRecorder.WebApi/records/Douyin/豆浆(S邦邦)/2026-00-16/030005_豆浆(S邦邦)_百星S邦邦 娱乐主播!一把__00001.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="UTF-8"?> +<i platform="Douyin" roomId="386835971310" liveRoomId="082677d1-f76c-4983-aeeb-3deb2cb91bca" recordSessionId="bd9b2b8f-739e-4be6-b6f7-358388925065" recordTaskId="8b3eb0b0-598a-415d-a2c0-f7d390ed805c" segmentIndex="1" startedAt="2026-04-16T03:00:05.1545652+00:00"> +</i>