From 30192c9d34a52924dce8692212d9af198a36bb17 Mon Sep 17 00:00:00 2001 From: mahoshojoHCG Date: Sat, 29 Aug 2026 23:20:06 +0800 Subject: [PATCH 1/3] feat: add server-side HLS transcoding --- .github/workflows/build.yml | 5 + Containerfile | 3 + ...ffmpeg-ffmpeg-npm-0.12.15-ef17f09f1b.patch | 47 - .../mock-server.mjs | 98 +- .../package.json | 3 +- .../src/i18n/locales/en/player.json | 24 +- .../src/i18n/locales/ja/player.json | 22 +- .../src/i18n/locales/zh-CN/player.json | 22 +- .../src/pages/PlayerPage.tsx | 237 ++++- .../playback/mkv/serverTranscoding.test.ts | 84 ++ .../src/playback/mkv/transcoder.ts | 397 -------- .../src/playback/serverTranscoding.ts | 121 +++ .../src/types/parcel-assets.d.ts | 10 - SecondDimensionWatcherReDive.Client/yarn.lock | 42 +- .../Helpers/FakeTranscodingService.cs | 103 ++ .../Transcoding/TranscodingApiTests.cs | 72 ++ .../WebDavWebApplicationFactory.cs | 5 + .../FfmpegProcessRunnerTests.cs | 126 +++ .../HlsTranscodingServiceTests.cs | 480 +++++++++ .../TranscodingControllerTests.cs | 178 ++++ .../TranscodingPlannerTests.cs | 145 +++ .../External/AppJsonSerializerContext.cs | 5 + .../Controllers/External/Transcoding.cs | 50 + .../Controllers/FileController.cs | 28 +- .../Controllers/TranscodingController.cs | 237 +++++ SecondDimensionWatcherReDive/Program.cs | 36 + .../Transcoding/FfmpegProcessRunner.cs | 545 ++++++++++ .../Transcoding/HlsTranscodingService.cs | 945 ++++++++++++++++++ .../Transcoding/IHlsTranscodingService.cs | 44 + .../Services/Transcoding/ScopeOwnedStream.cs | 97 ++ .../Transcoding/TranscodingMetrics.cs | 107 ++ .../Services/Transcoding/TranscodingModels.cs | 161 +++ .../Transcoding/TranscodingOptions.cs | 27 + .../Transcoding/TranscodingPlanner.cs | 126 +++ .../Utils/FileStore/PlaybackPathResolver.cs | 29 + .../appsettings.example.json | 23 + THIRD_PARTY_NOTICES.md | 31 +- deployments/podman-compose.yml | 1 + docs/container-deployment.md | 12 +- docs/server-deployment.md | 53 + packaging/appsettings.yml | 21 + packaging/nfpm.yaml | 9 + packaging/postinstall.sh | 2 + 43 files changed, 4196 insertions(+), 617 deletions(-) delete mode 100644 SecondDimensionWatcherReDive.Client/.yarn/patches/@ffmpeg-ffmpeg-npm-0.12.15-ef17f09f1b.patch create mode 100644 SecondDimensionWatcherReDive.Client/src/playback/mkv/serverTranscoding.test.ts delete mode 100644 SecondDimensionWatcherReDive.Client/src/playback/mkv/transcoder.ts create mode 100644 SecondDimensionWatcherReDive.Client/src/playback/serverTranscoding.ts create mode 100644 SecondDimensionWatcherReDive.IntegrationTest/Helpers/FakeTranscodingService.cs create mode 100644 SecondDimensionWatcherReDive.IntegrationTest/Transcoding/TranscodingApiTests.cs create mode 100644 SecondDimensionWatcherReDive.Test/FfmpegProcessRunnerTests.cs create mode 100644 SecondDimensionWatcherReDive.Test/HlsTranscodingServiceTests.cs create mode 100644 SecondDimensionWatcherReDive.Test/TranscodingControllerTests.cs create mode 100644 SecondDimensionWatcherReDive.Test/TranscodingPlannerTests.cs create mode 100644 SecondDimensionWatcherReDive/Controllers/External/Transcoding.cs create mode 100644 SecondDimensionWatcherReDive/Controllers/TranscodingController.cs create mode 100644 SecondDimensionWatcherReDive/Services/Transcoding/FfmpegProcessRunner.cs create mode 100644 SecondDimensionWatcherReDive/Services/Transcoding/HlsTranscodingService.cs create mode 100644 SecondDimensionWatcherReDive/Services/Transcoding/IHlsTranscodingService.cs create mode 100644 SecondDimensionWatcherReDive/Services/Transcoding/ScopeOwnedStream.cs create mode 100644 SecondDimensionWatcherReDive/Services/Transcoding/TranscodingMetrics.cs create mode 100644 SecondDimensionWatcherReDive/Services/Transcoding/TranscodingModels.cs create mode 100644 SecondDimensionWatcherReDive/Services/Transcoding/TranscodingOptions.cs create mode 100644 SecondDimensionWatcherReDive/Services/Transcoding/TranscodingPlanner.cs create mode 100644 SecondDimensionWatcherReDive/Utils/FileStore/PlaybackPathResolver.cs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 98cb3e1..6b635da 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,6 +26,11 @@ jobs: with: dotnet-version: '10.0.x' + - name: Install FFmpeg + run: | + sudo apt-get update + sudo apt-get install -y ffmpeg + - name: Run tests run: >- dotnet test SecondDimensionWatcherReDive.slnx -c Release diff --git a/Containerfile b/Containerfile index 72b49a9..5468f19 100644 --- a/Containerfile +++ b/Containerfile @@ -21,6 +21,9 @@ RUN dotnet publish SecondDimensionWatcherReDive/SecondDimensionWatcherReDive.csp # Stage 3: Runtime FROM mcr.microsoft.com/dotnet/aspnet:10.0 WORKDIR /app +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg \ + && rm -rf /var/lib/apt/lists/* COPY --from=backend-build /app . EXPOSE 8080 # Optional: read-only NFSv4 export (set Nfs:Enabled=true to activate; publish port at run time). diff --git a/SecondDimensionWatcherReDive.Client/.yarn/patches/@ffmpeg-ffmpeg-npm-0.12.15-ef17f09f1b.patch b/SecondDimensionWatcherReDive.Client/.yarn/patches/@ffmpeg-ffmpeg-npm-0.12.15-ef17f09f1b.patch deleted file mode 100644 index 94376e5..0000000 --- a/SecondDimensionWatcherReDive.Client/.yarn/patches/@ffmpeg-ffmpeg-npm-0.12.15-ef17f09f1b.patch +++ /dev/null @@ -1,47 +0,0 @@ -diff --git a/dist/esm/worker.js b/dist/esm/worker.js -index cca2a6116bab5349cfa35bc1dba0b6e696af91ee..84c1e701e92810b722a186a941db0a56a615407f 100644 ---- a/dist/esm/worker.js -+++ b/dist/esm/worker.js -@@ -6,21 +6,29 @@ import { ERROR_UNKNOWN_MESSAGE_TYPE, ERROR_NOT_LOADED, ERROR_IMPORT_FAILURE, } f - let ffmpeg; - const load = async ({ coreURL: _coreURL, wasmURL: _wasmURL, workerURL: _workerURL, }) => { - const first = !ffmpeg; -+ if (!_coreURL) -+ _coreURL = CORE_URL; -+ // Parcel emits the core as a self-registering module without native ESM -+ // exports. Expose the registered entry explicitly before importing it in -+ // this module worker; this also avoids forbidden importScripts() calls. -+ const response = await fetch(_coreURL); -+ if (!response.ok) { -+ throw ERROR_IMPORT_FAILURE; -+ } -+ const bundledCore = await response.text(); -+ const exposedCore = bundledCore.replace(/([$_A-Za-z][\w$]*)\("([$_A-Za-z0-9]+)"\);(\s*(?:\}\)\(\);?)?\s*)$/, 'self.createFFmpegCore=$1("$2").default;$3'); -+ if (exposedCore === bundledCore) { -+ throw ERROR_IMPORT_FAILURE; -+ } -+ const coreBlobURL = URL.createObjectURL(new Blob([exposedCore], { type: 'text/javascript' })); - try { -- if (!_coreURL) -- _coreURL = CORE_URL; -- // when web worker type is `classic`. -- importScripts(_coreURL); -+ await import(/* @vite-ignore */ coreBlobURL); - } -- catch { -- if (!_coreURL || _coreURL === CORE_URL) -- _coreURL = CORE_URL.replace('/umd/', '/esm/'); -- // when web worker type is `module`. -- self.createFFmpegCore = (await import( -- /* @vite-ignore */ _coreURL)).default; -- if (!self.createFFmpegCore) { -- throw ERROR_IMPORT_FAILURE; -- } -+ finally { -+ URL.revokeObjectURL(coreBlobURL); -+ } -+ if (!self.createFFmpegCore) { -+ throw ERROR_IMPORT_FAILURE; - } - const coreURL = _coreURL; - const wasmURL = _wasmURL ? _wasmURL : _coreURL.replace(/.js$/g, ".wasm"); diff --git a/SecondDimensionWatcherReDive.Client/mock-server.mjs b/SecondDimensionWatcherReDive.Client/mock-server.mjs index f5ee4d4..a43bb6b 100644 --- a/SecondDimensionWatcherReDive.Client/mock-server.mjs +++ b/SecondDimensionWatcherReDive.Client/mock-server.mjs @@ -1369,7 +1369,14 @@ async function route(method, pathname, searchParams, req, res) { } // --- All remaining endpoints require auth --- - if (!hasAuth(req) && !pathname.startsWith("/api/auth/")) { + const publicTranscodingSession = + (method === "GET" || method === "DELETE") && + pathname.startsWith("/api/transcoding/sessions/"); + if ( + !hasAuth(req) && + !pathname.startsWith("/api/auth/") && + !publicTranscodingSession + ) { return empty(res, 401); } @@ -2498,6 +2505,95 @@ async function route(method, pathname, searchParams, req, res) { // --- Files --- + if (method === "POST" && pathname === "/api/transcoding/prepare") { + const sessionId = randomUUID(); + const token = randomBytes(32).toString("hex"); + const base = `/api/transcoding/sessions/${sessionId}`; + return json(res, { + sessionId, + state: "ready", + strategy: "remux", + isPlayable: true, + cacheHit: false, + progress: 1, + speed: 8.5, + queuePosition: null, + error: null, + videoCodec: "h264", + audioCodec: "aac", + statusUrl: `${base}?token=${token}`, + cancelUrl: `${base}?token=${token}`, + playbackUrl: `${base}/media.m3u8?token=${token}`, + subtitles: [], + unsupportedSubtitleCount: 0, + }); + } + + const transcodeSessionMatch = pathname.match( + /^\/api\/transcoding\/sessions\/([^/]+)\/([^/]+)(?:\/([^/]+))?$/, + ); + if (method === "GET" && transcodeSessionMatch?.[2] === "media.m3u8") { + const sessionId = transcodeSessionMatch[1]; + const token = searchParams.get("token") ?? ""; + res.writeHead(200, { + "Content-Type": "application/vnd.apple.mpegurl", + "Cache-Control": "no-cache, no-store", + }); + return res.end( + `#EXTM3U\n#EXT-X-VERSION:3\n#EXTINF:6,\n/api/transcoding/sessions/${sessionId}/segments/segment-000000.ts?token=${token}\n#EXT-X-ENDLIST\n`, + ); + } + if ( + method === "GET" && + transcodeSessionMatch?.[2] === "segments" && + transcodeSessionMatch?.[3] + ) { + res.writeHead(200, { "Content-Type": "video/mp2t" }); + return res.end("Mock HLS segment"); + } + const transcodeStatusMatch = pathname.match( + /^\/api\/transcoding\/sessions\/([^/]+)$/, + ); + if (method === "GET" && transcodeStatusMatch) { + const sessionId = transcodeStatusMatch[1]; + const token = searchParams.get("token") ?? ""; + const base = `/api/transcoding/sessions/${sessionId}`; + return json(res, { + sessionId, + state: "ready", + strategy: "remux", + isPlayable: true, + cacheHit: true, + progress: 1, + speed: 8.5, + queuePosition: null, + error: null, + videoCodec: "h264", + audioCodec: "aac", + statusUrl: `${base}?token=${token}`, + cancelUrl: `${base}?token=${token}`, + playbackUrl: `${base}/media.m3u8?token=${token}`, + subtitles: [], + unsupportedSubtitleCount: 0, + }); + } + if (method === "DELETE" && transcodeStatusMatch) return empty(res, 204); + + if (method === "GET" && pathname === "/api/transcoding/metrics") { + return json(res, { + queuedJobs: 0, + activeJobs: 0, + completedJobs: 1, + failedJobs: 0, + canceledJobs: 0, + cacheHits: 1, + cacheBytes: 1048576, + averageFirstSegmentSeconds: 0.8, + averageTranscodeSpeed: 8.5, + failureRate: 0, + }); + } + if (method === "GET" && pathname === "/api/file/list") { const id = searchParams.get("id"); const relativeDir = searchParams.get("relativeDir") ?? ""; diff --git a/SecondDimensionWatcherReDive.Client/package.json b/SecondDimensionWatcherReDive.Client/package.json index 5f71a25..e7147e1 100644 --- a/SecondDimensionWatcherReDive.Client/package.json +++ b/SecondDimensionWatcherReDive.Client/package.json @@ -1,7 +1,5 @@ { "dependencies": { - "@ffmpeg/core": "^0.12.10", - "@ffmpeg/ffmpeg": "patch:@ffmpeg/ffmpeg@npm%3A0.12.15#~/.yarn/patches/@ffmpeg-ffmpeg-npm-0.12.15-ef17f09f1b.patch", "@radix-ui/react-dialog": "^1.1.23", "@radix-ui/react-dropdown-menu": "^2.1.24", "@radix-ui/react-progress": "^1.1.16", @@ -12,6 +10,7 @@ "artplayer-proxy-mediabunny": "^1.2.0", "clsx": "^2.1.1", "dayjs": "^1.11.23", + "hls.js": "^1.7.1", "i18next": "^26.4.0", "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^1.34.0", diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/player.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/player.json index 2c3c475..0225168 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/player.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/player.json @@ -56,25 +56,27 @@ "mkv": { "mode": { "demuxed": "Browser MKV demux", - "transcoded": "Browser transcoded" + "serverRemuxed": "Server HLS remux", + "serverTranscoded": "Server HLS transcode" }, "stages": { "probing": "Inspecting MKV audio and video tracks", "extractingSubtitles": "Extracting and converting embedded subtitles", - "loadingTranscoder": "Loading the browser transcoder", - "downloading": "Downloading the MKV for browser conversion", - "readingTracks": "Reading MKV track information", - "convertingSubtitles": "Converting embedded subtitles to WebVTT", - "transcodingVideo": "Converting unsupported audio or video codecs", - "finalizing": "Preparing the playable video" + "serverQueued": "Waiting in the server transcoding queue (position {{position}})", + "serverProbing": "The server is inspecting media tracks", + "serverRemuxing": "Remuxing compatible tracks into HLS", + "serverTranscoding": "Transcoding unsupported tracks into HLS", + "serverFinalizing": "Streaming generated HLS segments while the server finishes" }, - "playbackPreparationFailed": "This MKV could not be demuxed or converted in the browser", + "playbackPreparationFailed": "The server could not prepare this MKV for browser playback", "subtitleExtractionFailed": "The MKV can play, but its embedded subtitles could not be extracted", - "transcodeNotice": "Browser software conversion downloads the complete file and can take a while. Keep this page open.", + "serverNotice": "Playback starts as soon as the first server segment is ready; the source file is not downloaded to this device.", + "subtitleNotice": "The video is ready while compatible embedded subtitles are prepared in the background.", + "cacheHit": "Cached segments", "codecSummary": "MKV demuxed automatically · video {{video}} · audio {{audio}}", "noAudio": "none", - "bitmapSubtitlesSkipped_one": "Skipped {{count}} subtitle track that the browser could not convert", - "bitmapSubtitlesSkipped_other": "Skipped {{count}} subtitle tracks that the browser could not convert" + "bitmapSubtitlesSkipped_one": "{{count}} bitmap subtitle track is unavailable unless server burn-in is enabled", + "bitmapSubtitlesSkipped_other": "{{count}} bitmap subtitle tracks are unavailable unless server burn-in is enabled" }, "next": { "play": "Next episode", diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/player.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/player.json index 4e78732..34d486f 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/player.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/player.json @@ -53,24 +53,26 @@ "mkv": { "mode": { "demuxed": "ブラウザーで MKV 分離", - "transcoded": "ブラウザーで変換済み" + "serverRemuxed": "サーバー HLS リマックス", + "serverTranscoded": "サーバー HLS 変換" }, "stages": { "probing": "MKV の映像・音声トラックを確認しています", "extractingSubtitles": "内蔵字幕を抽出して変換しています", - "loadingTranscoder": "ブラウザー変換エンジンを読み込んでいます", - "downloading": "ブラウザー変換用に MKV をダウンロードしています", - "readingTracks": "MKV のトラック情報を読み取っています", - "convertingSubtitles": "内蔵字幕を WebVTT に変換しています", - "transcodingVideo": "未対応の映像・音声コーデックを変換しています", - "finalizing": "再生可能な動画を準備しています" + "serverQueued": "サーバー変換待ちです(キュー {{position}} 番)", + "serverProbing": "サーバーがメディアトラックを確認しています", + "serverRemuxing": "互換トラックを HLS にリマックスしています", + "serverTranscoding": "未対応トラックを HLS に変換しています", + "serverFinalizing": "生成済み HLS を再生しながら残りを処理しています" }, - "playbackPreparationFailed": "この MKV をブラウザーで分離または変換できませんでした", + "playbackPreparationFailed": "サーバーでこの MKV をブラウザー再生用に準備できませんでした", "subtitleExtractionFailed": "MKV は再生できますが、内蔵字幕を抽出できませんでした", - "transcodeNotice": "ブラウザーでのソフトウェア変換はファイル全体をダウンロードするため、時間がかかる場合があります。このページを開いたままにしてください。", + "serverNotice": "最初のサーバー分割ができ次第再生し、元ファイル全体は端末へダウンロードしません。", + "subtitleNotice": "動画は再生可能です。対応する内蔵字幕をバックグラウンドで準備しています。", + "cacheHit": "キャッシュ済み分割を再利用", "codecSummary": "MKV を自動分離しました · 映像 {{video}} · 音声 {{audio}}", "noAudio": "なし", - "bitmapSubtitlesSkipped": "ブラウザーで変換できない字幕トラック {{count}} 件をスキップしました" + "bitmapSubtitlesSkipped": "画像字幕 {{count}} 件は利用できません。サーバー側の焼き込み設定で有効化できます" }, "next": { "play": "次のエピソード", diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/player.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/player.json index 63a4d8b..417d87f 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/player.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/player.json @@ -53,24 +53,26 @@ "mkv": { "mode": { "demuxed": "MKV 前端拆包", - "transcoded": "浏览器已转码" + "serverRemuxed": "服务端 HLS 无损封装", + "serverTranscoded": "服务端 HLS 转码" }, "stages": { "probing": "正在检查 MKV 音视频轨道", "extractingSubtitles": "正在拆出并转换内封字幕", - "loadingTranscoder": "正在加载浏览器转码器", - "downloading": "正在下载 MKV 以供前端转换", - "readingTracks": "正在读取 MKV 轨道信息", - "convertingSubtitles": "正在把内封字幕转换为 WebVTT", - "transcodingVideo": "正在转换浏览器不支持的音视频编码", - "finalizing": "正在生成可播放的视频" + "serverQueued": "正在等待服务端转码(队列第 {{position}} 位)", + "serverProbing": "服务端正在探测媒体轨道", + "serverRemuxing": "正在将兼容轨道无损封装为 HLS", + "serverTranscoding": "正在将不兼容轨道转为 HLS", + "serverFinalizing": "已开始播放生成的分片,服务端继续处理剩余内容" }, - "playbackPreparationFailed": "无法在浏览器中拆包或转换此 MKV", + "playbackPreparationFailed": "服务端无法为浏览器准备此 MKV", "subtitleExtractionFailed": "MKV 可以播放,但内封字幕拆出失败", - "transcodeNotice": "前端软件转码需要完整下载文件,首次处理可能较慢,请保持此页面打开。", + "serverNotice": "首个服务端分片就绪后即可播放,源文件不会完整下载到本设备。", + "subtitleNotice": "视频已可播放,兼容的内封字幕仍在后台准备。", + "cacheHit": "已复用缓存分片", "codecSummary": "MKV 已自动拆包 · 视频 {{video}} · 音频 {{audio}}", "noAudio": "无", - "bitmapSubtitlesSkipped": "已跳过 {{count}} 条浏览器无法转换的字幕轨道" + "bitmapSubtitlesSkipped": "{{count}} 条位图字幕不可用;管理员可配置服务端烧录" }, "next": { "play": "下一集", diff --git a/SecondDimensionWatcherReDive.Client/src/pages/PlayerPage.tsx b/SecondDimensionWatcherReDive.Client/src/pages/PlayerPage.tsx index a6d86cc..46edbe4 100644 --- a/SecondDimensionWatcherReDive.Client/src/pages/PlayerPage.tsx +++ b/SecondDimensionWatcherReDive.Client/src/pages/PlayerPage.tsx @@ -1,5 +1,6 @@ import Artplayer from "artplayer"; import artplayerProxyMediabunny from "artplayer-proxy-mediabunny"; +import Hls from "hls.js"; import { CaptionsFileFormat, CaptionsRenderer, @@ -38,15 +39,17 @@ import { } from "../playback/mkv/subtitles"; import { MkvPlaybackProbe, - canCopyVideoCodecToMp4, isAbortError, isMkvPath, probeMkvPlayback, } from "../playback/mkv/support"; import { - MkvTranscodeStage, - transcodeMkvForBrowser, -} from "../playback/mkv/transcoder"; + ServerTranscodingStrategy, + prepareServerTranscoding, + releaseServerTranscoding, + touchServerTranscoding, + watchServerTranscoding, +} from "../playback/serverTranscoding"; import { ExternalSubtitle, PlaybackPreferences, @@ -66,13 +69,21 @@ interface ResolvedSubtitle extends ExternalSubtitle { source: "external" | "embedded"; } -type PlaybackMode = "native" | "mkvProxy" | "transcoded"; +type PlaybackMode = "native" | "mkvProxy" | "hls"; type MkvPreparationStage = - "probing" | "extractingSubtitles" | MkvTranscodeStage; + | "probing" + | "extractingSubtitles" + | "serverQueued" + | "serverProbing" + | "serverRemuxing" + | "serverTranscoding" + | "serverFinalizing"; interface MkvPreparationStatus { stage: MkvPreparationStage; progress?: number; + queuePosition?: number; + speed?: number; } interface BrowserAudioTrack { @@ -248,6 +259,9 @@ export const PlayerPage: React.FC = () => { const [mkvStatus, setMkvStatus] = React.useState( null, ); + const [serverStrategy, setServerStrategy] = + React.useState(null); + const [serverCacheHit, setServerCacheHit] = React.useState(false); const [skippedSubtitleCount, setSkippedSubtitleCount] = React.useState(0); const [subtitleDiscoveryComplete, setSubtitleDiscoveryComplete] = React.useState(false); @@ -301,6 +315,8 @@ export const PlayerPage: React.FC = () => { setPlaybackMode("native"); setMkvProbe(null); setMkvStatus(null); + setServerStrategy(null); + setServerCacheHit(false); setSkippedSubtitleCount(0); setSubtitleDiscoveryComplete(false); setSubtitles([]); @@ -318,6 +334,9 @@ export const PlayerPage: React.FC = () => { if (!animationId || !playbackContext) return; let cancelled = false; let releasePreparedMedia: (() => void) | null = null; + let cancelServerUrl: string | null = null; + let serverKeepAliveTimer: ReturnType | null = + null; const controller = new AbortController(); setLinkLoading(true); setLinkError(null); @@ -357,7 +376,7 @@ export const PlayerPage: React.FC = () => { } catch (error) { if (isAbortError(error)) throw error; // A server/probe incompatibility should still get a chance to use - // the full-file software fallback. + // the server-side probe and streaming fallback. } if (cancelled) return; setMkvProbe(probe); @@ -414,35 +433,78 @@ export const PlayerPage: React.FC = () => { return; } - const transcoded = await transcodeMkvForBrowser( - videoLink.url, - controller.signal, - (update) => { - if (!cancelled) setMkvStatus(update); - }, + const initialSession = await prepareServerTranscoding( { - copyVideo: - probe?.videoDecodable === true && - canCopyVideoCodecToMp4(probe.videoCodec), + id: animationId, + path: playbackContext.media.path, + quality: "auto", + audioLanguage: playbackContext.preferences.audioLanguage, + audioTrackLabel: playbackContext.preferences.audioTrackLabel, + subtitleLanguage: playbackContext.preferences.subtitleLanguage, + subtitleTrackLabel: playbackContext.preferences.subtitleTrackLabel, }, + controller.signal, ); - if (cancelled) { - transcoded.release(); - return; + cancelServerUrl = initialSession.cancelUrl; + const readySession = await watchServerTranscoding( + initialSession, + controller.signal, + (session) => { + if (cancelled) return; + cancelServerUrl = session.cancelUrl; + setServerStrategy(session.strategy); + setServerCacheHit(session.cacheHit); + setSkippedSubtitleCount(session.unsupportedSubtitleCount); + if (session.subtitles.length > 0) { + setSubtitles([ + ...subtitleLinks, + ...session.subtitles.map((subtitle) => ({ + ...subtitle, + source: "embedded" as const, + })), + ]); + } + + if (session.isPlayable && session.playbackUrl) { + setPlaybackMode(session.strategy === "direct" ? "native" : "hls"); + setPlaybackUrl(session.playbackUrl); + setLinkLoading(false); + } + + if (session.state === "ready") { + setMkvStatus(null); + setSubtitleDiscoveryComplete(true); + return; + } + const stage: MkvPreparationStage = + session.state === "queued" + ? "serverQueued" + : session.state === "probing" + ? "serverProbing" + : session.strategy === "remux" + ? "serverRemuxing" + : session.isPlayable + ? "serverFinalizing" + : "serverTranscoding"; + setMkvStatus({ + stage, + progress: session.progress ?? undefined, + queuePosition: session.queuePosition ?? undefined, + speed: session.speed ?? undefined, + }); + }, + ); + if (!cancelled) { + serverKeepAliveTimer = globalThis.setInterval( + () => { + void touchServerTranscoding( + readySession.statusUrl, + controller.signal, + ).catch(() => undefined); + }, + 5 * 60 * 1000, + ); } - releasePreparedMedia = transcoded.release; - setSkippedSubtitleCount(transcoded.skippedSubtitleCount); - setPlaybackMode("transcoded"); - setPlaybackUrl(transcoded.url); - setSubtitles([ - ...subtitleLinks, - ...transcoded.subtitles.map((subtitle) => ({ - ...subtitle, - source: "embedded" as const, - })), - ]); - setSubtitleDiscoveryComplete(true); - setMkvStatus(null); } catch (error) { if (cancelled || isAbortError(error)) return; const message = i18n.t( @@ -463,6 +525,10 @@ export const PlayerPage: React.FC = () => { cancelled = true; controller.abort(); releasePreparedMedia?.(); + if (serverKeepAliveTimer !== null) { + globalThis.clearInterval(serverKeepAliveTimer); + } + if (cancelServerUrl) releaseServerTranscoding(cancelServerUrl); }; }, [ animationId, @@ -613,9 +679,39 @@ export const PlayerPage: React.FC = () => { ? "ja" : "en"; + let hls: Hls | null = null; const art = new Artplayer({ container: playerContainerRef.current, url: playbackUrl, + type: playbackMode === "hls" ? "m3u8" : undefined, + customType: + playbackMode === "hls" + ? { + m3u8: (video: HTMLVideoElement, url: string) => { + if (!Hls.isSupported()) { + video.src = url; + return; + } + hls = new Hls({ + backBufferLength: 90, + maxBufferLength: 30, + manifestLoadingMaxRetry: 6, + levelLoadingMaxRetry: 6, + fragLoadingMaxRetry: 6, + }); + hls.on(Hls.Events.ERROR, (_event, data) => { + if (!data.fatal || !hls) return; + if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { + hls.startLoad(); + } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { + hls.recoverMediaError(); + } + }); + hls.loadSource(url); + hls.attachMedia(video); + }, + } + : undefined, proxy: playbackMode === "mkvProxy" ? artplayerProxyMediabunny({ @@ -661,27 +757,40 @@ export const PlayerPage: React.FC = () => { captionsRendererRef.current = captionsRenderer; } + const applyInitialSeek = () => { + const context = contextRef.current; + if (!context || initialSeekAppliedRef.current) return; + const resumeAt = context.state?.positionSeconds ?? 0; + const duration = art.duration; + if ( + playbackMode === "hls" && + !context.state?.isWatched && + resumeAt >= 5 && + (!Number.isFinite(duration) || resumeAt >= duration - 10) + ) { + // The event playlist grows while FFmpeg works. Wait for the requested + // timestamp to appear instead of discarding the cross-device resume. + return; + } + if ( + !context.state?.isWatched && + resumeAt >= 5 && + Number.isFinite(duration) && + resumeAt < duration - 10 + ) { + art.currentTime = resumeAt; + art.notice.show = i18n.t("player:progress.resumed", { + time: new Date(resumeAt * 1000).toISOString().slice(11, 19), + }); + lastSyncedTimeRef.current = resumeAt; + } + initialSeekAppliedRef.current = true; + }; + const onLoadedMetadata = () => { const context = contextRef.current; if (!context) return; - - if (!initialSeekAppliedRef.current) { - const resumeAt = context.state?.positionSeconds ?? 0; - const duration = art.duration; - if ( - !context.state?.isWatched && - resumeAt >= 5 && - Number.isFinite(duration) && - resumeAt < duration - 10 - ) { - art.currentTime = resumeAt; - art.notice.show = i18n.t("player:progress.resumed", { - time: new Date(resumeAt * 1000).toISOString().slice(11, 19), - }); - lastSyncedTimeRef.current = resumeAt; - } - initialSeekAppliedRef.current = true; - } + applyInitialSeek(); const discoveredTracks = readAudioTracks( art.video as VideoWithAudioTracks, @@ -741,6 +850,7 @@ export const PlayerPage: React.FC = () => { const onBeforeUnload = () => persistCurrentProgressRef.current(true, true); art.on("video:loadedmetadata", onLoadedMetadata); + art.on("video:durationchange", applyInitialSeek); art.on("video:timeupdate", onTimeUpdate); art.on("video:pause", onPause); art.on("video:seeked", onSeeked); @@ -754,6 +864,7 @@ export const PlayerPage: React.FC = () => { window.removeEventListener("beforeunload", onBeforeUnload); captionsRenderer?.destroy(); captionsOverlay?.remove(); + hls?.destroy(); if (captionsRendererRef.current === captionsRenderer) { captionsRendererRef.current = null; } @@ -968,7 +1079,13 @@ export const PlayerPage: React.FC = () => { mkvStatus?.progress == null ? null : Math.round(Math.min(1, Math.max(0, mkvStatus.progress)) * 100); - const mkvStatusLabel = mkvStatus ? t(`mkv.stages.${mkvStatus.stage}`) : null; + const mkvStatusLabel = mkvStatus + ? t(`mkv.stages.${mkvStatus.stage}`, { + position: mkvStatus.queuePosition ?? 1, + }) + : null; + const mkvSpeedLabel = + mkvStatus?.speed == null ? null : `${mkvStatus.speed.toFixed(2)}×`; return ( @@ -985,6 +1102,7 @@ export const PlayerPage: React.FC = () => {

