mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-22 05:16:22 -07:00
feat(subtitles): optimize anime stream audio extraction
- Prefer matching audio and lower-resolution sources with HLS sampling - Reuse verified audio within sessions and clean temporary files on shutdown - Allow character dictionary sync without loaded subtitles
This commit is contained in:
@@ -287,6 +287,15 @@ SubMiner builds on the work of these open-source projects:
|
|||||||
| [Yomitan](https://github.com/yomidevs/yomitan) | Dictionary engine powering all lookups and the morphological parser |
|
| [Yomitan](https://github.com/yomidevs/yomitan) | Dictionary engine powering all lookups and the morphological parser |
|
||||||
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
|
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
|
||||||
|
|
||||||
|
## Disclaimer
|
||||||
|
|
||||||
|
SubMiner is a media player and language-learning tool. The project does not
|
||||||
|
host, supply, or link to anime, video streams, or subtitle libraries. The anime
|
||||||
|
browser runs Aniyomi extensions that you install yourself from repositories you
|
||||||
|
choose; SubMiner bundles none and recommends none. The SubMiner developers are
|
||||||
|
not affiliated with any content provider and have no control over what those
|
||||||
|
sources offer, whether they stay available, or the quality of what they serve.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[GNU General Public License v3.0](LICENSE)
|
[GNU General Public License v3.0](LICENSE)
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: docs
|
||||||
|
area: anime
|
||||||
|
|
||||||
|
- Added a README disclaimer stating that SubMiner hosts no content, ships no anime sources or repositories, is not affiliated with any content provider, and that users are responsible for the content they access. The anime browser docs no longer describe SubMiner as a "host" of extensions.
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
type: docs
|
type: docs
|
||||||
area: subtitles
|
area: subtitles
|
||||||
|
|
||||||
- Document anime stream generation, cache locations, full-episode processing, supported audio tracks, and stream access limitations.
|
- Document anime stream generation, audio-only and lower-resolution extraction candidates, concurrent HLS downloads, temporary disk usage, audio cache reuse and limits, full-episode processing, supported audio tracks, and stream access limitations.
|
||||||
|
- Explain HLS source-check diagnostics and session-only audio cleanup on quit and after crashes.
|
||||||
|
|||||||
@@ -2,3 +2,5 @@ type: added
|
|||||||
area: subtitles
|
area: subtitles
|
||||||
|
|
||||||
- Generate Japanese subtitles from finite HTTP/HTTPS anime episode streams in the existing generation modal. Reuse mpv's supported request headers and the anime proxy, transcribe downloaded audio locally, retain generated SRT files in the application cache, and load them only into the matching stream. Local-file generation and the launcher keep their existing behavior.
|
- Generate Japanese subtitles from finite HTTP/HTTPS anime episode streams in the existing generation modal. Reuse mpv's supported request headers and the anime proxy, transcribe downloaded audio locally, retain generated SRT files in the application cache, and load them only into the matching stream. Local-file generation and the launcher keep their existing behavior.
|
||||||
|
- Prepare plain HLS episodes with up to four concurrent segment downloads, preserving audio order and timing. Show download progress, cancel active requests together, and retain FFmpeg extraction for complex playlists.
|
||||||
|
- Reduce anime-stream extraction downloads by preferring separate Japanese audio or lower-resolution video when duration and sampled audio match playback. Sample HLS through short local segment windows, account for timestamp origins, retry empty original samples, and report why candidates are rejected. Selected external HTTP audio tracks are extracted directly with their playback delay preserved. Reuse verified completed audio across transcription retries and model changes within the current app session. Remove downloaded audio and working files on quit, recover abandoned sessions on startup, and preserve saved subtitles.
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: docs
|
||||||
|
area: subtitles
|
||||||
|
|
||||||
|
- Clarify that character dictionary sync works without a subtitle track and explain readiness and import timeouts.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: subtitles
|
||||||
|
|
||||||
|
- Let character dictionaries finish syncing when no Japanese subtitles are loaded. Wait for Yomitan extension readiness instead of the first processed subtitle, and report readiness failures instead of leaving the notification stuck on Checking.
|
||||||
@@ -43,14 +43,16 @@ extension APK → bridge (JVM) → { url, headers } → mpv → SubMiner
|
|||||||
```
|
```
|
||||||
|
|
||||||
Because the extension resolves the stream, whichever sources you install decide
|
Because the extension resolves the stream, whichever sources you install decide
|
||||||
what is available. SubMiner only hosts them.
|
what is available. SubMiner runs the installed extensions locally and nothing
|
||||||
|
more.
|
||||||
|
|
||||||
## Installing extensions
|
## Installing extensions
|
||||||
|
|
||||||
**SubMiner ships no extension repositories and bundles no sources.** There is no
|
**SubMiner ships no extension repositories and bundles no sources.** There is no
|
||||||
default repository, no suggested list, and no discovery. Until you add one, the
|
default repository, no suggested list, and no discovery. Until you add one, the
|
||||||
browser has nothing to search — that is deliberate, and it is what keeps SubMiner
|
browser has nothing to search — that is deliberate. Every repository and
|
||||||
a neutral host rather than a distributor.
|
extension comes from you, and SubMiner is not affiliated with any of the sites
|
||||||
|
they connect to.
|
||||||
|
|
||||||
There are two ways to add extensions.
|
There are two ways to add extensions.
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ Character dictionary sync is disabled by default. To turn it on:
|
|||||||
|
|
||||||
::: tip
|
::: tip
|
||||||
The first sync for a media title takes a few seconds while character data and portraits are fetched from AniList. Subsequent launches reuse the cached media match and snapshot without a fresh AniList lookup.
|
The first sync for a media title takes a few seconds while character data and portraits are fetched from AniList. Subsequent launches reuse the cached media match and snapshot without a fresh AniList lookup.
|
||||||
|
|
||||||
|
Dictionary sync does not require a loaded Japanese subtitle track. Once Yomitan is ready, SubMiner can reuse an installed character dictionary even before the first subtitle is processed. If Yomitan readiness times out, the status reports a failure instead of staying on **Checking**.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::: info
|
::: info
|
||||||
@@ -168,7 +170,7 @@ These phases are emitted through the configured notification surface. Some phase
|
|||||||
2. **generating** - No cache hit: fetch characters from AniList GraphQL, download portraits (250ms throttle between image requests), save snapshot JSON.
|
2. **generating** - No cache hit: fetch characters from AniList GraphQL, download portraits (250ms throttle between image requests), save snapshot JSON.
|
||||||
3. MRU update (no notification) - add the media ID to the most-recently-used list and evict old entries beyond `maxLoaded`.
|
3. MRU update (no notification) - add the media ID to the most-recently-used list and evict old entries beyond `maxLoaded`.
|
||||||
4. **building** - Merge active snapshots into a single Yomitan ZIP. A SHA-1 revision hash is computed from the media set - if it matches the previously imported revision, the import is skipped.
|
4. **building** - Merge active snapshots into a single Yomitan ZIP. A SHA-1 revision hash is computed from the media set - if it matches the previously imported revision, the import is skipped.
|
||||||
5. **importing** - Push the ZIP into Yomitan. Waits for Yomitan mutation readiness (7-second timeout per operation).
|
5. **importing** - Push the ZIP into Yomitan when the installed revision differs. Dictionary checks and settings updates wait for the extension to be ready, independently of subtitle processing. Readiness and quick operations have bounded timeouts; imports have a separate budget that grows with the ZIP size.
|
||||||
6. **ready** - Dictionary is live. Character names will match on the next subtitle line.
|
6. **ready** - Dictionary is live. Character names will match on the next subtitle line.
|
||||||
|
|
||||||
**State tracking** is persisted in `character-dictionaries/auto-sync-state.json`. AniList media matches are cached separately in `character-dictionaries/anilist-resolution-cache.json` so snapshot hits do not need another AniList search.
|
**State tracking** is persisted in `character-dictionaries/auto-sync-state.json`. AniList media matches are cached separately in `character-dictionaries/anilist-resolution-cache.json` so snapshot hits do not need another AniList search.
|
||||||
|
|||||||
@@ -65,11 +65,19 @@ SubMiner saves `<video>.ja.generated.srt` beside the media, adding a numeric suf
|
|||||||
|
|
||||||
### Generating for anime streams
|
### Generating for anime streams
|
||||||
|
|
||||||
Start the episode from the [anime browser](/anime-browser), then open the generation modal as usual. HTTP and HTTPS episode streams, including HLS through the anime browser's proxy, can use the same models and dialogue options as local files. The selected audio track must be inside the stream; separately attached audio tracks are not supported.
|
Start the episode from the [anime browser](/anime-browser), then open the generation modal as usual. HTTP and HTTPS episode streams, including HLS through the anime browser's proxy, can use the same models and dialogue options as local files. The selected audio may be inside the stream or a separately attached HTTP/HTTPS audio track. For a selected external audio stream, SubMiner downloads that track directly and includes mpv's current audio delay in the subtitle timing. Local external audio files remain unsupported.
|
||||||
|
|
||||||
SubMiner fetches the episode's audio into a temporary WAV before transcription. It reuses mpv's supported request headers, referrer, and user agent. The existing media-fetch policy excludes Cookie and Authorization headers, so sources requiring those directly may fail unless the anime bridge handles authentication. Expired URLs or a stopped bridge can also interrupt extraction; reopen the episode and retry.
|
SubMiner fetches the episode's audio into a temporary WAV before transcription. It reuses mpv's supported request headers, referrer, and user agent. The existing media-fetch policy excludes Cookie and Authorization headers, so sources requiring those directly may fail unless the anime bridge handles authentication. Expired URLs or a stopped bridge can also interrupt extraction; reopen the episode and retry.
|
||||||
|
|
||||||
Generated SRT files are retained under `cache/generated-subtitles` in SubMiner's application data directory, using a URL hash for the filename and a numeric suffix for repeated generation. The result shows the saved path. Temporary audio is removed after success, failure, or cancellation. Playback can continue during generation; the completed SRT is selected only if the same stream is still playing. Cached results are not automatically loaded on a later visit.
|
For anime-browser episodes, extraction first considers separate Japanese audio tracks returned by the source, then video streams with lower resolution than playback. It checks duration, starting offset, and short audio samples at three points against the selected playback audio. Different dialogue, shifted timing, silence, or failed checks keep the original stream. Checking alternatives has a 20-second budget. For plain HLS, it downloads only the segments covering each sample and aligns their timestamps to playback before comparing audio. Empty original samples are retried once and never reused. Failed checks log the candidate number, phase, reason, and elapsed time without exposing stream URLs or request headers. Progress names the source used for extraction; playback keeps its selected quality. Sources that expose only one combined stream still require downloading its video bytes.
|
||||||
|
|
||||||
|
For plain HLS episodes with MPEG-TS segments, SubMiner downloads up to four segments at once, then extracts audio in playback order. Progress shows segment downloading followed by audio extraction. The temporary segments require space for the episode's combined video and audio and are removed after extraction, or when generation fails or is cancelled. Encrypted playlists, variant playlists, byte ranges, and other complex stream formats use the existing FFmpeg extraction path. Download speed still depends on the source and proxy.
|
||||||
|
|
||||||
|
Generated SRT files are retained under `cache/generated-subtitles` in SubMiner's application data directory, using a URL hash for the filename and a numeric suffix for repeated generation. The result shows the saved path. Playback can continue during generation; the completed SRT is selected only if the same stream is still playing. Cached SRT results are not automatically loaded on a later visit.
|
||||||
|
|
||||||
|
Completed audio extracts can be reused for retries and model changes during the current app session. They live with temporary job files under `cache/generated-subtitles/sessions`, separate from saved SRT files. The cache distinguishes the stream URL, selected audio track, and request headers, verifies contents before reuse, and evicts older audio above 512 MiB. Partial downloads are not cached.
|
||||||
|
|
||||||
|
Quitting cancels the active job, waits for its cleanup, and removes the session's downloaded audio and working files. On startup, SubMiner removes abandoned session directories from processes that have exited, plus audio retained by the older persistent cache. Audio is never reused across app restarts. Generated SRT files and installed speech models remain available.
|
||||||
|
|
||||||
This processes the complete episode, so subtitles appear after extraction and transcription finish. Streams must report a finite duration; live streams are not supported. A stream with combined video and audio segments may require downloading those segments even though only audio is retained.
|
This processes the complete episode, so subtitles appear after extraction and transcription finish. Streams must report a finite duration; live streams are not supported. A stream with combined video and audio segments may require downloading those segments even though only audio is retained.
|
||||||
|
|
||||||
@@ -87,4 +95,4 @@ With no file argument, the command uses the current local mpv media and its sele
|
|||||||
|
|
||||||
The SRT includes whisper.cpp's timestamps, adjusted for the audio stream's position on the media timeline and, when speech detection is configured, each passage's original start time. No alass step is required to load it. This version uses native Whisper timing; it does not run WhisperX or another forced aligner. Recognition can repeat or invent lines, and timing can be imperfect, especially with music or overlapping speech. Review generated text and audio boundaries when mining.
|
The SRT includes whisper.cpp's timestamps, adjusted for the audio stream's position on the media timeline and, when speech detection is configured, each passage's original start time. No alass step is required to load it. This version uses native Whisper timing; it does not run WhisperX or another forced aligner. Recognition can repeat or invent lines, and timing can be imperfect, especially with music or overlapping speech. Review generated text and audio boundaries when mining.
|
||||||
|
|
||||||
The overlay supports local files and finite HTTP/HTTPS streams with internal audio tracks. The launcher remains local-file only. Subtitle translation and transcription of a separately attached mpv audio track are not supported by the modal. Pass a separate local audio file to the launcher if needed. The destination directory needs writable space for subtitles; temporary storage needs enough space for the extracted mono audio. Cached local subtitle tracks and embedded text subtitles can guide stream transcription; unreadable references fall back to audio timing.
|
The overlay supports local files and finite HTTP/HTTPS streams with internal or selected external HTTP audio tracks. The launcher remains local-file only. Subtitle translation and separately attached local audio files are not supported by the modal. Pass a separate local audio file to the launcher if needed. The destination directory needs writable space for subtitles; temporary storage needs enough space for the extracted mono audio. Cached local subtitle tracks and embedded text subtitles can guide stream transcription; unreadable references fall back to audio timing.
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ Update checks and startup launcher migration share a serialized update-state sto
|
|||||||
- `src/main/boot/` owns boot-phase assembly seams so `src/main.ts` can stay focused on lifecycle coordination and startup-path selection.
|
- `src/main/boot/` owns boot-phase assembly seams so `src/main.ts` can stay focused on lifecycle coordination and startup-path selection.
|
||||||
- `src/main/runtime/linux-overlay-mode-runtime.ts` owns Linux fullscreen mode state and window replacement. App cleanup cancels pending replacements; `main.ts` supplies window creation and subtitle refresh hooks.
|
- `src/main/runtime/linux-overlay-mode-runtime.ts` owns Linux fullscreen mode state and window replacement. App cleanup cancels pending replacements; `main.ts` supplies window creation and subtitle refresh hooks.
|
||||||
- `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic.
|
- `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic.
|
||||||
- `src/core/services/subtitle-generation*.ts` shares local whisper.cpp transcription, safe model downloads, and progress between the launcher and Electron. The overlay also accepts finite HTTP/HTTPS episode streams, snapshots mpv's supported HTTP headers, and extracts audio through FFmpeg with bounded network reads. Network reference extraction uses the same headers. Generated stream subtitles persist under the injected application cache directory with hashed filenames; temporary audio is removed after the job. The launcher remains local-file only. Optional dialogue mode retains both Silero-detected speech and other audible sections, omits confidently silent gaps, decodes passages independently, and restores original media timing. `src/main/runtime/subtitle-generation-runtime.ts` owns the overlay job lifecycle and only loads completed subtitles into the same media; `src/shared/subtitle-generation*.ts` owns configuration, the multilingual model catalog, and IPC contracts. The overlay runtime retains a session model selection, validates picker requests through IPC, and keeps external model paths authoritative.
|
- `src/core/services/subtitle-generation*.ts` shares local whisper.cpp transcription, safe model downloads, and progress between the launcher and Electron. The overlay also accepts finite HTTP/HTTPS episode streams, snapshots mpv's supported HTTP headers, and extracts audio through FFmpeg with bounded network reads. Network reference extraction uses the same headers. Generated stream subtitles persist under the injected application cache directory with hashed filenames. `subtitle-generation-audio.ts` owns probing, extraction, and completed-audio reuse. Anime playback retains bounded per-URL alternative-source metadata; generation checks candidate duration, offset, and decoded audio samples before selecting separate Japanese audio or lower-resolution video. Verified extracts are reused only within the current app session, keyed by playback URL, audio stream index, and request headers. `subtitle-generation-session.ts` owns temporary audio and job directories, removes abandoned process directories on startup, and drains cancellation before shutdown cleanup. Saved SRT files remain separate. Plain HLS samples use short segment windows and container timestamps to avoid empty remote seeks; source rejection diagnostics omit URLs and headers. The launcher remains local-file only. Optional dialogue mode retains both Silero-detected speech and other audible sections, omits confidently silent gaps, decodes passages independently, and restores original media timing. `src/main/runtime/subtitle-generation-runtime.ts` owns the overlay job lifecycle and only loads completed subtitles into the same media; `src/shared/subtitle-generation*.ts` owns configuration, the multilingual model catalog, and IPC contracts. The overlay runtime retains a session model selection, validates picker requests through IPC, and keeps external model paths authoritative.
|
||||||
- Subtitle model recommendations use bounded `nvidia-smi` and Whisper CUDA discovery probes in `subtitle-generation-acceleration.ts`. The overlay runtime caches results by executable path for 30 seconds and exposes acceleration status through the existing status IPC. Recommendations do not alter model selection or transcription arguments.
|
- Subtitle model recommendations use bounded `nvidia-smi` and Whisper CUDA discovery probes in `subtitle-generation-acceleration.ts`. The overlay runtime caches results by executable path for 30 seconds and exposes acceleration status through the existing status IPC. Recommendations do not alter model selection or transcription arguments.
|
||||||
- `subtitle-generation-reference.ts` ranks mpv's loaded text subtitle tracks, excludes signs/songs and forced references, and extracts timing hints with FFmpeg. The overlay and launcher snapshot references only for matching media, including active subtitle delays. Hints guide long-passage cuts with or without VAD; they never limit audio coverage or replace Whisper timestamps.
|
- `subtitle-generation-reference.ts` ranks mpv's loaded text subtitle tracks, excludes signs/songs and forced references, and extracts timing hints with FFmpeg. The overlay and launcher snapshot references only for matching media, including active subtitle delays. Hints guide long-passage cuts with or without VAD; they never limit audio coverage or replace Whisper timestamps.
|
||||||
- `src/renderer/` owns overlay rendering and input behavior.
|
- `src/renderer/` owns overlay rendering and input behavior.
|
||||||
|
|||||||
@@ -81,6 +81,18 @@ function parseMpvHeaderField(value: string): [string, string] | null {
|
|||||||
return [name, headerValue.replace(/[\r\n]+/g, ' ')];
|
return [name, headerValue.replace(/[\r\n]+/g, ' ')];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Apply the same media-fetch header policy to extension-provided alternatives. */
|
||||||
|
export function sanitizeMediaHttpHeaders(
|
||||||
|
headers: Readonly<Record<string, string>>,
|
||||||
|
): Record<string, string> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(headers).flatMap(([name, value]) => {
|
||||||
|
const field = parseMpvHeaderField(`${name}: ${value}`);
|
||||||
|
return field ? [field] : [];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function toHeaderFields(value: unknown): string[] {
|
function toHeaderFields(value: unknown): string[] {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return value.filter((entry): entry is string => typeof entry === 'string');
|
return value.filter((entry): entry is string => typeof entry === 'string');
|
||||||
|
|||||||
@@ -0,0 +1,344 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { probeSubtitleGenerationAudio } from './subtitle-generation-probe';
|
||||||
|
import { selectSubtitleGenerationAlternative } from './subtitle-generation-alternative';
|
||||||
|
import { prepareSubtitleGenerationAudio } from './subtitle-generation-audio';
|
||||||
|
import { runSubtitleGenerationProcess } from './subtitle-generation-process';
|
||||||
|
import { resolveSubtitleGenerationTools } from './subtitle-generation-tools';
|
||||||
|
import { DEFAULT_SUBTITLE_GENERATION_CONFIG } from '../../shared/subtitle-generation';
|
||||||
|
|
||||||
|
test(
|
||||||
|
'real FFmpeg verifies matching alternatives, rejects different dialogue and reuses the extract',
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
async (t) => {
|
||||||
|
const tools = await resolveSubtitleGenerationTools(DEFAULT_SUBTITLE_GENERATION_CONFIG);
|
||||||
|
if (tools.ffmpeg.kind !== 'found' || tools.ffprobe.kind !== 'found') {
|
||||||
|
t.skip('Requires FFmpeg');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const directory = await mkdtemp(path.join(tmpdir(), 'subtitle-alternative-test-'));
|
||||||
|
let requests = 0;
|
||||||
|
const requestedFiles: string[] = [];
|
||||||
|
const server = createServer(async (req, res) => {
|
||||||
|
requests += 1;
|
||||||
|
if (req.headers.referer !== 'https://anime.test/') {
|
||||||
|
res.writeHead(403).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const name = req.url?.slice(1) ?? '';
|
||||||
|
requestedFiles.push(name);
|
||||||
|
if (
|
||||||
|
!['original.mkv', 'small.mkv', 'audio.mka', 'dub.mka', 'short.mka', 'shifted.mka'].includes(
|
||||||
|
name,
|
||||||
|
) &&
|
||||||
|
!/^(?:high|low)(?:\d+\.ts|\.m3u8)$/.test(name)
|
||||||
|
) {
|
||||||
|
res.writeHead(404).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bytes = await readFile(path.join(directory, name));
|
||||||
|
const range = /^bytes=(\d+)-(\d*)$/.exec(req.headers.range ?? '');
|
||||||
|
if (range) {
|
||||||
|
const start = Number(range[1]);
|
||||||
|
const end = range[2] ? Math.min(bytes.length - 1, Number(range[2])) : bytes.length - 1;
|
||||||
|
if (start >= bytes.length) {
|
||||||
|
res.writeHead(416).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(206, {
|
||||||
|
'Content-Range': `bytes ${start}-${end}/${bytes.length}`,
|
||||||
|
'Content-Length': end - start + 1,
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
});
|
||||||
|
res.end(bytes.subarray(start, end + 1));
|
||||||
|
} else
|
||||||
|
res.writeHead(200, { 'Content-Length': bytes.length, 'Accept-Ranges': 'bytes' }).end(bytes);
|
||||||
|
});
|
||||||
|
const ffmpeg = tools.ffmpeg.path;
|
||||||
|
try {
|
||||||
|
await runSubtitleGenerationProcess({
|
||||||
|
command: ffmpeg,
|
||||||
|
args: [
|
||||||
|
'-v',
|
||||||
|
'error',
|
||||||
|
'-f',
|
||||||
|
'lavfi',
|
||||||
|
'-i',
|
||||||
|
'testsrc2=size=128x72:rate=10:duration=20',
|
||||||
|
'-f',
|
||||||
|
'lavfi',
|
||||||
|
'-i',
|
||||||
|
'aevalsrc=0.2*sin(2*PI*(220+20*t)*t):d=20:s=48000',
|
||||||
|
'-c:v',
|
||||||
|
'mpeg4',
|
||||||
|
'-c:a',
|
||||||
|
'pcm_s16le',
|
||||||
|
'-metadata:s:a:0',
|
||||||
|
'language=jpn',
|
||||||
|
path.join(directory, 'original.mkv'),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
for (const [name, args] of [
|
||||||
|
['audio.mka', ['-vn', '-c:a', 'copy']],
|
||||||
|
['short.mka', ['-vn', '-c:a', 'copy', '-t', '15']],
|
||||||
|
['shifted.mka', ['-vn', '-af', 'adelay=250', '-c:a', 'pcm_s16le', '-t', '20']],
|
||||||
|
['small.mkv', ['-vf', 'scale=64:36', '-c:v', 'mpeg4', '-c:a', 'copy']],
|
||||||
|
] as const) {
|
||||||
|
await runSubtitleGenerationProcess({
|
||||||
|
command: ffmpeg,
|
||||||
|
args: [
|
||||||
|
'-v',
|
||||||
|
'error',
|
||||||
|
'-i',
|
||||||
|
path.join(directory, 'original.mkv'),
|
||||||
|
...args,
|
||||||
|
path.join(directory, name),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await runSubtitleGenerationProcess({
|
||||||
|
command: ffmpeg,
|
||||||
|
args: [
|
||||||
|
'-v',
|
||||||
|
'error',
|
||||||
|
'-f',
|
||||||
|
'lavfi',
|
||||||
|
'-i',
|
||||||
|
'sine=frequency=750:duration=20',
|
||||||
|
'-c:a',
|
||||||
|
'pcm_s16le',
|
||||||
|
path.join(directory, 'dub.mka'),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const address = server.address();
|
||||||
|
assert.ok(address && typeof address === 'object');
|
||||||
|
const origin = `http://127.0.0.1:${address.port}`;
|
||||||
|
const httpHeaders = { headers: { Referer: 'https://anime.test/' }, userAgent: null };
|
||||||
|
const config = {
|
||||||
|
ffmpeg,
|
||||||
|
ffprobe: tools.ffprobe.path,
|
||||||
|
mediaPath: `${origin}/original.mkv`,
|
||||||
|
audioStreamIndex: 1,
|
||||||
|
};
|
||||||
|
const referenceDirectory = path.join(directory, 'reference');
|
||||||
|
await mkdir(referenceDirectory);
|
||||||
|
const reference = await prepareSubtitleGenerationAudio({
|
||||||
|
...config,
|
||||||
|
directory: referenceDirectory,
|
||||||
|
mediaPath: path.join(directory, 'original.mkv'),
|
||||||
|
});
|
||||||
|
// Reproduce an HLS seek that exits successfully without producing audio.
|
||||||
|
const emptyOnceFfmpeg = path.join(directory, 'ffmpeg-empty-once');
|
||||||
|
const marker = path.join(directory, 'empty-sample-produced');
|
||||||
|
await writeFile(
|
||||||
|
emptyOnceFfmpeg,
|
||||||
|
`#!${process.execPath}
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const { spawnSync } = require('node:child_process');
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const output = args.at(-1);
|
||||||
|
if (output.endsWith('.pcm') && !fs.existsSync(${JSON.stringify(marker)})) {
|
||||||
|
fs.writeFileSync(${JSON.stringify(marker)}, '');
|
||||||
|
fs.writeFileSync(output, '');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
const result = spawnSync(${JSON.stringify(ffmpeg)}, args, {stdio: 'inherit'});
|
||||||
|
if (result.status === 0 && output.endsWith('.pcm') && fs.statSync(output).size % 4 === 0) fs.appendFileSync(output, Buffer.alloc(2));
|
||||||
|
process.exit(result.status ?? 1);
|
||||||
|
`,
|
||||||
|
{ mode: 0o755 },
|
||||||
|
);
|
||||||
|
for (const [name, size, timestamp] of [
|
||||||
|
['high', '128:72', '0'],
|
||||||
|
['low', '64:36', '10'],
|
||||||
|
] as const) {
|
||||||
|
await runSubtitleGenerationProcess({
|
||||||
|
command: ffmpeg,
|
||||||
|
args: [
|
||||||
|
'-v',
|
||||||
|
'error',
|
||||||
|
'-i',
|
||||||
|
path.join(directory, 'original.mkv'),
|
||||||
|
'-vf',
|
||||||
|
`scale=${size}`,
|
||||||
|
'-c:v',
|
||||||
|
'libx264',
|
||||||
|
'-g',
|
||||||
|
'20',
|
||||||
|
'-sc_threshold',
|
||||||
|
'0',
|
||||||
|
'-c:a',
|
||||||
|
'aac',
|
||||||
|
'-output_ts_offset',
|
||||||
|
timestamp,
|
||||||
|
'-f',
|
||||||
|
'hls',
|
||||||
|
'-hls_time',
|
||||||
|
'2',
|
||||||
|
'-hls_list_size',
|
||||||
|
'0',
|
||||||
|
path.join(directory, `${name}.m3u8`),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const highProbe = await probeSubtitleGenerationAudio({
|
||||||
|
command: tools.ffprobe.path,
|
||||||
|
mediaPath: `${origin}/high.m3u8`,
|
||||||
|
httpHeaders,
|
||||||
|
});
|
||||||
|
const lowProbe = await probeSubtitleGenerationAudio({
|
||||||
|
command: tools.ffprobe.path,
|
||||||
|
mediaPath: `${origin}/low.m3u8`,
|
||||||
|
httpHeaders,
|
||||||
|
});
|
||||||
|
assert.notEqual(
|
||||||
|
highProbe.startTime,
|
||||||
|
lowProbe.startTime,
|
||||||
|
'Fixture must exercise different timestamp origins',
|
||||||
|
);
|
||||||
|
const hlsDirectory = path.join(directory, 'hls-samples');
|
||||||
|
await mkdir(hlsDirectory);
|
||||||
|
const hlsResult = await selectSubtitleGenerationAlternative({
|
||||||
|
original: { url: `${origin}/high.m3u8`, probe: highProbe, httpHeaders },
|
||||||
|
alternatives: [{ kind: 'video', url: `${origin}/low.m3u8`, label: '480p', httpHeaders }],
|
||||||
|
ffmpeg,
|
||||||
|
ffprobe: tools.ffprobe.path,
|
||||||
|
directory: hlsDirectory,
|
||||||
|
});
|
||||||
|
assert.equal(hlsResult.label, '480p');
|
||||||
|
const emptyDirectory = path.join(directory, 'empty-first-sample');
|
||||||
|
await mkdir(emptyDirectory);
|
||||||
|
const emptyMessages: string[] = [];
|
||||||
|
await prepareSubtitleGenerationAudio({
|
||||||
|
...config,
|
||||||
|
ffmpeg: emptyOnceFfmpeg,
|
||||||
|
directory: emptyDirectory,
|
||||||
|
remote: {
|
||||||
|
cacheDirectory: path.join(directory, 'empty-cache'),
|
||||||
|
httpHeaders,
|
||||||
|
alternatives: [
|
||||||
|
{ kind: 'video', url: `${origin}/small.mkv`, label: 'small.mkv', httpHeaders },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
onProgress: (progress) => emptyMessages.push(progress.message ?? ''),
|
||||||
|
});
|
||||||
|
assert.ok(
|
||||||
|
emptyMessages.includes('Extracting audio from small.mkv...'),
|
||||||
|
emptyMessages.join('\n'),
|
||||||
|
);
|
||||||
|
const mismatchReasons: string[] = [];
|
||||||
|
const originalProbe = await probeSubtitleGenerationAudio({
|
||||||
|
command: tools.ffprobe.path,
|
||||||
|
mediaPath: config.mediaPath,
|
||||||
|
httpHeaders,
|
||||||
|
});
|
||||||
|
const mismatched = await selectSubtitleGenerationAlternative({
|
||||||
|
original: { url: config.mediaPath, probe: originalProbe, httpHeaders },
|
||||||
|
alternatives: [{ kind: 'audio', url: `${origin}/dub.mka`, label: 'dub', httpHeaders }],
|
||||||
|
ffmpeg: emptyOnceFfmpeg,
|
||||||
|
ffprobe: tools.ffprobe.path,
|
||||||
|
directory: emptyDirectory,
|
||||||
|
onDiagnostic: (event) => mismatchReasons.push(event.reason),
|
||||||
|
});
|
||||||
|
assert.equal(mismatched.label, 'current audio track');
|
||||||
|
assert.deepEqual(
|
||||||
|
mismatchReasons,
|
||||||
|
['audio-mismatch'],
|
||||||
|
'Odd sample counts must not turn a mismatch into a process error',
|
||||||
|
);
|
||||||
|
for (const variant of ['audio.mka', 'small.mkv'] as const) {
|
||||||
|
const jobDirectory = path.join(directory, variant + '-job');
|
||||||
|
const cacheDirectory = path.join(directory, variant + '-cache');
|
||||||
|
await mkdir(jobDirectory);
|
||||||
|
const messages: string[] = [];
|
||||||
|
const result = await prepareSubtitleGenerationAudio({
|
||||||
|
...config,
|
||||||
|
directory: jobDirectory,
|
||||||
|
remote: {
|
||||||
|
cacheDirectory,
|
||||||
|
sessionDirectory: cacheDirectory,
|
||||||
|
httpHeaders,
|
||||||
|
alternatives: [
|
||||||
|
{ kind: 'audio', url: `${origin}/short.mka`, label: 'wrong duration', httpHeaders },
|
||||||
|
{
|
||||||
|
kind: 'audio',
|
||||||
|
url: `${origin}/shifted.mka`,
|
||||||
|
label: 'shifted dialogue',
|
||||||
|
httpHeaders,
|
||||||
|
},
|
||||||
|
{ kind: 'audio', url: `${origin}/dub.mka`, label: 'different dialogue', httpHeaders },
|
||||||
|
{
|
||||||
|
kind: variant === 'audio.mka' ? 'audio' : 'video',
|
||||||
|
url: `${origin}/${variant}`,
|
||||||
|
label: variant,
|
||||||
|
httpHeaders,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
onProgress: (progress) => messages.push(progress.message ?? ''),
|
||||||
|
});
|
||||||
|
assert.ok(messages.includes(`Extracting audio from ${variant}...`), messages.join('\n'));
|
||||||
|
assert.deepEqual(await readFile(result.wavPath), await readFile(reference.wavPath));
|
||||||
|
assert.equal(result.offset, reference.offset);
|
||||||
|
const retryDirectory = path.join(directory, variant + '-retry');
|
||||||
|
await mkdir(retryDirectory);
|
||||||
|
const before = requests;
|
||||||
|
const reused = await prepareSubtitleGenerationAudio({
|
||||||
|
...config,
|
||||||
|
directory: retryDirectory,
|
||||||
|
remote: { cacheDirectory, sessionDirectory: cacheDirectory, httpHeaders },
|
||||||
|
});
|
||||||
|
assert.equal(requests, before, 'Reuse must not re-probe or download the stream');
|
||||||
|
assert.deepEqual(await readFile(reused.wavPath), await readFile(reference.wavPath));
|
||||||
|
}
|
||||||
|
const fallbackDirectory = path.join(directory, 'fallback');
|
||||||
|
await mkdir(fallbackDirectory);
|
||||||
|
const messages: string[] = [];
|
||||||
|
await prepareSubtitleGenerationAudio({
|
||||||
|
...config,
|
||||||
|
directory: fallbackDirectory,
|
||||||
|
remote: {
|
||||||
|
cacheDirectory: path.join(directory, 'fallback-cache'),
|
||||||
|
httpHeaders,
|
||||||
|
alternatives: [{ kind: 'audio', url: `${origin}/dub.mka`, label: 'dub', httpHeaders }],
|
||||||
|
},
|
||||||
|
onProgress: (progress) => messages.push(progress.message ?? ''),
|
||||||
|
});
|
||||||
|
assert.ok(messages.includes('Extracting audio...'));
|
||||||
|
assert.ok(!messages.includes('Extracting audio from dub...'));
|
||||||
|
const externalDirectory = path.join(directory, 'external');
|
||||||
|
await mkdir(externalDirectory);
|
||||||
|
const before = requestedFiles.length;
|
||||||
|
const external = await prepareSubtitleGenerationAudio({
|
||||||
|
...config,
|
||||||
|
audioStreamIndex: undefined,
|
||||||
|
directory: externalDirectory,
|
||||||
|
remote: {
|
||||||
|
cacheDirectory: path.join(directory, 'external-cache'),
|
||||||
|
httpHeaders,
|
||||||
|
selectedAudio: {
|
||||||
|
url: `${origin}/audio.mka`,
|
||||||
|
audioStreamIndex: 0,
|
||||||
|
delaySeconds: 0.25,
|
||||||
|
httpHeaders,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(external.offset, reference.offset + 0.25);
|
||||||
|
assert.ok(requestedFiles.length > before);
|
||||||
|
assert.ok(
|
||||||
|
requestedFiles.slice(before).every((name) => name === 'audio.mka'),
|
||||||
|
'Selected external audio must not download video',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
server.closeAllConnections();
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { runSubtitleGenerationProcess } from './subtitle-generation-process';
|
||||||
|
import {
|
||||||
|
probeSubtitleGenerationAudio,
|
||||||
|
type SubtitleGenerationAudioProbe,
|
||||||
|
} from './subtitle-generation-probe';
|
||||||
|
import {
|
||||||
|
subtitleGenerationHttpArgs,
|
||||||
|
type SubtitleGenerationAlternative,
|
||||||
|
} from './subtitle-generation-source';
|
||||||
|
import type { ResolvedMpvHttpHeaders } from './mpv-http-headers';
|
||||||
|
import type { SubtitleGenerationProgress } from '../../shared/subtitle-generation';
|
||||||
|
|
||||||
|
import { downloadSubtitleGenerationHlsWindow } from './subtitle-generation-hls';
|
||||||
|
import { createLogger } from '../../logger';
|
||||||
|
|
||||||
|
const logger = createLogger('subtitle-generation:sources');
|
||||||
|
export interface SubtitleSourceDiagnostic {
|
||||||
|
candidate: number;
|
||||||
|
kind: SubtitleGenerationAlternative['kind'];
|
||||||
|
phase: 'probe' | 'original-sample' | 'alternative-sample' | 'comparison';
|
||||||
|
result: 'retry' | 'rejected' | 'selected';
|
||||||
|
reason:
|
||||||
|
| 'empty-sample'
|
||||||
|
| 'short-sample'
|
||||||
|
| 'duration-mismatch'
|
||||||
|
| 'offset-mismatch'
|
||||||
|
| 'audio-mismatch'
|
||||||
|
| 'timeout'
|
||||||
|
| 'process-error'
|
||||||
|
| 'matched';
|
||||||
|
elapsedMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class InvalidSample extends Error {
|
||||||
|
constructor(readonly reason: 'empty-sample' | 'short-sample') {
|
||||||
|
super(reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AudioSource {
|
||||||
|
url: string;
|
||||||
|
httpHeaders: ResolvedMpvHttpHeaders;
|
||||||
|
probe: SubtitleGenerationAudioProbe;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A duration match alone accepts dubs and differently edited releases. Compare
|
||||||
|
// decoded audio at the same times too; uncertain matches keep the playback source.
|
||||||
|
function matchingSamples(a: Buffer, b: Buffer): boolean {
|
||||||
|
// FFmpeg resampling can round a two-second extract by a few trailing samples.
|
||||||
|
if (
|
||||||
|
Math.min(a.length, b.length) < 16000 ||
|
||||||
|
Math.abs(a.length - b.length) > 80 ||
|
||||||
|
a.length % 2 !== 0 ||
|
||||||
|
b.length % 2 !== 0
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
// Container seek precision and resampling can differ by fractional audio samples.
|
||||||
|
// This tolerance cannot hide a materially shifted subtitle timeline.
|
||||||
|
const count = Math.min(a.length, b.length) / 2;
|
||||||
|
for (let lag = -40; lag <= 40; lag += 0.25) {
|
||||||
|
let sumA = 0,
|
||||||
|
sumB = 0,
|
||||||
|
aa = 0,
|
||||||
|
bb = 0,
|
||||||
|
ab = 0,
|
||||||
|
samples = 0;
|
||||||
|
// Interpolation needs the next sample even at the maximum positive lag.
|
||||||
|
for (let i = 40; i < count - 41; i += 2) {
|
||||||
|
const index = Math.floor(i + lag);
|
||||||
|
const fraction = i + lag - index;
|
||||||
|
const x = a.readInt16LE(i * 2);
|
||||||
|
const y =
|
||||||
|
b.readInt16LE(index * 2) * (1 - fraction) + b.readInt16LE((index + 1) * 2) * fraction;
|
||||||
|
sumA += x;
|
||||||
|
sumB += y;
|
||||||
|
aa += x * x;
|
||||||
|
bb += y * y;
|
||||||
|
ab += x * y;
|
||||||
|
samples += 1;
|
||||||
|
}
|
||||||
|
const varianceA = aa - (sumA * sumA) / samples;
|
||||||
|
const varianceB = bb - (sumB * sumB) / samples;
|
||||||
|
if (varianceA / samples < 100 || varianceB / samples < 100) return false;
|
||||||
|
if ((ab - (sumA * sumB) / samples) / Math.sqrt(varianceA * varianceB) >= 0.98) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectSubtitleGenerationAlternative(input: {
|
||||||
|
original: AudioSource;
|
||||||
|
alternatives: readonly SubtitleGenerationAlternative[];
|
||||||
|
ffprobe: string;
|
||||||
|
ffmpeg: string;
|
||||||
|
directory: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
onProgress?: (progress: SubtitleGenerationProgress) => void;
|
||||||
|
onDiagnostic?: (event: SubtitleSourceDiagnostic) => void;
|
||||||
|
}): Promise<AudioSource & { label: string }> {
|
||||||
|
const original = { ...input.original, label: 'current audio track' };
|
||||||
|
const duration = original.probe.duration;
|
||||||
|
if (!duration || duration < 12 || !input.alternatives.length) return original;
|
||||||
|
const started = Date.now();
|
||||||
|
const budget = AbortSignal.timeout(20_000);
|
||||||
|
const signal = input.signal ? AbortSignal.any([input.signal, budget]) : budget;
|
||||||
|
const times = [0.15, 0.5, 0.85].map((fraction) => Math.min(duration - 3, duration * fraction));
|
||||||
|
const originals = new Map<number, Buffer>();
|
||||||
|
const sample = async (source: AudioSource, seconds: number) => {
|
||||||
|
const directory = await mkdtemp(path.join(input.directory, 'comparison-'));
|
||||||
|
const output = path.join(directory, 'sample.pcm');
|
||||||
|
const timeout = AbortSignal.timeout(15_000);
|
||||||
|
try {
|
||||||
|
const sampleSignal = AbortSignal.any([signal, timeout]);
|
||||||
|
const staged = source.probe.hls
|
||||||
|
? await downloadSubtitleGenerationHlsWindow({
|
||||||
|
mediaPath: source.url,
|
||||||
|
directory,
|
||||||
|
httpHeaders: source.httpHeaders,
|
||||||
|
signal: sampleSignal,
|
||||||
|
window: { startSeconds: Math.max(0, seconds - 0.1), durationSeconds: 2.2 },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const localProbe = staged
|
||||||
|
? await probeSubtitleGenerationAudio({
|
||||||
|
command: input.ffprobe,
|
||||||
|
mediaPath: staged.playlistPath,
|
||||||
|
audioStreamIndex: source.probe.index,
|
||||||
|
signal: sampleSignal,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
// Segment timestamps, not rounded EXTINF sums, locate the exact playback time.
|
||||||
|
const seek = localProbe ? seconds + source.probe.startTime - localProbe.startTime : seconds;
|
||||||
|
if (seek < 0) throw new InvalidSample('short-sample');
|
||||||
|
await runSubtitleGenerationProcess({
|
||||||
|
command: input.ffmpeg,
|
||||||
|
args: [
|
||||||
|
'-nostdin',
|
||||||
|
'-v',
|
||||||
|
'error',
|
||||||
|
...(staged
|
||||||
|
? ['-protocol_whitelist', 'file']
|
||||||
|
: subtitleGenerationHttpArgs(source.httpHeaders)),
|
||||||
|
...(staged ? [] : ['-ss', String(seconds)]),
|
||||||
|
'-i',
|
||||||
|
staged?.playlistPath ?? source.url,
|
||||||
|
...(staged ? ['-ss', String(seek)] : []),
|
||||||
|
'-map',
|
||||||
|
`0:${source.probe.index}`,
|
||||||
|
'-t',
|
||||||
|
'2',
|
||||||
|
'-vn',
|
||||||
|
'-ac',
|
||||||
|
'1',
|
||||||
|
'-ar',
|
||||||
|
'8000',
|
||||||
|
'-c:a',
|
||||||
|
'pcm_s16le',
|
||||||
|
'-f',
|
||||||
|
's16le',
|
||||||
|
output,
|
||||||
|
],
|
||||||
|
signal: sampleSignal,
|
||||||
|
});
|
||||||
|
const bytes = await readFile(output);
|
||||||
|
if (bytes.length === 0) throw new InvalidSample('empty-sample');
|
||||||
|
if (bytes.length < 16000 || bytes.length % 2 !== 0) throw new InvalidSample('short-sample');
|
||||||
|
return bytes;
|
||||||
|
} finally {
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const [candidateIndex, alternative] of input.alternatives.slice(0, 4).entries()) {
|
||||||
|
input.signal?.throwIfAborted();
|
||||||
|
if (budget.aborted) break;
|
||||||
|
if (!/^https?:\/\//i.test(alternative.url) || alternative.url === original.url) continue;
|
||||||
|
input.onProgress?.({
|
||||||
|
stage: 'extract',
|
||||||
|
message: `Checking ${alternative.label} audio and timing...`,
|
||||||
|
});
|
||||||
|
let phase: SubtitleSourceDiagnostic['phase'] = 'probe';
|
||||||
|
const report = (
|
||||||
|
result: SubtitleSourceDiagnostic['result'],
|
||||||
|
reason: SubtitleSourceDiagnostic['reason'],
|
||||||
|
) => {
|
||||||
|
const event = {
|
||||||
|
candidate: candidateIndex + 1,
|
||||||
|
kind: alternative.kind,
|
||||||
|
phase,
|
||||||
|
result,
|
||||||
|
reason,
|
||||||
|
elapsedMs: Date.now() - started,
|
||||||
|
};
|
||||||
|
input.onDiagnostic?.(event);
|
||||||
|
// Never log stream URLs, headers or raw FFmpeg errors containing credentials.
|
||||||
|
if (result === 'selected') logger.info('Alternative source selected', event);
|
||||||
|
else logger.warn('Alternative source check', event);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const timeout = AbortSignal.timeout(15_000);
|
||||||
|
const probe = await probeSubtitleGenerationAudio({
|
||||||
|
command: input.ffprobe,
|
||||||
|
mediaPath: alternative.url,
|
||||||
|
httpHeaders: alternative.httpHeaders,
|
||||||
|
signal: AbortSignal.any([signal, timeout]),
|
||||||
|
});
|
||||||
|
if (!probe.duration || Math.abs(probe.duration - duration) > 0.25) {
|
||||||
|
report('rejected', 'duration-mismatch');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Math.abs(probe.offset - original.probe.offset) > 0.05) {
|
||||||
|
report('rejected', 'offset-mismatch');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const candidate = {
|
||||||
|
url: alternative.url,
|
||||||
|
httpHeaders: alternative.httpHeaders,
|
||||||
|
probe,
|
||||||
|
label: alternative.label,
|
||||||
|
};
|
||||||
|
let matches = true;
|
||||||
|
for (const seconds of times) {
|
||||||
|
let baseline = originals.get(seconds);
|
||||||
|
if (!baseline) {
|
||||||
|
phase = 'original-sample';
|
||||||
|
try {
|
||||||
|
baseline = await sample(original, seconds);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof InvalidSample) || signal.aborted) throw error;
|
||||||
|
report('retry', error.reason);
|
||||||
|
baseline = await sample(original, seconds);
|
||||||
|
}
|
||||||
|
// Invalid samples never enter the reusable baseline cache.
|
||||||
|
originals.set(seconds, baseline);
|
||||||
|
}
|
||||||
|
phase = 'alternative-sample';
|
||||||
|
const comparison = await sample(candidate, seconds);
|
||||||
|
phase = 'comparison';
|
||||||
|
if (!matchingSamples(baseline, comparison)) {
|
||||||
|
report('rejected', 'audio-mismatch');
|
||||||
|
matches = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matches) {
|
||||||
|
report('selected', 'matched');
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
input.signal?.throwIfAborted();
|
||||||
|
report(
|
||||||
|
'rejected',
|
||||||
|
error instanceof InvalidSample
|
||||||
|
? error.reason
|
||||||
|
: signal.aborted || (error instanceof Error && /cancelled|timed out/i.test(error.message))
|
||||||
|
? 'timeout'
|
||||||
|
: 'process-error',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return original;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
readSubtitleAudioCache,
|
||||||
|
writeSubtitleAudioCache,
|
||||||
|
subtitleAudioCacheKey,
|
||||||
|
} from './subtitle-generation-audio-cache';
|
||||||
|
|
||||||
|
test('audio cache isolates tracks and headers, verifies content and preserves active copies', async () => {
|
||||||
|
const directory = await mkdtemp(path.join(tmpdir(), 'subtitle-audio-cache-'));
|
||||||
|
try {
|
||||||
|
const headers = { headers: { Referer: 'https://example.test/' }, userAgent: null };
|
||||||
|
const key = subtitleAudioCacheKey('https://example.test/video?secret=token', 2, headers);
|
||||||
|
assert.match(key, /^[a-f0-9]{64}$/);
|
||||||
|
assert.notEqual(
|
||||||
|
key,
|
||||||
|
subtitleAudioCacheKey('https://example.test/video?secret=token', 3, headers),
|
||||||
|
);
|
||||||
|
assert.notEqual(
|
||||||
|
key,
|
||||||
|
subtitleAudioCacheKey('https://example.test/video?secret=token', 2, {
|
||||||
|
...headers,
|
||||||
|
userAgent: 'different',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const wavPath = path.join(directory, 'original.wav');
|
||||||
|
const destination = path.join(directory, 'active.wav');
|
||||||
|
const bytes = Buffer.alloc(1024, 42);
|
||||||
|
await writeFile(wavPath, bytes);
|
||||||
|
await writeSubtitleAudioCache({ directory, key, wavPath, offset: 0.125 });
|
||||||
|
assert.deepEqual(await readSubtitleAudioCache({ directory, key, destination }), {
|
||||||
|
offset: 0.125,
|
||||||
|
});
|
||||||
|
assert.deepEqual(await readFile(destination), bytes);
|
||||||
|
const entry = path.join(directory, 'audio', key);
|
||||||
|
await writeFile(path.join(entry, 'audio.wav'), Buffer.alloc(1024, 43));
|
||||||
|
assert.equal(await readSubtitleAudioCache({ directory, key, destination }), null);
|
||||||
|
await assert.rejects(readFile(destination), /ENOENT/);
|
||||||
|
await writeSubtitleAudioCache({ directory, key, wavPath, offset: 0.125 });
|
||||||
|
assert.ok(await readSubtitleAudioCache({ directory, key, destination }));
|
||||||
|
await rm(entry, { recursive: true });
|
||||||
|
assert.deepEqual(await readFile(destination), bytes);
|
||||||
|
} finally {
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { createReadStream } from 'node:fs';
|
||||||
|
import {
|
||||||
|
copyFile,
|
||||||
|
mkdir,
|
||||||
|
mkdtemp,
|
||||||
|
readFile,
|
||||||
|
readdir,
|
||||||
|
rename,
|
||||||
|
rm,
|
||||||
|
stat,
|
||||||
|
utimes,
|
||||||
|
writeFile,
|
||||||
|
} from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { ResolvedMpvHttpHeaders } from './mpv-http-headers';
|
||||||
|
|
||||||
|
const MAX_BYTES = 512 * 1024 * 1024;
|
||||||
|
const ENTRY_NAME = /^[a-f0-9]{64}$/;
|
||||||
|
|
||||||
|
export function subtitleAudioCacheKey(
|
||||||
|
mediaPath: string,
|
||||||
|
index: number | undefined,
|
||||||
|
headers: ResolvedMpvHttpHeaders,
|
||||||
|
): string {
|
||||||
|
return createHash('sha256')
|
||||||
|
.update(
|
||||||
|
JSON.stringify([
|
||||||
|
1,
|
||||||
|
mediaPath,
|
||||||
|
index ?? null,
|
||||||
|
headers.userAgent,
|
||||||
|
Object.entries(headers.headers).sort(([a], [b]) => a.localeCompare(b)),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function digest(file: string): Promise<string> {
|
||||||
|
const hash = createHash('sha256');
|
||||||
|
for await (const chunk of createReadStream(file)) hash.update(chunk);
|
||||||
|
return hash.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copy into the job's directory so eviction by another job cannot remove active audio. */
|
||||||
|
export async function readSubtitleAudioCache(input: {
|
||||||
|
directory: string;
|
||||||
|
key: string;
|
||||||
|
destination: string;
|
||||||
|
}): Promise<{ offset: number } | null> {
|
||||||
|
const entry = path.join(input.directory, 'audio', input.key);
|
||||||
|
try {
|
||||||
|
const metadata: unknown = JSON.parse(await readFile(path.join(entry, 'metadata.json'), 'utf8'));
|
||||||
|
if (
|
||||||
|
typeof metadata !== 'object' ||
|
||||||
|
metadata === null ||
|
||||||
|
!('offset' in metadata) ||
|
||||||
|
typeof metadata.offset !== 'number' ||
|
||||||
|
!Number.isFinite(metadata.offset) ||
|
||||||
|
!('size' in metadata) ||
|
||||||
|
typeof metadata.size !== 'number' ||
|
||||||
|
metadata.size <= 44 ||
|
||||||
|
metadata.size > MAX_BYTES ||
|
||||||
|
!('sha256' in metadata) ||
|
||||||
|
typeof metadata.sha256 !== 'string'
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
const source = path.join(entry, 'audio.wav');
|
||||||
|
if ((await stat(source)).size !== metadata.size) return null;
|
||||||
|
await copyFile(source, input.destination);
|
||||||
|
if (
|
||||||
|
(await stat(input.destination)).size !== metadata.size ||
|
||||||
|
(await digest(input.destination)) !== metadata.sha256
|
||||||
|
) {
|
||||||
|
await rm(input.destination, { force: true });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await utimes(entry, new Date(), new Date());
|
||||||
|
return { offset: metadata.offset };
|
||||||
|
} catch {
|
||||||
|
await rm(input.destination, { force: true }).catch(() => undefined);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prune(directory: string): Promise<void> {
|
||||||
|
const entries = [];
|
||||||
|
for (const name of await readdir(directory)) {
|
||||||
|
if (!ENTRY_NAME.test(name)) continue;
|
||||||
|
const entry = path.join(directory, name);
|
||||||
|
try {
|
||||||
|
entries.push({
|
||||||
|
path: entry,
|
||||||
|
modified: (await stat(entry)).mtimeMs,
|
||||||
|
size: (await stat(path.join(entry, 'audio.wav'))).size,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* A concurrent eviction may already have removed it. */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let retained = 0;
|
||||||
|
for (const entry of entries.sort((a, b) => b.modified - a.modified)) {
|
||||||
|
if (retained + entry.size > MAX_BYTES) {
|
||||||
|
await rm(entry.path, { recursive: true, force: true });
|
||||||
|
} else retained += entry.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Publish only complete extracts. Cache failures must never discard generated subtitles. */
|
||||||
|
export async function writeSubtitleAudioCache(input: {
|
||||||
|
directory: string;
|
||||||
|
key: string;
|
||||||
|
wavPath: string;
|
||||||
|
offset: number;
|
||||||
|
}): Promise<void> {
|
||||||
|
const size = (await stat(input.wavPath)).size;
|
||||||
|
if (size <= 44 || size > MAX_BYTES) return;
|
||||||
|
const directory = path.join(input.directory, 'audio');
|
||||||
|
await mkdir(directory, { recursive: true });
|
||||||
|
const staged = await mkdtemp(path.join(directory, '.pending-'));
|
||||||
|
try {
|
||||||
|
await copyFile(input.wavPath, path.join(staged, 'audio.wav'));
|
||||||
|
await writeFile(
|
||||||
|
path.join(staged, 'metadata.json'),
|
||||||
|
JSON.stringify({ size, offset: input.offset, sha256: await digest(input.wavPath) }),
|
||||||
|
);
|
||||||
|
const destination = path.join(directory, input.key);
|
||||||
|
// Replace a stale or corrupt entry only after its replacement is complete.
|
||||||
|
await rm(destination, { recursive: true, force: true });
|
||||||
|
await rename(staged, destination);
|
||||||
|
await prune(directory);
|
||||||
|
} finally {
|
||||||
|
await rm(staged, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { rm } from 'node:fs/promises';
|
||||||
|
import { probeSubtitleGenerationAudio } from './subtitle-generation-probe';
|
||||||
|
import { runSubtitleGenerationProcess } from './subtitle-generation-process';
|
||||||
|
import { downloadSubtitleGenerationHls } from './subtitle-generation-hls';
|
||||||
|
import { selectSubtitleGenerationAlternative } from './subtitle-generation-alternative';
|
||||||
|
import {
|
||||||
|
readSubtitleAudioCache,
|
||||||
|
writeSubtitleAudioCache,
|
||||||
|
subtitleAudioCacheKey,
|
||||||
|
} from './subtitle-generation-audio-cache';
|
||||||
|
import {
|
||||||
|
subtitleGenerationHttpArgs,
|
||||||
|
type SubtitleGenerationRemoteSource,
|
||||||
|
} from './subtitle-generation-source';
|
||||||
|
import type { SubtitleGenerationProgress } from '../../shared/subtitle-generation';
|
||||||
|
|
||||||
|
export async function prepareSubtitleGenerationAudio(input: {
|
||||||
|
mediaPath: string;
|
||||||
|
audioStreamIndex?: number;
|
||||||
|
remote?: SubtitleGenerationRemoteSource;
|
||||||
|
ffprobe: string;
|
||||||
|
ffmpeg: string;
|
||||||
|
directory: string;
|
||||||
|
onProgress?: (progress: SubtitleGenerationProgress) => void;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}): Promise<{ wavPath: string; offset: number }> {
|
||||||
|
const wavPath = path.join(input.directory, 'audio.wav');
|
||||||
|
const selectedAudio = input.remote?.selectedAudio;
|
||||||
|
const mediaPath = selectedAudio?.url ?? input.mediaPath;
|
||||||
|
const audioStreamIndex = selectedAudio ? selectedAudio.audioStreamIndex : input.audioStreamIndex;
|
||||||
|
const httpHeaders = selectedAudio?.httpHeaders ?? input.remote?.httpHeaders;
|
||||||
|
const delaySeconds = selectedAudio?.delaySeconds ?? 0;
|
||||||
|
const cacheKey = input.remote
|
||||||
|
? subtitleAudioCacheKey(
|
||||||
|
selectedAudio
|
||||||
|
? JSON.stringify([input.mediaPath, mediaPath, delaySeconds])
|
||||||
|
: input.mediaPath,
|
||||||
|
audioStreamIndex,
|
||||||
|
httpHeaders ?? input.remote.httpHeaders,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
if (input.remote?.sessionDirectory && cacheKey) {
|
||||||
|
const cached = await readSubtitleAudioCache({
|
||||||
|
directory: input.remote.sessionDirectory,
|
||||||
|
key: cacheKey,
|
||||||
|
destination: wavPath,
|
||||||
|
});
|
||||||
|
input.signal?.throwIfAborted();
|
||||||
|
if (cached) {
|
||||||
|
input.onProgress?.({
|
||||||
|
stage: 'extract',
|
||||||
|
percent: 100,
|
||||||
|
message: 'Reusing downloaded audio...',
|
||||||
|
});
|
||||||
|
return { wavPath, offset: cached.offset };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input.onProgress?.({ stage: 'extract', message: 'Inspecting audio tracks...' });
|
||||||
|
const audio = await probeSubtitleGenerationAudio({
|
||||||
|
command: input.ffprobe,
|
||||||
|
mediaPath,
|
||||||
|
audioStreamIndex,
|
||||||
|
httpHeaders,
|
||||||
|
signal: input.signal,
|
||||||
|
});
|
||||||
|
const isRemote = Boolean(input.remote);
|
||||||
|
if (isRemote && (!audio.duration || !Number.isFinite(audio.duration) || audio.duration <= 0))
|
||||||
|
throw new Error(
|
||||||
|
'Stream generation requires a finite episode duration. Live streams are not supported.',
|
||||||
|
);
|
||||||
|
const original = {
|
||||||
|
url: mediaPath,
|
||||||
|
probe: audio,
|
||||||
|
httpHeaders: httpHeaders ?? { headers: {}, userAgent: null },
|
||||||
|
};
|
||||||
|
const source =
|
||||||
|
input.remote && !selectedAudio
|
||||||
|
? await selectSubtitleGenerationAlternative({
|
||||||
|
original,
|
||||||
|
alternatives: input.remote.alternatives ?? [],
|
||||||
|
ffprobe: input.ffprobe,
|
||||||
|
ffmpeg: input.ffmpeg,
|
||||||
|
directory: input.directory,
|
||||||
|
onProgress: input.onProgress,
|
||||||
|
signal: input.signal,
|
||||||
|
})
|
||||||
|
: { ...original, label: selectedAudio ? 'selected audio stream' : 'current audio track' };
|
||||||
|
const httpArgs = isRemote ? subtitleGenerationHttpArgs(source.httpHeaders) : [];
|
||||||
|
const extractionMessage =
|
||||||
|
source.url === input.mediaPath
|
||||||
|
? 'Extracting audio...'
|
||||||
|
: `Extracting audio from ${source.label}...`;
|
||||||
|
const stagedHls =
|
||||||
|
source.probe.hls && input.remote
|
||||||
|
? await downloadSubtitleGenerationHls({
|
||||||
|
mediaPath: source.url,
|
||||||
|
directory: input.directory,
|
||||||
|
httpHeaders: source.httpHeaders,
|
||||||
|
signal: input.signal,
|
||||||
|
onProgress: (percent) =>
|
||||||
|
input.onProgress?.({
|
||||||
|
stage: 'extract',
|
||||||
|
percent: Math.floor(percent * 0.9),
|
||||||
|
message:
|
||||||
|
source.url === input.mediaPath
|
||||||
|
? 'Downloading episode segments...'
|
||||||
|
: `Downloading ${source.label} segments...`,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
input.onProgress?.({
|
||||||
|
stage: 'extract',
|
||||||
|
percent: stagedHls ? 90 : 0,
|
||||||
|
message: extractionMessage,
|
||||||
|
});
|
||||||
|
await runSubtitleGenerationProcess({
|
||||||
|
command: input.ffmpeg,
|
||||||
|
args: [
|
||||||
|
'-nostdin',
|
||||||
|
'-hide_banner',
|
||||||
|
'-loglevel',
|
||||||
|
'error',
|
||||||
|
...(stagedHls ? ['-protocol_whitelist', 'file'] : httpArgs),
|
||||||
|
'-i',
|
||||||
|
stagedHls ?? source.url,
|
||||||
|
'-map',
|
||||||
|
`0:${source.probe.index}`,
|
||||||
|
...(isRemote ? ['-t', String(audio.duration)] : []),
|
||||||
|
'-vn',
|
||||||
|
'-af',
|
||||||
|
'asetpts=PTS-STARTPTS',
|
||||||
|
'-ac',
|
||||||
|
'1',
|
||||||
|
'-ar',
|
||||||
|
'16000',
|
||||||
|
'-c:a',
|
||||||
|
'pcm_s16le',
|
||||||
|
'-progress',
|
||||||
|
'pipe:1',
|
||||||
|
'-nostats',
|
||||||
|
wavPath,
|
||||||
|
],
|
||||||
|
signal: input.signal,
|
||||||
|
onLine: (line) => {
|
||||||
|
const match = /^out_time_us=(\d+)$/.exec(line);
|
||||||
|
if (match && source.probe.duration && source.probe.duration > 0) {
|
||||||
|
input.onProgress?.({
|
||||||
|
stage: 'extract',
|
||||||
|
percent: Math.min(
|
||||||
|
100,
|
||||||
|
Math.floor(
|
||||||
|
(stagedHls ? 90 : 0) +
|
||||||
|
(Number(match[1]) / 10000 / source.probe.duration) * (stagedHls ? 0.1 : 1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
message: extractionMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (stagedHls) await rm(path.dirname(stagedHls), { recursive: true, force: true });
|
||||||
|
|
||||||
|
input.signal?.throwIfAborted();
|
||||||
|
if (input.remote?.sessionDirectory && cacheKey) {
|
||||||
|
await writeSubtitleAudioCache({
|
||||||
|
directory: input.remote.sessionDirectory,
|
||||||
|
key: cacheKey,
|
||||||
|
wavPath,
|
||||||
|
offset: audio.offset + delaySeconds,
|
||||||
|
}).catch(() => undefined);
|
||||||
|
}
|
||||||
|
return { wavPath, offset: audio.offset + delaySeconds };
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createServer, type RequestListener } from 'node:http';
|
||||||
|
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
downloadSubtitleGenerationHls,
|
||||||
|
downloadSubtitleGenerationHlsWindow,
|
||||||
|
} from './subtitle-generation-hls';
|
||||||
|
|
||||||
|
function playlist(count: number) {
|
||||||
|
return (
|
||||||
|
'#EXTM3U\n#EXT-X-TARGETDURATION:1\n' +
|
||||||
|
Array.from({ length: count }, (_, i) => `#EXTINF:1,\nsegment${i}`).join('\n') +
|
||||||
|
'\n#EXT-X-ENDLIST\n'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fixture(
|
||||||
|
handler: RequestListener,
|
||||||
|
run: (url: string, directory: string) => Promise<void>,
|
||||||
|
) {
|
||||||
|
const directory = await mkdtemp(path.join(tmpdir(), 'subtitle-hls-test-'));
|
||||||
|
const server = createServer(handler);
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const address = server.address();
|
||||||
|
assert.ok(address && typeof address === 'object');
|
||||||
|
await run(`http://127.0.0.1:${address.port}`, directory);
|
||||||
|
} finally {
|
||||||
|
server.closeAllConnections();
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const httpHeaders = { headers: { Referer: 'https://anime.example/' }, userAgent: 'SubMiner test' };
|
||||||
|
|
||||||
|
test('HLS workers preserve order, resolve redirected relative URLs and bound concurrency', async () => {
|
||||||
|
let active = 0;
|
||||||
|
let peak = 0;
|
||||||
|
const completed: number[] = [];
|
||||||
|
const progress: number[] = [];
|
||||||
|
const bytes = (index: number) => {
|
||||||
|
const value = Buffer.alloc(188 * 3, index);
|
||||||
|
value[0] = value[188] = value[376] = 0x47;
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
await fixture(
|
||||||
|
async (req, res) => {
|
||||||
|
if (
|
||||||
|
req.headers.referer !== httpHeaders.headers.Referer ||
|
||||||
|
req.headers['user-agent'] !== httpHeaders.userAgent
|
||||||
|
) {
|
||||||
|
res.writeHead(403).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.url === '/episode') {
|
||||||
|
res.writeHead(302, { Location: '/media/list' }).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.url === '/media/list') {
|
||||||
|
res.end(playlist(10));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const match = /^\/media\/segment(\d+)$/.exec(req.url ?? '');
|
||||||
|
if (!match) {
|
||||||
|
res.writeHead(404).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const index = Number(match[1]);
|
||||||
|
active += 1;
|
||||||
|
peak = Math.max(active, peak);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, index === 0 ? 80 : 10));
|
||||||
|
completed.push(index);
|
||||||
|
active -= 1;
|
||||||
|
res.end(bytes(index));
|
||||||
|
},
|
||||||
|
async (url, directory) => {
|
||||||
|
const result = await downloadSubtitleGenerationHls({
|
||||||
|
mediaPath: `${url}/episode`,
|
||||||
|
directory,
|
||||||
|
httpHeaders,
|
||||||
|
onProgress: (percent) => progress.push(percent),
|
||||||
|
});
|
||||||
|
assert.ok(result);
|
||||||
|
assert.equal(peak, 4);
|
||||||
|
assert.notEqual(completed[0], 0);
|
||||||
|
assert.deepEqual(progress, [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]);
|
||||||
|
const staged = await readFile(result, 'utf8');
|
||||||
|
assert.equal(staged, playlist(10).replace(/segment(\d+)/g, 'segment-$1.ts'));
|
||||||
|
for (let i = 0; i < 10; i += 1) {
|
||||||
|
assert.deepEqual(
|
||||||
|
await readFile(path.join(path.dirname(result), `segment-${i}.ts`)),
|
||||||
|
bytes(i),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const mode of ['cancel', 'failure'] as const) {
|
||||||
|
test(
|
||||||
|
`HLS ${mode} aborts active downloads and drains workers before returning`,
|
||||||
|
{ timeout: 5000 },
|
||||||
|
async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
let requests = 0;
|
||||||
|
let closed = 0;
|
||||||
|
let triggerFailure = () => {};
|
||||||
|
let allClosed = () => {};
|
||||||
|
const closedPromise = new Promise<void>((resolve) => {
|
||||||
|
allClosed = resolve;
|
||||||
|
});
|
||||||
|
await fixture(
|
||||||
|
(req, res) => {
|
||||||
|
if (req.url === '/episode') {
|
||||||
|
res.end(playlist(12));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requests += 1;
|
||||||
|
let settled = false;
|
||||||
|
const settle = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
if (++closed === 4) allClosed();
|
||||||
|
};
|
||||||
|
res.once('finish', settle);
|
||||||
|
req.socket.once('close', settle);
|
||||||
|
if (req.url === '/segment0') triggerFailure = () => res.writeHead(503).end();
|
||||||
|
else res.write(Buffer.alloc(188));
|
||||||
|
if (requests === 4) {
|
||||||
|
if (mode === 'cancel') controller.abort();
|
||||||
|
else triggerFailure();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (url, directory) => {
|
||||||
|
await assert.rejects(
|
||||||
|
downloadSubtitleGenerationHls({
|
||||||
|
mediaPath: `${url}/episode`,
|
||||||
|
directory,
|
||||||
|
httpHeaders,
|
||||||
|
signal: controller.signal,
|
||||||
|
}),
|
||||||
|
mode === 'cancel' ? /abort/i : /HTTP 503/,
|
||||||
|
);
|
||||||
|
await closedPromise;
|
||||||
|
assert.equal(requests, 4);
|
||||||
|
await assert.rejects(readFile(path.join(directory, 'hls', 'episode.m3u8')), /ENOENT/);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('complex or live playlists fall back without downloading segments', async () => {
|
||||||
|
const unsupported = [
|
||||||
|
playlist(2).replace('#EXT-X-ENDLIST', ''),
|
||||||
|
playlist(2).replace('#EXTINF:1,', '#EXT-X-KEY:METHOD=AES-128,URI="key"\n#EXTINF:1,'),
|
||||||
|
playlist(2).replace('#EXTINF:1,', '#EXT-X-BYTERANGE:100@0\n#EXTINF:1,'),
|
||||||
|
playlist(2).replace('#EXTINF:1,', '#EXT-X-MAP:URI="init.mp4"\n#EXTINF:1,'),
|
||||||
|
playlist(2).replace('#EXTINF:1,', '#EXT-X-DISCONTINUITY\n#EXTINF:1,'),
|
||||||
|
'#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000\nchild.m3u8\n',
|
||||||
|
playlist(2).replace('segment0', 'file:///private/media.ts'),
|
||||||
|
playlist(2).replace('#EXTINF:1,', '#EXTINF:NaN,'),
|
||||||
|
];
|
||||||
|
let requests = 0;
|
||||||
|
await fixture(
|
||||||
|
(req, res) => {
|
||||||
|
requests += 1;
|
||||||
|
res.end(unsupported[Number(req.url?.slice(1))]);
|
||||||
|
},
|
||||||
|
async (url, directory) => {
|
||||||
|
for (let i = 0; i < unsupported.length; i += 1) {
|
||||||
|
assert.equal(
|
||||||
|
await downloadSubtitleGenerationHls({ mediaPath: `${url}/${i}`, directory, httpHeaders }),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.equal(requests, unsupported.length);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('packed audio retains the original FFmpeg path', async () => {
|
||||||
|
await fixture(
|
||||||
|
(req, res) => {
|
||||||
|
res.end(req.url === '/episode' ? playlist(1) : 'ID3 packed audio');
|
||||||
|
},
|
||||||
|
async (url, directory) => {
|
||||||
|
assert.equal(
|
||||||
|
await downloadSubtitleGenerationHls({
|
||||||
|
mediaPath: `${url}/episode`,
|
||||||
|
directory,
|
||||||
|
httpHeaders,
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HLS samples download only segments covering the requested window', async () => {
|
||||||
|
const requested: string[] = [];
|
||||||
|
await fixture(
|
||||||
|
(req, res) => {
|
||||||
|
if (req.url === '/episode') {
|
||||||
|
res.end(playlist(10));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requested.push(req.url ?? '');
|
||||||
|
const bytes = Buffer.alloc(188 * 3);
|
||||||
|
bytes[0] = bytes[188] = bytes[376] = 0x47;
|
||||||
|
res.end(bytes);
|
||||||
|
},
|
||||||
|
async (url, directory) => {
|
||||||
|
const result = await downloadSubtitleGenerationHlsWindow({
|
||||||
|
mediaPath: `${url}/episode`,
|
||||||
|
directory,
|
||||||
|
httpHeaders,
|
||||||
|
window: { startSeconds: 5.25, durationSeconds: 2 },
|
||||||
|
});
|
||||||
|
assert.ok(result);
|
||||||
|
assert.deepEqual(requested.sort(), ['/segment5', '/segment6', '/segment7']);
|
||||||
|
assert.equal(result.seekSeconds, 0.25);
|
||||||
|
assert.match(await readFile(result.playlistPath, 'utf8'), /segment-5.ts/);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { mkdir, open, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { toRequestHeaders, type ResolvedMpvHttpHeaders } from './mpv-http-headers';
|
||||||
|
|
||||||
|
const DOWNLOAD_CONCURRENCY = 4;
|
||||||
|
const REQUEST_TIMEOUT_MS = 15_000;
|
||||||
|
const MAX_PLAYLIST_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
// Keep complex playlists with FFmpeg, including encryption, byte ranges, variant
|
||||||
|
// selection, initialization segments and discontinuities that may change tracks.
|
||||||
|
const SUPPORTED_TAGS = new Set([
|
||||||
|
'#EXTM3U',
|
||||||
|
'#EXTINF',
|
||||||
|
'#EXT-X-VERSION',
|
||||||
|
'#EXT-X-TARGETDURATION',
|
||||||
|
'#EXT-X-MEDIA-SEQUENCE',
|
||||||
|
'#EXT-X-ENDLIST',
|
||||||
|
'#EXT-X-PLAYLIST-TYPE',
|
||||||
|
'#EXT-X-ALLOW-CACHE',
|
||||||
|
'#EXT-X-INDEPENDENT-SEGMENTS',
|
||||||
|
'#EXT-X-PROGRAM-DATE-TIME',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function parsePlaylist(text: string, base: string) {
|
||||||
|
const lines = text
|
||||||
|
.trim()
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim());
|
||||||
|
if (lines[0] !== '#EXTM3U' || !lines.includes('#EXT-X-ENDLIST')) return null;
|
||||||
|
const segments: { url: string; filename: string; duration: number }[] = [];
|
||||||
|
const localLines: string[] = [];
|
||||||
|
let duration: number | undefined;
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line) continue;
|
||||||
|
if (line.startsWith('#')) {
|
||||||
|
if (!SUPPORTED_TAGS.has(line.split(':', 1)[0] ?? '')) return null;
|
||||||
|
if (line.startsWith('#EXTINF:')) {
|
||||||
|
if (duration !== undefined) return null;
|
||||||
|
duration = Number(line.slice(8).split(',', 1)[0]);
|
||||||
|
if (!Number.isFinite(duration) || duration <= 0) return null;
|
||||||
|
}
|
||||||
|
localLines.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (duration === undefined) return null;
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(line, base);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return null;
|
||||||
|
const filename = `segment-${segments.length}.ts`;
|
||||||
|
segments.push({ url: url.href, filename, duration });
|
||||||
|
localLines.push(filename);
|
||||||
|
duration = undefined;
|
||||||
|
}
|
||||||
|
if (!segments.length || duration !== undefined) return null;
|
||||||
|
return { segments, contents: `${localLines.join('\n')}\n` };
|
||||||
|
}
|
||||||
|
|
||||||
|
class UnsupportedSegment extends Error {}
|
||||||
|
|
||||||
|
async function downloadSegment(input: {
|
||||||
|
url: string;
|
||||||
|
destination: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
signal: AbortSignal;
|
||||||
|
}): Promise<void> {
|
||||||
|
const timeout = new AbortController();
|
||||||
|
const timer = setTimeout(
|
||||||
|
() => timeout.abort(new Error('Episode segment download timed out.')),
|
||||||
|
REQUEST_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
timer.unref();
|
||||||
|
try {
|
||||||
|
const response = await fetch(input.url, {
|
||||||
|
headers: input.headers,
|
||||||
|
signal: AbortSignal.any([input.signal, timeout.signal]),
|
||||||
|
});
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
await response.body?.cancel();
|
||||||
|
throw new Error(`Episode segment download failed: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
try {
|
||||||
|
const file = await open(input.destination, 'wx');
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
input.signal.throwIfAborted();
|
||||||
|
// Match FFmpeg's inactivity timeout without limiting total transfer time.
|
||||||
|
timer.refresh();
|
||||||
|
await file.writeFile(value);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await file.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await reader.cancel().catch(() => undefined);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stage plain MPEG-TS VOD segments concurrently without changing their order or timestamps. */
|
||||||
|
async function stageSubtitleGenerationHls(input: {
|
||||||
|
mediaPath: string;
|
||||||
|
directory: string;
|
||||||
|
httpHeaders: ResolvedMpvHttpHeaders;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
onProgress?: (percent: number) => void;
|
||||||
|
window?: { startSeconds: number; durationSeconds: number };
|
||||||
|
}): Promise<{ playlistPath: string; seekSeconds: number } | null> {
|
||||||
|
input.signal?.throwIfAborted();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const signal = input.signal
|
||||||
|
? AbortSignal.any([input.signal, controller.signal])
|
||||||
|
: controller.signal;
|
||||||
|
const headers = toRequestHeaders(input.httpHeaders);
|
||||||
|
let playlist: ReturnType<typeof parsePlaylist>;
|
||||||
|
try {
|
||||||
|
const response = await fetch(input.mediaPath, {
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]),
|
||||||
|
});
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
await response.body?.cancel();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let bytes = 0;
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
bytes += value.byteLength;
|
||||||
|
if (bytes > MAX_PLAYLIST_BYTES) return null;
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await reader.cancel().catch(() => undefined);
|
||||||
|
}
|
||||||
|
playlist = parsePlaylist(Buffer.concat(chunks).toString('utf8'), response.url);
|
||||||
|
} catch {
|
||||||
|
input.signal?.throwIfAborted();
|
||||||
|
// FFmpeg supports request contexts and playlist formats that fetch may not.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!playlist) return null;
|
||||||
|
let seekSeconds = 0;
|
||||||
|
if (input.window) {
|
||||||
|
const { startSeconds, durationSeconds } = input.window;
|
||||||
|
if (
|
||||||
|
!Number.isFinite(startSeconds) ||
|
||||||
|
startSeconds < 0 ||
|
||||||
|
!Number.isFinite(durationSeconds) ||
|
||||||
|
durationSeconds <= 0
|
||||||
|
)
|
||||||
|
throw new Error('Invalid HLS sample window.');
|
||||||
|
let position = 0;
|
||||||
|
const segments = playlist.segments.filter((segment) => {
|
||||||
|
const start = position;
|
||||||
|
position += segment.duration;
|
||||||
|
if (position <= startSeconds || start >= startSeconds + durationSeconds) return false;
|
||||||
|
if (start <= startSeconds) seekSeconds = startSeconds - start;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (!segments.length) return null;
|
||||||
|
playlist = {
|
||||||
|
segments,
|
||||||
|
contents:
|
||||||
|
'#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:' +
|
||||||
|
Math.ceil(Math.max(...segments.map((segment) => segment.duration))) +
|
||||||
|
'\n' +
|
||||||
|
segments.map((segment) => `#EXTINF:${segment.duration},\n${segment.filename}\n`).join('') +
|
||||||
|
'#EXT-X-ENDLIST\n',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { segments } = playlist;
|
||||||
|
const directory = path.join(input.directory, 'hls');
|
||||||
|
await mkdir(directory);
|
||||||
|
const totalDuration = segments.reduce((sum, segment) => sum + segment.duration, 0);
|
||||||
|
let completedDuration = 0;
|
||||||
|
let next = 0;
|
||||||
|
input.onProgress?.(0);
|
||||||
|
const worker = async () => {
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
signal.throwIfAborted();
|
||||||
|
const segment = segments[next++];
|
||||||
|
if (!segment) return;
|
||||||
|
await downloadSegment({
|
||||||
|
url: segment.url,
|
||||||
|
destination: path.join(directory, segment.filename),
|
||||||
|
headers,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
// URL extensions are often absent or disguised. Only stage MPEG-TS here;
|
||||||
|
// packed audio and other formats retain FFmpeg's original demuxing path.
|
||||||
|
const file = await open(path.join(directory, segment.filename), 'r');
|
||||||
|
try {
|
||||||
|
const header = Buffer.alloc(377);
|
||||||
|
const { bytesRead } = await file.read(header, 0, header.length, 0);
|
||||||
|
if (
|
||||||
|
bytesRead < header.length ||
|
||||||
|
header[0] !== 0x47 ||
|
||||||
|
header[188] !== 0x47 ||
|
||||||
|
header[376] !== 0x47
|
||||||
|
)
|
||||||
|
throw new UnsupportedSegment();
|
||||||
|
} finally {
|
||||||
|
await file.close();
|
||||||
|
}
|
||||||
|
signal.throwIfAborted();
|
||||||
|
completedDuration += segment.duration;
|
||||||
|
input.onProgress?.(Math.floor((completedDuration / totalDuration) * 100));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
controller.abort(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Drain aborted workers before the caller removes the temporary directory.
|
||||||
|
await Promise.allSettled(
|
||||||
|
Array.from({ length: Math.min(DOWNLOAD_CONCURRENCY, segments.length) }, worker),
|
||||||
|
);
|
||||||
|
input.signal?.throwIfAborted();
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
if (controller.signal.reason instanceof UnsupportedSegment) {
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw controller.signal.reason;
|
||||||
|
}
|
||||||
|
const localPath = path.join(directory, 'episode.m3u8');
|
||||||
|
await writeFile(localPath, playlist.contents, { flag: 'wx' });
|
||||||
|
return { playlistPath: localPath, seekSeconds };
|
||||||
|
}
|
||||||
|
|
||||||
|
type HlsDownloadInput = Omit<Parameters<typeof stageSubtitleGenerationHls>[0], 'window'>;
|
||||||
|
|
||||||
|
export async function downloadSubtitleGenerationHls(
|
||||||
|
input: HlsDownloadInput,
|
||||||
|
): Promise<string | null> {
|
||||||
|
return (await stageSubtitleGenerationHls(input))?.playlistPath ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seek using the VOD segment timeline; some remote HLS seeks exit with empty output. */
|
||||||
|
export function downloadSubtitleGenerationHlsWindow(
|
||||||
|
input: HlsDownloadInput & {
|
||||||
|
window: { startSeconds: number; durationSeconds: number };
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return stageSubtitleGenerationHls(input);
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import { runSubtitleGenerationProcess } from './subtitle-generation-process';
|
|||||||
import { resolveSubtitleGenerationTools } from './subtitle-generation-tools';
|
import { resolveSubtitleGenerationTools } from './subtitle-generation-tools';
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'real FFmpeg extracts a header-protected HLS episode and cleans up downloaded audio',
|
'real FFmpeg extracts concurrent authenticated HLS downloads in audio order and cleans up',
|
||||||
{
|
{
|
||||||
skip: process.platform === 'win32' ? 'Requires a POSIX executable fixture.' : false,
|
skip: process.platform === 'win32' ? 'Requires a POSIX executable fixture.' : false,
|
||||||
},
|
},
|
||||||
@@ -22,6 +22,8 @@ test(
|
|||||||
}
|
}
|
||||||
const directory = await mkdtemp(path.join(tmpdir(), 'subminer-network-generation-'));
|
const directory = await mkdtemp(path.join(tmpdir(), 'subminer-network-generation-'));
|
||||||
const requests: string[] = [];
|
const requests: string[] = [];
|
||||||
|
let activeSegments = 0;
|
||||||
|
let peakSegments = 0;
|
||||||
const server = createServer(async (req, res) => {
|
const server = createServer(async (req, res) => {
|
||||||
if (
|
if (
|
||||||
req.headers.referer !== 'https://anime.example/' ||
|
req.headers.referer !== 'https://anime.example/' ||
|
||||||
@@ -36,10 +38,19 @@ test(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
requests.push(name);
|
requests.push(name);
|
||||||
|
const segment = name.endsWith('.ts');
|
||||||
|
if (segment) {
|
||||||
|
activeSegments += 1;
|
||||||
|
peakSegments = Math.max(peakSegments, activeSegments);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
|
if (segment)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, name === 'segment0.ts' ? 80 : 20));
|
||||||
res.end(await readFile(path.join(directory, name)));
|
res.end(await readFile(path.join(directory, name)));
|
||||||
} catch {
|
} catch {
|
||||||
res.writeHead(404).end();
|
res.writeHead(404).end();
|
||||||
|
} finally {
|
||||||
|
if (segment) activeSegments -= 1;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
@@ -51,7 +62,7 @@ test(
|
|||||||
'-f',
|
'-f',
|
||||||
'lavfi',
|
'lavfi',
|
||||||
'-i',
|
'-i',
|
||||||
'sine=frequency=440:duration=3',
|
'aevalsrc=sin(2*PI*(220+220*floor(t))*t):d=6',
|
||||||
'-c:a',
|
'-c:a',
|
||||||
'aac',
|
'aac',
|
||||||
'-f',
|
'-f',
|
||||||
@@ -65,6 +76,28 @@ test(
|
|||||||
path.join(directory, 'episode.m3u8'),
|
path.join(directory, 'episode.m3u8'),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
const expectedWav = path.join(directory, 'expected.wav');
|
||||||
|
await runSubtitleGenerationProcess({
|
||||||
|
command: tools.ffmpeg.path,
|
||||||
|
args: [
|
||||||
|
'-v',
|
||||||
|
'error',
|
||||||
|
'-i',
|
||||||
|
path.join(directory, 'episode.m3u8'),
|
||||||
|
'-map',
|
||||||
|
'0:0',
|
||||||
|
'-vn',
|
||||||
|
'-af',
|
||||||
|
'asetpts=PTS-STARTPTS',
|
||||||
|
'-ac',
|
||||||
|
'1',
|
||||||
|
'-ar',
|
||||||
|
'16000',
|
||||||
|
'-c:a',
|
||||||
|
'pcm_s16le',
|
||||||
|
expectedWav,
|
||||||
|
],
|
||||||
|
});
|
||||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
const address = server.address();
|
const address = server.address();
|
||||||
assert.ok(address && typeof address === 'object');
|
assert.ok(address && typeof address === 'object');
|
||||||
@@ -85,6 +118,7 @@ const wav = args[args.indexOf('-f') + 1];
|
|||||||
const bytes = fs.readFileSync(wav);
|
const bytes = fs.readFileSync(wav);
|
||||||
assert.equal(bytes.toString('ascii', 0, 4), 'RIFF');
|
assert.equal(bytes.toString('ascii', 0, 4), 'RIFF');
|
||||||
assert.ok(bytes.length > 90000);
|
assert.ok(bytes.length > 90000);
|
||||||
|
assert.deepEqual(bytes, fs.readFileSync(${JSON.stringify(expectedWav)}));
|
||||||
fs.writeFileSync(${JSON.stringify(path.join(directory, 'audio-path'))}, wav);
|
fs.writeFileSync(${JSON.stringify(path.join(directory, 'audio-path'))}, wav);
|
||||||
fs.writeFileSync(args[args.indexOf('-of') + 1] + '.srt', '1\\n00:00:00,500 --> 00:00:01,500\\nこんにちは\\n');
|
fs.writeFileSync(args[args.indexOf('-of') + 1] + '.srt', '1\\n00:00:00,500 --> 00:00:01,500\\nこんにちは\\n');
|
||||||
`,
|
`,
|
||||||
@@ -104,18 +138,42 @@ fs.writeFileSync(args[args.indexOf('-of') + 1] + '.srt', '1\\n00:00:00,500 --> 0
|
|||||||
audioStreamIndex: 0,
|
audioStreamIndex: 0,
|
||||||
remote: {
|
remote: {
|
||||||
cacheDirectory,
|
cacheDirectory,
|
||||||
|
sessionDirectory: cacheDirectory,
|
||||||
httpHeaders: {
|
httpHeaders: {
|
||||||
headers: { Referer: 'https://anime.example/' },
|
headers: { Referer: 'https://anime.example/' },
|
||||||
userAgent: 'SubMiner test',
|
userAgent: 'SubMiner test',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const output = await generateJapaneseSubtitles(input);
|
const failedWhisper = path.join(directory, 'whisper-failure');
|
||||||
assert.match(await readFile(output, 'utf8'), /00:00:00,500 --> 00:00:01,500\nこんにちは/);
|
await writeFile(failedWhisper, `#!${process.execPath}\nprocess.exit(1);\n`, { mode: 0o755 });
|
||||||
|
await assert.rejects(
|
||||||
|
generateJapaneseSubtitles({
|
||||||
|
...input,
|
||||||
|
config: { ...input.config, whisperPath: failedWhisper },
|
||||||
|
}),
|
||||||
|
/status 1/,
|
||||||
|
);
|
||||||
assert.ok(requests.includes('episode.m3u8'));
|
assert.ok(requests.includes('episode.m3u8'));
|
||||||
assert.ok(requests.includes('segment2.ts'));
|
assert.ok(requests.includes('segment2.ts'));
|
||||||
|
assert.ok(peakSegments >= 3, `Expected concurrent downloads, saw ${peakSegments}`);
|
||||||
|
assert.ok(peakSegments <= 4, `Download limit exceeded: ${peakSegments}`);
|
||||||
|
requests.length = 0;
|
||||||
|
const output = await generateJapaneseSubtitles(input);
|
||||||
|
assert.deepEqual(requests, [], 'A Whisper failure must not discard completed audio');
|
||||||
|
assert.match(await readFile(output, 'utf8'), /00:00:00,500 --> 00:00:01,500\nこんにちは/);
|
||||||
const wav = await readFile(path.join(directory, 'audio-path'), 'utf8');
|
const wav = await readFile(path.join(directory, 'audio-path'), 'utf8');
|
||||||
await assert.rejects(readFile(wav), /ENOENT/);
|
await assert.rejects(readFile(wav), /ENOENT/);
|
||||||
|
requests.length = 0;
|
||||||
|
const progress: string[] = [];
|
||||||
|
const secondOutput = await generateJapaneseSubtitles({
|
||||||
|
...input,
|
||||||
|
config: { ...input.config, managedModel: 'base' },
|
||||||
|
onProgress: (update) => progress.push(update.message ?? ''),
|
||||||
|
});
|
||||||
|
assert.notEqual(output, secondOutput);
|
||||||
|
assert.deepEqual(requests, []);
|
||||||
|
assert.ok(progress.includes('Reusing downloaded audio...'));
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
generateJapaneseSubtitles({
|
generateJapaneseSubtitles({
|
||||||
...input,
|
...input,
|
||||||
@@ -123,7 +181,10 @@ fs.writeFileSync(args[args.indexOf('-of') + 1] + '.srt', '1\\n00:00:00,500 --> 0
|
|||||||
}),
|
}),
|
||||||
/403/,
|
/403/,
|
||||||
);
|
);
|
||||||
assert.deepEqual(await readdir(cacheDirectory), [path.basename(output)]);
|
assert.deepEqual(
|
||||||
|
(await readdir(cacheDirectory)).sort(),
|
||||||
|
['audio', path.basename(output), path.basename(secondOutput)].sort(),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
server.closeAllConnections();
|
server.closeAllConnections();
|
||||||
await new Promise<void>((resolve, reject) =>
|
await new Promise<void>((resolve, reject) =>
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { runSubtitleGenerationProcess } from './subtitle-generation-process';
|
||||||
|
import { subtitleGenerationHttpArgs } from './subtitle-generation-source';
|
||||||
|
import type { ResolvedMpvHttpHeaders } from './mpv-http-headers';
|
||||||
|
|
||||||
|
function numericTime(value: unknown): number | undefined {
|
||||||
|
if (typeof value !== 'number' && typeof value !== 'string') return undefined;
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) ? number : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAudioProbe(raw: string, selectedIndex: number | undefined) {
|
||||||
|
const value: unknown = JSON.parse(raw);
|
||||||
|
if (
|
||||||
|
typeof value !== 'object' ||
|
||||||
|
value === null ||
|
||||||
|
!('streams' in value) ||
|
||||||
|
!Array.isArray(value.streams)
|
||||||
|
) {
|
||||||
|
throw new Error('ffprobe did not return media streams.');
|
||||||
|
}
|
||||||
|
const streams = value.streams.flatMap((stream: unknown) => {
|
||||||
|
if (
|
||||||
|
typeof stream !== 'object' ||
|
||||||
|
stream === null ||
|
||||||
|
!('codec_type' in stream) ||
|
||||||
|
stream.codec_type !== 'audio' ||
|
||||||
|
!('index' in stream) ||
|
||||||
|
typeof stream.index !== 'number' ||
|
||||||
|
!Number.isInteger(stream.index) ||
|
||||||
|
stream.index < 0
|
||||||
|
)
|
||||||
|
return [];
|
||||||
|
const tags = 'tags' in stream ? stream.tags : undefined;
|
||||||
|
const language =
|
||||||
|
typeof tags === 'object' && tags !== null && 'language' in tags ? tags.language : undefined;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
index: stream.index,
|
||||||
|
start: 'start_time' in stream ? numericTime(stream.start_time) : undefined,
|
||||||
|
duration: 'duration' in stream ? numericTime(stream.duration) : undefined,
|
||||||
|
language: typeof language === 'string' ? language : '',
|
||||||
|
japanese: language === 'ja' || language === 'jpn',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
const selected =
|
||||||
|
selectedIndex === undefined
|
||||||
|
? (streams.find((stream) => stream.japanese) ?? streams[0])
|
||||||
|
: streams.find((stream) => stream.index === selectedIndex);
|
||||||
|
if (!selected)
|
||||||
|
throw new Error(
|
||||||
|
selectedIndex === undefined
|
||||||
|
? 'No audio track found.'
|
||||||
|
: `Audio stream ${selectedIndex} was not found.`,
|
||||||
|
);
|
||||||
|
const format = 'format' in value ? value.format : undefined;
|
||||||
|
const formatStart =
|
||||||
|
typeof format === 'object' && format !== null && 'start_time' in format
|
||||||
|
? (numericTime(format.start_time) ?? 0)
|
||||||
|
: 0;
|
||||||
|
const duration =
|
||||||
|
selected.duration ??
|
||||||
|
(typeof format === 'object' && format !== null && 'duration' in format
|
||||||
|
? numericTime(format.duration)
|
||||||
|
: undefined);
|
||||||
|
const hls =
|
||||||
|
typeof format === 'object' &&
|
||||||
|
format !== null &&
|
||||||
|
'format_name' in format &&
|
||||||
|
typeof format.format_name === 'string' &&
|
||||||
|
format.format_name.split(',').includes('hls');
|
||||||
|
return {
|
||||||
|
index: selected.index,
|
||||||
|
startTime: formatStart,
|
||||||
|
language: selected.language,
|
||||||
|
// mpv rebases media timestamps to the container start. Extraction rebases the selected audio.
|
||||||
|
offset: (selected.start ?? formatStart) - formatStart,
|
||||||
|
duration,
|
||||||
|
hls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function probeSubtitleGenerationAudio(input: {
|
||||||
|
command: string;
|
||||||
|
mediaPath: string;
|
||||||
|
audioStreamIndex?: number;
|
||||||
|
httpHeaders?: ResolvedMpvHttpHeaders;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}) {
|
||||||
|
const raw = await runSubtitleGenerationProcess({
|
||||||
|
command: input.command,
|
||||||
|
args: [
|
||||||
|
'-v',
|
||||||
|
'error',
|
||||||
|
'-show_entries',
|
||||||
|
'stream=index,codec_type,start_time,duration:stream_tags=language:format=start_time,duration,format_name',
|
||||||
|
'-of',
|
||||||
|
'json',
|
||||||
|
...(input.httpHeaders ? subtitleGenerationHttpArgs(input.httpHeaders) : []),
|
||||||
|
input.mediaPath,
|
||||||
|
],
|
||||||
|
signal: input.signal,
|
||||||
|
});
|
||||||
|
return parseAudioProbe(raw, input.audioStreamIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubtitleGenerationAudioProbe = Awaited<ReturnType<typeof probeSubtitleGenerationAudio>>;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createSubtitleGenerationSession } from './subtitle-generation-session';
|
||||||
|
|
||||||
|
test('session cleanup removes audio and work files, recovers crashes and preserves results and live sessions', async () => {
|
||||||
|
const root = await mkdtemp(path.join(tmpdir(), 'subtitle-session-test-'));
|
||||||
|
const exited = spawn(process.execPath, ['-e', ''], { stdio: 'ignore' });
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
exited.once('error', reject);
|
||||||
|
exited.once('close', () => resolve());
|
||||||
|
});
|
||||||
|
assert.ok(exited.pid);
|
||||||
|
const old = path.join(root, 'sessions', `${exited.pid}-abandoned`);
|
||||||
|
const live = path.join(root, 'sessions', `${process.pid}-other`);
|
||||||
|
try {
|
||||||
|
for (const dir of [old, live, path.join(root, 'audio')]) {
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
await writeFile(path.join(dir, 'audio.wav'), 'audio');
|
||||||
|
}
|
||||||
|
const saved = path.join(root, 'episode.ja.generated.srt');
|
||||||
|
await writeFile(saved, 'saved subtitles');
|
||||||
|
const session = createSubtitleGenerationSession(root);
|
||||||
|
const directory = await session.directory();
|
||||||
|
assert.equal(await session.directory(), directory);
|
||||||
|
await assert.rejects(readFile(path.join(old, 'audio.wav')), /ENOENT/);
|
||||||
|
await assert.rejects(readFile(path.join(root, 'audio', 'audio.wav')), /ENOENT/);
|
||||||
|
await writeFile(path.join(directory, 'working.wav'), 'temporary');
|
||||||
|
await session.dispose();
|
||||||
|
await session.dispose();
|
||||||
|
await assert.rejects(readdir(directory), /ENOENT/);
|
||||||
|
await assert.rejects(session.directory(), /closed/);
|
||||||
|
assert.equal(await readFile(saved, 'utf8'), 'saved subtitles');
|
||||||
|
assert.equal(await readFile(path.join(live, 'audio.wav'), 'utf8'), 'audio');
|
||||||
|
const next = createSubtitleGenerationSession(root);
|
||||||
|
assert.notEqual(await next.directory(), directory);
|
||||||
|
await next.dispose();
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { mkdir, mkdtemp, readdir, rm } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
/** Owns downloaded audio and job files, never saved subtitles or speech models. */
|
||||||
|
export function createSubtitleGenerationSession(cacheDirectory: string) {
|
||||||
|
const root = path.join(cacheDirectory, 'sessions');
|
||||||
|
let directory: Promise<string> | undefined;
|
||||||
|
let disposed = false;
|
||||||
|
let disposal: Promise<void> | undefined;
|
||||||
|
async function prepare(): Promise<string> {
|
||||||
|
await mkdir(root, { recursive: true });
|
||||||
|
// Remove audio retained by the previous persistent-cache implementation.
|
||||||
|
await rm(path.join(cacheDirectory, 'audio'), { recursive: true, force: true });
|
||||||
|
for (const entry of await readdir(root, { withFileTypes: true })) {
|
||||||
|
const match = /^(\d+)-[a-zA-Z0-9]+$/.exec(entry.name);
|
||||||
|
if (!entry.isDirectory() || !match) continue;
|
||||||
|
try {
|
||||||
|
process.kill(Number(match[1]), 0);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && 'code' in error && error.code === 'ESRCH') {
|
||||||
|
await rm(path.join(root, entry.name), { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mkdtemp(path.join(root, `${process.pid}-`));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
directory(): Promise<string> {
|
||||||
|
if (disposed) return Promise.reject(new Error('Subtitle generation session has closed.'));
|
||||||
|
return (directory ??= prepare());
|
||||||
|
},
|
||||||
|
dispose(): Promise<void> {
|
||||||
|
disposed = true;
|
||||||
|
return (disposal ??= (async () => {
|
||||||
|
if (directory) await rm(await directory, { recursive: true, force: true });
|
||||||
|
})());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,6 +3,21 @@ import { toFfmpegInputHttpArgs, type ResolvedMpvHttpHeaders } from './mpv-http-h
|
|||||||
export interface SubtitleGenerationRemoteSource {
|
export interface SubtitleGenerationRemoteSource {
|
||||||
httpHeaders: ResolvedMpvHttpHeaders;
|
httpHeaders: ResolvedMpvHttpHeaders;
|
||||||
cacheDirectory: string;
|
cacheDirectory: string;
|
||||||
|
sessionDirectory?: string;
|
||||||
|
alternatives?: readonly SubtitleGenerationAlternative[];
|
||||||
|
selectedAudio?: {
|
||||||
|
url: string;
|
||||||
|
audioStreamIndex?: number;
|
||||||
|
delaySeconds: number;
|
||||||
|
httpHeaders: ResolvedMpvHttpHeaders;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubtitleGenerationAlternative {
|
||||||
|
kind: 'audio' | 'video';
|
||||||
|
url: string;
|
||||||
|
label: string;
|
||||||
|
httpHeaders: ResolvedMpvHttpHeaders;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Apply only to network inputs, including embedded subtitle reference extraction. */
|
/** Apply only to network inputs, including embedded subtitle reference extraction. */
|
||||||
|
|||||||
@@ -9,13 +9,11 @@ import type {
|
|||||||
} from '../../shared/subtitle-generation';
|
} from '../../shared/subtitle-generation';
|
||||||
import { isMissingFile, resolveSubtitleGenerationModel } from './subtitle-generation-models';
|
import { isMissingFile, resolveSubtitleGenerationModel } from './subtitle-generation-models';
|
||||||
import { runSubtitleGenerationProcess } from './subtitle-generation-process';
|
import { runSubtitleGenerationProcess } from './subtitle-generation-process';
|
||||||
|
import { prepareSubtitleGenerationAudio } from './subtitle-generation-audio';
|
||||||
import { publishSubtitleGenerationFile } from './subtitle-generation-files';
|
import { publishSubtitleGenerationFile } from './subtitle-generation-files';
|
||||||
import { formatTimestamp } from './subtitle-generation-srt';
|
import { formatTimestamp } from './subtitle-generation-srt';
|
||||||
import { transcribeSubtitleDialogue } from './subtitle-generation-dialogue';
|
import { transcribeSubtitleDialogue } from './subtitle-generation-dialogue';
|
||||||
import {
|
import { type SubtitleGenerationRemoteSource } from './subtitle-generation-source';
|
||||||
subtitleGenerationHttpArgs,
|
|
||||||
type SubtitleGenerationRemoteSource,
|
|
||||||
} from './subtitle-generation-source';
|
|
||||||
import {
|
import {
|
||||||
loadSubtitleGenerationReference,
|
loadSubtitleGenerationReference,
|
||||||
type SubtitleGenerationReference,
|
type SubtitleGenerationReference,
|
||||||
@@ -31,70 +29,6 @@ export {
|
|||||||
} from './subtitle-generation-models';
|
} from './subtitle-generation-models';
|
||||||
export { resolveSubtitleGenerationTools } from './subtitle-generation-tools';
|
export { resolveSubtitleGenerationTools } from './subtitle-generation-tools';
|
||||||
|
|
||||||
function numericTime(value: unknown): number | undefined {
|
|
||||||
if (typeof value !== 'number' && typeof value !== 'string') return undefined;
|
|
||||||
const number = Number(value);
|
|
||||||
return Number.isFinite(number) ? number : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAudioProbe(raw: string, selectedIndex: number | undefined) {
|
|
||||||
const value: unknown = JSON.parse(raw);
|
|
||||||
if (
|
|
||||||
typeof value !== 'object' ||
|
|
||||||
value === null ||
|
|
||||||
!('streams' in value) ||
|
|
||||||
!Array.isArray(value.streams)
|
|
||||||
) {
|
|
||||||
throw new Error('ffprobe did not return media streams.');
|
|
||||||
}
|
|
||||||
const streams = value.streams.flatMap((stream: unknown) => {
|
|
||||||
if (
|
|
||||||
typeof stream !== 'object' ||
|
|
||||||
stream === null ||
|
|
||||||
!('codec_type' in stream) ||
|
|
||||||
stream.codec_type !== 'audio' ||
|
|
||||||
!('index' in stream) ||
|
|
||||||
typeof stream.index !== 'number' ||
|
|
||||||
!Number.isInteger(stream.index) ||
|
|
||||||
stream.index < 0
|
|
||||||
)
|
|
||||||
return [];
|
|
||||||
const tags = 'tags' in stream ? stream.tags : undefined;
|
|
||||||
const language =
|
|
||||||
typeof tags === 'object' && tags !== null && 'language' in tags ? tags.language : undefined;
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
index: stream.index,
|
|
||||||
start: 'start_time' in stream ? numericTime(stream.start_time) : undefined,
|
|
||||||
duration: 'duration' in stream ? numericTime(stream.duration) : undefined,
|
|
||||||
japanese: language === 'ja' || language === 'jpn',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
|
||||||
const selected =
|
|
||||||
selectedIndex === undefined
|
|
||||||
? (streams.find((stream) => stream.japanese) ?? streams[0])
|
|
||||||
: streams.find((stream) => stream.index === selectedIndex);
|
|
||||||
if (!selected)
|
|
||||||
throw new Error(
|
|
||||||
selectedIndex === undefined
|
|
||||||
? 'No audio track found.'
|
|
||||||
: `Audio stream ${selectedIndex} was not found.`,
|
|
||||||
);
|
|
||||||
const format = 'format' in value ? value.format : undefined;
|
|
||||||
const formatStart =
|
|
||||||
typeof format === 'object' && format !== null && 'start_time' in format
|
|
||||||
? (numericTime(format.start_time) ?? 0)
|
|
||||||
: 0;
|
|
||||||
const duration =
|
|
||||||
selected.duration ??
|
|
||||||
(typeof format === 'object' && format !== null && 'duration' in format
|
|
||||||
? numericTime(format.duration)
|
|
||||||
: undefined);
|
|
||||||
// mpv rebases media timestamps to the container start. Extraction rebases the selected audio.
|
|
||||||
return { index: selected.index, offset: (selected.start ?? formatStart) - formatStart, duration };
|
|
||||||
}
|
|
||||||
|
|
||||||
function shiftSubtitleTimestamps(srt: string, offsetSeconds: number): string {
|
function shiftSubtitleTimestamps(srt: string, offsetSeconds: number): string {
|
||||||
let cueCount = 0;
|
let cueCount = 0;
|
||||||
const result = srt.replace(
|
const result = srt.replace(
|
||||||
@@ -211,7 +145,6 @@ export async function generateJapaneseSubtitles(input: {
|
|||||||
? path.dirname(path.resolve(input.outputPath))
|
? path.dirname(path.resolve(input.outputPath))
|
||||||
: path.dirname(destinationMediaPath),
|
: path.dirname(destinationMediaPath),
|
||||||
);
|
);
|
||||||
const httpArgs = input.remote ? subtitleGenerationHttpArgs(input.remote.httpHeaders) : [];
|
|
||||||
const model = await resolveSubtitleGenerationModel(input.config, input.modelDirectory);
|
const model = await resolveSubtitleGenerationModel(input.config, input.modelDirectory);
|
||||||
if (model.kind === 'missing')
|
if (model.kind === 'missing')
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -219,70 +152,22 @@ export async function generateJapaneseSubtitles(input: {
|
|||||||
);
|
);
|
||||||
if (model.kind === 'invalid') throw new Error(model.message);
|
if (model.kind === 'invalid') throw new Error(model.message);
|
||||||
const tools = requireSubtitleGenerationTools(await resolveSubtitleGenerationTools(input.config));
|
const tools = requireSubtitleGenerationTools(await resolveSubtitleGenerationTools(input.config));
|
||||||
input.onProgress?.({ stage: 'extract', message: 'Inspecting audio tracks...' });
|
const temporaryDirectory = await mkdtemp(
|
||||||
const probe = await runSubtitleGenerationProcess({
|
path.join(input.remote?.sessionDirectory ?? tmpdir(), 'subminer-whisper-'),
|
||||||
command: tools.ffprobe,
|
);
|
||||||
args: [
|
|
||||||
'-v',
|
|
||||||
'error',
|
|
||||||
'-show_entries',
|
|
||||||
'stream=index,codec_type,start_time,duration:stream_tags=language:format=start_time,duration',
|
|
||||||
'-of',
|
|
||||||
'json',
|
|
||||||
...httpArgs,
|
|
||||||
mediaPath,
|
|
||||||
],
|
|
||||||
signal: input.signal,
|
|
||||||
});
|
|
||||||
const audio = parseAudioProbe(probe, input.audioStreamIndex);
|
|
||||||
if (isRemote && (!audio.duration || !Number.isFinite(audio.duration) || audio.duration <= 0))
|
|
||||||
throw new Error(
|
|
||||||
'Stream generation requires a finite episode duration. Live streams are not supported.',
|
|
||||||
);
|
|
||||||
const temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'subminer-whisper-'));
|
|
||||||
try {
|
try {
|
||||||
const wavPath = path.join(temporaryDirectory, 'audio.wav');
|
|
||||||
const subtitleBase = path.join(temporaryDirectory, 'subtitles');
|
const subtitleBase = path.join(temporaryDirectory, 'subtitles');
|
||||||
input.onProgress?.({ stage: 'extract', percent: 0, message: 'Extracting audio...' });
|
const audio = await prepareSubtitleGenerationAudio({
|
||||||
await runSubtitleGenerationProcess({
|
mediaPath,
|
||||||
command: tools.ffmpeg,
|
audioStreamIndex: input.audioStreamIndex,
|
||||||
args: [
|
remote: input.remote,
|
||||||
'-nostdin',
|
ffprobe: tools.ffprobe,
|
||||||
'-hide_banner',
|
ffmpeg: tools.ffmpeg,
|
||||||
'-loglevel',
|
directory: temporaryDirectory,
|
||||||
'error',
|
onProgress: input.onProgress,
|
||||||
...httpArgs,
|
|
||||||
'-i',
|
|
||||||
mediaPath,
|
|
||||||
'-map',
|
|
||||||
`0:${audio.index}`,
|
|
||||||
...(isRemote ? ['-t', String(audio.duration)] : []),
|
|
||||||
'-vn',
|
|
||||||
'-af',
|
|
||||||
'asetpts=PTS-STARTPTS',
|
|
||||||
'-ac',
|
|
||||||
'1',
|
|
||||||
'-ar',
|
|
||||||
'16000',
|
|
||||||
'-c:a',
|
|
||||||
'pcm_s16le',
|
|
||||||
'-progress',
|
|
||||||
'pipe:1',
|
|
||||||
'-nostats',
|
|
||||||
wavPath,
|
|
||||||
],
|
|
||||||
signal: input.signal,
|
signal: input.signal,
|
||||||
onLine: (line) => {
|
|
||||||
const match = /^out_time_us=(\d+)$/.exec(line);
|
|
||||||
if (match && audio.duration && audio.duration > 0) {
|
|
||||||
input.onProgress?.({
|
|
||||||
stage: 'extract',
|
|
||||||
percent: Math.min(100, Math.floor(Number(match[1]) / 10000 / audio.duration)),
|
|
||||||
message: 'Extracting audio...',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
const { wavPath } = audio;
|
||||||
input.onProgress?.({
|
input.onProgress?.({
|
||||||
stage: 'transcribe',
|
stage: 'transcribe',
|
||||||
percent: 0,
|
percent: 0,
|
||||||
|
|||||||
+20
-5
@@ -2703,10 +2703,9 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
|||||||
getOrCreateCurrentSnapshot: (targetPath, progress) =>
|
getOrCreateCurrentSnapshot: (targetPath, progress) =>
|
||||||
characterDictionaryRuntime.getOrCreateCurrentSnapshot(targetPath, progress),
|
characterDictionaryRuntime.getOrCreateCurrentSnapshot(targetPath, progress),
|
||||||
buildMergedDictionary: (mediaIds) => characterDictionaryRuntime.buildMergedDictionary(mediaIds),
|
buildMergedDictionary: (mediaIds) => characterDictionaryRuntime.buildMergedDictionary(mediaIds),
|
||||||
waitForYomitanMutationReady: () =>
|
waitForYomitanMutationReady: async () => {
|
||||||
currentMediaTokenizationGate.waitUntilReady(
|
await ensureYomitanExtensionLoaded();
|
||||||
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null,
|
},
|
||||||
),
|
|
||||||
getYomitanDictionaryInfo: async () => {
|
getYomitanDictionaryInfo: async () => {
|
||||||
await ensureYomitanExtensionLoaded();
|
await ensureYomitanExtensionLoaded();
|
||||||
return await getYomitanDictionaryInfo(getYomitanParserRuntimeDeps(), {
|
return await getYomitanDictionaryInfo(getYomitanParserRuntimeDeps(), {
|
||||||
@@ -6886,6 +6885,7 @@ function setOverlayVisible(visible: boolean): void {
|
|||||||
|
|
||||||
registerIpcRuntimeHandlers();
|
registerIpcRuntimeHandlers();
|
||||||
const subtitleGenerationRuntime = createSubtitleGenerationRuntime({
|
const subtitleGenerationRuntime = createSubtitleGenerationRuntime({
|
||||||
|
getAlternativeSources: animeBrowserApplicationRuntime.getSubtitleGenerationSources,
|
||||||
getConfig: () => configService.getConfig().subtitleGeneration,
|
getConfig: () => configService.getConfig().subtitleGeneration,
|
||||||
getCacheDirectory: () => path.join(USER_DATA_PATH, 'cache', 'generated-subtitles'),
|
getCacheDirectory: () => path.join(USER_DATA_PATH, 'cache', 'generated-subtitles'),
|
||||||
getModelDirectory: () =>
|
getModelDirectory: () =>
|
||||||
@@ -6907,4 +6907,19 @@ registerSubtitleGenerationIpc({
|
|||||||
runtime: subtitleGenerationRuntime,
|
runtime: subtitleGenerationRuntime,
|
||||||
openModal: () => openSubtitleGenerationModal(createOverlayHostedModalOpenDeps()),
|
openModal: () => openSubtitleGenerationModal(createOverlayHostedModalOpenDeps()),
|
||||||
});
|
});
|
||||||
app.on('before-quit', () => subtitleGenerationRuntime.cancel());
|
void subtitleGenerationRuntime
|
||||||
|
.initialize()
|
||||||
|
.catch((error: unknown) => logger.warn('Could not prepare temporary subtitle storage', error));
|
||||||
|
let subtitleGenerationDisposed = false;
|
||||||
|
let subtitleGenerationDisposal: Promise<void> | undefined;
|
||||||
|
app.on('before-quit', (event) => {
|
||||||
|
if (subtitleGenerationDisposed) return;
|
||||||
|
event.preventDefault();
|
||||||
|
subtitleGenerationDisposal ??= subtitleGenerationRuntime
|
||||||
|
.dispose()
|
||||||
|
.catch((error: unknown) => logger.warn('Could not clean temporary subtitle storage', error))
|
||||||
|
.finally(() => {
|
||||||
|
subtitleGenerationDisposed = true;
|
||||||
|
app.quit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ export interface AnimeBrowserApplicationRuntimeDeps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AnimeBrowserApplicationRuntime {
|
export interface AnimeBrowserApplicationRuntime {
|
||||||
|
getSubtitleGenerationSources: ReturnType<
|
||||||
|
typeof createAnimeBrowserRuntime
|
||||||
|
>['getSubtitleGenerationSources'];
|
||||||
publishPlaybackState: (mediaPath: string | null) => void;
|
publishPlaybackState: (mediaPath: string | null) => void;
|
||||||
openWindow: () => boolean;
|
openWindow: () => boolean;
|
||||||
}
|
}
|
||||||
@@ -165,5 +168,9 @@ export function createAnimeBrowserApplicationRuntime(
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
return { publishPlaybackState, openWindow };
|
return {
|
||||||
|
publishPlaybackState,
|
||||||
|
openWindow,
|
||||||
|
getSubtitleGenerationSources: runtime.getSubtitleGenerationSources,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,44 @@ const request: AnimeBrowserPlayRequest = {
|
|||||||
episodeNumber: 1,
|
episodeNumber: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
test('prepared episode retains its extraction alternatives under the routed playback URL', async () => {
|
||||||
|
const client = {
|
||||||
|
getVideoList: async () => [
|
||||||
|
{
|
||||||
|
videoUrl: 'http://127.0.0.1:1234/high.m3u8',
|
||||||
|
quality: '1080p',
|
||||||
|
audioTracks: [{ url: 'http://127.0.0.1:1234/ja.m3u8', lang: 'Japanese' }],
|
||||||
|
},
|
||||||
|
{ videoUrl: 'http://127.0.0.1:1234/low.m3u8', quality: '360p' },
|
||||||
|
],
|
||||||
|
} as unknown as AnimeBridgeClient;
|
||||||
|
const playback = createAnimeBrowserPlayback({
|
||||||
|
deps: {
|
||||||
|
sendMpvCommand: () => undefined,
|
||||||
|
ensureMpvConnected: async () => true,
|
||||||
|
log: () => undefined,
|
||||||
|
},
|
||||||
|
bridge: async () => ({ client, baseUrl: 'http://127.0.0.1:1234' }),
|
||||||
|
sourceFor: async () => ({}) as BridgeSource,
|
||||||
|
stripProxy: () => ({
|
||||||
|
origin: 'http://127.0.0.1:5678',
|
||||||
|
port: 5678,
|
||||||
|
close: async () => undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const result = await playback.prepareEpisode(request);
|
||||||
|
assert.ok(result.ok);
|
||||||
|
if (!result.ok) return;
|
||||||
|
assert.equal(result.playback.stream.url, 'http://127.0.0.1:5678/high.m3u8');
|
||||||
|
assert.deepEqual(
|
||||||
|
playback.getSubtitleGenerationSources(result.playback.stream.url).map((source) => source.url),
|
||||||
|
['http://127.0.0.1:5678/ja.m3u8', 'http://127.0.0.1:5678/low.m3u8'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(playback.getSubtitleGenerationSources('http://another/episode'), []);
|
||||||
|
await playback.dispose();
|
||||||
|
assert.deepEqual(playback.getSubtitleGenerationSources(result.playback.stream.url), []);
|
||||||
|
});
|
||||||
|
|
||||||
test('playback returns the established error when an extension has no playable stream', async () => {
|
test('playback returns the established error when an extension has no playable stream', async () => {
|
||||||
let mpvConnections = 0;
|
let mpvConnections = 0;
|
||||||
const client = { getVideoList: async () => [] } as unknown as AnimeBridgeClient;
|
const client = { getVideoList: async () => [] } as unknown as AnimeBridgeClient;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type { AnimeStreamMetadata } from '../../anime-bridge/episode-metadata';
|
|||||||
import type { ResolvedStream } from '../../anime-bridge/types';
|
import type { ResolvedStream } from '../../anime-bridge/types';
|
||||||
import type { AnimeBrowserPlayRequest, AnimeBrowserPlayResult } from '../../types/anime-browser';
|
import type { AnimeBrowserPlayRequest, AnimeBrowserPlayResult } from '../../types/anime-browser';
|
||||||
import type { AnimeBrowserPlaybackDeps } from './anime-browser-runtime-deps';
|
import type { AnimeBrowserPlaybackDeps } from './anime-browser-runtime-deps';
|
||||||
|
import { animeSubtitleGenerationSources } from './anime-subtitle-generation-sources';
|
||||||
|
|
||||||
const TRACK_ATTACH_DELAY_MS = 300;
|
const TRACK_ATTACH_DELAY_MS = 300;
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ export function createAnimeBrowserPlayback(options: AnimeBrowserPlaybackOptions)
|
|||||||
// Overlapping playEpisode calls share mpv and subtitleCacheDir, so each call
|
// Overlapping playEpisode calls share mpv and subtitleCacheDir, so each call
|
||||||
// carries a generation and only acts while it is still the newest one.
|
// carries a generation and only acts while it is still the newest one.
|
||||||
let playbackGeneration = 0;
|
let playbackGeneration = 0;
|
||||||
|
const generationSources = new Map<string, ReturnType<typeof animeSubtitleGenerationSources>>();
|
||||||
|
|
||||||
async function clearSubtitleCache(generation: number): Promise<void> {
|
async function clearSubtitleCache(generation: number): Promise<void> {
|
||||||
// A stale call must not delete the directory a newer playback now owns.
|
// A stale call must not delete the directory a newer playback now owns.
|
||||||
@@ -117,6 +119,17 @@ export function createAnimeBrowserPlayback(options: AnimeBrowserPlaybackOptions)
|
|||||||
const stream = proxy
|
const stream = proxy
|
||||||
? { ...selected, url: routeHlsThroughProxy(selected.url, baseUrl, proxy.origin) }
|
? { ...selected, url: routeHlsThroughProxy(selected.url, baseUrl, proxy.origin) }
|
||||||
: selected;
|
: selected;
|
||||||
|
generationSources.set(
|
||||||
|
stream.url,
|
||||||
|
animeSubtitleGenerationSources(selected, streams).map((candidate) => ({
|
||||||
|
...candidate,
|
||||||
|
url: proxy ? routeHlsThroughProxy(candidate.url, baseUrl, proxy.origin) : candidate.url,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
if (generationSources.size > 32) {
|
||||||
|
const oldest = generationSources.keys().next().value;
|
||||||
|
if (oldest !== undefined) generationSources.delete(oldest);
|
||||||
|
}
|
||||||
const metadata = buildAnimeStreamMetadata({
|
const metadata = buildAnimeStreamMetadata({
|
||||||
sourceId: request.sourceId,
|
sourceId: request.sourceId,
|
||||||
animeUrl: request.animeUrl,
|
animeUrl: request.animeUrl,
|
||||||
@@ -340,6 +353,7 @@ export function createAnimeBrowserPlayback(options: AnimeBrowserPlaybackOptions)
|
|||||||
// Bumping the generation makes any in-flight playEpisode stale, so a cache
|
// Bumping the generation makes any in-flight playEpisode stale, so a cache
|
||||||
// it is still writing gets removed by that call instead of outliving us.
|
// it is still writing gets removed by that call instead of outliving us.
|
||||||
playbackGeneration += 1;
|
playbackGeneration += 1;
|
||||||
|
generationSources.clear();
|
||||||
const cacheDir = subtitleCacheDir;
|
const cacheDir = subtitleCacheDir;
|
||||||
subtitleCacheDir = null;
|
subtitleCacheDir = null;
|
||||||
await Promise.all(queuedTrackPreparations);
|
await Promise.all(queuedTrackPreparations);
|
||||||
@@ -351,7 +365,15 @@ export function createAnimeBrowserPlayback(options: AnimeBrowserPlaybackOptions)
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { playEpisode, prepareEpisode, appendEpisode, activateEpisode, discardEpisode, dispose };
|
return {
|
||||||
|
playEpisode,
|
||||||
|
prepareEpisode,
|
||||||
|
appendEpisode,
|
||||||
|
activateEpisode,
|
||||||
|
discardEpisode,
|
||||||
|
dispose,
|
||||||
|
getSubtitleGenerationSources: (mediaPath: string) => generationSources.get(mediaPath) ?? [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function superseded(): AnimeBrowserPlayResult {
|
function superseded(): AnimeBrowserPlayResult {
|
||||||
|
|||||||
@@ -529,6 +529,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
getSubtitleGenerationSources: playback.getSubtitleGenerationSources,
|
||||||
getSnapshot(sessionId = 'default'): AnimeBrowserSnapshot {
|
getSnapshot(sessionId = 'default'): AnimeBrowserSnapshot {
|
||||||
const session = getBrowserSession(sessionId);
|
const session = getBrowserSession(sessionId);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { animeSubtitleGenerationSources } from './anime-subtitle-generation-sources';
|
||||||
|
import type { ResolvedStream } from '../../anime-bridge/types';
|
||||||
|
|
||||||
|
test('extraction prioritizes Japanese audio then smaller video without changing playback', () => {
|
||||||
|
const selected: ResolvedStream = {
|
||||||
|
url: 'https://stream/high',
|
||||||
|
quality: '1080p',
|
||||||
|
headers: { Referer: 'https://source/', Cookie: 'private', Authorization: 'private' },
|
||||||
|
subtitles: [],
|
||||||
|
audios: [
|
||||||
|
{ url: 'https://stream/en', lang: 'English' },
|
||||||
|
{ url: 'https://stream/ja', lang: 'Japanese' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const candidates = animeSubtitleGenerationSources(selected, [
|
||||||
|
selected,
|
||||||
|
{ ...selected, url: 'https://stream/720', quality: '720p' },
|
||||||
|
{ ...selected, url: 'https://stream/dub', quality: '360p Dub' },
|
||||||
|
{ ...selected, url: 'https://stream/360', quality: '360p' },
|
||||||
|
{ ...selected, url: 'https://stream/unknown', quality: 'Auto' },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
candidates.map(({ kind, url }) => [kind, url]),
|
||||||
|
[
|
||||||
|
['audio', 'https://stream/ja'],
|
||||||
|
['video', 'https://stream/360'],
|
||||||
|
['video', 'https://stream/720'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.equal(selected.url, 'https://stream/high');
|
||||||
|
assert.deepEqual(candidates[0]?.httpHeaders.headers, { Referer: 'https://source/' });
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { ResolvedStream } from '../../anime-bridge/types';
|
||||||
|
import { sanitizeMediaHttpHeaders } from '../../core/services/mpv-http-headers';
|
||||||
|
import type { SubtitleGenerationAlternative } from '../../core/services/subtitle-generation-source';
|
||||||
|
|
||||||
|
function height(label: string): number | null {
|
||||||
|
const match = /\b(\d{3,4})p\b/i.exec(label);
|
||||||
|
return match ? Number(match[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** These are candidates only. Generation verifies their audio against playback. */
|
||||||
|
export function animeSubtitleGenerationSources(
|
||||||
|
selected: ResolvedStream,
|
||||||
|
streams: readonly ResolvedStream[],
|
||||||
|
): SubtitleGenerationAlternative[] {
|
||||||
|
const httpHeaders = { headers: sanitizeMediaHttpHeaders(selected.headers), userAgent: null };
|
||||||
|
const audios: SubtitleGenerationAlternative[] = selected.audios
|
||||||
|
.filter((track) => /^(?:ja|jpn|japanese)(?:[-_\s]|$)|日本語/i.test(track.lang))
|
||||||
|
.map((track) => ({ kind: 'audio', url: track.url, label: 'Japanese audio', httpHeaders }));
|
||||||
|
const selectedHeight = height(selected.quality);
|
||||||
|
const videos: SubtitleGenerationAlternative[] = streams
|
||||||
|
.filter((stream) => {
|
||||||
|
const resolution = height(stream.quality);
|
||||||
|
return (
|
||||||
|
stream.url !== selected.url &&
|
||||||
|
selectedHeight !== null &&
|
||||||
|
resolution !== null &&
|
||||||
|
resolution < selectedHeight &&
|
||||||
|
!/\bdub(?:bed)?\b/i.test(stream.quality)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.sort((a, b) => (height(a.quality) ?? Infinity) - (height(b.quality) ?? Infinity))
|
||||||
|
.map((stream) => ({
|
||||||
|
kind: 'video',
|
||||||
|
url: stream.url,
|
||||||
|
label: stream.quality,
|
||||||
|
httpHeaders: { headers: sanitizeMediaHttpHeaders(stream.headers), userAgent: null },
|
||||||
|
}));
|
||||||
|
return [...audios, ...videos]
|
||||||
|
.filter(
|
||||||
|
(candidate, index, all) =>
|
||||||
|
/^https?:\/\//i.test(candidate.url) &&
|
||||||
|
all.findIndex((entry) => entry.url === candidate.url) === index,
|
||||||
|
)
|
||||||
|
.slice(0, 4);
|
||||||
|
}
|
||||||
@@ -988,7 +988,7 @@ test('auto sync emits building while merged dictionary generation is in flight',
|
|||||||
await syncPromise;
|
await syncPromise;
|
||||||
});
|
});
|
||||||
|
|
||||||
test('auto sync waits for tokenization-ready gate before Yomitan mutations', async () => {
|
test('auto sync waits for Yomitan readiness before dictionary mutations', async () => {
|
||||||
const userDataPath = makeTempDir();
|
const userDataPath = makeTempDir();
|
||||||
const gate = (() => {
|
const gate = (() => {
|
||||||
let resolve!: () => void;
|
let resolve!: () => void;
|
||||||
@@ -1043,7 +1043,7 @@ test('auto sync waits for tokenization-ready gate before Yomitan mutations', asy
|
|||||||
});
|
});
|
||||||
|
|
||||||
const syncPromise = runtime.runSyncNow();
|
const syncPromise = runtime.runSyncNow();
|
||||||
await waitUntil(() => calls.includes('wait'), 'the tokenization-ready gate');
|
await waitUntil(() => calls.includes('wait'), 'Yomitan readiness');
|
||||||
|
|
||||||
assert.deepEqual(calls, ['build', 'wait']);
|
assert.deepEqual(calls, ['build', 'wait']);
|
||||||
|
|
||||||
@@ -1053,6 +1053,84 @@ test('auto sync waits for tokenization-ready gate before Yomitan mutations', asy
|
|||||||
assert.deepEqual(calls, ['build', 'wait', 'info', 'import', 'settings']);
|
assert.deepEqual(calls, ['build', 'wait', 'info', 'import', 'settings']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('cached dictionary readiness cannot leave checking stuck and can retry after Yomitan becomes ready', async () => {
|
||||||
|
const userDataPath = makeTempDir();
|
||||||
|
const directory = path.join(userDataPath, 'character-dictionaries');
|
||||||
|
fs.mkdirSync(directory, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(directory, 'auto-sync-state.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
activeMediaIds: ['7054 - Maid-Sama!'],
|
||||||
|
mergedRevision: 'cached-revision',
|
||||||
|
mergedDictionaryTitle: 'SubMiner Character Dictionary',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const readiness = createDeferred<void>();
|
||||||
|
const events: string[] = [];
|
||||||
|
const calls: string[] = [];
|
||||||
|
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||||
|
userDataPath,
|
||||||
|
getConfig: () => ({ enabled: true, maxLoaded: 3, profileScope: 'all' }),
|
||||||
|
getOrCreateCurrentSnapshot: async (_target, progress) => {
|
||||||
|
progress?.onChecking?.({ mediaId: 7054, mediaTitle: 'Maid-Sama!' });
|
||||||
|
return {
|
||||||
|
mediaId: 7054,
|
||||||
|
mediaTitle: 'Maid-Sama!',
|
||||||
|
entryCount: 3184,
|
||||||
|
fromCache: true,
|
||||||
|
updatedAt: 1000,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
buildMergedDictionary: async () => {
|
||||||
|
throw new Error('Cached dictionary must not be rebuilt');
|
||||||
|
},
|
||||||
|
waitForYomitanMutationReady: () => readiness.promise,
|
||||||
|
getYomitanDictionaryInfo: async () => {
|
||||||
|
calls.push('info');
|
||||||
|
return [{ title: 'SubMiner Character Dictionary', revision: 'cached-revision' }];
|
||||||
|
},
|
||||||
|
importYomitanDictionary: async () => {
|
||||||
|
throw new Error('Current dictionary must not be imported');
|
||||||
|
},
|
||||||
|
deleteYomitanDictionary: async () => {
|
||||||
|
throw new Error('Current dictionary must not be deleted');
|
||||||
|
},
|
||||||
|
upsertYomitanDictionarySettings: async () => {
|
||||||
|
calls.push('settings');
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
operationTimeoutMs: 20,
|
||||||
|
now: Date.now,
|
||||||
|
onSyncStatus: (event) => events.push(event.phase),
|
||||||
|
});
|
||||||
|
const result = runtime.runSyncNow().then(
|
||||||
|
() => 'ready',
|
||||||
|
(error: unknown) => error,
|
||||||
|
);
|
||||||
|
let watchdog: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
try {
|
||||||
|
const outcome = await Promise.race([
|
||||||
|
result,
|
||||||
|
new Promise<string>((resolve) => {
|
||||||
|
watchdog = setTimeout(() => resolve('still checking'), 500);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
assert.ok(outcome instanceof Error, `Expected readiness failure, got ${String(outcome)}`);
|
||||||
|
assert.match(outcome.message, /Yomitan.*timed out/);
|
||||||
|
assert.deepEqual(events, ['checking', 'failed']);
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
readiness.resolve();
|
||||||
|
await runtime.runSyncNow();
|
||||||
|
assert.deepEqual(events, ['checking', 'failed', 'checking', 'ready']);
|
||||||
|
assert.deepEqual(calls, ['info', 'settings']);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(watchdog);
|
||||||
|
readiness.resolve();
|
||||||
|
await result;
|
||||||
|
fs.rmSync(userDataPath, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('auto sync scales the import timeout with the merged dictionary size', async () => {
|
test('auto sync scales the import timeout with the merged dictionary size', async () => {
|
||||||
const userDataPath = makeTempDir();
|
const userDataPath = makeTempDir();
|
||||||
const dictionariesDir = path.join(userDataPath, 'character-dictionaries');
|
const dictionariesDir = path.join(userDataPath, 'character-dictionaries');
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export interface CharacterDictionaryAutoSyncRuntimeDeps {
|
|||||||
progress?: CharacterDictionarySnapshotProgressCallbacks,
|
progress?: CharacterDictionarySnapshotProgressCallbacks,
|
||||||
) => Promise<CharacterDictionarySnapshotResult>;
|
) => Promise<CharacterDictionarySnapshotResult>;
|
||||||
buildMergedDictionary: (mediaIds: number[]) => Promise<MergedCharacterDictionaryBuildResult>;
|
buildMergedDictionary: (mediaIds: number[]) => Promise<MergedCharacterDictionaryBuildResult>;
|
||||||
|
/** Extension readiness only: dictionary sync must work before any subtitle is tokenized. */
|
||||||
waitForYomitanMutationReady?: () => Promise<void>;
|
waitForYomitanMutationReady?: () => Promise<void>;
|
||||||
getYomitanDictionaryInfo: () => Promise<AutoSyncDictionaryInfo[]>;
|
getYomitanDictionaryInfo: () => Promise<AutoSyncDictionaryInfo[]>;
|
||||||
importYomitanDictionary: (zipPath: string) => Promise<boolean>;
|
importYomitanDictionary: (zipPath: string) => Promise<boolean>;
|
||||||
@@ -639,7 +640,9 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
|||||||
mergedDictionaryTitle: merged?.dictionaryTitle ?? dictionaryTitle,
|
mergedDictionaryTitle: merged?.dictionaryTitle ?? dictionaryTitle,
|
||||||
});
|
});
|
||||||
|
|
||||||
await deps.waitForYomitanMutationReady?.();
|
if (deps.waitForYomitanMutationReady) {
|
||||||
|
await withOperationTimeout('Yomitan readiness', deps.waitForYomitanMutationReady());
|
||||||
|
}
|
||||||
|
|
||||||
const dictionaryInfo = await withOperationTimeout(
|
const dictionaryInfo = await withOperationTimeout(
|
||||||
'getYomitanDictionaryInfo',
|
'getYomitanDictionaryInfo',
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ function fixture(overrides: Partial<SubtitleGenerationRuntimeDeps> = {}) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const runtime = createSubtitleGenerationRuntime({
|
const runtime = createSubtitleGenerationRuntime({
|
||||||
|
session: { directory: async () => '/session', dispose: async () => {} },
|
||||||
getConfig: () => DEFAULT_SUBTITLE_GENERATION_CONFIG,
|
getConfig: () => DEFAULT_SUBTITLE_GENERATION_CONFIG,
|
||||||
getModelDirectory: () => '/models',
|
getModelDirectory: () => '/models',
|
||||||
getCacheDirectory: () => '/cache/generated-subtitles',
|
getCacheDirectory: () => '/cache/generated-subtitles',
|
||||||
@@ -60,6 +61,36 @@ test('generation uses the selected audio track and loads the timed SRT with zero
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('stream alternatives are snapshotted without changing the playback identity', async () => {
|
||||||
|
const url = 'http://127.0.0.1:7777/video/high';
|
||||||
|
const alternatives = [
|
||||||
|
{
|
||||||
|
kind: 'audio' as const,
|
||||||
|
url: 'http://127.0.0.1:7777/audio/ja',
|
||||||
|
label: 'Japanese audio',
|
||||||
|
httpHeaders: { headers: {}, userAgent: null },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const subject = fixture({
|
||||||
|
getAlternativeSources: (mediaPath) => {
|
||||||
|
assert.equal(mediaPath, url);
|
||||||
|
return alternatives;
|
||||||
|
},
|
||||||
|
generate: async (input) => {
|
||||||
|
assert.equal(input.mediaPath, url);
|
||||||
|
assert.deepEqual(input.remote?.alternatives, alternatives);
|
||||||
|
return '/cache/generated.srt';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const original = subject.client.requestProperty;
|
||||||
|
subject.client.requestProperty = async (name) => (name === 'path' ? url : original(name));
|
||||||
|
assert.equal((await subject.runtime.start()).ok, true);
|
||||||
|
assert.deepEqual(
|
||||||
|
subject.commands.map((command) => command[0]),
|
||||||
|
['sub-add', 'set_property'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('generation preserves the output without attaching it to a different video', async () => {
|
test('generation preserves the output without attaching it to a different video', async () => {
|
||||||
const subject = fixture({
|
const subject = fixture({
|
||||||
generate: async () => {
|
generate: async () => {
|
||||||
@@ -81,6 +112,7 @@ test('stream generation snapshots mpv headers and saves in the cache before load
|
|||||||
assert.equal(input.audioStreamIndex, 3);
|
assert.equal(input.audioStreamIndex, 3);
|
||||||
assert.deepEqual(input.remote, {
|
assert.deepEqual(input.remote, {
|
||||||
cacheDirectory: '/cache/generated-subtitles',
|
cacheDirectory: '/cache/generated-subtitles',
|
||||||
|
sessionDirectory: '/session',
|
||||||
httpHeaders: {
|
httpHeaders: {
|
||||||
headers: { Referer: 'https://anime.example/', 'X-Stream': 'episode' },
|
headers: { Referer: 'https://anime.example/', 'X-Stream': 'episode' },
|
||||||
userAgent: 'Anime Player',
|
userAgent: 'Anime Player',
|
||||||
@@ -224,6 +256,45 @@ test('external audio cannot silently generate from a different internal track',
|
|||||||
assert.match(result.message, /audio track inside/);
|
assert.match(result.message, /audio track inside/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a selected HTTP audio track keeps its own stream index, headers and playback delay', async () => {
|
||||||
|
const mediaPath = 'https://example.test/video';
|
||||||
|
const audioUrl = 'https://example.test/japanese.m4a';
|
||||||
|
const httpHeaders = { headers: { Referer: 'https://audio.example/' }, userAgent: null };
|
||||||
|
const subject = fixture({
|
||||||
|
getAlternativeSources: () => [
|
||||||
|
{ kind: 'audio', url: audioUrl, label: 'Japanese audio', httpHeaders },
|
||||||
|
],
|
||||||
|
generate: async (input) => {
|
||||||
|
assert.equal(input.mediaPath, mediaPath);
|
||||||
|
assert.equal(input.audioStreamIndex, undefined);
|
||||||
|
assert.deepEqual(input.remote?.selectedAudio, {
|
||||||
|
url: audioUrl,
|
||||||
|
audioStreamIndex: 0,
|
||||||
|
delaySeconds: 0.25,
|
||||||
|
httpHeaders,
|
||||||
|
});
|
||||||
|
return '/cache/generated.srt';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
subject.client.requestProperty = async (name) => {
|
||||||
|
if (name === 'path') return mediaPath;
|
||||||
|
if (name === 'audio-delay') return 0.25;
|
||||||
|
if (name === 'track-list')
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'audio',
|
||||||
|
selected: true,
|
||||||
|
external: true,
|
||||||
|
'external-filename': audioUrl,
|
||||||
|
'ff-index': 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
assert.equal((await subject.runtime.start()).ok, true);
|
||||||
|
assert.equal(subject.commands[0]?.[0], 'sub-add');
|
||||||
|
});
|
||||||
|
|
||||||
test('model selection is retained and used for status, download, and generation', async () => {
|
test('model selection is retained and used for status, download, and generation', async () => {
|
||||||
const seen: string[] = [];
|
const seen: string[] = [];
|
||||||
const { runtime } = fixture({
|
const { runtime } = fixture({
|
||||||
@@ -375,3 +446,42 @@ test('speech model downloads share the job lock and cancellation', async () => {
|
|||||||
runtime.cancel();
|
runtime.cancel();
|
||||||
assert.deepEqual(await download, { ok: false, message: 'Cancelled.' });
|
assert.deepEqual(await download, { ok: false, message: 'Cancelled.' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('shutdown cancels and drains the active job before deleting session files', async () => {
|
||||||
|
const events: string[] = [];
|
||||||
|
let started = () => {};
|
||||||
|
const ready = new Promise<void>((resolve) => {
|
||||||
|
started = resolve;
|
||||||
|
});
|
||||||
|
const { runtime } = fixture({
|
||||||
|
session: {
|
||||||
|
directory: async () => '/session',
|
||||||
|
dispose: async () => {
|
||||||
|
events.push('disposed');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
generate: async (input) => {
|
||||||
|
started();
|
||||||
|
await new Promise<void>((resolve) =>
|
||||||
|
input.signal?.addEventListener(
|
||||||
|
'abort',
|
||||||
|
() => {
|
||||||
|
events.push('aborted');
|
||||||
|
setImmediate(() => {
|
||||||
|
events.push('job-cleaned');
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
throw new Error('cancelled');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const job = runtime.start();
|
||||||
|
await ready;
|
||||||
|
await runtime.dispose();
|
||||||
|
assert.equal((await job).ok, false);
|
||||||
|
assert.deepEqual(events, ['aborted', 'job-cleaned', 'disposed']);
|
||||||
|
assert.equal((await runtime.start()).ok, false);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { createSubtitleGenerationSession } from '../../core/services/subtitle-generation-session';
|
||||||
|
import type {
|
||||||
|
SubtitleGenerationAlternative,
|
||||||
|
SubtitleGenerationRemoteSource,
|
||||||
|
} from '../../core/services/subtitle-generation-source';
|
||||||
import { resolveMpvHttpHeaders } from '../../core/services/mpv-http-headers';
|
import { resolveMpvHttpHeaders } from '../../core/services/mpv-http-headers';
|
||||||
import { readSubtitleGenerationReferences } from '../../core/services/subtitle-generation-reference';
|
import { readSubtitleGenerationReferences } from '../../core/services/subtitle-generation-reference';
|
||||||
import { detectSubtitleGenerationAcceleration } from '../../core/services/subtitle-generation-acceleration';
|
import { detectSubtitleGenerationAcceleration } from '../../core/services/subtitle-generation-acceleration';
|
||||||
@@ -30,10 +35,12 @@ interface GenerationMpvClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SubtitleGenerationRuntimeDeps {
|
export interface SubtitleGenerationRuntimeDeps {
|
||||||
|
session?: ReturnType<typeof createSubtitleGenerationSession>;
|
||||||
getConfig: () => SubtitleGenerationConfig;
|
getConfig: () => SubtitleGenerationConfig;
|
||||||
getModelDirectory: () => string;
|
getModelDirectory: () => string;
|
||||||
getCacheDirectory: () => string;
|
getCacheDirectory: () => string;
|
||||||
getMpvClient: () => GenerationMpvClient | null;
|
getMpvClient: () => GenerationMpvClient | null;
|
||||||
|
getAlternativeSources?: (mediaPath: string) => readonly SubtitleGenerationAlternative[];
|
||||||
onProgress: (progress: SubtitleGenerationProgress) => void;
|
onProgress: (progress: SubtitleGenerationProgress) => void;
|
||||||
generate?: typeof generateJapaneseSubtitles;
|
generate?: typeof generateJapaneseSubtitles;
|
||||||
download?: typeof downloadSubtitleGenerationModel;
|
download?: typeof downloadSubtitleGenerationModel;
|
||||||
@@ -55,7 +62,9 @@ async function currentMedia(client: GenerationMpvClient | null): Promise<string
|
|||||||
return typeof directory === 'string' ? path.resolve(directory, media) : null;
|
return typeof directory === 'string' ? path.resolve(directory, media) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedAudioIndex(tracks: unknown): number {
|
function selectedAudio(
|
||||||
|
tracks: unknown,
|
||||||
|
): { kind: 'internal'; index: number } | { kind: 'external'; url: string; index?: number } {
|
||||||
if (!Array.isArray(tracks)) throw new Error('Unable to inspect the selected audio track.');
|
if (!Array.isArray(tracks)) throw new Error('Unable to inspect the selected audio track.');
|
||||||
for (const track of tracks) {
|
for (const track of tracks) {
|
||||||
if (
|
if (
|
||||||
@@ -67,23 +76,42 @@ function selectedAudioIndex(tracks: unknown): number {
|
|||||||
track.selected !== true
|
track.selected !== true
|
||||||
)
|
)
|
||||||
continue;
|
continue;
|
||||||
if ('external' in track && track.external === true)
|
if ('external' in track && track.external === true) {
|
||||||
throw new Error(
|
if (
|
||||||
'Select an audio track inside the video or stream before generating subtitles.',
|
!('external-filename' in track) ||
|
||||||
);
|
typeof track['external-filename'] !== 'string' ||
|
||||||
|
!/^https?:\/\//i.test(track['external-filename'])
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
'Select an audio track inside the video or an HTTP audio stream before generating subtitles.',
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
kind: 'external',
|
||||||
|
url: track['external-filename'],
|
||||||
|
...('ff-index' in track &&
|
||||||
|
typeof track['ff-index'] === 'number' &&
|
||||||
|
Number.isInteger(track['ff-index']) &&
|
||||||
|
track['ff-index'] >= 0
|
||||||
|
? { index: track['ff-index'] }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
'ff-index' in track &&
|
'ff-index' in track &&
|
||||||
typeof track['ff-index'] === 'number' &&
|
typeof track['ff-index'] === 'number' &&
|
||||||
Number.isInteger(track['ff-index']) &&
|
Number.isInteger(track['ff-index']) &&
|
||||||
track['ff-index'] >= 0
|
track['ff-index'] >= 0
|
||||||
)
|
)
|
||||||
return track['ff-index'];
|
return { kind: 'internal', index: track['ff-index'] };
|
||||||
throw new Error('The selected audio track has no FFmpeg stream index.');
|
throw new Error('The selected audio track has no FFmpeg stream index.');
|
||||||
}
|
}
|
||||||
throw new Error('Select an audio track in mpv before generating subtitles.');
|
throw new Error('Select an audio track in mpv before generating subtitles.');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeDeps) {
|
export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeDeps) {
|
||||||
|
const session = deps.session ?? createSubtitleGenerationSession(deps.getCacheDirectory());
|
||||||
|
let closed = false;
|
||||||
|
let finished: Promise<void> = Promise.resolve();
|
||||||
let controller: AbortController | null = null;
|
let controller: AbortController | null = null;
|
||||||
let progress: SubtitleGenerationProgress | null = null;
|
let progress: SubtitleGenerationProgress | null = null;
|
||||||
let lastResult: SubtitleGenerationResult | null = null;
|
let lastResult: SubtitleGenerationResult | null = null;
|
||||||
@@ -118,8 +146,13 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
|
|||||||
async function run(
|
async function run(
|
||||||
operation: (signal: AbortSignal) => Promise<SubtitleGenerationResult>,
|
operation: (signal: AbortSignal) => Promise<SubtitleGenerationResult>,
|
||||||
): Promise<SubtitleGenerationResult> {
|
): Promise<SubtitleGenerationResult> {
|
||||||
|
if (closed) return { ok: false, message: 'Subtitle generation session has closed.' };
|
||||||
if (controller)
|
if (controller)
|
||||||
return { ok: false, message: 'A subtitle generation or model download is already running.' };
|
return { ok: false, message: 'A subtitle generation or model download is already running.' };
|
||||||
|
let resolveFinished = () => {};
|
||||||
|
finished = new Promise<void>((resolve) => {
|
||||||
|
resolveFinished = resolve;
|
||||||
|
});
|
||||||
const active = new AbortController();
|
const active = new AbortController();
|
||||||
controller = active;
|
controller = active;
|
||||||
progress = null;
|
progress = null;
|
||||||
@@ -137,6 +170,7 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
|
|||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
controller = null;
|
controller = null;
|
||||||
|
resolveFinished();
|
||||||
}
|
}
|
||||||
return lastResult;
|
return lastResult;
|
||||||
}
|
}
|
||||||
@@ -185,6 +219,13 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
getStatus,
|
getStatus,
|
||||||
|
initialize: () => session.directory(),
|
||||||
|
async dispose(): Promise<void> {
|
||||||
|
closed = true;
|
||||||
|
controller?.abort();
|
||||||
|
await finished;
|
||||||
|
await session.dispose();
|
||||||
|
},
|
||||||
async setVadEnabled(enabled: boolean): Promise<SubtitleGenerationStatus> {
|
async setVadEnabled(enabled: boolean): Promise<SubtitleGenerationStatus> {
|
||||||
if (controller)
|
if (controller)
|
||||||
throw new Error('Wait for the current operation before changing speech detection.');
|
throw new Error('Wait for the current operation before changing speech detection.');
|
||||||
@@ -246,13 +287,32 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
|
|||||||
if (!client || !mediaPath)
|
if (!client || !mediaPath)
|
||||||
throw new Error('Open a local media file or HTTP stream in mpv first.');
|
throw new Error('Open a local media file or HTTP stream in mpv first.');
|
||||||
const tracks = await client.requestProperty('track-list');
|
const tracks = await client.requestProperty('track-list');
|
||||||
const audioStreamIndex = selectedAudioIndex(tracks);
|
const audio = selectedAudio(tracks);
|
||||||
const remote = /^https?:\/\//i.test(mediaPath)
|
const audioStreamIndex = audio.kind === 'internal' ? audio.index : undefined;
|
||||||
|
if (audio.kind === 'external' && !/^https?:\/\//i.test(mediaPath))
|
||||||
|
throw new Error('External audio generation requires an HTTP episode stream.');
|
||||||
|
const remote: SubtitleGenerationRemoteSource | undefined = /^https?:\/\//i.test(mediaPath)
|
||||||
? {
|
? {
|
||||||
httpHeaders: await resolveMpvHttpHeaders(client),
|
httpHeaders: await resolveMpvHttpHeaders(client),
|
||||||
cacheDirectory: deps.getCacheDirectory(),
|
cacheDirectory: deps.getCacheDirectory(),
|
||||||
|
sessionDirectory: await session.directory(),
|
||||||
|
...(deps.getAlternativeSources
|
||||||
|
? { alternatives: deps.getAlternativeSources(mediaPath) }
|
||||||
|
: {}),
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
if (audio.kind === 'external' && remote) {
|
||||||
|
const delay = await client.requestProperty('audio-delay');
|
||||||
|
if (typeof delay !== 'number' || !Number.isFinite(delay))
|
||||||
|
throw new Error('Unable to inspect the selected audio delay.');
|
||||||
|
const matched = remote.alternatives?.find((candidate) => candidate.url === audio.url);
|
||||||
|
remote.selectedAudio = {
|
||||||
|
url: audio.url,
|
||||||
|
audioStreamIndex: audio.index,
|
||||||
|
delaySeconds: delay,
|
||||||
|
httpHeaders: matched?.httpHeaders ?? remote.httpHeaders,
|
||||||
|
};
|
||||||
|
}
|
||||||
const references = await readSubtitleGenerationReferences(tracks, (name) =>
|
const references = await readSubtitleGenerationReferences(tracks, (name) =>
|
||||||
client.requestProperty(name),
|
client.requestProperty(name),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user