{mkvStatusLabel} {mkvProgressPercent == null ? "" : ` · ${mkvProgressPercent}%`} + {mkvSpeedLabel ? ` · ${mkvSpeedLabel}` : ""}

{mkvProgressPercent == null ? null : (
@@ -996,7 +1114,11 @@ export const PlayerPage: React.FC = () => { )} {mkvStatus?.stage === "probing" ? null : (

- {t("mkv.transcodeNotice")} + {t( + mkvStatus?.stage === "extractingSubtitles" + ? "mkv.subtitleNotice" + : "mkv.serverNotice", + )}

)}
@@ -1033,14 +1155,21 @@ export const PlayerPage: React.FC = () => { {t( playbackMode === "mkvProxy" ? "mkv.mode.demuxed" - : "mkv.mode.transcoded", + : serverStrategy === "remux" + ? "mkv.mode.serverRemuxed" + : "mkv.mode.serverTranscoded", )} ) : null} + {serverCacheHit ? ( + + {t("mkv.cacheHit")} + + ) : null}

{mkvStatusLabel - ? `${mkvStatusLabel}${mkvProgressPercent == null ? "" : ` · ${mkvProgressPercent}%`}` + ? `${mkvStatusLabel}${mkvProgressPercent == null ? "" : ` · ${mkvProgressPercent}%`}${mkvSpeedLabel ? ` · ${mkvSpeedLabel}` : ""}` : playbackMode === "mkvProxy" && mkvProbe ? t("mkv.codecSummary", { video: mkvProbe.videoCodec, diff --git a/SecondDimensionWatcherReDive.Client/src/playback/mkv/serverTranscoding.test.ts b/SecondDimensionWatcherReDive.Client/src/playback/mkv/serverTranscoding.test.ts new file mode 100644 index 0000000..c64390b --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/playback/mkv/serverTranscoding.test.ts @@ -0,0 +1,84 @@ +import { + ServerTranscodingSession, + watchServerTranscoding, +} from "../serverTranscoding"; + +type TestCallback = () => void | Promise; +type TestFunction = (name: string, callback: TestCallback) => void; + +declare const require: (specifier: string) => unknown; + +const { rejects, strictEqual } = require("node:assert") as { + rejects: ( + promise: Promise, + check: (error: unknown) => boolean, + ) => Promise; + strictEqual: (actual: unknown, expected: unknown) => void; +}; +const { describe, it } = require("node:test") as { + describe: TestFunction; + it: TestFunction; +}; + +const createSession = ( + state: ServerTranscodingSession["state"], +): ServerTranscodingSession => ({ + sessionId: "session", + state, + strategy: state === "queued" ? null : "remux", + isPlayable: state === "ready", + cacheHit: false, + progress: null, + speed: null, + queuePosition: state === "queued" ? 1 : null, + error: state === "failed" ? "fixture failure" : null, + videoCodec: null, + audioCodec: null, + statusUrl: "/status", + cancelUrl: "/cancel", + playbackUrl: state === "ready" ? "/media.m3u8" : null, + subtitles: [], + unsupportedSubtitleCount: 0, +}); + +describe("watchServerTranscoding", () => { + it("returns an already-ready cache entry without polling", async () => { + const controller = new AbortController(); + let updates = 0; + const result = await watchServerTranscoding( + createSession("ready"), + controller.signal, + () => { + updates += 1; + }, + ); + + strictEqual(result.state, "ready"); + strictEqual(updates, 1); + }); + + it("surfaces terminal server failures", async () => { + await rejects( + watchServerTranscoding( + createSession("failed"), + new AbortController().signal, + () => undefined, + ), + (error) => error instanceof Error && error.message === "fixture failure", + ); + }); + + it("aborts queue polling when the player is closed", async () => { + const controller = new AbortController(); + const pending = watchServerTranscoding( + createSession("queued"), + controller.signal, + () => controller.abort(), + ); + + await rejects( + pending, + (error) => error instanceof DOMException && error.name === "AbortError", + ); + }); +}); diff --git a/SecondDimensionWatcherReDive.Client/src/playback/mkv/transcoder.ts b/SecondDimensionWatcherReDive.Client/src/playback/mkv/transcoder.ts deleted file mode 100644 index d1f6742..0000000 --- a/SecondDimensionWatcherReDive.Client/src/playback/mkv/transcoder.ts +++ /dev/null @@ -1,397 +0,0 @@ -import { FFFSType, FFmpeg } from "@ffmpeg/ffmpeg"; -import ffmpegCoreUrl from "url:@ffmpeg/core"; -import ffmpegWasmUrl from "url:@ffmpeg/core/wasm"; - -export type MkvTranscodeStage = - | "loadingTranscoder" - | "downloading" - | "readingTracks" - | "convertingSubtitles" - | "transcodingVideo" - | "finalizing"; - -export interface MkvTranscodeUpdate { - stage: MkvTranscodeStage; - progress?: number; -} - -export interface TranscodedMkvSubtitle { - path: string; - virtualPath: string; - language: string | null; - label: string; - format: "vtt"; - url: string; -} - -export interface TranscodedMkvResult { - url: string; - videoCodec: string; - audioCodec: string | null; - subtitles: TranscodedMkvSubtitle[]; - skippedSubtitleCount: number; - release: () => void; -} - -export interface MkvTranscodeOptions { - /** Preserve a browser-decodable video stream when only audio needs conversion. */ - copyVideo?: boolean; -} - -interface ProbeStream { - index: number; - codec_name?: string; - codec_type?: "video" | "audio" | "subtitle" | string; - disposition?: { - attached_pic?: number; - default?: number; - forced?: number; - }; - tags?: { - language?: string; - title?: string; - }; -} - -interface ProbeResult { - streams?: ProbeStream[]; -} - -interface ExtractTextSubtitlesResult { - subtitles: TranscodedMkvSubtitle[]; - skippedCount: number; -} - -const TEXT_SUBTITLE_CODECS = new Set([ - "ass", - "jacosub", - "microdvd", - "mov_text", - "mpl2", - "realtext", - "sami", - "ssa", - "subrip", - "subviewer", - "subviewer1", - "text", - "vplayer", - "webvtt", -]); - -const abortError = (): DOMException => - new DOMException("The operation was aborted", "AbortError"); - -const clampProgress = (value: number): number => - Math.min(1, Math.max(0, Number.isFinite(value) ? value : 0)); - -const uint8ArrayBuffer = (value: Uint8Array): ArrayBuffer => - value.buffer.slice( - value.byteOffset, - value.byteOffset + value.byteLength, - ) as ArrayBuffer; - -const fetchBlobWithProgress = async ( - url: string, - signal: AbortSignal, - onProgress: (progress?: number) => void, -): Promise => { - const response = await fetch(url, { signal }); - if (!response.ok) { - throw new Error(`Unable to download MKV (${response.status})`); - } - - const total = Number(response.headers.get("content-length")); - if (!response.body) { - const blob = await response.blob(); - onProgress(1); - return blob; - } - - const reader = response.body.getReader(); - let loaded = 0; - const stream = new ReadableStream({ - async pull(controller) { - if (signal.aborted) { - await reader.cancel(); - controller.error(abortError()); - return; - } - const { done, value } = await reader.read(); - if (done) { - controller.close(); - onProgress(1); - return; - } - loaded += value.byteLength; - onProgress( - Number.isFinite(total) && total > 0 ? loaded / total : undefined, - ); - controller.enqueue(value); - }, - cancel(reason) { - return reader.cancel(reason); - }, - }); - - return await new Response(stream, { - headers: { - "Content-Type": - response.headers.get("content-type") ?? "video/x-matroska", - }, - }).blob(); -}; - -const subtitleLabel = (stream: ProbeStream, ordinal: number): string => { - const title = stream.tags?.title?.trim(); - const language = stream.tags?.language?.trim(); - if (title) return title; - if (language) return `${language.toUpperCase()} · Embedded`; - return `Embedded subtitle ${ordinal}`; -}; - -const extractTextSubtitles = async ( - ffmpeg: FFmpeg, - inputPath: string, - streams: ProbeStream[], - signal: AbortSignal, -): Promise => { - const subtitles: TranscodedMkvSubtitle[] = []; - let skippedCount = 0; - - // Convert tracks independently. A malformed or nominally text subtitle must - // not prevent the audio/video fallback from producing playable media. - for (const [ordinal, stream] of streams.entries()) { - const outputPath = `/subtitle-${stream.index}.vtt`; - try { - const exitCode = await ffmpeg.exec( - [ - "-i", - inputPath, - "-map", - `0:${stream.index}`, - "-c:s", - "webvtt", - outputPath, - ], - -1, - { signal }, - ); - if (exitCode !== 0) { - skippedCount += 1; - continue; - } - - const data = await ffmpeg.readFile(outputPath, undefined, { signal }); - if (typeof data === "string") { - skippedCount += 1; - continue; - } - - const url = URL.createObjectURL( - new Blob([uint8ArrayBuffer(data)], { type: "text/vtt;charset=utf-8" }), - ); - subtitles.push({ - path: `__mkv_subtitle_${stream.index}`, - virtualPath: `mkv://subtitle/${stream.index}`, - language: stream.tags?.language ?? null, - label: subtitleLabel(stream, ordinal + 1), - format: "vtt", - url, - }); - } catch { - if (signal.aborted) throw abortError(); - skippedCount += 1; - } - } - - return { subtitles, skippedCount }; -}; - -/** - * Last-resort software conversion for codecs that WebCodecs cannot decode. - * WORKERFS avoids copying the input into the WebAssembly heap; the output is - * still materialized as a Blob because native playback needs a seekable file. - */ -export const transcodeMkvForBrowser = async ( - sourceUrl: string, - signal: AbortSignal, - onUpdate: (update: MkvTranscodeUpdate) => void, - options: MkvTranscodeOptions = {}, -): Promise => { - if (signal.aborted) throw abortError(); - - const ffmpeg = new FFmpeg(); - const createdUrls: string[] = []; - const recentLogs: string[] = []; - const logListener = ({ message }: { message: string }) => { - recentLogs.push(message); - if (recentLogs.length > 8) recentLogs.shift(); - }; - const conversionError = (message: string): Error => - new Error( - recentLogs.length > 0 ? `${message}: ${recentLogs.join(" | ")}` : message, - ); - ffmpeg.on("log", logListener); - let mounted = false; - const onAbort = () => ffmpeg.terminate(); - signal.addEventListener("abort", onAbort, { once: true }); - - try { - onUpdate({ stage: "loadingTranscoder" }); - await ffmpeg.load( - { coreURL: ffmpegCoreUrl, wasmURL: ffmpegWasmUrl }, - { signal }, - ); - - onUpdate({ stage: "downloading", progress: 0 }); - const sourceBlob = await fetchBlobWithProgress( - sourceUrl, - signal, - (progress) => onUpdate({ stage: "downloading", progress }), - ); - - await ffmpeg.createDir("/source", { signal }); - mounted = await ffmpeg.mount( - FFFSType.WORKERFS, - { blobs: [{ name: "episode.mkv", data: sourceBlob }] }, - "/source", - ); - if (!mounted) { - throw conversionError("Unable to mount the MKV in FFmpeg"); - } - const inputPath = "/source/episode.mkv"; - - onUpdate({ stage: "readingTracks" }); - const probePath = "/probe.json"; - const probeExitCode = await ffmpeg.ffprobe( - [ - "-v", - "error", - "-show_streams", - "-of", - "json", - inputPath, - "-o", - probePath, - ], - -1, - { signal }, - ); - let probeData: string | Uint8Array; - try { - probeData = await ffmpeg.readFile(probePath, "utf8", { signal }); - } catch { - throw conversionError( - `Unable to inspect MKV tracks (exit ${probeExitCode})`, - ); - } - const probe = JSON.parse(String(probeData)) as ProbeResult; - const streams = probe.streams ?? []; - const videoStream = streams.find( - (stream) => - stream.codec_type === "video" && stream.disposition?.attached_pic !== 1, - ); - if (!videoStream) throw new Error("The MKV file has no video track"); - const audioStream = streams.find((stream) => stream.codec_type === "audio"); - const subtitleStreams = streams.filter( - (stream) => stream.codec_type === "subtitle", - ); - const textSubtitleStreams = subtitleStreams.filter((stream) => - TEXT_SUBTITLE_CODECS.has(stream.codec_name?.toLowerCase() ?? ""), - ); - - onUpdate({ stage: "convertingSubtitles" }); - const subtitleExtraction = await extractTextSubtitles( - ffmpeg, - inputPath, - textSubtitleStreams, - signal, - ); - const subtitles = subtitleExtraction.subtitles; - createdUrls.push(...subtitles.map((subtitle) => subtitle.url)); - - onUpdate({ stage: "transcodingVideo", progress: 0 }); - recentLogs.length = 0; - const progressListener = ({ progress }: { progress: number }) => { - onUpdate({ - stage: "transcodingVideo", - progress: clampProgress(progress), - }); - }; - ffmpeg.on("progress", progressListener); - const outputPath = "/episode-browser.mp4"; - const transcodeExitCode = await ffmpeg.exec( - [ - "-i", - inputPath, - "-map", - `0:${videoStream.index}`, - ...(audioStream ? ["-map", `0:${audioStream.index}`] : []), - "-sn", - ...(options.copyVideo - ? ["-c:v", "copy"] - : [ - "-c:v", - "libx264", - "-preset", - "ultrafast", - "-crf", - "23", - "-pix_fmt", - "yuv420p", - ]), - ...(audioStream ? ["-c:a", "aac", "-b:a", "192k", "-ac", "2"] : []), - "-movflags", - "+faststart", - "-max_muxing_queue_size", - "1024", - outputPath, - ], - -1, - { signal }, - ); - ffmpeg.off("progress", progressListener); - if (transcodeExitCode !== 0) { - throw conversionError("Unable to convert the MKV video stream"); - } - - onUpdate({ stage: "finalizing" }); - const outputData = await ffmpeg.readFile(outputPath, undefined, { signal }); - if (typeof outputData === "string") { - throw new Error("FFmpeg returned an invalid video payload"); - } - const videoUrl = URL.createObjectURL( - new Blob([uint8ArrayBuffer(outputData)], { type: "video/mp4" }), - ); - createdUrls.push(videoUrl); - - let released = false; - return { - url: videoUrl, - videoCodec: videoStream.codec_name ?? "unknown", - audioCodec: audioStream?.codec_name ?? null, - subtitles, - skippedSubtitleCount: - subtitleStreams.length - - textSubtitleStreams.length + - subtitleExtraction.skippedCount, - release: () => { - if (released) return; - released = true; - createdUrls.forEach((url) => URL.revokeObjectURL(url)); - }, - }; - } catch (error) { - createdUrls.forEach((url) => URL.revokeObjectURL(url)); - if (signal.aborted) throw abortError(); - throw error; - } finally { - signal.removeEventListener("abort", onAbort); - ffmpeg.off("log", logListener); - if (mounted && !signal.aborted && ffmpeg.loaded) { - await ffmpeg.unmount("/source").catch(() => undefined); - } - ffmpeg.terminate(); - } -}; diff --git a/SecondDimensionWatcherReDive.Client/src/playback/serverTranscoding.ts b/SecondDimensionWatcherReDive.Client/src/playback/serverTranscoding.ts new file mode 100644 index 0000000..075c8cb --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/playback/serverTranscoding.ts @@ -0,0 +1,121 @@ +import fetcher from "../auth/httpClient"; + +export type ServerTranscodingState = + "queued" | "probing" | "transcoding" | "ready" | "failed" | "canceled"; + +export type ServerTranscodingStrategy = "direct" | "remux" | "transcode"; + +export interface ServerTranscodingSubtitle { + path: string; + virtualPath: string; + language: string | null; + label: string; + format: "vtt"; + url: string; +} + +export interface ServerTranscodingSession { + sessionId: string; + state: ServerTranscodingState; + strategy: ServerTranscodingStrategy | null; + isPlayable: boolean; + cacheHit: boolean; + progress: number | null; + speed: number | null; + queuePosition: number | null; + error: string | null; + videoCodec: string | null; + audioCodec: string | null; + statusUrl: string; + cancelUrl: string; + playbackUrl: string | null; + subtitles: ServerTranscodingSubtitle[]; + unsupportedSubtitleCount: number; +} + +export interface PrepareServerTranscodingRequest { + id: string; + path: string; + quality?: "auto" | "720p" | "1080p"; + audioLanguage?: string | null; + audioTrackLabel?: string | null; + subtitleLanguage?: string | null; + subtitleTrackLabel?: string | null; +} + +const abortError = (): DOMException => + new DOMException("The operation was aborted", "AbortError"); + +const pollDelay = async ( + milliseconds: number, + signal: AbortSignal, +): Promise => { + if (signal.aborted) throw abortError(); + await new Promise((resolve, reject) => { + const onAbort = () => { + globalThis.clearTimeout(timeout); + reject(abortError()); + }; + const timeout = globalThis.setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, milliseconds); + signal.addEventListener("abort", onAbort, { once: true }); + }); +}; + +export const prepareServerTranscoding = async ( + request: PrepareServerTranscodingRequest, + signal: AbortSignal, +): Promise => + await fetcher("/api/transcoding/prepare", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + signal, + }); + +export const watchServerTranscoding = async ( + initial: ServerTranscodingSession, + signal: AbortSignal, + onUpdate: (session: ServerTranscodingSession) => void, +): Promise => { + let current = initial; + let transientFailures = 0; + while (true) { + if (signal.aborted) throw abortError(); + onUpdate(current); + if (current.state === "ready") return current; + if (current.state === "failed" || current.state === "canceled") { + throw new Error(current.error || `Server transcoding ${current.state}`); + } + + await pollDelay(current.state === "queued" ? 1000 : 750, signal); + try { + const response = await fetch(current.statusUrl, { signal }); + if (!response.ok) + throw new Error(`Transcoding status ${response.status}`); + current = (await response.json()) as ServerTranscodingSession; + transientFailures = 0; + } catch (error) { + if (signal.aborted) throw abortError(); + transientFailures += 1; + if (transientFailures >= 3) throw error; + } + } +}; + +export const touchServerTranscoding = async ( + statusUrl: string, + signal: AbortSignal, +): Promise => { + const response = await fetch(statusUrl, { signal }); + if (!response.ok) throw new Error(`Transcoding status ${response.status}`); + await response.json(); +}; + +export const releaseServerTranscoding = (cancelUrl: string): void => { + void fetch(cancelUrl, { method: "DELETE", keepalive: true }).catch( + () => undefined, + ); +}; diff --git a/SecondDimensionWatcherReDive.Client/src/types/parcel-assets.d.ts b/SecondDimensionWatcherReDive.Client/src/types/parcel-assets.d.ts index 7f2ed7d..d80e98f 100644 --- a/SecondDimensionWatcherReDive.Client/src/types/parcel-assets.d.ts +++ b/SecondDimensionWatcherReDive.Client/src/types/parcel-assets.d.ts @@ -1,15 +1,5 @@ declare module "*.css"; -declare module "url:@ffmpeg/core" { - const url: string; - export default url; -} - -declare module "url:@ffmpeg/core/wasm" { - const url: string; - export default url; -} - declare module "bundle-text:*" { const source: string; export default source; diff --git a/SecondDimensionWatcherReDive.Client/yarn.lock b/SecondDimensionWatcherReDive.Client/yarn.lock index dbd57a3..c4f6f4c 100644 --- a/SecondDimensionWatcherReDive.Client/yarn.lock +++ b/SecondDimensionWatcherReDive.Client/yarn.lock @@ -330,38 +330,6 @@ __metadata: languageName: node linkType: hard -"@ffmpeg/core@npm:^0.12.10": - version: 0.12.10 - resolution: "@ffmpeg/core@npm:0.12.10" - checksum: 10/1385a695b5c3f2ceac75f59a4413c7e529e70b5eef9813d92fb929571e14124c26b8f760bfa89aefc442dc0bbb154c35bfde5a3b31b90a063a6507f7640ebef6 - languageName: node - linkType: hard - -"@ffmpeg/ffmpeg@npm:0.12.15": - version: 0.12.15 - resolution: "@ffmpeg/ffmpeg@npm:0.12.15" - dependencies: - "@ffmpeg/types": "npm:^0.12.4" - checksum: 10/8969f3e99be5ba318c6b2aa635703687d8c534d59a295042af28a395c5aa29d6338627200d4d81c339056c93b7e7782246450dfdebf9392081c3753a3d41028f - languageName: node - linkType: hard - -"@ffmpeg/ffmpeg@patch:@ffmpeg/ffmpeg@npm%3A0.12.15#~/.yarn/patches/@ffmpeg-ffmpeg-npm-0.12.15-ef17f09f1b.patch": - version: 0.12.15 - resolution: "@ffmpeg/ffmpeg@patch:@ffmpeg/ffmpeg@npm%3A0.12.15#~/.yarn/patches/@ffmpeg-ffmpeg-npm-0.12.15-ef17f09f1b.patch::version=0.12.15&hash=cbebad" - dependencies: - "@ffmpeg/types": "npm:^0.12.4" - checksum: 10/124f2a16e18f6dc7e9654f03a7ff05ec4a3f4459ca5e5507a7e5aa6801cef70076eadda43465e5e18443784fc35b4a77466ca8bad15a38e0ff9bbd237642d719 - languageName: node - linkType: hard - -"@ffmpeg/types@npm:^0.12.4": - version: 0.12.4 - resolution: "@ffmpeg/types@npm:0.12.4" - checksum: 10/8b898163e79945d2eba26f2a46f35a22d00296cb53424f16118060f28c36f694d74d48c49f6570f2974320dc089b7aa9f64c3a0fde45109ba125aa9e3c0926db - languageName: node - linkType: hard - "@floating-ui/core@npm:^1.8.0": version: 1.8.0 resolution: "@floating-ui/core@npm:1.8.0" @@ -404,8 +372,6 @@ __metadata: version: 0.0.0-use.local resolution: "@hcgstudio/sdwr-client@workspace:." dependencies: - "@ffmpeg/core": "npm:^0.12.10" - "@ffmpeg/ffmpeg": "patch:@ffmpeg/ffmpeg@npm%3A0.12.15#~/.yarn/patches/@ffmpeg-ffmpeg-npm-0.12.15-ef17f09f1b.patch" "@parcel/core": "npm:^2.16.4" "@parcel/transformer-inline-string": "npm:2.16.4" "@radix-ui/react-dialog": "npm:^1.1.23" @@ -423,6 +389,7 @@ __metadata: clsx: "npm:^2.1.1" color-convert: "npm:^3.1.3" dayjs: "npm:^1.11.23" + hls.js: "npm:^1.7.1" http-proxy-middleware: "npm:^4.2.0" i18next: "npm:^26.4.0" i18next-browser-languagedetector: "npm:^8.2.1" @@ -3692,6 +3659,13 @@ __metadata: languageName: node linkType: hard +"hls.js@npm:^1.7.1": + version: 1.7.1 + resolution: "hls.js@npm:1.7.1" + checksum: 10/5bf1c5ba1cbb82a4274f55afe62c361137bad7a60e6c7a3cfd881ca47a6883357b660c080982a7393c840bcd710c6ca97ad12094a2db72d0c0dbe1cc9e48edb1 + languageName: node + linkType: hard + "hpagent@npm:^1.2.0": version: 1.2.0 resolution: "hpagent@npm:1.2.0" diff --git a/SecondDimensionWatcherReDive.IntegrationTest/Helpers/FakeTranscodingService.cs b/SecondDimensionWatcherReDive.IntegrationTest/Helpers/FakeTranscodingService.cs new file mode 100644 index 0000000..17cb5d0 --- /dev/null +++ b/SecondDimensionWatcherReDive.IntegrationTest/Helpers/FakeTranscodingService.cs @@ -0,0 +1,103 @@ +using SecondDimensionWatcherReDive.Services.Transcoding; + +namespace SecondDimensionWatcherReDive.IntegrationTest.Helpers; + +internal sealed class FakeTranscodingService : IHlsTranscodingService +{ + public Guid SessionId { get; private set; } = Guid.NewGuid(); + public string Token { get; private set; } = "integration-transcoding-token"; + + public void Reset() + { + SessionId = Guid.NewGuid(); + Token = "integration-transcoding-token"; + } + + public Task PrepareAsync( + Guid animationInfoId, + string? relativePath, + TranscodingSelection selection, + CancellationToken cancellationToken) + => Task.FromResult(CreateStatus()); + + public Task GetStatusAsync( + Guid sessionId, + string accessToken, + CancellationToken cancellationToken) + => Task.FromResult(IsValid(sessionId, accessToken) ? CreateStatus() : null); + + public Task GetPlaylistAsync( + Guid sessionId, + string accessToken, + CancellationToken cancellationToken) + => Task.FromResult(IsValid(sessionId, accessToken) + ? "#EXTM3U\n#EXTINF:6,\nsegment-000000.ts\n#EXT-X-ENDLIST\n" + : null); + + public Task OpenSegmentAsync( + Guid sessionId, + string accessToken, + string fileName, + CancellationToken cancellationToken) + => Task.FromResult( + IsValid(sessionId, accessToken) && fileName == "segment-000000.ts" + ? new TranscodingContent( + new MemoryStream([1, 2, 3], writable: false), + "video/mp2t", + fileName, + 3, + DateTimeOffset.UnixEpoch) + : null); + + public Task OpenSubtitleAsync( + Guid sessionId, + string accessToken, + string fileName, + CancellationToken cancellationToken) + => Task.FromResult(null); + + public Task OpenDirectAsync( + Guid sessionId, + string accessToken, + CancellationToken cancellationToken) + => Task.FromResult(null); + + public Task CancelAsync( + Guid sessionId, + string accessToken, + CancellationToken cancellationToken) + => Task.FromResult(IsValid(sessionId, accessToken)); + + public Task GetMetricsAsync(CancellationToken cancellationToken) + => Task.FromResult(new TranscodingMetricsSnapshot( + 0, + 0, + 4, + 1, + 1, + 2, + 4096, + 0.75, + 3.2, + 0.2)); + + private bool IsValid(Guid sessionId, string accessToken) + => sessionId == SessionId && accessToken == Token; + + private TranscodingSessionStatus CreateStatus() + => new( + SessionId, + Token, + TranscodingJobState.Ready, + TranscodingStrategy.Remux, + true, + true, + 1, + 3.2, + null, + null, + "h264", + "aac", + [], + 0); +} diff --git a/SecondDimensionWatcherReDive.IntegrationTest/Transcoding/TranscodingApiTests.cs b/SecondDimensionWatcherReDive.IntegrationTest/Transcoding/TranscodingApiTests.cs new file mode 100644 index 0000000..419ca35 --- /dev/null +++ b/SecondDimensionWatcherReDive.IntegrationTest/Transcoding/TranscodingApiTests.cs @@ -0,0 +1,72 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; + +namespace SecondDimensionWatcherReDive.IntegrationTest.Transcoding; + +[TestClass] +public sealed class TranscodingApiTests +{ + private WebDavWebApplicationFactory _factory = null!; + + [TestInitialize] + public void Setup() + { + _factory = new WebDavWebApplicationFactory(); + _factory.ResetState(); + } + + [TestCleanup] + public void Cleanup() => _factory.Dispose(); + + [TestMethod] + public async Task PrepareRequiresJwtButTokenizedHlsResourcesAreAnonymous() + { + using var anonymous = _factory.CreateUnauthenticatedClient(); + using var unauthorized = await anonymous.PostAsJsonAsync( + "/api/transcoding/prepare", + new { id = Guid.NewGuid(), path = "episode.mkv", quality = "auto" }); + Assert.AreEqual(HttpStatusCode.Unauthorized, unauthorized.StatusCode); + + using var jwt = _factory.CreateJwtClient(); + using var prepared = await jwt.PostAsJsonAsync( + "/api/transcoding/prepare", + new { id = Guid.NewGuid(), path = "episode.mkv", quality = "auto" }); + Assert.AreEqual(HttpStatusCode.OK, prepared.StatusCode); + using var payload = JsonDocument.Parse(await prepared.Content.ReadAsStringAsync()); + var playbackUrl = payload.RootElement.GetProperty("playbackUrl").GetString(); + var statusUrl = payload.RootElement.GetProperty("statusUrl").GetString(); + Assert.IsNotNull(playbackUrl); + Assert.IsNotNull(statusUrl); + + using var status = await anonymous.GetAsync(statusUrl); + Assert.AreEqual(HttpStatusCode.OK, status.StatusCode); + using var playlist = await anonymous.GetAsync(playbackUrl); + Assert.AreEqual(HttpStatusCode.OK, playlist.StatusCode); + Assert.AreEqual("application/vnd.apple.mpegurl", playlist.Content.Headers.ContentType?.MediaType); + var segmentUrl = (await playlist.Content.ReadAsStringAsync()) + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Single(line => !line.StartsWith('#')); + StringAssert.Contains(segmentUrl, _factory.TranscodingService.Token); + using var segment = await anonymous.GetAsync(segmentUrl); + Assert.AreEqual(HttpStatusCode.OK, segment.StatusCode); + CollectionAssert.AreEqual(new byte[] { 1, 2, 3 }, await segment.Content.ReadAsByteArrayAsync()); + } + + [TestMethod] + public async Task InvalidSessionTokenIsRejectedAndMetricsRequireJwt() + { + using var anonymous = _factory.CreateUnauthenticatedClient(); + using var invalid = await anonymous.GetAsync( + $"/api/transcoding/sessions/{_factory.TranscodingService.SessionId}?token=wrong"); + Assert.AreEqual(HttpStatusCode.NotFound, invalid.StatusCode); + using var unauthorizedMetrics = await anonymous.GetAsync("/api/transcoding/metrics"); + Assert.AreEqual(HttpStatusCode.Unauthorized, unauthorizedMetrics.StatusCode); + + using var jwt = _factory.CreateJwtClient(); + using var metrics = await jwt.GetAsync("/api/transcoding/metrics"); + Assert.AreEqual(HttpStatusCode.OK, metrics.StatusCode); + using var payload = JsonDocument.Parse(await metrics.Content.ReadAsStringAsync()); + Assert.AreEqual(0.2, payload.RootElement.GetProperty("failureRate").GetDouble()); + } +} diff --git a/SecondDimensionWatcherReDive.IntegrationTest/WebDavWebApplicationFactory.cs b/SecondDimensionWatcherReDive.IntegrationTest/WebDavWebApplicationFactory.cs index ae5cf79..379a089 100644 --- a/SecondDimensionWatcherReDive.IntegrationTest/WebDavWebApplicationFactory.cs +++ b/SecondDimensionWatcherReDive.IntegrationTest/WebDavWebApplicationFactory.cs @@ -18,6 +18,7 @@ using SecondDimensionWatcherReDive.Framework.Tasks; using SecondDimensionWatcherReDive.IntegrationTest.Helpers; using SecondDimensionWatcherReDive.MigrationTasks; +using SecondDimensionWatcherReDive.Services.Transcoding; using FileMapping = SecondDimensionWatcherReDive.Framework.DataRepository.FileMapping; using ApplicationContext = SecondDimensionWatcherReDive.Models.ApplicationContext; @@ -52,6 +53,7 @@ static WebDavWebApplicationFactory() public Mock FileStoreMock { get; } = new(); public Mock FileStoreProviderMock { get; } = new(); public Helpers.FakeFileMappingRepository MappingRepository { get; } + public FakeTranscodingService TranscodingService { get; } = new(); private readonly object _mappingsLock = new(); @@ -122,6 +124,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.RemoveAll(); services.RemoveAll(); services.RemoveAll(); + services.RemoveAll(); services.AddSingleton(FileStoreMock.Object); services.AddSingleton(FileStoreProviderMock.Object); @@ -130,6 +133,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.AddSingleton(_ => new FakeWebDavTokenRepository(TestUserName, BCrypt.Net.BCrypt.HashPassword(TestPassword))); services.AddSingleton(); + services.AddSingleton(TranscodingService); }); } @@ -146,6 +150,7 @@ public void ResetState() FileStoreProviderMock .Setup(p => p.GetClient(It.IsAny())) .Returns(FileStoreMock.Object); + TranscodingService.Reset(); } public HttpClient CreateBasicAuthClient(string user = TestUserName, string pass = TestPassword) diff --git a/SecondDimensionWatcherReDive.Test/FfmpegProcessRunnerTests.cs b/SecondDimensionWatcherReDive.Test/FfmpegProcessRunnerTests.cs new file mode 100644 index 0000000..c1a5114 --- /dev/null +++ b/SecondDimensionWatcherReDive.Test/FfmpegProcessRunnerTests.cs @@ -0,0 +1,126 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using SecondDimensionWatcherReDive.Services.Transcoding; + +namespace SecondDimensionWatcherReDive.Test; + +[TestClass] +public class FfmpegProcessRunnerTests +{ + [TestMethod] + public async Task ProbeAndGenerateHlsAsync_ProducesProgressivePlaylistFromPipeInput() + { + var root = Path.Combine(Path.GetTempPath(), $"sdw-ffmpeg-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + try + { + var sourcePath = Path.Combine(root, "sample.mkv"); + await CreateSampleAsync(sourcePath, CancellationToken.None); + var options = Options.Create(new TranscodingOptions + { + FfmpegPath = "ffmpeg", + FfprobePath = "ffprobe", + MaxThreadsPerJob = 1, + SegmentDurationSeconds = 2, + MaxMemoryBytesPerJob = 1024L * 1024 * 1024, + MaxDiskBytesPerJob = 64L * 1024 * 1024 + }); + var runner = new FfmpegProcessRunner( + options, + NullLogger.Instance); + MediaProbe probe; + await using (var source = File.OpenRead(sourcePath)) + probe = await runner.ProbeAsync(source, CancellationToken.None); + var sourceInfo = new FileInfo(sourcePath); + var sourceModel = new TranscodingSource( + Guid.NewGuid(), + Guid.NewGuid(), + "/Anime/Group/sample.mkv", + sourcePath, + "test", + sourceInfo.Name, + sourceInfo.Length, + sourceInfo.LastWriteTimeUtc); + var selection = TranscodingSelection.Create("auto", null, null, null, null); + var plan = TranscodingPlanner.CreatePlan(sourceModel, probe, selection, false); + Assert.AreEqual(TranscodingStrategy.Remux, plan.Strategy); + + var output = Path.Combine(root, "hls"); + Directory.CreateDirectory(output); + var updates = new List(); + FfmpegRunResult result; + await using (var source = File.OpenRead(sourcePath)) + result = await runner.GenerateHlsAsync( + source, + plan, + selection, + output, + useHardwareEncoder: false, + update => updates.Add(update), + CancellationToken.None); + + Assert.AreEqual(0, result.ExitCode, result.ErrorOutput); + Assert.IsTrue(File.Exists(Path.Combine(output, "media.m3u8"))); + Assert.IsTrue(Directory.EnumerateFiles(output, "segment-*.ts").Any()); + Assert.IsTrue(updates.Any(update => update.FirstSegmentReady)); + StringAssert.Contains( + await File.ReadAllTextAsync(Path.Combine(output, "media.m3u8")), + "#EXT-X-ENDLIST"); + IReadOnlyList subtitles; + await using (var source = File.OpenRead(sourcePath)) + subtitles = await runner.ExtractTextSubtitlesAsync( + source, + plan, + output, + CancellationToken.None); + Assert.AreEqual(1, subtitles.Count); + StringAssert.StartsWith( + await File.ReadAllTextAsync(Path.Combine(output, subtitles[0].FileName)), + "WEBVTT"); + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + } + } + + private static async Task CreateSampleAsync(string path, CancellationToken cancellationToken) + { + var subtitlePath = Path.ChangeExtension(path, ".srt"); + await File.WriteAllTextAsync( + subtitlePath, + "1\n00:00:00,000 --> 00:00:01,000\nHello from SDW\n", + cancellationToken); + var startInfo = new ProcessStartInfo + { + FileName = "ffmpeg", + UseShellExecute = false, + RedirectStandardError = true, + RedirectStandardOutput = true, + CreateNoWindow = true + }; + foreach (var argument in new[] + { + "-hide_banner", "-loglevel", "error", "-y", + "-f", "lavfi", "-i", "testsrc=size=160x90:rate=10", + "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000", + "-f", "srt", "-i", subtitlePath, + "-t", "2", + "-map", "0:v:0", "-map", "1:a:0", "-map", "2:s:0", + "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", + "-c:a", "aac", "-b:a", "64k", + "-c:s", "srt", + "-f", "matroska", path + }) + startInfo.ArgumentList.Add(argument); + using var process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Unable to start FFmpeg test fixture generation."); + var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); + var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + await process.WaitForExitAsync(cancellationToken); + var error = await errorTask; + await outputTask; + Assert.AreEqual(0, process.ExitCode, error); + } +} diff --git a/SecondDimensionWatcherReDive.Test/HlsTranscodingServiceTests.cs b/SecondDimensionWatcherReDive.Test/HlsTranscodingServiceTests.cs new file mode 100644 index 0000000..e77c747 --- /dev/null +++ b/SecondDimensionWatcherReDive.Test/HlsTranscodingServiceTests.cs @@ -0,0 +1,480 @@ +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Framework.FileStore; +using SecondDimensionWatcherReDive.Services.Transcoding; + +namespace SecondDimensionWatcherReDive.Test; + +[TestClass] +public class HlsTranscodingServiceTests +{ + [TestMethod] + public async Task PrepareAsync_GeneratesPlayableHlsAndReusesCompletedCache() + { + var runner = new CompletingRunner(); + await using var fixture = await TranscodingFixture.CreateAsync(runner); + + var initial = await fixture.Service.PrepareAsync( + fixture.AnimationInfoId, + "episode.mkv", + TranscodingSelection.Create("auto", "ja", null, "en", null), + CancellationToken.None); + var ready = await WaitForStateAsync( + fixture.Service, + initial, + TranscodingJobState.Ready); + + Assert.IsTrue(ready.IsPlayable); + Assert.AreEqual(TranscodingStrategy.Remux, ready.Strategy); + Assert.AreEqual(1, ready.Subtitles.Count); + Assert.AreEqual(1, runner.GenerateCalls); + StringAssert.Contains( + await fixture.Service.GetPlaylistAsync( + ready.SessionId, + ready.AccessToken, + CancellationToken.None), + "segment-000000.ts"); + var segment = await fixture.Service.OpenSegmentAsync( + ready.SessionId, + ready.AccessToken, + "segment-000000.ts", + CancellationToken.None); + Assert.IsNotNull(segment); + Assert.AreEqual("video/mp2t", segment.ContentType); + await segment.Stream.DisposeAsync(); + + var repeated = await fixture.Service.PrepareAsync( + fixture.AnimationInfoId, + "episode.mkv", + TranscodingSelection.Create("auto", "ja", null, "en", null), + CancellationToken.None); + + Assert.AreEqual(TranscodingJobState.Ready, repeated.State); + Assert.IsTrue(repeated.CacheHit); + Assert.AreEqual(1, runner.GenerateCalls); + await fixture.RestartServiceAsync(); + var afterRestart = await fixture.Service.PrepareAsync( + fixture.AnimationInfoId, + "episode.mkv", + TranscodingSelection.Create("auto", "ja", null, "en", null), + CancellationToken.None); + Assert.AreEqual(TranscodingJobState.Ready, afterRestart.State); + Assert.IsTrue(afterRestart.CacheHit); + Assert.AreEqual(1, runner.GenerateCalls); + var metrics = await fixture.Service.GetMetricsAsync(CancellationToken.None); + Assert.AreEqual(1, metrics.CompletedJobs); + Assert.AreEqual(2, metrics.CacheHits); + Assert.IsTrue(metrics.CacheBytes > 0); + } + + [TestMethod] + public async Task PrepareAsync_SourceVersionChangeDoesNotReuseOldSegments() + { + var runner = new CompletingRunner(); + await using var fixture = await TranscodingFixture.CreateAsync(runner); + + var first = await fixture.Service.PrepareAsync( + fixture.AnimationInfoId, + "episode.mkv", + TranscodingSelection.Create("auto", null, null, null, null), + CancellationToken.None); + await WaitForStateAsync(fixture.Service, first, TranscodingJobState.Ready); + fixture.LastModifiedUtc = fixture.LastModifiedUtc.AddSeconds(1); + + var changed = await fixture.Service.PrepareAsync( + fixture.AnimationInfoId, + "episode.mkv", + TranscodingSelection.Create("auto", null, null, null, null), + CancellationToken.None); + await WaitForStateAsync(fixture.Service, changed, TranscodingJobState.Ready); + + Assert.IsFalse(changed.CacheHit); + Assert.AreEqual(2, runner.GenerateCalls); + } + + [TestMethod] + public async Task PrepareAsync_ConcurrentLimitQueuesAndRejectsOnlyWhenBoundedQueueIsFull() + { + var runner = new BlockingRunner(); + await using var fixture = await TranscodingFixture.CreateAsync( + runner, + queueCapacity: 1, + relativePaths: ["one.mkv", "two.mkv", "three.mkv"]); + + var first = await fixture.Service.PrepareAsync( + fixture.AnimationInfoId, + "one.mkv", + TranscodingSelection.Create("auto", null, null, null, null), + CancellationToken.None); + await runner.Started.Task.WaitAsync(TimeSpan.FromSeconds(3)); + var second = await fixture.Service.PrepareAsync( + fixture.AnimationInfoId, + "two.mkv", + TranscodingSelection.Create("auto", null, null, null, null), + CancellationToken.None); + + Assert.AreEqual(TranscodingJobState.Queued, second.State); + Assert.AreEqual(1, second.QueuePosition); + await Assert.ThrowsExactlyAsync(() => + fixture.Service.PrepareAsync( + fixture.AnimationInfoId, + "three.mkv", + TranscodingSelection.Create("auto", null, null, null, null), + CancellationToken.None)); + var metrics = await fixture.Service.GetMetricsAsync(CancellationToken.None); + Assert.AreEqual(1, metrics.ActiveJobs); + Assert.AreEqual(1, metrics.QueuedJobs); + + Assert.IsTrue(await fixture.Service.CancelAsync( + first.SessionId, + first.AccessToken, + CancellationToken.None)); + Assert.IsTrue(await fixture.Service.CancelAsync( + second.SessionId, + second.AccessToken, + CancellationToken.None)); + using var cleanupTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + TranscodingMetricsSnapshot afterCancellation; + do + { + afterCancellation = await fixture.Service.GetMetricsAsync(cleanupTimeout.Token); + if (afterCancellation.CanceledJobs == 2 + && !Directory.EnumerateDirectories(fixture.CachePath).Any()) break; + await Task.Delay(10, cleanupTimeout.Token); + } while (true); + Assert.AreEqual(2, afterCancellation.CanceledJobs); + } + + [TestMethod] + public async Task FailedJobDeletesPartialOutputAndReportsFailureRate() + { + await using var fixture = await TranscodingFixture.CreateAsync(new FailingRunner()); + var initial = await fixture.Service.PrepareAsync( + fixture.AnimationInfoId, + "episode.mkv", + TranscodingSelection.Create("auto", null, null, null, null), + CancellationToken.None); + + var failed = await WaitForStateAsync( + fixture.Service, + initial, + TranscodingJobState.Failed); + + StringAssert.Contains(failed.Error, "fixture FFmpeg failure"); + Assert.IsFalse(Directory.EnumerateDirectories(fixture.CachePath).Any()); + var metrics = await fixture.Service.GetMetricsAsync(CancellationToken.None); + Assert.AreEqual(1, metrics.FailedJobs); + Assert.AreEqual(1, metrics.FailureRate); + } + + private static async Task WaitForStateAsync( + IHlsTranscodingService service, + TranscodingSessionStatus session, + TranscodingJobState expected) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (true) + { + var current = await service.GetStatusAsync( + session.SessionId, + session.AccessToken, + timeout.Token); + Assert.IsNotNull(current); + if (current.State == expected) return current; + if (current.State is TranscodingJobState.Failed or TranscodingJobState.Canceled) + Assert.Fail($"Transcoding ended in {current.State}: {current.Error}"); + await Task.Delay(10, timeout.Token); + } + } + + private sealed class CompletingRunner : IFfmpegProcessRunner + { + private int _generateCalls; + public int GenerateCalls => Volatile.Read(ref _generateCalls); + + public Task ProbeAsync(Stream source, CancellationToken cancellationToken) + => Task.FromResult(new MediaProbe( + "matroska", + TimeSpan.FromSeconds(30), + [ + new MediaStreamProbe(0, "video", "h264", null, null, true, false, false), + new MediaStreamProbe(1, "audio", "aac", "jpn", "Japanese", true, false, false), + new MediaStreamProbe(2, "subtitle", "ass", "eng", "English", true, false, false) + ])); + + public async Task GenerateHlsAsync( + Stream source, + TranscodingPlan plan, + TranscodingSelection selection, + string outputDirectory, + bool useHardwareEncoder, + Action onProgress, + CancellationToken cancellationToken) + { + Interlocked.Increment(ref _generateCalls); + await File.WriteAllBytesAsync( + Path.Combine(outputDirectory, "segment-000000.ts"), + [1, 2, 3], + cancellationToken); + await File.WriteAllTextAsync( + Path.Combine(outputDirectory, "media.m3u8"), + "#EXTM3U\n#EXTINF:6,\nsegment-000000.ts\n#EXT-X-ENDLIST\n", + cancellationToken); + onProgress(new FfmpegProgress(30, 2, true)); + return new FfmpegRunResult(0, string.Empty); + } + + public async Task> ExtractTextSubtitlesAsync( + Stream source, + TranscodingPlan plan, + string outputDirectory, + CancellationToken cancellationToken) + { + const string name = "subtitle-2.vtt"; + await File.WriteAllTextAsync( + Path.Combine(outputDirectory, name), + "WEBVTT\n", + cancellationToken); + return [new TranscodingSubtitle(name, "English", "eng", "vtt")]; + } + } + + private sealed class BlockingRunner : IFfmpegProcessRunner + { + public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task ProbeAsync(Stream source, CancellationToken cancellationToken) + => Task.FromResult(new MediaProbe( + "matroska", + TimeSpan.FromSeconds(30), + [ + new MediaStreamProbe(0, "video", "h264", null, null, true, false, false), + new MediaStreamProbe(1, "audio", "aac", null, null, true, false, false) + ])); + + public async Task GenerateHlsAsync( + Stream source, + TranscodingPlan plan, + TranscodingSelection selection, + string outputDirectory, + bool useHardwareEncoder, + Action onProgress, + CancellationToken cancellationToken) + { + await File.WriteAllTextAsync( + Path.Combine(outputDirectory, "partial.tmp"), + "partial", + cancellationToken); + Started.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return new FfmpegRunResult(0, string.Empty); + } + + public Task> ExtractTextSubtitlesAsync( + Stream source, + TranscodingPlan plan, + string outputDirectory, + CancellationToken cancellationToken) + => Task.FromResult>([]); + } + + private sealed class FailingRunner : IFfmpegProcessRunner + { + public Task ProbeAsync(Stream source, CancellationToken cancellationToken) + => Task.FromResult(new MediaProbe( + "matroska", + TimeSpan.FromSeconds(30), + [ + new MediaStreamProbe(0, "video", "h264", null, null, true, false, false), + new MediaStreamProbe(1, "audio", "aac", null, null, true, false, false) + ])); + + public async Task GenerateHlsAsync( + Stream source, + TranscodingPlan plan, + TranscodingSelection selection, + string outputDirectory, + bool useHardwareEncoder, + Action onProgress, + CancellationToken cancellationToken) + { + await File.WriteAllTextAsync( + Path.Combine(outputDirectory, "partial.tmp"), + "partial", + cancellationToken); + return new FfmpegRunResult(1, "fixture FFmpeg failure"); + } + + public Task> ExtractTextSubtitlesAsync( + Stream source, + TranscodingPlan plan, + string outputDirectory, + CancellationToken cancellationToken) + => Task.FromResult>([]); + } + + private sealed class TranscodingFixture : IAsyncDisposable + { + private readonly ServiceProvider _provider; + private readonly TranscodingMetrics _metrics; + private readonly string _cachePath; + private readonly IFfmpegProcessRunner _runner; + private readonly IOptions _options; + + private TranscodingFixture( + ServiceProvider provider, + TranscodingMetrics metrics, + IFfmpegProcessRunner runner, + IOptions options, + HlsTranscodingService service, + string cachePath, + Guid animationInfoId) + { + _provider = provider; + _metrics = metrics; + _runner = runner; + _options = options; + Service = service; + _cachePath = cachePath; + AnimationInfoId = animationInfoId; + } + + public HlsTranscodingService Service { get; private set; } + public Guid AnimationInfoId { get; } + public string CachePath => _cachePath; + public DateTimeOffset LastModifiedUtc { get; set; } = + new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + public static async Task CreateAsync( + IFfmpegProcessRunner runner, + int queueCapacity = 8, + IReadOnlyList? relativePaths = null) + { + var cachePath = Path.Combine(Path.GetTempPath(), $"sdw-transcoding-test-{Guid.NewGuid():N}"); + var animationInfoId = Guid.NewGuid(); + var animation = new Animation(Guid.NewGuid(), "42", "Anime", "Anime", null); + var group = new AnimationGroup(Guid.NewGuid(), "Group"); + var info = new AnimationInfo( + animationInfoId, + "Episode", + string.Empty, + DateTimeOffset.UtcNow, + string.Empty, + string.Empty, + [], + string.Empty, + false, + DateTimeOffset.UnixEpoch, + DateTimeOffset.UnixEpoch, + true, + "test", + "/physical", + 1, + 1, + group, + animation, + true, + 0); + relativePaths ??= ["episode.mkv"]; + var mappings = relativePaths.ToDictionary( + relative => $"/Anime/Group/{relative}", + relative => new FileMapping( + Guid.NewGuid(), + animationInfoId, + $"/Anime/Group/{relative}", + $"/physical/{relative}", + "test")); + + var animationRepository = new Mock(); + animationRepository.Setup(repository => repository.FindByIdWithAnimationAsync( + animationInfoId, + It.IsAny())) + .ReturnsAsync(info); + var mappingRepository = new Mock(); + mappingRepository.Setup(repository => repository.FindByVirtualPathAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync((string path, CancellationToken _) => + mappings.GetValueOrDefault(path)); + var store = new Mock(); + store.SetupGet(item => item.Name).Returns("test"); + var storeProvider = new Mock(); + storeProvider.Setup(provider => provider.GetRequiredClient("test")).Returns(store.Object); + storeProvider.Setup(provider => provider.GetClient("test")).Returns(store.Object); + + var services = new ServiceCollection(); + services.AddSingleton(animationRepository.Object); + services.AddSingleton(mappingRepository.Object); + services.AddSingleton(store.Object); + services.AddSingleton(storeProvider.Object); + var provider = services.BuildServiceProvider(); + var options = Options.Create(new TranscodingOptions + { + CachePath = cachePath, + MaxConcurrentJobs = 1, + QueueCapacity = queueCapacity, + CleanupInterval = TimeSpan.FromHours(1), + CacheTtl = TimeSpan.FromDays(1), + SessionTtl = TimeSpan.FromHours(1) + }); + var metrics = new TranscodingMetrics(); + var service = new HlsTranscodingService( + provider.GetRequiredService(), + runner, + metrics, + new FileExtensionContentTypeProvider(), + options, + NullLogger.Instance); + var fixture = new TranscodingFixture( + provider, + metrics, + runner, + options, + service, + cachePath, + animationInfoId); + store.Setup(item => item.FileInfoAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync((string path, CancellationToken _) => new FileStoreInfo( + false, + path, + Path.GetFileName(path), + 1024, + fixture.LastModifiedUtc)); + store.Setup(item => item.OpenReadStreamAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(() => new MemoryStream([1, 2, 3])); + await service.StartAsync(CancellationToken.None); + return fixture; + } + + public async Task RestartServiceAsync() + { + await Service.StopAsync(CancellationToken.None); + Service.Dispose(); + Service = new HlsTranscodingService( + _provider.GetRequiredService(), + _runner, + _metrics, + new FileExtensionContentTypeProvider(), + _options, + NullLogger.Instance); + await Service.StartAsync(CancellationToken.None); + } + + public async ValueTask DisposeAsync() + { + await Service.StopAsync(CancellationToken.None); + Service.Dispose(); + _metrics.Dispose(); + await _provider.DisposeAsync(); + if (Directory.Exists(_cachePath)) Directory.Delete(_cachePath, recursive: true); + } + } +} diff --git a/SecondDimensionWatcherReDive.Test/TranscodingControllerTests.cs b/SecondDimensionWatcherReDive.Test/TranscodingControllerTests.cs new file mode 100644 index 0000000..be2779c --- /dev/null +++ b/SecondDimensionWatcherReDive.Test/TranscodingControllerTests.cs @@ -0,0 +1,178 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Routing; +using Moq; +using SecondDimensionWatcherReDive.Controllers; +using SecondDimensionWatcherReDive.Controllers.External; +using SecondDimensionWatcherReDive.Services.Transcoding; + +namespace SecondDimensionWatcherReDive.Test; + +[TestClass] +public class TranscodingControllerTests +{ + private StubTranscodingService _service = null!; + private TranscodingController _controller = null!; + + [TestInitialize] + public void Setup() + { + _service = new StubTranscodingService(); + _controller = new TranscodingController(_service) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }, + Url = CreateUrlHelper() + }; + } + + [TestMethod] + public async Task Prepare_QueuedJobReturnsAcceptedWithStatusAndCancelUrls() + { + var session = CreateStatus(TranscodingJobState.Queued, isPlayable: false); + _service.PrepareResult = session; + + var result = await _controller.Prepare( + new PrepareTranscodingRequest( + Guid.NewGuid(), + "episode.mkv", + "720p", + "ja", + null, + "en", + null), + CancellationToken.None); + + var accepted = Assert.IsInstanceOfType(result); + var response = Assert.IsInstanceOfType(accepted.Value); + Assert.AreEqual("queued", response.State); + Assert.IsNull(response.PlaybackUrl); + StringAssert.Contains(response.StatusUrl, session.SessionId.ToString()); + StringAssert.Contains(response.CancelUrl, session.AccessToken); + } + + [TestMethod] + public async Task Prepare_QueueFullReturns429AndRetryAfter() + { + _service.PrepareException = new TranscodingQueueFullException(); + + var result = await _controller.Prepare( + new PrepareTranscodingRequest(Guid.NewGuid(), "episode.mkv", null, null, null, null, null), + CancellationToken.None); + + var response = Assert.IsInstanceOfType(result); + Assert.AreEqual(StatusCodes.Status429TooManyRequests, response.StatusCode); + Assert.AreEqual("5", _controller.Response.Headers.RetryAfter.ToString()); + } + + [TestMethod] + public async Task GetPlaylist_RewritesEverySegmentWithSessionToken() + { + var sessionId = Guid.NewGuid(); + const string token = "secret-token"; + _service.Playlist = "#EXTM3U\n#EXTINF:6,\nsegment-000000.ts\n#EXT-X-ENDLIST\n"; + + var result = await _controller.GetPlaylist(sessionId, token, CancellationToken.None); + + var content = Assert.IsInstanceOfType(result); + StringAssert.Contains(content.Content, "#EXTM3U"); + StringAssert.Contains(content.Content, "GetSegment"); + StringAssert.Contains(content.Content, "segment-000000.ts"); + StringAssert.Contains(content.Content, token); + Assert.AreEqual("no-cache, no-store", _controller.Response.Headers.CacheControl.ToString()); + } + + private static TranscodingSessionStatus CreateStatus( + TranscodingJobState state, + bool isPlayable) + => new( + Guid.NewGuid(), + "access-token", + state, + state == TranscodingJobState.Queued ? null : TranscodingStrategy.Transcode, + isPlayable, + false, + null, + null, + state == TranscodingJobState.Queued ? 1 : null, + null, + null, + null, + [], + 0); + + private static IUrlHelper CreateUrlHelper() + { + var helper = new Mock(); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Scheme = "https"; + httpContext.Request.Host = new HostString("example.test"); + helper.SetupGet(url => url.ActionContext) + .Returns(new ActionContext(httpContext, new RouteData(), new Microsoft.AspNetCore.Mvc.Abstractions.ActionDescriptor())); + helper.Setup(url => url.Action(It.IsAny())) + .Returns((UrlActionContext context) => + { + var values = new RouteValueDictionary(context.Values); + var suffix = string.Join("&", values.Select(pair => $"{pair.Key}={pair.Value}")); + return $"https://example.test/{context.Action}?{suffix}"; + }); + return helper.Object; + } + + private sealed class StubTranscodingService : IHlsTranscodingService + { + public TranscodingSessionStatus? PrepareResult { get; set; } + public Exception? PrepareException { get; set; } + public string? Playlist { get; set; } + + public Task PrepareAsync( + Guid animationInfoId, + string? relativePath, + TranscodingSelection selection, + CancellationToken cancellationToken) + => PrepareException is null + ? Task.FromResult(PrepareResult!) + : Task.FromException(PrepareException); + + public Task GetStatusAsync( + Guid sessionId, + string accessToken, + CancellationToken cancellationToken) + => Task.FromResult(PrepareResult); + + public Task GetPlaylistAsync( + Guid sessionId, + string accessToken, + CancellationToken cancellationToken) + => Task.FromResult(Playlist); + + public Task OpenSegmentAsync( + Guid sessionId, + string accessToken, + string fileName, + CancellationToken cancellationToken) + => Task.FromResult(null); + + public Task OpenSubtitleAsync( + Guid sessionId, + string accessToken, + string fileName, + CancellationToken cancellationToken) + => Task.FromResult(null); + + public Task OpenDirectAsync( + Guid sessionId, + string accessToken, + CancellationToken cancellationToken) + => Task.FromResult(null); + + public Task CancelAsync( + Guid sessionId, + string accessToken, + CancellationToken cancellationToken) + => Task.FromResult(false); + + public Task GetMetricsAsync(CancellationToken cancellationToken) + => Task.FromResult(new TranscodingMetricsSnapshot(0, 0, 0, 0, 0, 0, 0, null, null, 0)); + } +} diff --git a/SecondDimensionWatcherReDive.Test/TranscodingPlannerTests.cs b/SecondDimensionWatcherReDive.Test/TranscodingPlannerTests.cs new file mode 100644 index 0000000..a696c89 --- /dev/null +++ b/SecondDimensionWatcherReDive.Test/TranscodingPlannerTests.cs @@ -0,0 +1,145 @@ +using SecondDimensionWatcherReDive.Services.Transcoding; + +namespace SecondDimensionWatcherReDive.Test; + +[TestClass] +public class TranscodingPlannerTests +{ + [TestMethod] + public void CreatePlan_BrowserCompatibleMp4_UsesDirectPlay() + { + var plan = TranscodingPlanner.CreatePlan( + CreateSource("episode.mp4"), + CreateProbe(Video("h264"), Audio("aac")), + TranscodingSelection.Create("auto", null, null, null, null), + burnBitmapSubtitles: false); + + Assert.AreEqual(TranscodingStrategy.Direct, plan.Strategy); + Assert.IsTrue(plan.CopyVideo); + Assert.IsTrue(plan.CopyAudio); + } + + [TestMethod] + public void CreatePlan_CompatibleTracksInMkv_UsesLosslessRemux() + { + var plan = TranscodingPlanner.CreatePlan( + CreateSource("episode.mkv"), + CreateProbe(Video("h264"), Audio("aac")), + TranscodingSelection.Create("auto", null, null, null, null), + burnBitmapSubtitles: false); + + Assert.AreEqual(TranscodingStrategy.Remux, plan.Strategy); + Assert.IsTrue(plan.CopyVideo); + Assert.IsTrue(plan.CopyAudio); + } + + [TestMethod] + public void CreatePlan_UnsupportedCodecs_TranscodesAndSelectsPreferredAudio() + { + var japanese = Audio("flac", 1, "jpn", "Japanese"); + var english = Audio("aac", 2, "eng", "English", isDefault: true); + var plan = TranscodingPlanner.CreatePlan( + CreateSource("episode.mkv"), + CreateProbe(Video("hevc"), japanese, english), + TranscodingSelection.Create("720p", "ja", null, null, null), + burnBitmapSubtitles: false); + + Assert.AreEqual(TranscodingStrategy.Transcode, plan.Strategy); + Assert.AreEqual(japanese.Index, plan.Audio?.Index); + Assert.IsFalse(plan.CopyVideo); + Assert.IsFalse(plan.CopyAudio); + } + + [TestMethod] + public void CreatePlan_TextSubtitlesBecomeWebVttAndBitmapTrackCanBeBurned() + { + var ass = Subtitle("ass", 2, "eng", "English signs"); + var pgs = Subtitle("hdmv_pgs_subtitle", 3, "zho", "Chinese PGS"); + var plan = TranscodingPlanner.CreatePlan( + CreateSource("episode.mkv"), + CreateProbe(Video("h264"), Audio("aac"), ass, pgs), + TranscodingSelection.Create("auto", null, null, "zh", null), + burnBitmapSubtitles: true); + + Assert.AreEqual(TranscodingStrategy.Transcode, plan.Strategy); + CollectionAssert.AreEqual(new[] { ass.Index }, plan.TextSubtitles.Select(item => item.Index).ToArray()); + Assert.AreEqual(pgs.Index, plan.BitmapSubtitleToBurn?.Index); + Assert.AreEqual(0, plan.UnsupportedSubtitleCount); + } + + [TestMethod] + public void CreatePlan_SubtitlesOffNeverBurnsDefaultBitmapTrack() + { + var plan = TranscodingPlanner.CreatePlan( + CreateSource("episode.mkv"), + CreateProbe( + Video("h264"), + Audio("aac"), + Subtitle("hdmv_pgs_subtitle", 3, "zho", "Chinese PGS")), + TranscodingSelection.Create("auto", null, null, "off", null), + burnBitmapSubtitles: true); + + Assert.IsNull(plan.BitmapSubtitleToBurn); + Assert.AreEqual(TranscodingStrategy.Remux, plan.Strategy); + Assert.AreEqual(1, plan.UnsupportedSubtitleCount); + } + + [TestMethod] + public void BuildCacheKey_ChangesForSourceVersionTrackAndQuality() + { + var source = CreateSource("episode.mkv"); + var baseline = source.BuildCacheKey( + TranscodingSelection.Create("auto", "ja", null, "zh", null)); + + var changedSource = source with { LastModifiedUtc = source.LastModifiedUtc.AddSeconds(1) }; + var changedTrack = source.BuildCacheKey( + TranscodingSelection.Create("auto", "en", null, "zh", null)); + var changedQuality = source.BuildCacheKey( + TranscodingSelection.Create("720p", "ja", null, "zh", null)); + + Assert.AreNotEqual(baseline, changedSource.BuildCacheKey( + TranscodingSelection.Create("auto", "ja", null, "zh", null))); + Assert.AreNotEqual(baseline, changedTrack); + Assert.AreNotEqual(baseline, changedQuality); + } + + [TestMethod] + public void ToProgressFraction_UsesMediaDurationAndClamps() + { + Assert.AreEqual(0.5, FfmpegProcessRunner.ToProgressFraction(50, TimeSpan.FromSeconds(100))); + Assert.AreEqual(1, FfmpegProcessRunner.ToProgressFraction(110, TimeSpan.FromSeconds(100))); + Assert.IsNull(FfmpegProcessRunner.ToProgressFraction(1, null)); + } + + private static TranscodingSource CreateSource(string fileName) + => new( + Guid.NewGuid(), + Guid.NewGuid(), + $"/Anime/Group/{fileName}", + $"/media/{fileName}", + "test", + fileName, + 1024, + new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero)); + + private static MediaProbe CreateProbe(params MediaStreamProbe[] streams) + => new("matroska", TimeSpan.FromMinutes(24), streams); + + private static MediaStreamProbe Video(string codec) + => new(0, "video", codec, null, null, true, false, false); + + private static MediaStreamProbe Audio( + string codec, + int index = 1, + string? language = null, + string? title = null, + bool isDefault = false) + => new(index, "audio", codec, language, title, isDefault, false, false); + + private static MediaStreamProbe Subtitle( + string codec, + int index, + string? language, + string? title) + => new(index, "subtitle", codec, language, title, false, false, false); +} diff --git a/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs b/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs index 0f20309..058878f 100644 --- a/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs +++ b/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs @@ -65,4 +65,9 @@ namespace SecondDimensionWatcherReDive.Controllers.External; [JsonSerializable(typeof(QueueMediaLibraryScanResponse))] [JsonSerializable(typeof(ApplicationSettingsResponse))] [JsonSerializable(typeof(PatchApplicationSettingsRequest))] +[JsonSerializable(typeof(PrepareTranscodingRequest))] +[JsonSerializable(typeof(TranscodingSessionResponse))] +[JsonSerializable(typeof(TranscodingSubtitleResponse))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(TranscodingMetricsResponse))] internal partial class AppJsonSerializerContext : JsonSerializerContext; diff --git a/SecondDimensionWatcherReDive/Controllers/External/Transcoding.cs b/SecondDimensionWatcherReDive/Controllers/External/Transcoding.cs new file mode 100644 index 0000000..f35dc34 --- /dev/null +++ b/SecondDimensionWatcherReDive/Controllers/External/Transcoding.cs @@ -0,0 +1,50 @@ +using System.ComponentModel.DataAnnotations; + +namespace SecondDimensionWatcherReDive.Controllers.External; + +internal sealed record PrepareTranscodingRequest( + [Required] Guid Id, + [Required] string Path, + string? Quality, + string? AudioLanguage, + string? AudioTrackLabel, + string? SubtitleLanguage, + string? SubtitleTrackLabel); + +internal sealed record TranscodingSubtitleResponse( + string Path, + string VirtualPath, + string? Language, + string Label, + string Format, + string Url); + +internal sealed record TranscodingSessionResponse( + Guid SessionId, + string State, + string? Strategy, + bool IsPlayable, + bool CacheHit, + double? Progress, + double? Speed, + int? QueuePosition, + string? Error, + string? VideoCodec, + string? AudioCodec, + string StatusUrl, + string CancelUrl, + string? PlaybackUrl, + IReadOnlyList Subtitles, + int UnsupportedSubtitleCount); + +internal sealed record TranscodingMetricsResponse( + int QueuedJobs, + int ActiveJobs, + long CompletedJobs, + long FailedJobs, + long CanceledJobs, + long CacheHits, + long CacheBytes, + double? AverageFirstSegmentSeconds, + double? AverageTranscodeSpeed, + double FailureRate); diff --git a/SecondDimensionWatcherReDive/Controllers/FileController.cs b/SecondDimensionWatcherReDive/Controllers/FileController.cs index 0d37050..8416c53 100644 --- a/SecondDimensionWatcherReDive/Controllers/FileController.cs +++ b/SecondDimensionWatcherReDive/Controllers/FileController.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Caching.Distributed; using SecondDimensionWatcherReDive.Framework.DataRepository; using SecondDimensionWatcherReDive.Framework.FileStore; +using SecondDimensionWatcherReDive.Utils.FileStore; namespace SecondDimensionWatcherReDive.Controllers; @@ -41,7 +42,7 @@ public async Task GetFileLink([FromBody] External.FileLinkResultR return NotFound(); } - var virtualPath = ResolveVirtualPath(info, payload.Path); + var virtualPath = PlaybackPathResolver.ResolveVirtualPath(info, payload.Path); LogResolvedTargetPath(logger, virtualPath, "virtual path"); var token = GenerateToken(64); @@ -93,7 +94,7 @@ public async Task GetSubDir([FromQuery] [Required] Guid id, return NotFound(); } - var virtualPath = ResolveVirtualPath(info, relativeDir); + var virtualPath = PlaybackPathResolver.ResolveVirtualPath(info, relativeDir); LogListPathInfo(logger, virtualPath, true); var tokens = await fileExplorer.EnumerateDirectoryAsync( @@ -109,29 +110,6 @@ public async Task GetSubDir([FromQuery] [Required] Guid id, return Ok(results); } - private static string ResolveVirtualPath(AnimationInfo info, string? relative) - { - var root = GetAnimationVirtualRoot(info); - if (string.IsNullOrWhiteSpace(relative)) return root; - var trimmed = relative.Trim('/'); - return string.IsNullOrEmpty(trimmed) ? root : $"{root}/{trimmed}"; - } - - private static string GetAnimationVirtualRoot(AnimationInfo info) - { - if (info.Animation is null || info.Season is null) return "/unknown"; - var animationName = SanitizePathSegment(info.Animation.Name); - var subGroup = SanitizePathSegment(info.Group?.Name ?? "Unknown"); - return $"/{animationName}/{subGroup}"; - } - - private static string SanitizePathSegment(string name) - { - var invalid = Path.GetInvalidFileNameChars(); - var sanitized = string.Concat(name.Select(c => invalid.Contains(c) || c == '/' ? '_' : c)).Trim(); - return string.IsNullOrEmpty(sanitized) ? "Unknown" : sanitized; - } - [LoggerMessage(Level = LogLevel.Debug, Message = "GenerateLink request for animation {Id}, relative path: {Path}")] private static partial void LogGenerateLinkRequest(ILogger logger, Guid id, string? path); diff --git a/SecondDimensionWatcherReDive/Controllers/TranscodingController.cs b/SecondDimensionWatcherReDive/Controllers/TranscodingController.cs new file mode 100644 index 0000000..fa1c592 --- /dev/null +++ b/SecondDimensionWatcherReDive/Controllers/TranscodingController.cs @@ -0,0 +1,237 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Net.Http.Headers; +using SecondDimensionWatcherReDive.Services.Transcoding; + +namespace SecondDimensionWatcherReDive.Controllers; + +[ApiController] +[Route("api/transcoding")] +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] +internal sealed class TranscodingController(IHlsTranscodingService transcodingService) : ControllerBase +{ + [HttpPost("prepare")] + public async Task Prepare( + [FromBody] External.PrepareTranscodingRequest request, + CancellationToken cancellationToken) + { + try + { + var selection = TranscodingSelection.Create( + request.Quality, + request.AudioLanguage, + request.AudioTrackLabel, + request.SubtitleLanguage, + request.SubtitleTrackLabel); + var status = await transcodingService.PrepareAsync( + request.Id, + request.Path, + selection, + cancellationToken); + var response = ToResponse(status); + return status.State == TranscodingJobState.Ready ? Ok(response) : Accepted(response); + } + catch (ArgumentException exception) + { + return BadRequest(new ProblemDetails { Title = "Invalid transcoding request", Detail = exception.Message }); + } + catch (KeyNotFoundException) + { + return NotFound(); + } + catch (TranscodingQueueFullException exception) + { + Response.Headers.RetryAfter = "5"; + return StatusCode(StatusCodes.Status429TooManyRequests, + new ProblemDetails { Title = "Transcoding queue full", Detail = exception.Message }); + } + catch (TranscodingDisabledException exception) + { + return StatusCode(StatusCodes.Status503ServiceUnavailable, + new ProblemDetails { Title = "Transcoding unavailable", Detail = exception.Message }); + } + } + + [AllowAnonymous] + [HttpGet("sessions/{sessionId:guid}")] + public async Task GetStatus( + Guid sessionId, + [FromQuery][Required] string token, + CancellationToken cancellationToken) + { + var status = await transcodingService.GetStatusAsync(sessionId, token, cancellationToken); + return status is null ? NotFound() : Ok(ToResponse(status)); + } + + [AllowAnonymous] + [HttpDelete("sessions/{sessionId:guid}")] + public async Task Cancel( + Guid sessionId, + [FromQuery][Required] string token, + CancellationToken cancellationToken) + { + return await transcodingService.CancelAsync(sessionId, token, cancellationToken) + ? NoContent() + : NotFound(); + } + + [AllowAnonymous] + [HttpGet("sessions/{sessionId:guid}/source")] + public async Task GetSource( + Guid sessionId, + [FromQuery][Required] string token, + CancellationToken cancellationToken) + { + var content = await transcodingService.OpenDirectAsync(sessionId, token, cancellationToken); + if (content is null) return NotFound(); + SetContentHeaders(content, immutable: false); + return File(content.Stream, content.ContentType, content.FileName, enableRangeProcessing: true); + } + + [AllowAnonymous] + [HttpGet("sessions/{sessionId:guid}/media.m3u8")] + public async Task GetPlaylist( + Guid sessionId, + [FromQuery][Required] string token, + CancellationToken cancellationToken) + { + var playlist = await transcodingService.GetPlaylistAsync(sessionId, token, cancellationToken); + if (playlist is null) return NotFound(); + + var rewritten = new List(); + using var reader = new StringReader(playlist); + while (reader.ReadLine() is { } line) + { + if (line.Length > 0 && line[0] != '#') + { + var segmentUrl = Url.ActionLink( + nameof(GetSegment), + values: new { sessionId, fileName = line, token }); + rewritten.Add(segmentUrl ?? line); + } + else + { + rewritten.Add(line); + } + } + Response.Headers.CacheControl = "no-cache, no-store"; + return Content(string.Join('\n', rewritten) + "\n", "application/vnd.apple.mpegurl"); + } + + [AllowAnonymous] + [HttpGet("sessions/{sessionId:guid}/segments/{fileName}")] + public async Task GetSegment( + Guid sessionId, + string fileName, + [FromQuery][Required] string token, + CancellationToken cancellationToken) + { + var content = await transcodingService.OpenSegmentAsync( + sessionId, + token, + fileName, + cancellationToken); + if (content is null) return NotFound(); + SetContentHeaders(content, immutable: true); + return File(content.Stream, content.ContentType, enableRangeProcessing: true); + } + + [AllowAnonymous] + [HttpGet("sessions/{sessionId:guid}/subtitles/{fileName}")] + public async Task GetSubtitle( + Guid sessionId, + string fileName, + [FromQuery][Required] string token, + CancellationToken cancellationToken) + { + var content = await transcodingService.OpenSubtitleAsync( + sessionId, + token, + fileName, + cancellationToken); + if (content is null) return NotFound(); + SetContentHeaders(content, immutable: true); + return File(content.Stream, content.ContentType, enableRangeProcessing: true); + } + + [HttpGet("metrics")] + public async Task GetMetrics(CancellationToken cancellationToken) + { + var snapshot = await transcodingService.GetMetricsAsync(cancellationToken); + return Ok(new External.TranscodingMetricsResponse( + snapshot.QueuedJobs, + snapshot.ActiveJobs, + snapshot.CompletedJobs, + snapshot.FailedJobs, + snapshot.CanceledJobs, + snapshot.CacheHits, + snapshot.CacheBytes, + snapshot.AverageFirstSegmentSeconds, + snapshot.AverageTranscodeSpeed, + snapshot.FailureRate)); + } + + private External.TranscodingSessionResponse ToResponse(TranscodingSessionStatus status) + { + var statusUrl = Url.ActionLink( + nameof(GetStatus), + values: new { sessionId = status.SessionId, token = status.AccessToken })!; + var cancelUrl = Url.ActionLink( + nameof(Cancel), + values: new { sessionId = status.SessionId, token = status.AccessToken })!; + var playbackUrl = status.IsPlayable + ? status.Strategy == TranscodingStrategy.Direct + ? Url.ActionLink( + nameof(GetSource), + values: new { sessionId = status.SessionId, token = status.AccessToken }) + : Url.ActionLink( + nameof(GetPlaylist), + values: new { sessionId = status.SessionId, token = status.AccessToken }) + : null; + var subtitles = status.Subtitles.Select(subtitle => + new External.TranscodingSubtitleResponse( + $"__server_subtitle_{subtitle.FileName}", + $"transcoding://subtitle/{subtitle.FileName}", + subtitle.Language, + subtitle.Label, + subtitle.Format, + Url.ActionLink( + nameof(GetSubtitle), + values: new + { + sessionId = status.SessionId, + fileName = subtitle.FileName, + token = status.AccessToken + })!)).ToArray(); + return new External.TranscodingSessionResponse( + status.SessionId, + status.State.ToString().ToLowerInvariant(), + status.Strategy?.ToString().ToLowerInvariant(), + status.IsPlayable, + status.CacheHit, + status.Progress, + status.Speed, + status.QueuePosition, + status.Error, + status.VideoCodec, + status.AudioCodec, + statusUrl, + cancelUrl, + playbackUrl, + subtitles, + status.UnsupportedSubtitleCount); + } + + private void SetContentHeaders(TranscodingContent content, bool immutable) + { + Response.Headers.CacheControl = immutable + ? "private, max-age=1209600, immutable" + : "private, no-cache"; + if (content.LastModifiedUtc is { } lastModified) + Response.Headers.LastModified = lastModified.ToUniversalTime().ToString("R"); + if (content.Length is { } length) Response.ContentLength = length; + Response.Headers[HeaderNames.AcceptRanges] = "bytes"; + } +} diff --git a/SecondDimensionWatcherReDive/Program.cs b/SecondDimensionWatcherReDive/Program.cs index 80f5f19..26f593a 100644 --- a/SecondDimensionWatcherReDive/Program.cs +++ b/SecondDimensionWatcherReDive/Program.cs @@ -27,6 +27,7 @@ using SecondDimensionWatcherReDive.Chat; using SecondDimensionWatcherReDive.Plugin; using SecondDimensionWatcherReDive.Services; +using SecondDimensionWatcherReDive.Services.Transcoding; using SecondDimensionWatcherReDive.MigrationTasks; using SecondDimensionWatcherReDive.Utils.Feed; using SecondDimensionWatcherReDive.Utils.FileDownload; @@ -92,6 +93,33 @@ var localStore = builder.Configuration["FileStore:Local"] ?? "./download"; options.DownloadRoot = Path.GetFullPath(localStore); }); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(TranscodingOptions.SectionName)) + .PostConfigure(options => + { + if (string.IsNullOrWhiteSpace(options.CachePath)) + options.CachePath = Path.Combine( + Path.GetDirectoryName(Path.GetFullPath(passwordFile))!, + "transcode-cache"); + else + options.CachePath = Path.GetFullPath(options.CachePath); + }) + .Validate(options => options.MaxConcurrentJobs > 0, "MaxConcurrentJobs must be positive.") + .Validate(options => options.QueueCapacity > 0, "QueueCapacity must be positive.") + .Validate(options => options.MaxThreadsPerJob > 0, "MaxThreadsPerJob must be positive.") + .Validate(options => options.MaxMemoryBytesPerJob > 0, "MaxMemoryBytesPerJob must be positive.") + .Validate(options => options.MaxDiskBytesPerJob > 0, "MaxDiskBytesPerJob must be positive.") + .Validate(options => options.MaxCacheBytes > 0, "MaxCacheBytes must be positive.") + .Validate(options => options.SegmentDurationSeconds is >= 2 and <= 30, + "SegmentDurationSeconds must be between 2 and 30.") + .Validate(options => options.VideoCrf is >= 0 and <= 51, "VideoCrf must be between 0 and 51.") + .Validate(options => !string.IsNullOrWhiteSpace(options.FfmpegPath), "FfmpegPath is required.") + .Validate(options => !string.IsNullOrWhiteSpace(options.FfprobePath), "FfprobePath is required.") + .Validate(options => options.JobTimeout > TimeSpan.Zero, "JobTimeout must be positive.") + .Validate(options => options.CacheTtl > TimeSpan.Zero, "CacheTtl must be positive.") + .Validate(options => options.CleanupInterval > TimeSpan.Zero, "CleanupInterval must be positive.") + .Validate(options => options.SessionTtl > TimeSpan.Zero, "SessionTtl must be positive.") + .ValidateOnStart(); builder.Services.AddDbContext(options => { @@ -216,12 +244,20 @@ // Persistent incident inbox and health probes. builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => + sp.GetRequiredService()); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => + sp.GetRequiredService()); //Add hosting services builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); diff --git a/SecondDimensionWatcherReDive/Services/Transcoding/FfmpegProcessRunner.cs b/SecondDimensionWatcherReDive/Services/Transcoding/FfmpegProcessRunner.cs new file mode 100644 index 0000000..c5189a2 --- /dev/null +++ b/SecondDimensionWatcherReDive/Services/Transcoding/FfmpegProcessRunner.cs @@ -0,0 +1,545 @@ +using System.Diagnostics; +using System.Globalization; +using System.Runtime.ExceptionServices; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Options; + +namespace SecondDimensionWatcherReDive.Services.Transcoding; + +internal sealed record FfmpegProgress(double? ProcessedSeconds, double? Speed, bool FirstSegmentReady); + +internal sealed record FfmpegRunResult(int ExitCode, string ErrorOutput); + +internal interface IFfmpegProcessRunner +{ + Task ProbeAsync(Stream source, CancellationToken cancellationToken); + + Task GenerateHlsAsync( + Stream source, + TranscodingPlan plan, + TranscodingSelection selection, + string outputDirectory, + bool useHardwareEncoder, + Action onProgress, + CancellationToken cancellationToken); + + Task> ExtractTextSubtitlesAsync( + Stream source, + TranscodingPlan plan, + string outputDirectory, + CancellationToken cancellationToken); +} + +internal sealed partial class FfmpegProcessRunner( + IOptions options, + ILogger logger) : IFfmpegProcessRunner +{ + private readonly TranscodingOptions _options = options.Value; + private static readonly JsonSerializerOptions ProbeJsonOptions = new(JsonSerializerDefaults.Web); + + public async Task ProbeAsync(Stream source, CancellationToken cancellationToken) + { + var startInfo = CreateStartInfo(_options.FfprobePath, redirectOutput: true); + AddArguments(startInfo, + "-v", "error", + "-analyzeduration", "10000000", + "-probesize", "10000000", + "-read_intervals", "%+#32", + "-show_format", + "-show_streams", + "-of", "json", + "-i", "pipe:0"); + + using var process = Start(startInfo); + var pumpTask = PumpInputAsync(process, source, cancellationToken); + var outputTask = process.StandardOutput.ReadToEndAsync(); + var errorTask = process.StandardError.ReadToEndAsync(); + try + { + await process.WaitForExitAsync(cancellationToken); + } + catch + { + Kill(process); + await WaitForExitIgnoringErrorsAsync(process); + await IgnoreBrokenPipeAsync(pumpTask, cancellationToken); + await Task.WhenAll(outputTask, errorTask); + throw; + } + + await IgnoreBrokenPipeAsync(pumpTask, cancellationToken); + var output = await outputTask; + var error = await errorTask; + if (process.ExitCode != 0) + throw new InvalidOperationException( + $"ffprobe exited with code {process.ExitCode}: {TrimError(error)}"); + + var document = JsonSerializer.Deserialize(output, ProbeJsonOptions) + ?? throw new InvalidOperationException("ffprobe returned an empty response."); + var streams = (document.Streams ?? []) + .Where(stream => stream.Index is not null && !string.IsNullOrWhiteSpace(stream.CodecType)) + .Select(stream => new MediaStreamProbe( + stream.Index!.Value, + stream.CodecType!.Trim().ToLowerInvariant(), + string.IsNullOrWhiteSpace(stream.CodecName) + ? "unknown" + : stream.CodecName.Trim().ToLowerInvariant(), + stream.Tags?.Language, + stream.Tags?.Title, + stream.Disposition?.Default == 1, + stream.Disposition?.Forced == 1, + stream.Disposition?.AttachedPic == 1)) + .ToArray(); + var duration = ParseDuration(document.Format?.Duration) + ?? (document.Streams ?? []).Select(stream => ParseDuration(stream.Duration)).FirstOrDefault(value => value is not null); + return new MediaProbe(document.Format?.FormatName ?? "unknown", duration, streams); + } + + public async Task GenerateHlsAsync( + Stream source, + TranscodingPlan plan, + TranscodingSelection selection, + string outputDirectory, + bool useHardwareEncoder, + Action onProgress, + CancellationToken cancellationToken) + { + var startInfo = CreateStartInfo(_options.FfmpegPath, redirectOutput: false); + AddArguments(startInfo, "-hide_banner", "-y"); + if (useHardwareEncoder) + foreach (var argument in _options.HardwareInputArguments) startInfo.ArgumentList.Add(argument); + AddArguments(startInfo, "-i", "pipe:0"); + if (plan.BitmapSubtitleToBurn is null) + AddArguments(startInfo, "-map", $"0:{plan.Video.Index}"); + + if (plan.BitmapSubtitleToBurn is not null) + { + var maximumHeight = selection.Quality switch + { + "720p" => 720, + "1080p" => 1080, + _ => 0 + }; + var filter = $"[0:{plan.Video.Index}][0:{plan.BitmapSubtitleToBurn.Index}]overlay"; + if (maximumHeight > 0) filter += $",scale=-2:min({maximumHeight}\\,ih)"; + filter += "[vout]"; + AddArguments(startInfo, + "-filter_complex", + filter, + "-map", "[vout]"); + } + if (plan.Audio is not null) AddArguments(startInfo, "-map", $"0:{plan.Audio.Index}"); + + if (plan.CopyVideo) + { + AddArguments(startInfo, "-c:v", "copy"); + } + else + { + AddArguments(startInfo, + "-c:v", useHardwareEncoder ? _options.HardwareVideoEncoder! : "libx264"); + if (!useHardwareEncoder) + AddArguments(startInfo, "-preset", _options.VideoPreset, "-crf", _options.VideoCrf.ToString(CultureInfo.InvariantCulture)); + AddArguments(startInfo, "-pix_fmt", "yuv420p"); + var maximumHeight = selection.Quality switch + { + "720p" => 720, + "1080p" => 1080, + _ => 0 + }; + if (maximumHeight > 0 && plan.BitmapSubtitleToBurn is null) + AddArguments(startInfo, "-vf", $"scale=-2:min({maximumHeight}\\,ih)"); + AddArguments(startInfo, + "-force_key_frames", + $"expr:gte(t,n_forced*{_options.SegmentDurationSeconds})"); + } + + if (plan.Audio is not null) + { + if (plan.CopyAudio) AddArguments(startInfo, "-c:a", "copy"); + else AddArguments(startInfo, "-c:a", "aac", "-b:a", "192k", "-ac", "2"); + } + + var playlistPath = Path.Combine(outputDirectory, "media.m3u8"); + var segmentPattern = Path.Combine(outputDirectory, "segment-%06d.ts"); + AddArguments(startInfo, + "-sn", + "-threads", _options.MaxThreadsPerJob.ToString(CultureInfo.InvariantCulture), + "-max_muxing_queue_size", "1024", + "-f", "hls", + "-hls_time", _options.SegmentDurationSeconds.ToString(CultureInfo.InvariantCulture), + "-hls_list_size", "0", + "-hls_playlist_type", "event", + "-hls_flags", "independent_segments+temp_file", + "-hls_segment_filename", segmentPattern, + "-progress", "pipe:2", + "-nostats", + playlistPath); + + return await RunFfmpegAsync( + startInfo, + source, + outputDirectory, + onProgress, + cancellationToken); + } + + public async Task> ExtractTextSubtitlesAsync( + Stream source, + TranscodingPlan plan, + string outputDirectory, + CancellationToken cancellationToken) + { + if (plan.TextSubtitles.Count == 0) return []; + + var startInfo = CreateStartInfo(_options.FfmpegPath, redirectOutput: false); + AddArguments(startInfo, + "-hide_banner", "-y", + "-i", "pipe:0", + "-threads", _options.MaxThreadsPerJob.ToString(CultureInfo.InvariantCulture), + "-nostats"); + var pending = new List<(MediaStreamProbe Stream, string TemporaryPath, string FinalPath)>(); + foreach (var stream in plan.TextSubtitles) + { + var finalPath = Path.Combine(outputDirectory, $"subtitle-{stream.Index}.vtt"); + var temporaryPath = $"{finalPath}.tmp"; + pending.Add((stream, temporaryPath, finalPath)); + AddArguments(startInfo, + "-map", $"0:{stream.Index}", + "-c:s", "webvtt", + "-f", "webvtt", + temporaryPath); + } + var result = await RunFfmpegAsync( + startInfo, + source, + outputDirectory, + _ => { }, + cancellationToken, + detectFirstSegment: false); + if (result.ExitCode != 0) + { + LogSubtitleExtractionFailed(logger, result.ExitCode, result.ErrorOutput); + foreach (var item in pending) TryDelete(item.TemporaryPath); + return []; + } + + var subtitles = new List(); + foreach (var item in pending) + { + if (!File.Exists(item.TemporaryPath)) continue; + File.Move(item.TemporaryPath, item.FinalPath, overwrite: true); + subtitles.Add(new TranscodingSubtitle( + Path.GetFileName(item.FinalPath), + BuildSubtitleLabel(item.Stream, subtitles.Count + 1), + item.Stream.Language, + "vtt")); + } + return subtitles; + } + + private async Task RunFfmpegAsync( + ProcessStartInfo startInfo, + Stream source, + string outputDirectory, + Action onProgress, + CancellationToken cancellationToken, + bool detectFirstSegment = true) + { + using var process = Start(startInfo); + var recentErrors = new Queue(); + var errorGate = new object(); + double? lastSpeed = null; + double? lastProcessedSeconds = null; + var firstSegmentReady = 0; + string? resourceViolation = null; + var pumpTask = PumpInputAsync(process, source, cancellationToken); + var errorTask = Task.Run(async () => + { + while (await process.StandardError.ReadLineAsync(cancellationToken) is { } line) + { + if (TryParseProgress(line, out var processedSeconds, out var speed)) + { + if (processedSeconds is not null) lastProcessedSeconds = processedSeconds; + if (speed is not null) lastSpeed = speed; + onProgress(new FfmpegProgress( + lastProcessedSeconds, + lastSpeed, + Volatile.Read(ref firstSegmentReady) == 1)); + } + lock (errorGate) + { + recentErrors.Enqueue(line); + while (recentErrors.Count > 20) recentErrors.Dequeue(); + } + } + }, CancellationToken.None); + var monitorTask = Task.Run(async () => + { + while (!process.HasExited) + { + await Task.Delay(250, cancellationToken); + if (_options.MaxMemoryBytesPerJob > 0 + && TryGetWorkingSet(process, out var workingSet) + && workingSet > _options.MaxMemoryBytesPerJob) + { + resourceViolation = $"FFmpeg exceeded its {_options.MaxMemoryBytesPerJob} byte memory limit."; + Kill(process); + return; + } + + if (_options.MaxDiskBytesPerJob > 0 + && GetDirectorySize(outputDirectory) > _options.MaxDiskBytesPerJob) + { + resourceViolation = $"FFmpeg exceeded its {_options.MaxDiskBytesPerJob} byte disk limit."; + Kill(process); + return; + } + + if (detectFirstSegment + && Volatile.Read(ref firstSegmentReady) == 0 + && HasPlayableSegment(outputDirectory) + && Interlocked.Exchange(ref firstSegmentReady, 1) == 0) + { + onProgress(new FfmpegProgress(lastProcessedSeconds, lastSpeed, true)); + } + } + }, CancellationToken.None); + + Exception? waitException = null; + try + { + await process.WaitForExitAsync(cancellationToken); + } + catch (Exception exception) + { + Kill(process); + await WaitForExitIgnoringErrorsAsync(process); + waitException = exception; + } + finally + { + await IgnoreBrokenPipeAsync(pumpTask, cancellationToken); + } + + try { await errorTask; } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } + try { await monitorTask; } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } + if (waitException is not null) ExceptionDispatchInfo.Capture(waitException).Throw(); + if (resourceViolation is not null) throw new TranscodingResourceLimitException(resourceViolation); + if (detectFirstSegment + && Volatile.Read(ref firstSegmentReady) == 0 + && HasPlayableSegment(outputDirectory) + && Interlocked.Exchange(ref firstSegmentReady, 1) == 0) + onProgress(new FfmpegProgress(lastProcessedSeconds, lastSpeed, true)); + string errors; + lock (errorGate) errors = string.Join(" | ", recentErrors); + return new FfmpegRunResult(process.ExitCode, TrimError(errors)); + } + + private static ProcessStartInfo CreateStartInfo(string path, bool redirectOutput) + => new() + { + FileName = path, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardInput = true, + RedirectStandardOutput = redirectOutput, + RedirectStandardError = true + }; + + private static Process Start(ProcessStartInfo startInfo) + { + try + { + return Process.Start(startInfo) + ?? throw new InvalidOperationException($"Unable to start {startInfo.FileName}."); + } + catch (Exception exception) when (exception is System.ComponentModel.Win32Exception or InvalidOperationException) + { + throw new InvalidOperationException( + $"Unable to start {startInfo.FileName}. Install FFmpeg or update Transcoding paths.", + exception); + } + } + + private static void AddArguments(ProcessStartInfo startInfo, params string[] arguments) + { + foreach (var argument in arguments) startInfo.ArgumentList.Add(argument); + } + + private static async Task PumpInputAsync( + Process process, + Stream source, + CancellationToken cancellationToken) + { + try + { + await source.CopyToAsync(process.StandardInput.BaseStream, cancellationToken); + } + finally + { + try { process.StandardInput.Close(); } + catch (IOException) { } + } + } + + private static async Task IgnoreBrokenPipeAsync(Task pumpTask, CancellationToken cancellationToken) + { + try { await pumpTask; } + catch (IOException) { } + catch (ObjectDisposedException) { } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } + } + + private static bool TryParseProgress( + string line, + out double? processedSeconds, + out double? speed) + { + processedSeconds = null; + speed = null; + var separator = line.IndexOf('='); + if (separator <= 0) return false; + var key = line[..separator]; + var value = line[(separator + 1)..]; + if (key is "out_time_us" or "out_time_ms") + { + // Current FFmpeg reports microseconds for both legacy out_time_ms and out_time_us. + if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var microseconds)) + { + processedSeconds = Math.Max(0, microseconds / 1_000_000d); + return true; + } + } + else if (key == "speed") + { + var normalized = value.TrimEnd('x'); + if (double.TryParse(normalized, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)) + speed = parsed; + return true; + } + return key == "progress"; + } + + public static double? ToProgressFraction(double? processedSeconds, TimeSpan? duration) + { + if (processedSeconds is null || duration is null || duration.Value.TotalSeconds <= 0) return null; + return Math.Clamp(processedSeconds.Value / duration.Value.TotalSeconds, 0, 1); + } + + private static TimeSpan? ParseDuration(string? value) + => double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds) + && double.IsFinite(seconds) + && seconds > 0 + ? TimeSpan.FromSeconds(seconds) + : null; + + private static bool TryGetWorkingSet(Process process, out long workingSet) + { + try + { + process.Refresh(); + workingSet = process.WorkingSet64; + return true; + } + catch (InvalidOperationException) + { + workingSet = 0; + return false; + } + } + + private static long GetDirectorySize(string path) + { + try + { + return Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories) + .Sum(file => + { + try { return new FileInfo(file).Length; } + catch (IOException) { return 0; } + }); + } + catch (DirectoryNotFoundException) + { + return 0; + } + } + + private static bool HasPlayableSegment(string outputDirectory) + { + var playlist = Path.Combine(outputDirectory, "media.m3u8"); + if (!File.Exists(playlist)) return false; + try + { + return Directory.EnumerateFiles(outputDirectory, "segment-*.ts") + .Any(path => new FileInfo(path).Length > 0); + } + catch (IOException) + { + return false; + } + } + + private static void Kill(Process process) + { + try + { + if (!process.HasExited) process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) { } + } + + private static async Task WaitForExitIgnoringErrorsAsync(Process process) + { + try { await process.WaitForExitAsync(CancellationToken.None); } + catch (InvalidOperationException) { } + } + + private static string BuildSubtitleLabel(MediaStreamProbe stream, int ordinal) + { + if (!string.IsNullOrWhiteSpace(stream.Title)) return stream.Title; + if (!string.IsNullOrWhiteSpace(stream.Language)) return $"{stream.Language.ToUpperInvariant()} · Embedded"; + return $"Embedded subtitle {ordinal}"; + } + + private static string TrimError(string error) + => error.Length <= 4000 ? error : error[^4000..]; + + private static void TryDelete(string path) + { + try { File.Delete(path); } + catch (IOException) { } + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "FFmpeg subtitle extraction exited with code {ExitCode}: {Error}")] + private static partial void LogSubtitleExtractionFailed(ILogger logger, int exitCode, string error); + + private sealed record FfprobeDocument( + [property: JsonPropertyName("streams")] FfprobeStream[]? Streams, + [property: JsonPropertyName("format")] FfprobeFormat? Format); + + private sealed record FfprobeStream( + [property: JsonPropertyName("index")] int? Index, + [property: JsonPropertyName("codec_name")] string? CodecName, + [property: JsonPropertyName("codec_type")] string? CodecType, + [property: JsonPropertyName("duration")] string? Duration, + [property: JsonPropertyName("disposition")] FfprobeDisposition? Disposition, + [property: JsonPropertyName("tags")] FfprobeTags? Tags); + + private sealed record FfprobeDisposition( + [property: JsonPropertyName("default")] int Default, + [property: JsonPropertyName("forced")] int Forced, + [property: JsonPropertyName("attached_pic")] int AttachedPic); + + private sealed record FfprobeTags( + [property: JsonPropertyName("language")] string? Language, + [property: JsonPropertyName("title")] string? Title); + + private sealed record FfprobeFormat( + [property: JsonPropertyName("format_name")] string? FormatName, + [property: JsonPropertyName("duration")] string? Duration); +} diff --git a/SecondDimensionWatcherReDive/Services/Transcoding/HlsTranscodingService.cs b/SecondDimensionWatcherReDive/Services/Transcoding/HlsTranscodingService.cs new file mode 100644 index 0000000..dcf6537 --- /dev/null +++ b/SecondDimensionWatcherReDive/Services/Transcoding/HlsTranscodingService.cs @@ -0,0 +1,945 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.Options; +using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Framework.FileStore; +using SecondDimensionWatcherReDive.Utils.FileStore; + +namespace SecondDimensionWatcherReDive.Services.Transcoding; + +internal sealed partial class HlsTranscodingService : BackgroundService, IHlsTranscodingService +{ + private const int CacheManifestVersion = 1; + private const string CacheOwnershipMarker = ".sdw-transcode-cache"; + private readonly IServiceScopeFactory _scopeFactory; + private readonly IFfmpegProcessRunner _processRunner; + private readonly TranscodingMetrics _metrics; + private readonly IContentTypeProvider _contentTypeProvider; + private readonly ILogger _logger; + private readonly TranscodingOptions _options; + private readonly Channel _queue; + private readonly ConcurrentDictionary _jobs = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _sessions = new(); + private readonly SemaphoreSlim _creationGate = new(1, 1); + private long _queueOrdinal; + + public HlsTranscodingService( + IServiceScopeFactory scopeFactory, + IFfmpegProcessRunner processRunner, + TranscodingMetrics metrics, + IContentTypeProvider contentTypeProvider, + IOptions options, + ILogger logger) + { + _scopeFactory = scopeFactory; + _processRunner = processRunner; + _metrics = metrics; + _contentTypeProvider = contentTypeProvider; + _logger = logger; + _options = options.Value; + _queue = Channel.CreateBounded(new BoundedChannelOptions(_options.QueueCapacity) + { + FullMode = BoundedChannelFullMode.Wait, + SingleReader = _options.MaxConcurrentJobs == 1, + SingleWriter = false, + AllowSynchronousContinuations = false + }); + } + + public async Task PrepareAsync( + Guid animationInfoId, + string? relativePath, + TranscodingSelection selection, + CancellationToken cancellationToken) + { + if (!_options.Enabled) throw new TranscodingDisabledException(); + var source = await ResolveSourceAsync(animationInfoId, relativePath, cancellationToken); + var cacheKey = BuildCacheKey(source, selection); + + await _creationGate.WaitAsync(cancellationToken); + try + { + if (_jobs.TryGetValue(cacheKey, out var terminalJob) + && terminalJob.GetState() is TranscodingJobState.Failed or TranscodingJobState.Canceled) + _jobs.TryRemove(new KeyValuePair(cacheKey, terminalJob)); + + var isNewJob = false; + var cacheHit = false; + if (!_jobs.TryGetValue(cacheKey, out var job)) + { + var cacheDirectory = Path.Combine(_options.CachePath, cacheKey); + var manifest = await TryLoadManifestAsync(cacheDirectory, cancellationToken); + if (manifest is not null) + { + job = TranscodingJob.FromManifest( + cacheKey, + cacheDirectory, + source, + selection, + manifest); + cacheHit = true; + _metrics.RecordCacheHit(); + } + else + { + job = new TranscodingJob( + cacheKey, + cacheDirectory, + source, + selection, + Interlocked.Increment(ref _queueOrdinal)); + isNewJob = true; + } + _jobs[cacheKey] = job; + } + else + { + cacheHit = job.GetState() == TranscodingJobState.Ready; + if (cacheHit) _metrics.RecordCacheHit(); + } + + var session = new TranscodingSession(job, cacheHit, _options.SessionTtl); + _sessions[session.Id] = session; + job.AddSession(session.Id); + + if (isNewJob && !_queue.Writer.TryWrite(job)) + { + _sessions.TryRemove(session.Id, out _); + job.RemoveSession(session.Id); + _jobs.TryRemove(new KeyValuePair(cacheKey, job)); + job.Cancellation.Dispose(); + throw new TranscodingQueueFullException(); + } + + UpdateJobGauges(); + TouchCache(job, session); + return BuildStatus(session); + } + finally + { + _creationGate.Release(); + } + } + + public Task GetStatusAsync( + Guid sessionId, + string accessToken, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var session = FindSession(sessionId, accessToken); + if (session is null) return Task.FromResult(null); + TouchCache(session.Job, session); + return Task.FromResult(BuildStatus(session)); + } + + public async Task GetPlaylistAsync( + Guid sessionId, + string accessToken, + CancellationToken cancellationToken) + { + var session = FindSession(sessionId, accessToken); + if (session is null || !session.Job.GetIsPlayable()) return null; + TouchCache(session.Job, session); + var playlistPath = Path.Combine(session.Job.CacheDirectory, "media.m3u8"); + for (var attempt = 0; attempt < 3; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + return await File.ReadAllTextAsync(playlistPath, cancellationToken); + } + catch (IOException) when (attempt < 2) + { + await Task.Delay(25, cancellationToken); + } + catch (FileNotFoundException) + { + return null; + } + } + return null; + } + + public Task OpenSegmentAsync( + Guid sessionId, + string accessToken, + string fileName, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var session = FindSession(sessionId, accessToken); + if (session is null || !session.Job.GetIsPlayable() || !IsSegmentName(fileName)) + return Task.FromResult(null); + + var path = Path.Combine(session.Job.CacheDirectory, fileName); + if (!File.Exists(path)) return Task.FromResult(null); + TouchCache(session.Job, session); + return Task.FromResult(OpenCachedContent(path, "video/mp2t")); + } + + public Task OpenSubtitleAsync( + Guid sessionId, + string accessToken, + string fileName, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var session = FindSession(sessionId, accessToken); + if (session is null || !session.Job.HasSubtitle(fileName)) + return Task.FromResult(null); + + var path = Path.Combine(session.Job.CacheDirectory, fileName); + if (!File.Exists(path)) return Task.FromResult(null); + TouchCache(session.Job, session); + return Task.FromResult(OpenCachedContent(path, "text/vtt; charset=utf-8")); + } + + public async Task OpenDirectAsync( + Guid sessionId, + string accessToken, + CancellationToken cancellationToken) + { + var session = FindSession(sessionId, accessToken); + if (session is null || session.Job.GetStrategy() != TranscodingStrategy.Direct) return null; + session.Touch(_options.SessionTtl); + var stream = await OpenSourceStreamAsync(session.Job.Source, cancellationToken); + var contentType = _contentTypeProvider.TryGetContentType(session.Job.Source.FileName, out var type) + ? type + : "application/octet-stream"; + return new TranscodingContent( + stream, + contentType, + session.Job.Source.FileName, + session.Job.Source.Length, + session.Job.Source.LastModifiedUtc); + } + + public Task CancelAsync( + Guid sessionId, + string accessToken, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var session = FindSession(sessionId, accessToken, touch: false); + if (session is null || !_sessions.TryRemove(sessionId, out _)) return Task.FromResult(false); + ReleaseSession(session); + return Task.FromResult(true); + } + + public Task GetMetricsAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(_metrics.Snapshot()); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + Directory.CreateDirectory(_options.CachePath); + await CleanupCacheAsync(removeIncomplete: true, stoppingToken); + var workers = Enumerable.Range(0, _options.MaxConcurrentJobs) + .Select(_ => RunWorkerAsync(stoppingToken)) + .ToArray(); + var cleanup = RunCleanupLoopAsync(stoppingToken); + await Task.WhenAll(workers.Append(cleanup)); + } + + private async Task RunWorkerAsync(CancellationToken stoppingToken) + { + await foreach (var job in _queue.Reader.ReadAllAsync(stoppingToken)) + { + try + { + await ProcessJobAsync(job, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + } + } + + private async Task ProcessJobAsync(TranscodingJob job, CancellationToken stoppingToken) + { + if (job.Cancellation.IsCancellationRequested) + { + MarkCanceled(job); + return; + } + + using var timeout = new CancellationTokenSource(_options.JobTimeout); + using var linked = CancellationTokenSource.CreateLinkedTokenSource( + stoppingToken, + job.Cancellation.Token, + timeout.Token); + var cancellationToken = linked.Token; + var startedAt = DateTimeOffset.UtcNow; + try + { + job.SetState(TranscodingJobState.Probing); + UpdateJobGauges(); + MediaProbe probe; + await using (var source = await OpenSourceStreamAsync(job.Source, cancellationToken)) + probe = await _processRunner.ProbeAsync(source, cancellationToken); + var plan = TranscodingPlanner.CreatePlan( + job.Source, + probe, + job.Selection, + _options.BurnBitmapSubtitles); + job.SetPlan(plan); + + if (plan.Strategy == TranscodingStrategy.Direct) + { + job.MarkPlayable(); + _metrics.RecordFirstSegment(DateTimeOffset.UtcNow - startedAt); + _metrics.RecordCompleted(); + job.SetReady([]); + UpdateJobGauges(); + return; + } + + RecreateJobDirectory(job.CacheDirectory); + job.SetState(TranscodingJobState.Transcoding); + UpdateJobGauges(); + var firstSegmentRecorded = 0; + void OnProgress(FfmpegProgress update) + { + var fraction = FfmpegProcessRunner.ToProgressFraction(update.ProcessedSeconds, probe.Duration); + job.SetProgress(fraction, update.Speed); + if (update.FirstSegmentReady) + { + job.MarkPlayable(); + if (Interlocked.Exchange(ref firstSegmentRecorded, 1) == 0) + _metrics.RecordFirstSegment(DateTimeOffset.UtcNow - startedAt); + } + } + + var useHardware = !plan.CopyVideo && !string.IsNullOrWhiteSpace(_options.HardwareVideoEncoder); + FfmpegRunResult result; + await using (var source = await OpenSourceStreamAsync(job.Source, cancellationToken)) + result = await _processRunner.GenerateHlsAsync( + source, + plan, + job.Selection, + job.CacheDirectory, + useHardware, + OnProgress, + cancellationToken); + if (result.ExitCode != 0 && useHardware) + { + LogHardwareFallback(_logger, _options.HardwareVideoEncoder!, result.ErrorOutput); + DeleteGeneratedFiles(job.CacheDirectory); + await using var source = await OpenSourceStreamAsync(job.Source, cancellationToken); + result = await _processRunner.GenerateHlsAsync( + source, + plan, + job.Selection, + job.CacheDirectory, + useHardwareEncoder: false, + OnProgress, + cancellationToken); + } + if (result.ExitCode != 0) + throw new InvalidOperationException($"FFmpeg exited with code {result.ExitCode}: {result.ErrorOutput}"); + + IReadOnlyList subtitles; + await using (var source = await OpenSourceStreamAsync(job.Source, cancellationToken)) + subtitles = await _processRunner.ExtractTextSubtitlesAsync( + source, + plan, + job.CacheDirectory, + cancellationToken); + job.SetSubtitles(subtitles); + await WriteManifestAsync(job, cancellationToken); + _metrics.RecordCompleted(); + if (job.GetSpeed() is { } speed) _metrics.RecordSpeed(speed); + UpdateCacheBytes(); + job.SetReady(subtitles); + UpdateJobGauges(); + await CleanupCacheAsync(removeIncomplete: false, cancellationToken); + } + catch (OperationCanceledException) when (job.Cancellation.IsCancellationRequested) + { + MarkCanceled(job); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + MarkFailed(job, "The transcoding job exceeded its configured timeout."); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + MarkCanceled(job); + throw; + } + catch (Exception exception) + { + LogJobFailed(_logger, job.Source.VirtualPath, exception); + MarkFailed(job, exception.Message); + } + } + + private async Task RunCleanupLoopAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(_options.CleanupInterval); + while (await timer.WaitForNextTickAsync(stoppingToken)) + await CleanupCacheAsync(removeIncomplete: true, stoppingToken); + } + + private async Task ResolveSourceAsync( + Guid animationInfoId, + string? relativePath, + CancellationToken cancellationToken) + { + await using var scope = _scopeFactory.CreateAsyncScope(); + var animationRepository = scope.ServiceProvider.GetRequiredService(); + var info = await animationRepository.FindByIdWithAnimationAsync(animationInfoId, cancellationToken); + if (info is null || !info.IsDownloadFinished) + throw new KeyNotFoundException("The requested animation is not available for playback."); + + var virtualPath = PlaybackPathResolver.ResolveVirtualPath(info, relativePath); + var mapping = await scope.ServiceProvider.GetRequiredService() + .FindByVirtualPathAsync(virtualPath, cancellationToken); + if (mapping is null || mapping.AnimationInfoId != animationInfoId) + throw new KeyNotFoundException("The requested playback file mapping was not found."); + + var store = scope.ServiceProvider.GetRequiredService() + .GetRequiredClient(mapping.FileStore); + var fileInfo = await store.FileInfoAsync(mapping.PhysicalPath, cancellationToken); + if (fileInfo.IsDirectory) + throw new KeyNotFoundException("The requested playback path is a directory."); + return new TranscodingSource( + animationInfoId, + mapping.Id, + mapping.VirtualPath, + mapping.PhysicalPath, + mapping.FileStore, + fileInfo.FileName, + fileInfo.Length ?? 0, + fileInfo.LastModifiedUtc ?? DateTimeOffset.UnixEpoch); + } + + private async Task OpenSourceStreamAsync( + TranscodingSource source, + CancellationToken cancellationToken) + { + var scope = _scopeFactory.CreateAsyncScope(); + try + { + var store = scope.ServiceProvider.GetRequiredService() + .GetRequiredClient(source.FileStore); + var stream = await store.OpenReadStreamAsync(source.PhysicalPath, cancellationToken); + return new ScopeOwnedStream(stream, scope); + } + catch + { + await scope.DisposeAsync(); + throw; + } + } + + private TranscodingSession? FindSession(Guid id, string token, bool touch = true) + { + if (!_sessions.TryGetValue(id, out var session) || !TokensEqual(session.AccessToken, token)) + return null; + if (session.IsExpired) + { + if (_sessions.TryRemove(id, out _)) ReleaseSession(session); + return null; + } + if (touch) session.Touch(_options.SessionTtl); + return session; + } + + private TranscodingSessionStatus BuildStatus(TranscodingSession session) + { + var job = session.Job; + var state = job.GetState(); + int? queuePosition = state == TranscodingJobState.Queued + ? _jobs.Values.Count(candidate => + candidate.GetState() == TranscodingJobState.Queued + && candidate.QueueOrdinal <= job.QueueOrdinal) + : null; + return job.CreateStatus(session.Id, session.AccessToken, session.CacheHit, queuePosition); + } + + private async Task TryLoadManifestAsync( + string directory, + CancellationToken cancellationToken) + { + var path = Path.Combine(directory, "complete.json"); + if (!File.Exists(Path.Combine(directory, CacheOwnershipMarker)) + || !File.Exists(path) + || !File.Exists(Path.Combine(directory, "media.m3u8"))) return null; + try + { + await using var stream = File.OpenRead(path); + var manifest = await JsonSerializer.DeserializeAsync(stream, cancellationToken: cancellationToken); + return manifest?.Version == CacheManifestVersion ? manifest : null; + } + catch (Exception exception) when (exception is IOException or JsonException) + { + LogInvalidCacheManifest(_logger, path, exception); + TryDeleteDirectory(directory); + return null; + } + } + + private async Task WriteManifestAsync(TranscodingJob job, CancellationToken cancellationToken) + { + var manifest = job.CreateManifest(); + var path = Path.Combine(job.CacheDirectory, "complete.json"); + var temporaryPath = $"{path}.tmp"; + await using (var stream = new FileStream( + temporaryPath, + FileMode.Create, + FileAccess.Write, + FileShare.None, + 4096, + FileOptions.Asynchronous | FileOptions.WriteThrough)) + await JsonSerializer.SerializeAsync(stream, manifest, cancellationToken: cancellationToken); + File.Move(temporaryPath, path, overwrite: true); + TouchCache(job, null); + } + + private async Task CleanupCacheAsync(bool removeIncomplete, CancellationToken cancellationToken) + { + CleanupExpiredSessions(); + if (!Directory.Exists(_options.CachePath)) return; + var now = DateTimeOffset.UtcNow; + var candidates = new List(); + foreach (var directory in Directory.EnumerateDirectories(_options.CachePath)) + { + cancellationToken.ThrowIfCancellationRequested(); + var key = Path.GetFileName(directory); + if (!IsCacheKey(key)) continue; + if (!File.Exists(Path.Combine(directory, CacheOwnershipMarker))) continue; + var completePath = Path.Combine(directory, "complete.json"); + if (!File.Exists(completePath)) + { + if (removeIncomplete && !IsActive(key)) TryDeleteDirectory(directory); + continue; + } + var accessPath = Path.Combine(directory, ".access"); + var lastAccess = File.Exists(accessPath) + ? File.GetLastWriteTimeUtc(accessPath) + : File.GetLastWriteTimeUtc(completePath); + candidates.Add(new CacheDirectory(key, directory, lastAccess, GetDirectorySize(directory))); + } + + foreach (var expired in candidates + .Where(candidate => now - candidate.LastAccess > _options.CacheTtl) + .OrderBy(candidate => candidate.LastAccess) + .ToArray()) + { + if (IsInUse(expired.Key)) continue; + RemoveCacheDirectory(expired); + candidates.Remove(expired); + } + + var total = candidates.Sum(candidate => candidate.Size); + foreach (var candidate in candidates.OrderBy(candidate => candidate.LastAccess)) + { + if (total <= _options.MaxCacheBytes) break; + if (IsInUse(candidate.Key)) continue; + RemoveCacheDirectory(candidate); + total -= candidate.Size; + } + _metrics.SetCacheBytes(Math.Max(0, total)); + await Task.CompletedTask; + } + + private void CleanupExpiredSessions() + { + foreach (var pair in _sessions) + if (pair.Value.IsExpired && _sessions.TryRemove(pair.Key, out var session)) ReleaseSession(session); + } + + private void ReleaseSession(TranscodingSession session) + { + var remaining = session.Job.RemoveSession(session.Id); + if (remaining == 0 + && session.Job.GetState() is TranscodingJobState.Queued + or TranscodingJobState.Probing + or TranscodingJobState.Transcoding) + session.Job.Cancellation.Cancel(); + else if (remaining == 0 + && session.Job.GetState() == TranscodingJobState.Ready + && session.Job.GetStrategy() == TranscodingStrategy.Direct + && _jobs.TryRemove(new KeyValuePair( + session.Job.CacheKey, + session.Job))) + session.Job.Cancellation.Dispose(); + } + + private bool IsActive(string key) + => _jobs.TryGetValue(key, out var job) + && job.GetState() is TranscodingJobState.Queued + or TranscodingJobState.Probing + or TranscodingJobState.Transcoding; + + private bool IsInUse(string key) + => _jobs.TryGetValue(key, out var job) && (job.SessionCount > 0 || IsActive(key)); + + private void RemoveCacheDirectory(CacheDirectory candidate) + { + TryDeleteDirectory(candidate.Path); + if (_jobs.TryGetValue(candidate.Key, out var job) && job.GetState() == TranscodingJobState.Ready) + _jobs.TryRemove(new KeyValuePair(candidate.Key, job)); + } + + private void MarkCanceled(TranscodingJob job) + { + job.SetCanceled(); + _jobs.TryRemove(new KeyValuePair(job.CacheKey, job)); + TryDeleteDirectory(job.CacheDirectory); + _metrics.RecordCanceled(); + UpdateCacheBytes(); + UpdateJobGauges(); + } + + private void MarkFailed(TranscodingJob job, string error) + { + job.SetFailed(error); + _jobs.TryRemove(new KeyValuePair(job.CacheKey, job)); + TryDeleteDirectory(job.CacheDirectory); + _metrics.RecordFailed(); + UpdateCacheBytes(); + UpdateJobGauges(); + } + + private void UpdateJobGauges() + { + _metrics.SetQueued(_jobs.Values.Count(job => job.GetState() == TranscodingJobState.Queued)); + _metrics.SetActive(_jobs.Values.Count(job => + job.GetState() is TranscodingJobState.Probing or TranscodingJobState.Transcoding)); + } + + private void UpdateCacheBytes() => _metrics.SetCacheBytes(GetDirectorySize(_options.CachePath)); + + private void TouchCache(TranscodingJob job, TranscodingSession? session) + { + session?.Touch(_options.SessionTtl); + if (!Directory.Exists(job.CacheDirectory)) return; + if (session is not null && !session.ShouldTouchCache) return; + try + { + var marker = Path.Combine(job.CacheDirectory, ".access"); + if (!File.Exists(marker)) File.WriteAllText(marker, string.Empty); + File.SetLastWriteTimeUtc(marker, DateTime.UtcNow); + session?.MarkCacheTouched(); + } + catch (IOException) { } + } + + private string BuildCacheKey(TranscodingSource source, TranscodingSelection selection) + { + var material = string.Join('|', + source.BuildCacheKey(selection), + CacheManifestVersion, + _options.SegmentDurationSeconds, + _options.VideoCrf, + _options.VideoPreset, + _options.HardwareVideoEncoder ?? string.Empty, + string.Join('\u001f', _options.HardwareInputArguments), + _options.BurnBitmapSubtitles); + return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(material))); + } + + private static TranscodingContent OpenCachedContent(string path, string contentType) + { + var info = new FileInfo(path); + var stream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete, + 64 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + return new TranscodingContent( + stream, + contentType, + info.Name, + info.Length, + new DateTimeOffset(info.LastWriteTimeUtc, TimeSpan.Zero)); + } + + private static void RecreateJobDirectory(string path) + { + if (Directory.Exists(path) && !File.Exists(Path.Combine(path, CacheOwnershipMarker))) + throw new InvalidOperationException( + $"The transcoding cache path '{path}' is occupied by an unmanaged directory."); + TryDeleteDirectory(path); + Directory.CreateDirectory(path); + File.WriteAllText(Path.Combine(path, CacheOwnershipMarker), string.Empty); + } + + private static void DeleteGeneratedFiles(string directory) + { + foreach (var path in Directory.EnumerateFiles(directory)) + if (Path.GetFileName(path) is not (".access" or CacheOwnershipMarker)) + try { File.Delete(path); } + catch (IOException) { } + } + + private static void TryDeleteDirectory(string path) + { + try + { + if (Directory.Exists(path) + && File.Exists(Path.Combine(path, CacheOwnershipMarker))) + Directory.Delete(path, recursive: true); + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + + private static long GetDirectorySize(string path) + { + if (!Directory.Exists(path)) return 0; + try + { + return Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories) + .Sum(file => + { + try { return new FileInfo(file).Length; } + catch (IOException) { return 0; } + }); + } + catch (IOException) + { + return 0; + } + } + + private static bool TokensEqual(string expected, string actual) + { + if (expected.Length != actual.Length) return false; + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(expected), + Encoding.UTF8.GetBytes(actual)); + } + + private static bool IsSegmentName(string name) + => name == Path.GetFileName(name) + && name.StartsWith("segment-", StringComparison.Ordinal) + && name.EndsWith(".ts", StringComparison.Ordinal) + && name[8..^3].All(char.IsAsciiDigit); + + private static bool IsCacheKey(string name) + => name.Length == 64 && name.All(char.IsAsciiHexDigit); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Hardware encoder {Encoder} failed; retrying with the CPU encoder. FFmpeg: {Error}")] + private static partial void LogHardwareFallback(ILogger logger, string encoder, string error); + + [LoggerMessage(Level = LogLevel.Error, Message = "Transcoding failed for {VirtualPath}")] + private static partial void LogJobFailed(ILogger logger, string virtualPath, Exception exception); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Ignoring invalid transcoding cache manifest {Path}")] + private static partial void LogInvalidCacheManifest(ILogger logger, string path, Exception exception); + + private sealed record CacheDirectory(string Key, string Path, DateTimeOffset LastAccess, long Size); + + private sealed record CacheManifest( + int Version, + TranscodingStrategy Strategy, + string VideoCodec, + string? AudioCodec, + TranscodingSubtitle[] Subtitles, + int UnsupportedSubtitleCount); + + private sealed class TranscodingSession + { + private long _expiresAtTicks; + private long _lastCacheTouchTicks; + + public TranscodingSession(TranscodingJob job, bool cacheHit, TimeSpan ttl) + { + Job = job; + CacheHit = cacheHit; + Id = Guid.NewGuid(); + AccessToken = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(32)); + Touch(ttl); + } + + public Guid Id { get; } + public string AccessToken { get; } + public TranscodingJob Job { get; } + public bool CacheHit { get; } + public bool IsExpired => DateTimeOffset.UtcNow.UtcTicks > Interlocked.Read(ref _expiresAtTicks); + public bool ShouldTouchCache + => DateTimeOffset.UtcNow.UtcTicks - Interlocked.Read(ref _lastCacheTouchTicks) > TimeSpan.FromMinutes(1).Ticks; + + public void Touch(TimeSpan ttl) + => Interlocked.Exchange(ref _expiresAtTicks, (DateTimeOffset.UtcNow + ttl).UtcTicks); + + public void MarkCacheTouched() + => Interlocked.Exchange(ref _lastCacheTouchTicks, DateTimeOffset.UtcNow.UtcTicks); + } + + private sealed class TranscodingJob + { + private readonly object _gate = new(); + private readonly HashSet _sessions = []; + private TranscodingJobState _state = TranscodingJobState.Queued; + private TranscodingPlan? _plan; + private bool _isPlayable; + private double? _progress; + private double? _speed; + private string? _error; + private IReadOnlyList _subtitles = []; + + public TranscodingJob( + string cacheKey, + string cacheDirectory, + TranscodingSource source, + TranscodingSelection selection, + long queueOrdinal) + { + CacheKey = cacheKey; + CacheDirectory = cacheDirectory; + Source = source; + Selection = selection; + QueueOrdinal = queueOrdinal; + } + + public string CacheKey { get; } + public string CacheDirectory { get; } + public TranscodingSource Source { get; } + public TranscodingSelection Selection { get; } + public long QueueOrdinal { get; } + public CancellationTokenSource Cancellation { get; } = new(); + public int SessionCount { get { lock (_gate) return _sessions.Count; } } + + public static TranscodingJob FromManifest( + string cacheKey, + string cacheDirectory, + TranscodingSource source, + TranscodingSelection selection, + CacheManifest manifest) + { + var job = new TranscodingJob(cacheKey, cacheDirectory, source, selection, 0) + { + _state = TranscodingJobState.Ready, + _isPlayable = true, + _progress = 1, + _subtitles = manifest.Subtitles + }; + var video = new MediaStreamProbe(0, "video", manifest.VideoCodec, null, null, true, false, false); + var audio = manifest.AudioCodec is null + ? null + : new MediaStreamProbe(1, "audio", manifest.AudioCodec, null, null, true, false, false); + job._plan = new TranscodingPlan( + manifest.Strategy, + video, + audio, + null, + [], + manifest.UnsupportedSubtitleCount, + manifest.Strategy == TranscodingStrategy.Remux, + manifest.Strategy == TranscodingStrategy.Remux); + return job; + } + + public void AddSession(Guid id) { lock (_gate) _sessions.Add(id); } + public int RemoveSession(Guid id) { lock (_gate) { _sessions.Remove(id); return _sessions.Count; } } + public TranscodingJobState GetState() { lock (_gate) return _state; } + public TranscodingStrategy? GetStrategy() { lock (_gate) return _plan?.Strategy; } + public bool GetIsPlayable() { lock (_gate) return _isPlayable; } + public double? GetSpeed() { lock (_gate) return _speed; } + public bool HasSubtitle(string fileName) + { + lock (_gate) return fileName == Path.GetFileName(fileName) && _subtitles.Any(item => item.FileName == fileName); + } + + public void SetState(TranscodingJobState state) { lock (_gate) _state = state; } + public void SetPlan(TranscodingPlan plan) { lock (_gate) _plan = plan; } + public void MarkPlayable() { lock (_gate) _isPlayable = true; } + public void SetProgress(double? progress, double? speed) + { + lock (_gate) + { + _progress = progress; + if (speed is not null) _speed = speed; + } + } + + public void SetReady(IReadOnlyList subtitles) + { + lock (_gate) + { + _subtitles = subtitles; + _progress = 1; + _isPlayable = true; + _state = TranscodingJobState.Ready; + } + } + + public void SetSubtitles(IReadOnlyList subtitles) + { + lock (_gate) _subtitles = subtitles; + } + + public void SetCanceled() + { + lock (_gate) + { + _state = TranscodingJobState.Canceled; + _isPlayable = false; + _error = "The transcoding job was canceled."; + } + } + + public void SetFailed(string error) + { + lock (_gate) + { + _state = TranscodingJobState.Failed; + _isPlayable = false; + _error = error; + } + } + + public TranscodingSessionStatus CreateStatus( + Guid sessionId, + string token, + bool cacheHit, + int? queuePosition) + { + lock (_gate) + return new TranscodingSessionStatus( + sessionId, + token, + _state, + _plan?.Strategy, + _isPlayable, + cacheHit, + _progress, + _speed, + queuePosition, + _error, + _plan?.Video.CodecName, + _plan?.Audio?.CodecName, + _subtitles, + _plan?.UnsupportedSubtitleCount ?? 0); + } + + public CacheManifest CreateManifest() + { + lock (_gate) + { + var plan = _plan ?? throw new InvalidOperationException("A completed job has no transcoding plan."); + return new CacheManifest( + CacheManifestVersion, + plan.Strategy, + plan.Video.CodecName, + plan.Audio?.CodecName, + _subtitles.ToArray(), + plan.UnsupportedSubtitleCount); + } + } + } +} diff --git a/SecondDimensionWatcherReDive/Services/Transcoding/IHlsTranscodingService.cs b/SecondDimensionWatcherReDive/Services/Transcoding/IHlsTranscodingService.cs new file mode 100644 index 0000000..b2fc65a --- /dev/null +++ b/SecondDimensionWatcherReDive/Services/Transcoding/IHlsTranscodingService.cs @@ -0,0 +1,44 @@ +namespace SecondDimensionWatcherReDive.Services.Transcoding; + +internal interface IHlsTranscodingService +{ + Task PrepareAsync( + Guid animationInfoId, + string? relativePath, + TranscodingSelection selection, + CancellationToken cancellationToken); + + Task GetStatusAsync( + Guid sessionId, + string accessToken, + CancellationToken cancellationToken); + + Task GetPlaylistAsync( + Guid sessionId, + string accessToken, + CancellationToken cancellationToken); + + Task OpenSegmentAsync( + Guid sessionId, + string accessToken, + string fileName, + CancellationToken cancellationToken); + + Task OpenSubtitleAsync( + Guid sessionId, + string accessToken, + string fileName, + CancellationToken cancellationToken); + + Task OpenDirectAsync( + Guid sessionId, + string accessToken, + CancellationToken cancellationToken); + + Task CancelAsync( + Guid sessionId, + string accessToken, + CancellationToken cancellationToken); + + Task GetMetricsAsync(CancellationToken cancellationToken); +} diff --git a/SecondDimensionWatcherReDive/Services/Transcoding/ScopeOwnedStream.cs b/SecondDimensionWatcherReDive/Services/Transcoding/ScopeOwnedStream.cs new file mode 100644 index 0000000..362ea00 --- /dev/null +++ b/SecondDimensionWatcherReDive/Services/Transcoding/ScopeOwnedStream.cs @@ -0,0 +1,97 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace SecondDimensionWatcherReDive.Services.Transcoding; + +///

+/// Keeps the scoped file-store implementation alive for as long as its stream. +/// +internal sealed class ScopeOwnedStream(Stream inner, AsyncServiceScope scope) : Stream +{ + private int _disposed; + + public override bool CanRead => inner.CanRead; + public override bool CanSeek => inner.CanSeek; + public override bool CanWrite => inner.CanWrite; + public override long Length => inner.Length; + + public override long Position + { + get => inner.Position; + set => inner.Position = value; + } + + public override void Flush() => inner.Flush(); + + public override Task FlushAsync(CancellationToken cancellationToken) + => inner.FlushAsync(cancellationToken); + + public override int Read(byte[] buffer, int offset, int count) + => inner.Read(buffer, offset, count); + + public override int Read(Span buffer) => inner.Read(buffer); + + public override ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) + => inner.ReadAsync(buffer, cancellationToken); + + public override Task ReadAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken) + => inner.ReadAsync(buffer, offset, count, cancellationToken); + + public override long Seek(long offset, SeekOrigin origin) => inner.Seek(offset, origin); + + public override void SetLength(long value) => inner.SetLength(value); + + public override void Write(byte[] buffer, int offset, int count) + => inner.Write(buffer, offset, count); + + public override void Write(ReadOnlySpan buffer) => inner.Write(buffer); + + public override ValueTask WriteAsync( + ReadOnlyMemory buffer, + CancellationToken cancellationToken = default) + => inner.WriteAsync(buffer, cancellationToken); + + public override Task WriteAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken) + => inner.WriteAsync(buffer, offset, count, cancellationToken); + + protected override void Dispose(bool disposing) + { + if (disposing && Interlocked.Exchange(ref _disposed, 1) == 0) + { + try + { + inner.Dispose(); + } + finally + { + scope.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + } + base.Dispose(disposing); + } + + public override async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) + { + try + { + await inner.DisposeAsync(); + } + finally + { + await scope.DisposeAsync(); + } + } + GC.SuppressFinalize(this); + } +} diff --git a/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingMetrics.cs b/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingMetrics.cs new file mode 100644 index 0000000..a7fce59 --- /dev/null +++ b/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingMetrics.cs @@ -0,0 +1,107 @@ +using System.Diagnostics.Metrics; + +namespace SecondDimensionWatcherReDive.Services.Transcoding; + +internal sealed class TranscodingMetrics : IDisposable +{ + private readonly Meter _meter = new("SecondDimensionWatcherReDive.Transcoding", "1.0.0"); + private readonly Counter _completedCounter; + private readonly Counter _failedCounter; + private readonly Counter _canceledCounter; + private readonly Counter _cacheHitCounter; + private readonly Histogram _firstSegmentHistogram; + private readonly Histogram _speedHistogram; + private long _completed; + private long _failed; + private long _canceled; + private long _cacheHits; + private long _cacheBytes; + private long _firstSegmentSamples; + private long _firstSegmentMilliseconds; + private long _speedSamples; + private double _speedTotal; + private readonly object _speedGate = new(); + private int _queued; + private int _active; + + public TranscodingMetrics() + { + _completedCounter = _meter.CreateCounter("sdw.transcoding.jobs.completed"); + _failedCounter = _meter.CreateCounter("sdw.transcoding.jobs.failed"); + _canceledCounter = _meter.CreateCounter("sdw.transcoding.jobs.canceled"); + _cacheHitCounter = _meter.CreateCounter("sdw.transcoding.cache.hits"); + _firstSegmentHistogram = _meter.CreateHistogram("sdw.transcoding.first_segment.seconds", "s"); + _speedHistogram = _meter.CreateHistogram("sdw.transcoding.speed", "x"); + _meter.CreateObservableGauge("sdw.transcoding.jobs.queued", () => Volatile.Read(ref _queued)); + _meter.CreateObservableGauge("sdw.transcoding.jobs.active", () => Volatile.Read(ref _active)); + _meter.CreateObservableGauge("sdw.transcoding.cache.bytes", () => Interlocked.Read(ref _cacheBytes), "By"); + } + + public void SetQueued(int value) => Volatile.Write(ref _queued, value); + public void SetActive(int value) => Volatile.Write(ref _active, value); + public void SetCacheBytes(long value) => Interlocked.Exchange(ref _cacheBytes, value); + + public void RecordCompleted() + { + Interlocked.Increment(ref _completed); + _completedCounter.Add(1); + } + + public void RecordFailed() + { + Interlocked.Increment(ref _failed); + _failedCounter.Add(1); + } + + public void RecordCanceled() + { + Interlocked.Increment(ref _canceled); + _canceledCounter.Add(1); + } + + public void RecordCacheHit() + { + Interlocked.Increment(ref _cacheHits); + _cacheHitCounter.Add(1); + } + + public void RecordFirstSegment(TimeSpan elapsed) + { + Interlocked.Increment(ref _firstSegmentSamples); + Interlocked.Add(ref _firstSegmentMilliseconds, (long)elapsed.TotalMilliseconds); + _firstSegmentHistogram.Record(elapsed.TotalSeconds); + } + + public void RecordSpeed(double speed) + { + if (!double.IsFinite(speed) || speed <= 0) return; + Interlocked.Increment(ref _speedSamples); + lock (_speedGate) _speedTotal += speed; + _speedHistogram.Record(speed); + } + + public TranscodingMetricsSnapshot Snapshot() + { + var firstSamples = Interlocked.Read(ref _firstSegmentSamples); + var speedSamples = Interlocked.Read(ref _speedSamples); + var completed = Interlocked.Read(ref _completed); + var failed = Interlocked.Read(ref _failed); + double speedTotal; + lock (_speedGate) speedTotal = _speedTotal; + return new TranscodingMetricsSnapshot( + Volatile.Read(ref _queued), + Volatile.Read(ref _active), + completed, + failed, + Interlocked.Read(ref _canceled), + Interlocked.Read(ref _cacheHits), + Interlocked.Read(ref _cacheBytes), + firstSamples == 0 + ? null + : Interlocked.Read(ref _firstSegmentMilliseconds) / 1000d / firstSamples, + speedSamples == 0 ? null : speedTotal / speedSamples, + completed + failed == 0 ? 0 : failed / (double)(completed + failed)); + } + + public void Dispose() => _meter.Dispose(); +} diff --git a/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingModels.cs b/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingModels.cs new file mode 100644 index 0000000..860130d --- /dev/null +++ b/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingModels.cs @@ -0,0 +1,161 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +namespace SecondDimensionWatcherReDive.Services.Transcoding; + +internal enum TranscodingJobState +{ + Queued, + Probing, + Transcoding, + Ready, + Failed, + Canceled +} + +internal enum TranscodingStrategy +{ + Direct, + Remux, + Transcode +} + +internal sealed record TranscodingSelection( + string Quality, + string? AudioLanguage, + string? AudioTrackLabel, + string? SubtitleLanguage, + string? SubtitleTrackLabel) +{ + public static TranscodingSelection Create( + string? quality, + string? audioLanguage, + string? audioTrackLabel, + string? subtitleLanguage, + string? subtitleTrackLabel) + { + var normalizedQuality = string.IsNullOrWhiteSpace(quality) + ? "auto" + : quality.Trim().ToLowerInvariant(); + if (normalizedQuality is not ("auto" or "720p" or "1080p")) + throw new ArgumentException("Quality must be auto, 720p, or 1080p.", nameof(quality)); + + return new TranscodingSelection( + normalizedQuality, + Normalize(audioLanguage), + Normalize(audioTrackLabel), + Normalize(subtitleLanguage), + Normalize(subtitleTrackLabel)); + } + + private static string? Normalize(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant(); +} + +internal sealed record TranscodingSource( + Guid AnimationInfoId, + Guid MappingId, + string VirtualPath, + string PhysicalPath, + string FileStore, + string FileName, + long Length, + DateTimeOffset LastModifiedUtc) +{ + public string BuildCacheKey(TranscodingSelection selection) + { + var material = string.Join('\n', + MappingId.ToString("N"), + VirtualPath, + PhysicalPath, + FileStore, + Length.ToString(CultureInfo.InvariantCulture), + LastModifiedUtc.UtcTicks.ToString(CultureInfo.InvariantCulture), + selection.Quality, + selection.AudioLanguage ?? string.Empty, + selection.AudioTrackLabel ?? string.Empty, + selection.SubtitleLanguage ?? string.Empty, + selection.SubtitleTrackLabel ?? string.Empty); + return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(material))); + } +} + +internal sealed record MediaStreamProbe( + int Index, + string CodecType, + string CodecName, + string? Language, + string? Title, + bool IsDefault, + bool IsForced, + bool IsAttachedPicture); + +internal sealed record MediaProbe( + string Container, + TimeSpan? Duration, + IReadOnlyList Streams) +{ + public MediaStreamProbe? Video => Streams.FirstOrDefault(stream => + stream.CodecType == "video" && !stream.IsAttachedPicture); +} + +internal sealed record TranscodingPlan( + TranscodingStrategy Strategy, + MediaStreamProbe Video, + MediaStreamProbe? Audio, + MediaStreamProbe? BitmapSubtitleToBurn, + IReadOnlyList TextSubtitles, + int UnsupportedSubtitleCount, + bool CopyVideo, + bool CopyAudio); + +internal sealed record TranscodingSubtitle( + string FileName, + string Label, + string? Language, + string Format); + +internal sealed record TranscodingSessionStatus( + Guid SessionId, + string AccessToken, + TranscodingJobState State, + TranscodingStrategy? Strategy, + bool IsPlayable, + bool CacheHit, + double? Progress, + double? Speed, + int? QueuePosition, + string? Error, + string? VideoCodec, + string? AudioCodec, + IReadOnlyList Subtitles, + int UnsupportedSubtitleCount); + +internal sealed record TranscodingContent( + Stream Stream, + string ContentType, + string? FileName, + long? Length, + DateTimeOffset? LastModifiedUtc); + +internal sealed record TranscodingMetricsSnapshot( + int QueuedJobs, + int ActiveJobs, + long CompletedJobs, + long FailedJobs, + long CanceledJobs, + long CacheHits, + long CacheBytes, + double? AverageFirstSegmentSeconds, + double? AverageTranscodeSpeed, + double FailureRate); + +internal sealed class TranscodingQueueFullException() + : InvalidOperationException("The transcoding queue is full. Try again later."); + +internal sealed class TranscodingDisabledException() + : InvalidOperationException("Server-side transcoding is disabled."); + +internal sealed class TranscodingResourceLimitException(string message) + : InvalidOperationException(message); diff --git a/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingOptions.cs b/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingOptions.cs new file mode 100644 index 0000000..20e39fd --- /dev/null +++ b/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingOptions.cs @@ -0,0 +1,27 @@ +namespace SecondDimensionWatcherReDive.Services.Transcoding; + +internal sealed class TranscodingOptions +{ + public const string SectionName = "Transcoding"; + + public bool Enabled { get; set; } = true; + public string CachePath { get; set; } = string.Empty; + public string FfmpegPath { get; set; } = "ffmpeg"; + public string FfprobePath { get; set; } = "ffprobe"; + public int MaxConcurrentJobs { get; set; } = 1; + public int QueueCapacity { get; set; } = 8; + public int MaxThreadsPerJob { get; set; } = 2; + public long MaxMemoryBytesPerJob { get; set; } = 2L * 1024 * 1024 * 1024; + public long MaxDiskBytesPerJob { get; set; } = 20L * 1024 * 1024 * 1024; + public long MaxCacheBytes { get; set; } = 100L * 1024 * 1024 * 1024; + public TimeSpan JobTimeout { get; set; } = TimeSpan.FromHours(6); + public TimeSpan CacheTtl { get; set; } = TimeSpan.FromDays(14); + public TimeSpan CleanupInterval { get; set; } = TimeSpan.FromMinutes(5); + public TimeSpan SessionTtl { get; set; } = TimeSpan.FromMinutes(15); + public int SegmentDurationSeconds { get; set; } = 6; + public int VideoCrf { get; set; } = 23; + public string VideoPreset { get; set; } = "veryfast"; + public string? HardwareVideoEncoder { get; set; } + public string[] HardwareInputArguments { get; set; } = []; + public bool BurnBitmapSubtitles { get; set; } +} diff --git a/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingPlanner.cs b/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingPlanner.cs new file mode 100644 index 0000000..ef4a2db --- /dev/null +++ b/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingPlanner.cs @@ -0,0 +1,126 @@ +namespace SecondDimensionWatcherReDive.Services.Transcoding; + +internal static class TranscodingPlanner +{ + private static readonly HashSet TextSubtitleCodecs = new(StringComparer.OrdinalIgnoreCase) + { + "ass", "jacosub", "microdvd", "mov_text", "mpl2", "realtext", "sami", "ssa", + "subrip", "subviewer", "subviewer1", "text", "vplayer", "webvtt" + }; + + private static readonly HashSet HlsAudioCodecs = new(StringComparer.OrdinalIgnoreCase) + { + "aac", "mp3" + }; + + public static TranscodingPlan CreatePlan( + TranscodingSource source, + MediaProbe probe, + TranscodingSelection selection, + bool burnBitmapSubtitles) + { + var video = probe.Video + ?? throw new InvalidOperationException("The selected file has no video track."); + var audio = SelectStream( + probe.Streams.Where(stream => stream.CodecType == "audio"), + selection.AudioLanguage, + selection.AudioTrackLabel); + var subtitles = probe.Streams.Where(stream => stream.CodecType == "subtitle").ToArray(); + var textSubtitles = subtitles.Where(stream => TextSubtitleCodecs.Contains(stream.CodecName)).ToArray(); + var bitmapSubtitles = subtitles.Where(stream => !TextSubtitleCodecs.Contains(stream.CodecName)).ToArray(); + var hasSubtitlePreference = selection.SubtitleLanguage is not null + || selection.SubtitleTrackLabel is not null; + var bitmapToBurn = burnBitmapSubtitles + && selection.SubtitleLanguage != "off" + ? SelectStream( + bitmapSubtitles, + selection.SubtitleLanguage, + selection.SubtitleTrackLabel, + fallbackToDefault: !hasSubtitlePreference) + : null; + + var extension = Path.GetExtension(source.FileName); + var copyVideo = video.CodecName.Equals("h264", StringComparison.OrdinalIgnoreCase) + && selection.Quality == "auto" + && bitmapToBurn is null; + var copyAudio = audio is null || HlsAudioCodecs.Contains(audio.CodecName); + var direct = IsDirectPlayContainer(extension, video.CodecName, audio?.CodecName) + && selection.Quality == "auto" + && bitmapToBurn is null; + var strategy = direct + ? TranscodingStrategy.Direct + : copyVideo && copyAudio + ? TranscodingStrategy.Remux + : TranscodingStrategy.Transcode; + + return new TranscodingPlan( + strategy, + video, + audio, + bitmapToBurn, + textSubtitles, + bitmapSubtitles.Length - (bitmapToBurn is null ? 0 : 1), + copyVideo, + copyAudio); + } + + private static MediaStreamProbe? SelectStream( + IEnumerable streams, + string? preferredLanguage, + string? preferredLabel, + bool fallbackToDefault = true) + { + var candidates = streams.ToArray(); + if (preferredLabel is not null) + { + var labelMatch = candidates.FirstOrDefault(stream => + string.Equals(stream.Title, preferredLabel, StringComparison.OrdinalIgnoreCase)); + if (labelMatch is not null) return labelMatch; + } + + if (preferredLanguage is not null) + { + var languageMatch = candidates.FirstOrDefault(stream => + LanguagesMatch(stream.Language, preferredLanguage)); + if (languageMatch is not null) return languageMatch; + } + + return fallbackToDefault + ? candidates.FirstOrDefault(stream => stream.IsDefault) ?? candidates.FirstOrDefault() + : null; + } + + private static bool LanguagesMatch(string? actual, string preferred) + { + if (actual is null) return false; + var normalizedActual = NormalizeLanguage(actual); + return normalizedActual == NormalizeLanguage(preferred); + } + + private static string NormalizeLanguage(string language) + { + var normalized = language.Trim().ToLowerInvariant().Replace('_', '-'); + if (normalized is "chi" or "zho" || normalized.StartsWith("zh-", StringComparison.Ordinal)) return "zh"; + if (normalized is "jpn" || normalized.StartsWith("ja-", StringComparison.Ordinal)) return "ja"; + if (normalized is "eng" || normalized.StartsWith("en-", StringComparison.Ordinal)) return "en"; + var separator = normalized.IndexOf('-'); + return separator < 0 ? normalized : normalized[..separator]; + } + + private static bool IsDirectPlayContainer(string extension, string videoCodec, string? audioCodec) + { + if (extension.Equals(".mp4", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".m4v", StringComparison.OrdinalIgnoreCase)) + return videoCodec.Equals("h264", StringComparison.OrdinalIgnoreCase) + && (audioCodec is null || audioCodec.Equals("aac", StringComparison.OrdinalIgnoreCase)); + + if (!extension.Equals(".webm", StringComparison.OrdinalIgnoreCase)) return false; + var supportedVideo = videoCodec.Equals("vp8", StringComparison.OrdinalIgnoreCase) + || videoCodec.Equals("vp9", StringComparison.OrdinalIgnoreCase) + || videoCodec.Equals("av1", StringComparison.OrdinalIgnoreCase); + var supportedAudio = audioCodec is null + || audioCodec.Equals("opus", StringComparison.OrdinalIgnoreCase) + || audioCodec.Equals("vorbis", StringComparison.OrdinalIgnoreCase); + return supportedVideo && supportedAudio; + } +} diff --git a/SecondDimensionWatcherReDive/Utils/FileStore/PlaybackPathResolver.cs b/SecondDimensionWatcherReDive/Utils/FileStore/PlaybackPathResolver.cs new file mode 100644 index 0000000..d818129 --- /dev/null +++ b/SecondDimensionWatcherReDive/Utils/FileStore/PlaybackPathResolver.cs @@ -0,0 +1,29 @@ +using SecondDimensionWatcherReDive.Framework.DataRepository; + +namespace SecondDimensionWatcherReDive.Utils.FileStore; + +internal static class PlaybackPathResolver +{ + public static string ResolveVirtualPath(AnimationInfo info, string? relative) + { + var root = GetAnimationVirtualRoot(info); + if (string.IsNullOrWhiteSpace(relative)) return root; + var trimmed = relative.Trim('/'); + return string.IsNullOrEmpty(trimmed) ? root : $"{root}/{trimmed}"; + } + + private static string GetAnimationVirtualRoot(AnimationInfo info) + { + if (info.Animation is null || info.Season is null) return "/unknown"; + var animationName = SanitizePathSegment(info.Animation.Name); + var subGroup = SanitizePathSegment(info.Group?.Name ?? "Unknown"); + return $"/{animationName}/{subGroup}"; + } + + private static string SanitizePathSegment(string name) + { + var invalid = Path.GetInvalidFileNameChars(); + var sanitized = string.Concat(name.Select(c => invalid.Contains(c) || c == '/' ? '_' : c)).Trim(); + return string.IsNullOrEmpty(sanitized) ? "Unknown" : sanitized; + } +} diff --git a/SecondDimensionWatcherReDive/appsettings.example.json b/SecondDimensionWatcherReDive/appsettings.example.json index da9d5ab..67745cc 100644 --- a/SecondDimensionWatcherReDive/appsettings.example.json +++ b/SecondDimensionWatcherReDive/appsettings.example.json @@ -27,6 +27,29 @@ "SettlingPeriod": "00:00:30", "MissingGracePeriod": "1.00:00:00" }, + // Server-side HLS fallback for containers/codecs that browsers cannot play. + // FFmpeg/ffprobe must be installed. HardwareVideoEncoder is optional (for example + // h264_nvenc or h264_vaapi); a failed hardware attempt automatically retries on CPU. + "Transcoding": { + "Enabled": true, + "CachePath": "/var/lib/sdw-redive/transcode-cache", + "FfmpegPath": "ffmpeg", + "FfprobePath": "ffprobe", + "MaxConcurrentJobs": 1, + "QueueCapacity": 8, + "MaxThreadsPerJob": 2, + "MaxMemoryBytesPerJob": 2147483648, + "MaxDiskBytesPerJob": 21474836480, + "MaxCacheBytes": 107374182400, + "JobTimeout": "06:00:00", + "CacheTtl": "14.00:00:00", + "CleanupInterval": "00:05:00", + "SessionTtl": "00:15:00", + "SegmentDurationSeconds": 6, + "HardwareVideoEncoder": null, + "HardwareInputArguments": [], + "BurnBitmapSubtitles": false + }, "MikananiFeeds": [], "TmdbApiKey": "", diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 9bfc688..f90b183 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -3,8 +3,7 @@ This project is licensed under the **Apache License 2.0** (see [`LICENSE`](LICENSE)). It bundles, redistributes, or links against the third-party software listed below. This file exists to satisfy the notice / attribution requirements those -licenses impose on downstream distributors. Components are used as published -except for the `@ffmpeg/ffmpeg` Parcel compatibility patch noted below. +licenses impose on downstream distributors. Components are used as published. If you ship our binaries or container images, you are also distributing some of these components — please carry this notice along. @@ -71,8 +70,7 @@ time. All are MIT-licensed unless noted. | mediabunny | **MPL-2.0** | https://github.com/Vanilagy/mediabunny | | media-captions | MIT | https://github.com/vidstack/media-captions | | matroska-subtitles | MIT | https://github.com/mathiasvr/matroska-subtitles | -| @ffmpeg/ffmpeg | MIT | https://github.com/ffmpegwasm/ffmpeg.wasm | -| @ffmpeg/core | **GPL-2.0-or-later** | https://github.com/ffmpegwasm/ffmpeg.wasm | +| hls.js | Apache-2.0 | https://github.com/video-dev/hls.js | | clsx | MIT | https://github.com/lukeed/clsx | | dayjs | MIT | https://github.com/iamkun/dayjs | | i18next + react-i18next + i18next-browser-languagedetector | MIT | https://github.com/i18next/i18next | @@ -85,20 +83,23 @@ Build-only / development dependencies (Parcel, Prettier, TypeScript, etc.) are listed in `SecondDimensionWatcherReDive.Client/package.json` — they are not embedded in shipping artifacts and are not enumerated here. -### Browser MKV support +### Browser media support `mediabunny` is distributed under MPL-2.0; modifications to MPL-covered files must remain available under that license. This project does not modify its -sources. - -The unsupported-codec fallback ships the `@ffmpeg/core` WebAssembly binary, -which is GPL-2.0-or-later. Distributors enabling or shipping the web client must -comply with the GPL's source and license requirements for that component. Its -corresponding source is the upstream `ffmpeg.wasm` project linked above. - -The MIT-licensed `@ffmpeg/ffmpeg` wrapper is patched locally so its worker uses -the ESM core loader accepted by Parcel. The complete patch is distributed in -`SecondDimensionWatcherReDive.Client/.yarn/patches/`. +sources. `hls.js` provides Media Source Extensions playback for the server-side +HLS fallback on browsers without native HLS support. + +### FFmpeg / ffprobe runtime dependency + +Server-side media probing, remuxing, transcoding, segmentation, and WebVTT +conversion invoke the separately installed FFmpeg command-line tools. Official +container images install their base distribution's FFmpeg package; Linux system +packages list FFmpeg as a runtime dependency. FFmpeg's effective +license depends on the codecs enabled by the distributor (the current container +build is GPL-licensed); downstream redistributors must carry the corresponding +distro package notices and source offer. The application does not statically link +FFmpeg or copy its libraries into the .NET binaries. --- diff --git a/deployments/podman-compose.yml b/deployments/podman-compose.yml index 7a4f463..afe8adf 100644 --- a/deployments/podman-compose.yml +++ b/deployments/podman-compose.yml @@ -57,6 +57,7 @@ services: MediaLibrary__MissingGracePeriod: "1.00:00:00" PasswordFile: "/app/data/password.json" DataProtection__KeyRingPath: "/app/data/data-protection-keys" + Transcoding__CachePath: "/app/data/transcode-cache" Torrent__Remote__Url: "http://qbittorrent:8080" Torrent__Remote__UserName: "" Torrent__Remote__Password: "" diff --git a/docs/container-deployment.md b/docs/container-deployment.md index f14757e..77aa138 100644 --- a/docs/container-deployment.md +++ b/docs/container-deployment.md @@ -19,7 +19,7 @@ - `downloads` — sdw-redive 和 qbittorrent **共享**,用于下载文件的读写 - `pgdata` — PostgreSQL 数据持久化 - `valkeydata` — Valkey 缓存数据持久化 -- `appdata` — 登录密码文件与运行时敏感配置的 Data Protection 密钥环 +- `appdata` — 登录密码、Data Protection 密钥环与可复用 HLS 转码缓存 ## 快速开始 @@ -120,6 +120,11 @@ podman logs qbittorrent 2>&1 | grep "temporary password" | `MediaLibrary__SettlingPeriod` | 新文件写入完成后的稳定等待时间 | `00:00:30` | | `MediaLibrary__MissingGracePeriod` | 条目缺失后保留观看/审核记录的宽限期 | `1.00:00:00` | | `MediaLibrary__AllowedRoots__0`, `__1`, ... | 允许导入的服务端根目录白名单 | `/media` | +| `Transcoding__CachePath` | 服务端 HLS 分片缓存(应挂载持久卷) | `/app/data/transcode-cache` | +| `Transcoding__MaxConcurrentJobs` | 同时运行的 FFmpeg 任务数 | `1` | +| `Transcoding__QueueCapacity` | 等待队列容量;满时返回 429 | `8` | +| `Transcoding__MaxMemoryBytesPerJob` | 单个 FFmpeg 工作集上限 | `2147483648` | +| `Transcoding__MaxCacheBytes` | HLS 缓存总上限 | `107374182400` | | `Torrent__Remote__Url` | qBittorrent API 地址 | `http://qbittorrent:8080` | | `Valkey__ConnectionString` | Valkey 连接字符串 | 空(使用内存缓存) | | `TmdbApiKey` | TMDB API 密钥 | 空(海报功能不可用) | @@ -135,6 +140,11 @@ podman logs qbittorrent 2>&1 | grep "temporary password" | `AI__CodexAppServer__BearerToken` | app-server / 反向代理要求的 Bearer token | 空 | | `AI__CodexAppServer__PermissionProfile` | `:read-only` 或管理员定义的 permission profile id | `:read-only` | +镜像已包含 FFmpeg。浏览器会继续优先直放兼容源;只有不兼容轨道才进入有界服务端队列, +首个 HLS 分片生成后立即开始播放。`appdata` 必须留有足够空间,缓存会按 TTL/LRU 自动清理。 +硬件转码需要额外映射 GPU 设备/驱动并设置 `Transcoding__HardwareVideoEncoder`;硬件失败会 +自动回退 CPU。 + ### 网页运行时设置 首次登录后可在「设置」中修改 AI/TMDB、qBittorrent、媒体库扫描、异常阈值和 NFS。网页值保存在 PostgreSQL,优先于上表的环境变量;敏感值加密后存储且不会通过 API 回显。`appdata` 卷中的 Data Protection 密钥环必须保留,否则重启后的应用无法解密已保存的密钥。 diff --git a/docs/server-deployment.md b/docs/server-deployment.md index 5814636..2ca46ca 100644 --- a/docs/server-deployment.md +++ b/docs/server-deployment.md @@ -15,6 +15,7 @@ - **ASP.NET Core 10 Runtime** — 应用以 framework-dependent 方式打包,需预先安装运行时 - **PostgreSQL** — 数据库 - **qBittorrent** — 开启 Web API +- **FFmpeg / ffprobe** — 浏览器不兼容媒体的服务端 HLS 探测、封装与转码 ### 安装 ASP.NET Core Runtime @@ -31,6 +32,19 @@ sudo dnf install aspnetcore-runtime-10.0 sudo pacman -S aspnet-runtime-10.0 ``` +通过系统包安装时,FFmpeg 会作为依赖一并安装。使用 tar.gz 或手动部署时请另外安装: + +```bash +# Debian / Ubuntu +sudo apt install ffmpeg + +# Fedora / RHEL +sudo dnf install ffmpeg + +# Arch Linux +sudo pacman -S ffmpeg +``` + ## 安装 ### 快速安装(推荐) @@ -67,6 +81,7 @@ sudo pacman -U sdw-redive-*.pkg.tar.zst | `/etc/sdw-redive/appsettings.yml` | 配置文件(YAML 格式,升级时保留用户修改) | | `/var/lib/sdw-redive/downloads/` | 默认下载存储目录 | | `/var/lib/sdw-redive/data-protection-keys/` | 网页保存的敏感配置所用持久加密密钥环 | +| `/var/lib/sdw-redive/transcode-cache/` | 可复用的 HLS 分片、WebVTT 字幕与缓存清单 | | `/usr/lib/systemd/system/sdw-redive.service` | systemd 服务单元 | 安装时自动创建 `sdw-redive` 系统用户和组用于运行服务。 @@ -103,6 +118,19 @@ MediaLibrary: SettlingPeriod: "00:00:30" MissingGracePeriod: "1.00:00:00" +Transcoding: + Enabled: true + CachePath: /var/lib/sdw-redive/transcode-cache + MaxConcurrentJobs: 1 + QueueCapacity: 8 + MaxThreadsPerJob: 2 + MaxMemoryBytesPerJob: 2147483648 # 2 GiB + MaxDiskBytesPerJob: 21474836480 # 20 GiB / job + MaxCacheBytes: 107374182400 # 100 GiB total + CacheTtl: "14.00:00:00" + SessionTtl: "00:15:00" + SegmentDurationSeconds: 6 + # TMDB API 密钥(用于海报和元数据) TmdbApiKey: "YOUR_TMDB_API_KEY" @@ -131,6 +159,31 @@ Inference: # InstanceName: "sdw-redive:" ``` +### 服务端流式播放与转码 + +网页播放器仍优先使用原文件直放;可由浏览器解码的 MKV 使用按需 Range 拆包。不兼容的 +容器或轨道才会提交到服务端:H.264/AAC 等兼容轨道优先无损封装为 HLS,只有不兼容轨道 +才转码。首个分片完成后即可播放、拖动已生成范围并同步观看进度,源文件不会先完整下载到 +浏览器。文本内封字幕会转换为 WebVTT;位图字幕默认明确标记为不可用,可用 +`Transcoding:BurnBitmapSubtitles=true` 按字幕偏好烧录(会强制视频转码)。 + +同一源版本、音轨/字幕偏好与质量会复用缓存。源文件长度或修改时间变化时会生成新缓存键; +后台按 `CacheTtl` 和 LRU 清理,且始终优先保留正在播放或生成的任务。并发数、队列长度、 +FFmpeg 线程、工作集内存、单任务磁盘、总缓存和任务超时均可配置。队列满时 API 返回 429, +不会启动额外 FFmpeg 进程。登录用户可通过 `GET /api/transcoding/metrics` 查看排队/活动任务、 +成功/失败/取消、失败率、缓存命中与占用、平均首分片时间和平均转码速度。 + +可选硬件编码示例(实际编码器及输入参数取决于主机 FFmpeg 构建和设备映射): + +```yaml +Transcoding: + HardwareVideoEncoder: h264_nvenc + HardwareInputArguments: ["-hwaccel", "cuda"] +``` + +硬件进程失败时会删除不完整输出并自动用 `libx264` 重试。容器部署还需把对应 GPU 设备和 +驱动映射进容器;未配置硬件编码器时始终使用 CPU。 + `DataProtection:KeyRingPath` 是运行时敏感设置的解密根密钥,不是普通缓存。请持久化并备份该目录,权限应仅允许应用服务账号读取。多副本连接同一个 PostgreSQL 数据库时,**所有副本必须挂载同一份共享密钥环**;否则一个副本写入的 API key/密码无法被其他副本解密。所有副本也必须保持应用内置的 Data Protection application name 一致(`SecondDimensionWatcherReDive`)。 > **注意**:配置文件在包升级时不会被覆盖(标记为 conffile / noreplace)。 diff --git a/packaging/appsettings.yml b/packaging/appsettings.yml index 4cf660f..6bccbac 100644 --- a/packaging/appsettings.yml +++ b/packaging/appsettings.yml @@ -29,6 +29,27 @@ MediaLibrary: SettlingPeriod: "00:00:30" MissingGracePeriod: "1.00:00:00" +# 浏览器不兼容媒体的服务端 HLS 后备路径。硬件编码器失败时自动回退 CPU。 +Transcoding: + Enabled: true + CachePath: /var/lib/sdw-redive/transcode-cache + FfmpegPath: ffmpeg + FfprobePath: ffprobe + MaxConcurrentJobs: 1 + QueueCapacity: 8 + MaxThreadsPerJob: 2 + MaxMemoryBytesPerJob: 2147483648 + MaxDiskBytesPerJob: 21474836480 + MaxCacheBytes: 107374182400 + JobTimeout: "06:00:00" + CacheTtl: "14.00:00:00" + CleanupInterval: "00:05:00" + SessionTtl: "00:15:00" + SegmentDurationSeconds: 6 + HardwareVideoEncoder: null + HardwareInputArguments: [] + BurnBitmapSubtitles: false + # 密码文件路径 PasswordFile: /var/lib/sdw-redive/password.json diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml index 3dcd289..3a0a580 100644 --- a/packaging/nfpm.yaml +++ b/packaging/nfpm.yaml @@ -11,6 +11,7 @@ license: "Apache-2.0" depends: - aspnetcore-runtime-10.0 + - ffmpeg recommends: - valkey @@ -19,6 +20,7 @@ overrides: archlinux: depends: - aspnet-runtime-10.0 + - ffmpeg recommends: - valkey @@ -77,6 +79,13 @@ contents: owner: sdw-redive group: sdw-redive + - dst: /var/lib/sdw-redive/transcode-cache + type: dir + file_info: + mode: 0750 + owner: sdw-redive + group: sdw-redive + scripts: postinstall: ./packaging/postinstall.sh preremove: ./packaging/preremove.sh diff --git a/packaging/postinstall.sh b/packaging/postinstall.sh index 1530a32..016e6bd 100755 --- a/packaging/postinstall.sh +++ b/packaging/postinstall.sh @@ -44,6 +44,8 @@ chown -R sdw-redive:sdw-redive /var/lib/sdw-redive # private directory also protects keys created by future application runs. install -d -m 0700 -o sdw-redive -g sdw-redive \ /var/lib/sdw-redive/data-protection-keys +install -d -m 0750 -o sdw-redive -g sdw-redive \ + /var/lib/sdw-redive/transcode-cache if [ -f /var/lib/sdw-redive/password.json ]; then chown sdw-redive:sdw-redive /var/lib/sdw-redive/password.json chmod 0600 /var/lib/sdw-redive/password.json From e0fc4c24bfe3d5af013f9f3daf511eb93ccf54e8 Mon Sep 17 00:00:00 2001 From: mahoshojoHCG Date: Sun, 30 Aug 2026 11:13:56 +0800 Subject: [PATCH 2/3] fix: harden progressive transcoding --- .../FfmpegProcessRunnerTests.cs | 15 +- .../HlsTranscodingServiceTests.cs | 211 ++++++++++++++++-- .../TranscodingPlannerTests.cs | 38 +++- .../Transcoding/FfmpegProcessRunner.cs | 72 +++--- .../Transcoding/HlsTranscodingService.cs | 172 ++++++++++---- .../Services/Transcoding/TranscodingModels.cs | 4 +- .../Transcoding/TranscodingPlanner.cs | 34 ++- 7 files changed, 430 insertions(+), 116 deletions(-) diff --git a/SecondDimensionWatcherReDive.Test/FfmpegProcessRunnerTests.cs b/SecondDimensionWatcherReDive.Test/FfmpegProcessRunnerTests.cs index c1a5114..6aaaee2 100644 --- a/SecondDimensionWatcherReDive.Test/FfmpegProcessRunnerTests.cs +++ b/SecondDimensionWatcherReDive.Test/FfmpegProcessRunnerTests.cs @@ -32,6 +32,8 @@ public async Task ProbeAndGenerateHlsAsync_ProducesProgressivePlaylistFromPipeIn MediaProbe probe; await using (var source = File.OpenRead(sourcePath)) probe = await runner.ProbeAsync(source, CancellationToken.None); + Assert.IsTrue(probe.Video?.Profile is "Baseline" or "Constrained Baseline" or "Main" or "High"); + Assert.AreEqual("yuv420p", probe.Video?.PixelFormat); var sourceInfo = new FileInfo(sourcePath); var sourceModel = new TranscodingSource( Guid.NewGuid(), @@ -67,13 +69,18 @@ public async Task ProbeAndGenerateHlsAsync_ProducesProgressivePlaylistFromPipeIn StringAssert.Contains( await File.ReadAllTextAsync(Path.Combine(output, "media.m3u8")), "#EXT-X-ENDLIST"); - IReadOnlyList subtitles; - await using (var source = File.OpenRead(sourcePath)) - subtitles = await runner.ExtractTextSubtitlesAsync( + var subtitles = new List(); + for (var index = 0; index < plan.TextSubtitles.Count; index++) + { + await using var source = File.OpenRead(sourcePath); + var subtitle = await runner.ExtractTextSubtitleAsync( source, - plan, + plan.TextSubtitles[index], + index + 1, output, CancellationToken.None); + if (subtitle is not null) subtitles.Add(subtitle); + } Assert.AreEqual(1, subtitles.Count); StringAssert.StartsWith( await File.ReadAllTextAsync(Path.Combine(output, subtitles[0].FileName)), diff --git a/SecondDimensionWatcherReDive.Test/HlsTranscodingServiceTests.cs b/SecondDimensionWatcherReDive.Test/HlsTranscodingServiceTests.cs index e77c747..f46da09 100644 --- a/SecondDimensionWatcherReDive.Test/HlsTranscodingServiceTests.cs +++ b/SecondDimensionWatcherReDive.Test/HlsTranscodingServiceTests.cs @@ -1,3 +1,4 @@ +using System.Reflection; using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; @@ -171,6 +172,99 @@ public async Task FailedJobDeletesPartialOutputAndReportsFailureRate() Assert.AreEqual(1, metrics.FailureRate); } + [TestMethod] + public async Task EmbeddedSubtitleIsPublishedWhileHlsGenerationIsStillRunning() + { + var runner = new ProgressiveSubtitleRunner(failFirstSubtitle: false); + await using var fixture = await TranscodingFixture.CreateAsync(runner); + var initial = await fixture.Service.PrepareAsync( + fixture.AnimationInfoId, + "episode.mkv", + TranscodingSelection.Create("auto", null, null, null, null), + CancellationToken.None); + + try + { + await runner.GenerationStarted.Task.WaitAsync(TimeSpan.FromSeconds(3)); + await runner.ExtractionCompleted.Task.WaitAsync(TimeSpan.FromSeconds(3)); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + TranscodingSessionStatus status; + do + { + status = await fixture.Service.GetStatusAsync( + initial.SessionId, + initial.AccessToken, + timeout.Token) + ?? throw new AssertFailedException("The transcoding session disappeared."); + if (status.Subtitles.Count == 1) break; + await Task.Delay(10, timeout.Token); + } while (true); + + Assert.AreEqual(TranscodingJobState.Transcoding, status.State); + Assert.IsTrue(status.IsPlayable); + Assert.AreEqual("subtitle-2.vtt", status.Subtitles[0].FileName); + } + finally + { + runner.ReleaseGeneration.TrySetResult(); + } + + await WaitForStateAsync(fixture.Service, initial, TranscodingJobState.Ready); + } + + [TestMethod] + public async Task FailedEmbeddedSubtitleDoesNotSuppressValidTrack() + { + var runner = new ProgressiveSubtitleRunner(failFirstSubtitle: true); + runner.ReleaseGeneration.TrySetResult(); + await using var fixture = await TranscodingFixture.CreateAsync(runner); + var initial = await fixture.Service.PrepareAsync( + fixture.AnimationInfoId, + "episode.mkv", + TranscodingSelection.Create("auto", null, null, null, null), + CancellationToken.None); + + var ready = await WaitForStateAsync(fixture.Service, initial, TranscodingJobState.Ready); + + CollectionAssert.AreEqual(new[] { 2, 3 }, runner.ExtractedStreamIndices.ToArray()); + Assert.AreEqual(1, ready.Subtitles.Count); + Assert.AreEqual("subtitle-3.vtt", ready.Subtitles[0].FileName); + } + + [TestMethod] + public async Task CacheCleanup_WaitsForSessionCreationCriticalSection() + { + await using var fixture = await TranscodingFixture.CreateAsync(new CompletingRunner()); + var serviceType = typeof(HlsTranscodingService); + var creationGate = (SemaphoreSlim)(serviceType.GetField( + "_creationGate", + BindingFlags.Instance | BindingFlags.NonPublic)?.GetValue(fixture.Service) + ?? throw new AssertFailedException("The cache creation gate was not found.")); + var cleanupMethod = serviceType.GetMethod( + "CleanupCacheAsync", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new AssertFailedException("The cache cleanup method was not found."); + + await creationGate.WaitAsync(CancellationToken.None); + Task cleanupTask; + try + { + cleanupTask = (Task)(cleanupMethod.Invoke( + fixture.Service, + [false, CancellationToken.None]) + ?? throw new AssertFailedException("Cache cleanup did not return a task.")); + Assert.IsFalse( + cleanupTask.IsCompleted, + "Cleanup must not inspect or evict cache entries while PrepareAsync can attach a session."); + } + finally + { + creationGate.Release(); + } + + await cleanupTask.WaitAsync(TimeSpan.FromSeconds(3)); + } + private static async Task WaitForStateAsync( IHlsTranscodingService service, TranscodingSessionStatus session, @@ -201,9 +295,9 @@ public Task ProbeAsync(Stream source, CancellationToken cancellation "matroska", TimeSpan.FromSeconds(30), [ - new MediaStreamProbe(0, "video", "h264", null, null, true, false, false), - new MediaStreamProbe(1, "audio", "aac", "jpn", "Japanese", true, false, false), - new MediaStreamProbe(2, "subtitle", "ass", "eng", "English", true, false, false) + new MediaStreamProbe(0, "video", "h264", null, null, true, false, false, "High", "yuv420p"), + new MediaStreamProbe(1, "audio", "aac", "jpn", "Japanese", true, false, false, null, null), + new MediaStreamProbe(2, "subtitle", "ass", "eng", "English", true, false, false, null, null) ])); public async Task GenerateHlsAsync( @@ -228,18 +322,19 @@ await File.WriteAllTextAsync( return new FfmpegRunResult(0, string.Empty); } - public async Task> ExtractTextSubtitlesAsync( + public async Task ExtractTextSubtitleAsync( Stream source, - TranscodingPlan plan, + MediaStreamProbe subtitle, + int ordinal, string outputDirectory, CancellationToken cancellationToken) { - const string name = "subtitle-2.vtt"; + var name = $"subtitle-{subtitle.Index}.vtt"; await File.WriteAllTextAsync( Path.Combine(outputDirectory, name), "WEBVTT\n", cancellationToken); - return [new TranscodingSubtitle(name, "English", "eng", "vtt")]; + return new TranscodingSubtitle(name, "English", "eng", "vtt"); } } @@ -252,8 +347,8 @@ public Task ProbeAsync(Stream source, CancellationToken cancellation "matroska", TimeSpan.FromSeconds(30), [ - new MediaStreamProbe(0, "video", "h264", null, null, true, false, false), - new MediaStreamProbe(1, "audio", "aac", null, null, true, false, false) + new MediaStreamProbe(0, "video", "h264", null, null, true, false, false, "High", "yuv420p"), + new MediaStreamProbe(1, "audio", "aac", null, null, true, false, false, null, null) ])); public async Task GenerateHlsAsync( @@ -274,12 +369,13 @@ await File.WriteAllTextAsync( return new FfmpegRunResult(0, string.Empty); } - public Task> ExtractTextSubtitlesAsync( + public Task ExtractTextSubtitleAsync( Stream source, - TranscodingPlan plan, + MediaStreamProbe subtitle, + int ordinal, string outputDirectory, CancellationToken cancellationToken) - => Task.FromResult>([]); + => Task.FromResult(null); } private sealed class FailingRunner : IFfmpegProcessRunner @@ -289,8 +385,8 @@ public Task ProbeAsync(Stream source, CancellationToken cancellation "matroska", TimeSpan.FromSeconds(30), [ - new MediaStreamProbe(0, "video", "h264", null, null, true, false, false), - new MediaStreamProbe(1, "audio", "aac", null, null, true, false, false) + new MediaStreamProbe(0, "video", "h264", null, null, true, false, false, "High", "yuv420p"), + new MediaStreamProbe(1, "audio", "aac", null, null, true, false, false, null, null) ])); public async Task GenerateHlsAsync( @@ -309,12 +405,95 @@ await File.WriteAllTextAsync( return new FfmpegRunResult(1, "fixture FFmpeg failure"); } - public Task> ExtractTextSubtitlesAsync( + public Task ExtractTextSubtitleAsync( + Stream source, + MediaStreamProbe subtitle, + int ordinal, + string outputDirectory, + CancellationToken cancellationToken) + => Task.FromResult(null); + } + + private sealed class ProgressiveSubtitleRunner(bool failFirstSubtitle) : IFfmpegProcessRunner + { + private readonly object _gate = new(); + private readonly List _extractedStreamIndices = []; + + public TaskCompletionSource GenerationStarted { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource ReleaseGeneration { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource ExtractionCompleted { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + public IReadOnlyList ExtractedStreamIndices + { + get { lock (_gate) return _extractedStreamIndices.ToArray(); } + } + + public Task ProbeAsync(Stream source, CancellationToken cancellationToken) + { + var streams = new List + { + new(0, "video", "h264", null, null, true, false, false, "High", "yuv420p"), + new(1, "audio", "aac", null, null, true, false, false, null, null), + new(2, "subtitle", "ass", "eng", failFirstSubtitle ? "Broken" : "English", false, false, false, null, null) + }; + if (failFirstSubtitle) + streams.Add(new MediaStreamProbe( + 3, + "subtitle", + "subrip", + "jpn", + "Japanese", + true, + false, + false, + null, + null)); + return Task.FromResult(new MediaProbe("matroska", TimeSpan.FromSeconds(30), streams)); + } + + public async Task GenerateHlsAsync( Stream source, TranscodingPlan plan, + TranscodingSelection selection, string outputDirectory, + bool useHardwareEncoder, + Action onProgress, CancellationToken cancellationToken) - => Task.FromResult>([]); + { + await File.WriteAllBytesAsync( + Path.Combine(outputDirectory, "segment-000000.ts"), + [1, 2, 3], + cancellationToken); + await File.WriteAllTextAsync( + Path.Combine(outputDirectory, "media.m3u8"), + "#EXTM3U\n#EXTINF:6,\nsegment-000000.ts\n#EXT-X-ENDLIST\n", + cancellationToken); + onProgress(new FfmpegProgress(1, 1, true)); + GenerationStarted.TrySetResult(); + await ReleaseGeneration.Task.WaitAsync(cancellationToken); + return new FfmpegRunResult(0, string.Empty); + } + + public async Task ExtractTextSubtitleAsync( + Stream source, + MediaStreamProbe subtitle, + int ordinal, + string outputDirectory, + CancellationToken cancellationToken) + { + lock (_gate) _extractedStreamIndices.Add(subtitle.Index); + if (failFirstSubtitle && subtitle.Index == 2) return null; + + var name = $"subtitle-{subtitle.Index}.vtt"; + await File.WriteAllTextAsync( + Path.Combine(outputDirectory, name), + "WEBVTT\n", + cancellationToken); + if (subtitle.Index == 3 || !failFirstSubtitle) ExtractionCompleted.TrySetResult(); + return new TranscodingSubtitle(name, subtitle.Title ?? $"Subtitle {ordinal}", subtitle.Language, "vtt"); + } } private sealed class TranscodingFixture : IAsyncDisposable diff --git a/SecondDimensionWatcherReDive.Test/TranscodingPlannerTests.cs b/SecondDimensionWatcherReDive.Test/TranscodingPlannerTests.cs index a696c89..d14d145 100644 --- a/SecondDimensionWatcherReDive.Test/TranscodingPlannerTests.cs +++ b/SecondDimensionWatcherReDive.Test/TranscodingPlannerTests.cs @@ -33,6 +33,33 @@ public void CreatePlan_CompatibleTracksInMkv_UsesLosslessRemux() Assert.IsTrue(plan.CopyAudio); } + [TestMethod] + public void CreatePlan_Hi10pH264_TranscodesToBrowserCompatibleVideo() + { + var plan = TranscodingPlanner.CreatePlan( + CreateSource("episode.mkv"), + CreateProbe(Video("h264", "High 10", "yuv420p10le"), Audio("aac")), + TranscodingSelection.Create("auto", null, null, null, null), + burnBitmapSubtitles: false); + + Assert.AreEqual(TranscodingStrategy.Transcode, plan.Strategy); + Assert.IsFalse(plan.CopyVideo); + Assert.IsTrue(plan.CopyAudio); + } + + [TestMethod] + public void CreatePlan_H264WithoutCompatibilityMetadata_FailsClosedToTranscode() + { + var plan = TranscodingPlanner.CreatePlan( + CreateSource("episode.mp4"), + CreateProbe(Video("h264", null, null), Audio("aac")), + TranscodingSelection.Create("auto", null, null, null, null), + burnBitmapSubtitles: false); + + Assert.AreEqual(TranscodingStrategy.Transcode, plan.Strategy); + Assert.IsFalse(plan.CopyVideo); + } + [TestMethod] public void CreatePlan_UnsupportedCodecs_TranscodesAndSelectsPreferredAudio() { @@ -125,8 +152,11 @@ private static TranscodingSource CreateSource(string fileName) private static MediaProbe CreateProbe(params MediaStreamProbe[] streams) => new("matroska", TimeSpan.FromMinutes(24), streams); - private static MediaStreamProbe Video(string codec) - => new(0, "video", codec, null, null, true, false, false); + private static MediaStreamProbe Video( + string codec, + string? profile = "High", + string? pixelFormat = "yuv420p") + => new(0, "video", codec, null, null, true, false, false, profile, pixelFormat); private static MediaStreamProbe Audio( string codec, @@ -134,12 +164,12 @@ private static MediaStreamProbe Audio( string? language = null, string? title = null, bool isDefault = false) - => new(index, "audio", codec, language, title, isDefault, false, false); + => new(index, "audio", codec, language, title, isDefault, false, false, null, null); private static MediaStreamProbe Subtitle( string codec, int index, string? language, string? title) - => new(index, "subtitle", codec, language, title, false, false, false); + => new(index, "subtitle", codec, language, title, false, false, false, null, null); } diff --git a/SecondDimensionWatcherReDive/Services/Transcoding/FfmpegProcessRunner.cs b/SecondDimensionWatcherReDive/Services/Transcoding/FfmpegProcessRunner.cs index c5189a2..9d46544 100644 --- a/SecondDimensionWatcherReDive/Services/Transcoding/FfmpegProcessRunner.cs +++ b/SecondDimensionWatcherReDive/Services/Transcoding/FfmpegProcessRunner.cs @@ -24,9 +24,10 @@ Task GenerateHlsAsync( Action onProgress, CancellationToken cancellationToken); - Task> ExtractTextSubtitlesAsync( + Task ExtractTextSubtitleAsync( Stream source, - TranscodingPlan plan, + MediaStreamProbe subtitle, + int ordinal, string outputDirectory, CancellationToken cancellationToken); } @@ -89,7 +90,9 @@ public async Task ProbeAsync(Stream source, CancellationToken cancel stream.Tags?.Title, stream.Disposition?.Default == 1, stream.Disposition?.Forced == 1, - stream.Disposition?.AttachedPic == 1)) + stream.Disposition?.AttachedPic == 1, + stream.Profile, + stream.PixelFormat)) .ToArray(); var duration = ParseDuration(document.Format?.Duration) ?? (document.Streams ?? []).Select(stream => ParseDuration(stream.Duration)).FirstOrDefault(value => value is not null); @@ -185,32 +188,26 @@ public async Task GenerateHlsAsync( cancellationToken); } - public async Task> ExtractTextSubtitlesAsync( + public async Task ExtractTextSubtitleAsync( Stream source, - TranscodingPlan plan, + MediaStreamProbe subtitle, + int ordinal, string outputDirectory, CancellationToken cancellationToken) { - if (plan.TextSubtitles.Count == 0) return []; - + var finalPath = Path.Combine(outputDirectory, $"subtitle-{subtitle.Index}.vtt"); + var temporaryPath = $"{finalPath}.tmp"; + TryDelete(temporaryPath); var startInfo = CreateStartInfo(_options.FfmpegPath, redirectOutput: false); AddArguments(startInfo, "-hide_banner", "-y", "-i", "pipe:0", "-threads", _options.MaxThreadsPerJob.ToString(CultureInfo.InvariantCulture), - "-nostats"); - var pending = new List<(MediaStreamProbe Stream, string TemporaryPath, string FinalPath)>(); - foreach (var stream in plan.TextSubtitles) - { - var finalPath = Path.Combine(outputDirectory, $"subtitle-{stream.Index}.vtt"); - var temporaryPath = $"{finalPath}.tmp"; - pending.Add((stream, temporaryPath, finalPath)); - AddArguments(startInfo, - "-map", $"0:{stream.Index}", - "-c:s", "webvtt", - "-f", "webvtt", - temporaryPath); - } + "-nostats", + "-map", $"0:{subtitle.Index}", + "-c:s", "webvtt", + "-f", "webvtt", + temporaryPath); var result = await RunFfmpegAsync( startInfo, source, @@ -220,23 +217,18 @@ public async Task> ExtractTextSubtitlesAsync( detectFirstSegment: false); if (result.ExitCode != 0) { - LogSubtitleExtractionFailed(logger, result.ExitCode, result.ErrorOutput); - foreach (var item in pending) TryDelete(item.TemporaryPath); - return []; + LogSubtitleExtractionFailed(logger, subtitle.Index, result.ExitCode, result.ErrorOutput); + TryDelete(temporaryPath); + return null; } - var subtitles = new List(); - foreach (var item in pending) - { - if (!File.Exists(item.TemporaryPath)) continue; - File.Move(item.TemporaryPath, item.FinalPath, overwrite: true); - subtitles.Add(new TranscodingSubtitle( - Path.GetFileName(item.FinalPath), - BuildSubtitleLabel(item.Stream, subtitles.Count + 1), - item.Stream.Language, - "vtt")); - } - return subtitles; + if (!File.Exists(temporaryPath)) return null; + File.Move(temporaryPath, finalPath, overwrite: true); + return new TranscodingSubtitle( + Path.GetFileName(finalPath), + BuildSubtitleLabel(subtitle, ordinal), + subtitle.Language, + "vtt"); } private async Task RunFfmpegAsync( @@ -515,8 +507,12 @@ private static void TryDelete(string path) catch (IOException) { } } - [LoggerMessage(Level = LogLevel.Warning, Message = "FFmpeg subtitle extraction exited with code {ExitCode}: {Error}")] - private static partial void LogSubtitleExtractionFailed(ILogger logger, int exitCode, string error); + [LoggerMessage(Level = LogLevel.Warning, Message = "FFmpeg subtitle stream {StreamIndex} extraction exited with code {ExitCode}: {Error}")] + private static partial void LogSubtitleExtractionFailed( + ILogger logger, + int streamIndex, + int exitCode, + string error); private sealed record FfprobeDocument( [property: JsonPropertyName("streams")] FfprobeStream[]? Streams, @@ -526,6 +522,8 @@ private sealed record FfprobeStream( [property: JsonPropertyName("index")] int? Index, [property: JsonPropertyName("codec_name")] string? CodecName, [property: JsonPropertyName("codec_type")] string? CodecType, + [property: JsonPropertyName("profile")] string? Profile, + [property: JsonPropertyName("pix_fmt")] string? PixelFormat, [property: JsonPropertyName("duration")] string? Duration, [property: JsonPropertyName("disposition")] FfprobeDisposition? Disposition, [property: JsonPropertyName("tags")] FfprobeTags? Tags); diff --git a/SecondDimensionWatcherReDive/Services/Transcoding/HlsTranscodingService.cs b/SecondDimensionWatcherReDive/Services/Transcoding/HlsTranscodingService.cs index dcf6537..6837408 100644 --- a/SecondDimensionWatcherReDive/Services/Transcoding/HlsTranscodingService.cs +++ b/SecondDimensionWatcherReDive/Services/Transcoding/HlsTranscodingService.cs @@ -13,7 +13,7 @@ namespace SecondDimensionWatcherReDive.Services.Transcoding; internal sealed partial class HlsTranscodingService : BackgroundService, IHlsTranscodingService { - private const int CacheManifestVersion = 1; + private const int CacheManifestVersion = 2; private const string CacheOwnershipMarker = ".sdw-transcode-cache"; private readonly IServiceScopeFactory _scopeFactory; private readonly IFfmpegProcessRunner _processRunner; @@ -305,6 +305,9 @@ private async Task ProcessJobAsync(TranscodingJob job, CancellationToken stoppin RecreateJobDirectory(job.CacheDirectory); job.SetState(TranscodingJobState.Transcoding); UpdateJobGauges(); + using var subtitleCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var subtitleTask = ExtractTextSubtitlesAsync(job, plan, subtitleCancellation.Token); + var subtitleTaskObserved = false; var firstSegmentRecorded = 0; void OnProgress(FfmpegProgress update) { @@ -318,49 +321,59 @@ void OnProgress(FfmpegProgress update) } } - var useHardware = !plan.CopyVideo && !string.IsNullOrWhiteSpace(_options.HardwareVideoEncoder); - FfmpegRunResult result; - await using (var source = await OpenSourceStreamAsync(job.Source, cancellationToken)) - result = await _processRunner.GenerateHlsAsync( - source, - plan, - job.Selection, - job.CacheDirectory, - useHardware, - OnProgress, - cancellationToken); - if (result.ExitCode != 0 && useHardware) + try { - LogHardwareFallback(_logger, _options.HardwareVideoEncoder!, result.ErrorOutput); - DeleteGeneratedFiles(job.CacheDirectory); - await using var source = await OpenSourceStreamAsync(job.Source, cancellationToken); - result = await _processRunner.GenerateHlsAsync( - source, - plan, - job.Selection, - job.CacheDirectory, - useHardwareEncoder: false, - OnProgress, - cancellationToken); - } - if (result.ExitCode != 0) - throw new InvalidOperationException($"FFmpeg exited with code {result.ExitCode}: {result.ErrorOutput}"); + var useHardware = !plan.CopyVideo && !string.IsNullOrWhiteSpace(_options.HardwareVideoEncoder); + FfmpegRunResult result; + await using (var source = await OpenSourceStreamAsync(job.Source, cancellationToken)) + result = await _processRunner.GenerateHlsAsync( + source, + plan, + job.Selection, + job.CacheDirectory, + useHardware, + OnProgress, + cancellationToken); + if (result.ExitCode != 0 && useHardware) + { + LogHardwareFallback(_logger, _options.HardwareVideoEncoder!, result.ErrorOutput); + DeleteGeneratedHlsFiles(job.CacheDirectory); + await using var source = await OpenSourceStreamAsync(job.Source, cancellationToken); + result = await _processRunner.GenerateHlsAsync( + source, + plan, + job.Selection, + job.CacheDirectory, + useHardwareEncoder: false, + OnProgress, + cancellationToken); + } + if (result.ExitCode != 0) + throw new InvalidOperationException($"FFmpeg exited with code {result.ExitCode}: {result.ErrorOutput}"); - IReadOnlyList subtitles; - await using (var source = await OpenSourceStreamAsync(job.Source, cancellationToken)) - subtitles = await _processRunner.ExtractTextSubtitlesAsync( - source, - plan, - job.CacheDirectory, - cancellationToken); - job.SetSubtitles(subtitles); - await WriteManifestAsync(job, cancellationToken); - _metrics.RecordCompleted(); - if (job.GetSpeed() is { } speed) _metrics.RecordSpeed(speed); - UpdateCacheBytes(); - job.SetReady(subtitles); - UpdateJobGauges(); - await CleanupCacheAsync(removeIncomplete: false, cancellationToken); + var subtitles = await subtitleTask; + subtitleTaskObserved = true; + await WriteManifestAsync(job, cancellationToken); + _metrics.RecordCompleted(); + if (job.GetSpeed() is { } speed) _metrics.RecordSpeed(speed); + UpdateCacheBytes(); + job.SetReady(subtitles); + UpdateJobGauges(); + await CleanupCacheAsync(removeIncomplete: false, cancellationToken); + } + finally + { + if (!subtitleTaskObserved) + { + try + { + await subtitleCancellation.CancelAsync(); + await subtitleTask; + } + catch (OperationCanceledException) when (subtitleCancellation.IsCancellationRequested) { } + catch (Exception exception) { LogSubtitleCleanupFailed(_logger, exception); } + } + } } catch (OperationCanceledException) when (job.Cancellation.IsCancellationRequested) { @@ -441,6 +454,32 @@ private async Task OpenSourceStreamAsync( } } + private async Task> ExtractTextSubtitlesAsync( + TranscodingJob job, + TranscodingPlan plan, + CancellationToken cancellationToken) + { + var subtitles = new List(); + for (var index = 0; index < plan.TextSubtitles.Count; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + var track = plan.TextSubtitles[index]; + TranscodingSubtitle? subtitle; + await using (var source = await OpenSourceStreamAsync(job.Source, cancellationToken)) + subtitle = await _processRunner.ExtractTextSubtitleAsync( + source, + track, + index + 1, + job.CacheDirectory, + cancellationToken); + if (subtitle is null) continue; + + subtitles.Add(subtitle); + job.SetSubtitles(subtitles.ToArray()); + } + return subtitles; + } + private TranscodingSession? FindSession(Guid id, string token, bool touch = true) { if (!_sessions.TryGetValue(id, out var session) || !TokensEqual(session.AccessToken, token)) @@ -506,6 +545,19 @@ private async Task WriteManifestAsync(TranscodingJob job, CancellationToken canc } private async Task CleanupCacheAsync(bool removeIncomplete, CancellationToken cancellationToken) + { + await _creationGate.WaitAsync(cancellationToken); + try + { + CleanupCacheCore(removeIncomplete, cancellationToken); + } + finally + { + _creationGate.Release(); + } + } + + private void CleanupCacheCore(bool removeIncomplete, CancellationToken cancellationToken) { CleanupExpiredSessions(); if (!Directory.Exists(_options.CachePath)) return; @@ -549,7 +601,6 @@ private async Task CleanupCacheAsync(bool removeIncomplete, CancellationToken ca total -= candidate.Size; } _metrics.SetCacheBytes(Math.Max(0, total)); - await Task.CompletedTask; } private void CleanupExpiredSessions() @@ -677,12 +728,16 @@ private static void RecreateJobDirectory(string path) File.WriteAllText(Path.Combine(path, CacheOwnershipMarker), string.Empty); } - private static void DeleteGeneratedFiles(string directory) + private static void DeleteGeneratedHlsFiles(string directory) { foreach (var path in Directory.EnumerateFiles(directory)) - if (Path.GetFileName(path) is not (".access" or CacheOwnershipMarker)) + { + var fileName = Path.GetFileName(path); + if (fileName is "media.m3u8" or "media.m3u8.tmp" + || fileName.StartsWith("segment-", StringComparison.Ordinal)) try { File.Delete(path); } catch (IOException) { } + } } private static void TryDeleteDirectory(string path) @@ -741,6 +796,9 @@ private static bool IsCacheKey(string name) [LoggerMessage(Level = LogLevel.Warning, Message = "Ignoring invalid transcoding cache manifest {Path}")] private static partial void LogInvalidCacheManifest(ILogger logger, string path, Exception exception); + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed while stopping background subtitle extraction")] + private static partial void LogSubtitleCleanupFailed(ILogger logger, Exception exception); + private sealed record CacheDirectory(string Key, string Path, DateTimeOffset LastAccess, long Size); private sealed record CacheManifest( @@ -828,10 +886,30 @@ public static TranscodingJob FromManifest( _progress = 1, _subtitles = manifest.Subtitles }; - var video = new MediaStreamProbe(0, "video", manifest.VideoCodec, null, null, true, false, false); + var video = new MediaStreamProbe( + 0, + "video", + manifest.VideoCodec, + null, + null, + true, + false, + false, + null, + null); var audio = manifest.AudioCodec is null ? null - : new MediaStreamProbe(1, "audio", manifest.AudioCodec, null, null, true, false, false); + : new MediaStreamProbe( + 1, + "audio", + manifest.AudioCodec, + null, + null, + true, + false, + false, + null, + null); job._plan = new TranscodingPlan( manifest.Strategy, video, diff --git a/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingModels.cs b/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingModels.cs index 860130d..ca0e599 100644 --- a/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingModels.cs +++ b/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingModels.cs @@ -89,7 +89,9 @@ internal sealed record MediaStreamProbe( string? Title, bool IsDefault, bool IsForced, - bool IsAttachedPicture); + bool IsAttachedPicture, + string? Profile, + string? PixelFormat); internal sealed record MediaProbe( string Container, diff --git a/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingPlanner.cs b/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingPlanner.cs index ef4a2db..9d28b58 100644 --- a/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingPlanner.cs +++ b/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingPlanner.cs @@ -40,11 +40,11 @@ public static TranscodingPlan CreatePlan( : null; var extension = Path.GetExtension(source.FileName); - var copyVideo = video.CodecName.Equals("h264", StringComparison.OrdinalIgnoreCase) + var copyVideo = IsBrowserCompatibleH264(video) && selection.Quality == "auto" && bitmapToBurn is null; var copyAudio = audio is null || HlsAudioCodecs.Contains(audio.CodecName); - var direct = IsDirectPlayContainer(extension, video.CodecName, audio?.CodecName) + var direct = IsDirectPlayContainer(extension, video, audio?.CodecName) && selection.Quality == "auto" && bitmapToBurn is null; var strategy = direct @@ -107,20 +107,40 @@ private static string NormalizeLanguage(string language) return separator < 0 ? normalized : normalized[..separator]; } - private static bool IsDirectPlayContainer(string extension, string videoCodec, string? audioCodec) + private static bool IsDirectPlayContainer( + string extension, + MediaStreamProbe video, + string? audioCodec) { if (extension.Equals(".mp4", StringComparison.OrdinalIgnoreCase) || extension.Equals(".m4v", StringComparison.OrdinalIgnoreCase)) - return videoCodec.Equals("h264", StringComparison.OrdinalIgnoreCase) + return IsBrowserCompatibleH264(video) && (audioCodec is null || audioCodec.Equals("aac", StringComparison.OrdinalIgnoreCase)); if (!extension.Equals(".webm", StringComparison.OrdinalIgnoreCase)) return false; - var supportedVideo = videoCodec.Equals("vp8", StringComparison.OrdinalIgnoreCase) - || videoCodec.Equals("vp9", StringComparison.OrdinalIgnoreCase) - || videoCodec.Equals("av1", StringComparison.OrdinalIgnoreCase); + var supportedVideo = video.CodecName.Equals("vp8", StringComparison.OrdinalIgnoreCase) + || video.CodecName.Equals("vp9", StringComparison.OrdinalIgnoreCase) + || video.CodecName.Equals("av1", StringComparison.OrdinalIgnoreCase); var supportedAudio = audioCodec is null || audioCodec.Equals("opus", StringComparison.OrdinalIgnoreCase) || audioCodec.Equals("vorbis", StringComparison.OrdinalIgnoreCase); return supportedVideo && supportedAudio; } + + private static bool IsBrowserCompatibleH264(MediaStreamProbe video) + { + if (!video.CodecName.Equals("h264", StringComparison.OrdinalIgnoreCase)) return false; + + var profile = video.Profile?.Trim(); + var compatibleProfile = profile is not null + && (profile.Equals("Baseline", StringComparison.OrdinalIgnoreCase) + || profile.Equals("Constrained Baseline", StringComparison.OrdinalIgnoreCase) + || profile.Equals("Main", StringComparison.OrdinalIgnoreCase) + || profile.Equals("High", StringComparison.OrdinalIgnoreCase)); + var pixelFormat = video.PixelFormat?.Trim(); + var compatiblePixelFormat = pixelFormat is not null + && (pixelFormat.Equals("yuv420p", StringComparison.OrdinalIgnoreCase) + || pixelFormat.Equals("yuvj420p", StringComparison.OrdinalIgnoreCase)); + return compatibleProfile && compatiblePixelFormat; + } } From 3a7518c3d56b3f2f3183bed6e124c152855724d1 Mon Sep 17 00:00:00 2001 From: mahoshojoHCG Date: Mon, 7 Sep 2026 13:48:46 +0800 Subject: [PATCH 3/3] Load HLS on demand and retire obsolete WASM asset requirement --- .../scripts/check-bundle-budget.mjs | 1 - .../src/pages/PlayerPage.tsx | 12 +++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/SecondDimensionWatcherReDive.Client/scripts/check-bundle-budget.mjs b/SecondDimensionWatcherReDive.Client/scripts/check-bundle-budget.mjs index 8a57f0d..b655850 100644 --- a/SecondDimensionWatcherReDive.Client/scripts/check-bundle-budget.mjs +++ b/SecondDimensionWatcherReDive.Client/scripts/check-bundle-budget.mjs @@ -91,7 +91,6 @@ const report = { mainPageJavaScript, homeRouteJavaScriptBytes, asyncJavaScript, - wasmAssets, checks, }; const rows = checks.map( diff --git a/SecondDimensionWatcherReDive.Client/src/pages/PlayerPage.tsx b/SecondDimensionWatcherReDive.Client/src/pages/PlayerPage.tsx index ccf0a98..eaf686f 100644 --- a/SecondDimensionWatcherReDive.Client/src/pages/PlayerPage.tsx +++ b/SecondDimensionWatcherReDive.Client/src/pages/PlayerPage.tsx @@ -1,6 +1,6 @@ import Artplayer from "artplayer"; import artplayerProxyMediabunny from "artplayer-proxy-mediabunny"; -import Hls from "hls.js"; +import type Hls from "hls.js"; import { CaptionsFileFormat, CaptionsRenderer, @@ -369,8 +369,7 @@ export const PlayerPage: React.FC = () => { setMkvStatus({ stage: "probing" }); let probe: MkvPlaybackProbe | null = null; - const { probeMkvPlayback } = - await import("../playback/mkv/support"); + const { probeMkvPlayback } = await import("../playback/mkv/support"); try { probe = await probeMkvPlayback(videoLink.url, controller.signal); } catch (error) { @@ -436,7 +435,6 @@ export const PlayerPage: React.FC = () => { } const initialSession = await prepareServerTranscoding( - { id: animationId, path: playbackContext.media.path, @@ -683,6 +681,7 @@ export const PlayerPage: React.FC = () => { : "en"; let hls: Hls | null = null; + let disposed = false; const art = new Artplayer({ container: playerContainerRef.current, url: playbackUrl, @@ -690,7 +689,9 @@ export const PlayerPage: React.FC = () => { customType: playbackMode === "hls" ? { - m3u8: (video: HTMLVideoElement, url: string) => { + m3u8: async (video: HTMLVideoElement, url: string) => { + const { default: Hls } = await import("hls.js"); + if (disposed) return; if (!Hls.isSupported()) { video.src = url; return; @@ -867,6 +868,7 @@ export const PlayerPage: React.FC = () => { window.removeEventListener("beforeunload", onBeforeUnload); captionsRenderer?.destroy(); captionsOverlay?.remove(); + disposed = true; hls?.destroy(); if (captionsRendererRef.current === captionsRenderer) { captionsRendererRef.current = null;