Compare commits

...
53 changed files with 1559 additions and 106 deletions
+7
View File
@@ -0,0 +1,7 @@
type: fixed
area: jellyfin
- Set the mpv title before loading Jellyfin streams and reject URL-derived titles from metadata lookups, Anki source fields, Discord presence, and stats.
- Keep authenticated stream URLs out of stats identities even when playback metadata has not arrived.
- Remove previously cached credential-bearing Jellyfin stream parser metadata without changing unrelated anime metadata, watch history, or library assignments.
- Use safe media identities for persisted AniList retry keys, discard URL-derived queued searches, and skip updates and retries whose media identity is empty.
@@ -2,3 +2,4 @@ type: docs
area: subtitles
- Explain how dialogue generation retains uncertain audible sections, why songs may also be transcribed, how detected speech starts guide long-passage cuts to reduce early subtitles, and why each passage uses a fresh Whisper process.
- Document automatic loaded subtitle references, English dialogue preference, signs/songs filtering, fallback behavior, and the limits of reference-guided timing.
+2
View File
@@ -4,3 +4,5 @@ area: subtitles
- Generate local Japanese SRT subtitles with whisper.cpp from a standalone modal opened with Ctrl+Shift+G, the empty subtitle sidebar's generation button, or `subminer generate-subs`, with shared progress reporting, cancellation, safe output files, and automatic loading into the matching mpv video. The sidebar button hides while subtitle lines are loaded.
- Configure an existing multilingual model in Settings or choose an official multilingual model, including quantized variants, in the modal or launcher. The modal shows download sizes, speed and accuracy guidance, and a recommended starting model before explicitly downloading a verified SubMiner-managed model. Executable paths are optional overrides; empty fields find whisper-cli, ffmpeg, and ffprobe on PATH. The modal's Local tools check and the launcher name any missing executable and its setting before downloading a model or extracting audio, and generation confirms the destination directory grants write and search permissions up front.
- Optionally select Focus on spoken dialogue in the modal and use Download speech detection model to install the separate Silero model with progress and cancellation. The choice lasts for the session; a configured VAD model path sets the default. Retain uncertain audible sections so VAD rejection does not discard dialogue under music, accepting that songs may also be transcribed. Keep passages intact within Whisper's audio window, split longer passages near detected speech starts or quiet pauses with overlapping context to reduce early subtitle timing, and combine overlapping duplicate cues even when punctuation differs. Run each passage in a fresh Whisper process to prevent repeated-character output caused by state carried between files, at the cost of reloading the model per passage. Preserve original media timing and separate repeated dialogue.
- Recommend large-v3-turbo in the model picker when NVIDIA hardware and CUDA device support in the selected Whisper executable are confirmed; recommend small otherwise. Preserve configured and session model selections.
- Automatically use an eligible embedded or external subtitle track loaded in mpv to guide long-passage cuts, preferring English dialogue and excluding tracks marked signs, songs, forced, or generated. Honor active subtitle delays, retain unreferenced audio, and fall back to audio timing when references cannot be read. Available in the overlay and launcher, with or without VAD. The launcher captures audio selection, reference tracks, and delays before model setup so playback changes during a download do not mix inputs from different times, and rejects an unverified media snapshot before using mpv's selected audio stream.
+5
View File
@@ -0,0 +1,5 @@
type: docs
area: jellyfin
- Explain how Jellyfin media titles, unknown-title fallbacks, and stats identities keep stream credentials out of metadata.
- Document AniList rejection of empty media identities and the conditions for clearing cached Jellyfin parser metadata.
+2
View File
@@ -47,6 +47,8 @@ The update flow:
Failed AniList updates are persisted to a retry queue on disk and retried with exponential backoff.
Updates are skipped if the media path cannot produce a safe, nonempty identity. Invalid entries are discarded when loading or adding to the retry queue.
| Parameter | Value |
| ---------------- | ---------- |
| Initial backoff | 30 seconds |
+3
View File
@@ -52,10 +52,13 @@ From then on, pause / resume / seek / stop and audio or subtitle track changes y
- **mpv launches automatically.** If mpv isn't already running when you cast, SubMiner starts it with SubMiner defaults and the bundled mpv plugin, so keybindings work right away.
- **The overlay is managed by SubMiner,** so your configured `subtitleStyle` controls how subtitles look. Use the [overlay-toggle shortcut](/shortcuts) to hide it for a session.
- **Resume works.** If Jellyfin has a saved position for the item, SubMiner seeks there on load.
- **Titles and credentials stay separate.** AniList, character dictionaries, Anki source fields, and Discord presence use media titles, never authenticated stream URLs. If a usable title is unavailable, lookups are skipped and source fields show an unknown-media label. Stats identifies Jellyfin videos by server and item ID without the stream URL or API key.
- **Direct play first.** When the source allows it and the container is in your direct-play allowlist, SubMiner streams the original file; otherwise it requests a transcoded stream from Jellyfin.
- **Japanese subtitles are auto-selected,** preferring Jellyfin's default and embedded tracks over external sidecar files when several match.
- **Downloaded subtitles keep their original timing.** SubMiner removes Jellyfin's server-selected subtitle stream from the mpv load URL, suppresses the mpv plugin's one-shot subtitle auto-selection and overlay auto-start for managed Jellyfin loads, stages the subtitle files exposed by Jellyfin without letting mpv auto-switch between tracks, resets mpv's subtitle delay to zero, then selects the Japanese track. SubMiner does not compare Japanese and English cue timelines or save an inferred delay.
On startup, SubMiner clears cached anime parser metadata containing both API-key text and Jellyfin stream markers. Metadata containing only one of these is preserved.
## Settings
All Jellyfin options live under **Settings → Integrations → Jellyfin** (open settings from the tray's **Open SubMiner Settings**). The ones that matter for casting:
+12 -2
View File
@@ -29,11 +29,21 @@ With speech detection configured, SubMiner keeps detected speech and other audib
Passages that fit within Whisper's 30-second audio window stay intact. Longer passages prefer nearby detected speech starts when choosing cuts, falling back to quiet pauses, with a small overlap to provide context. This reduces early subtitles caused by starting a clip well before its dialogue, while keeping all retained audio covered. Matching overlapping cues are combined even when punctuation differs; repeated dialogue at separate times remains separate. SubMiner runs each passage in a fresh Whisper process so decoder state from earlier audio cannot affect later passages. This reloads the model for each passage and can increase generation time. Subtitle cues stay within the supplied audio and retain each passage's position on the original timeline. Progress reports the current passage.
This mode favors retaining dialogue over excluding music, so songs and background sounds may also produce subtitles. It can take longer than transcribing only VAD-approved speech. Whisper can still miss or misrecognize dialogue, and its timestamps remain estimates. Uncheck **Focus on spoken dialogue** to return to full-audio transcription for the session, or clear `vadModelPath` to change the default. A selected detector or model that fails stops generation with an error. Existing subtitles are preserved.
This mode favors retaining dialogue over excluding music, so songs and background sounds may also produce subtitles. It can take longer than transcribing only VAD-approved speech. Whisper can still miss or misrecognize dialogue, and its timestamps remain estimates. Uncheck **Focus on spoken dialogue** to disable VAD for the session, or clear `vadModelPath` to change the default. Without a usable subtitle reference, disabling VAD returns to full-audio transcription. A selected detector or model that fails stops generation with an error. Existing subtitles are preserved.
## Using loaded subtitles as timing references
When generating for the video currently open in mpv, SubMiner automatically looks for a dialogue subtitle track among its embedded subtitles and loaded external SRT, ASS/SSA, or WebVTT files. It prefers English, then tracks labeled full or dialogue. Forced tracks, image subtitles, generated subtitles, and tracks whose titles or filenames identify signs, songs, lyrics, karaoke, or opening/ending subtitles are skipped. These checks rely on metadata; an unlabeled signs-only file cannot always be identified.
The selected reference appears in generation progress. SubMiner reads its timestamps, including the active primary or secondary track's subtitle delay, and uses nearby cue starts to guide cuts in long audio passages. Short passages stay intact. The reference works with or without **Focus on spoken dialogue**. With that option enabled, reference starts take priority over VAD starts when choosing a nearby cut; VAD still helps identify speech. Audio outside reference cues remains eligible for transcription, and Whisper still supplies the Japanese text and final timestamps. The reference is assumed to be timed for the playing video; this does not automatically sync a mistimed reference.
Unreadable or empty references are skipped in favor of another eligible loaded track. If none can be read, generation uses its normal audio timing. The launcher uses loaded references only when its input matches the video currently open in mpv; standalone generation keeps its existing behavior. It captures reference tracks and delays together with the initial audio selection, before checking or downloading a model. When relying on mpv's selected audio, it stops and asks you to retry if the media changes or cannot be verified during capture.
## Choosing a model
Start with **small** for a balance of Japanese recognition quality and CPU time. This is a general starting recommendation, not a benchmark for your hardware. Tiny and base need less memory and usually finish sooner, with more recognition errors. Medium and large models favor accuracy but need more resources. Large-v3-turbo is optimized for speed compared with large-v3, with some accuracy tradeoff; actual performance depends on your CPU, GPU, whisper.cpp build, and audio.
The modal recommends **large-v3-turbo** when it detects an NVIDIA GPU through `nvidia-smi` and the selected `whisper-cli` discovers an available CUDA device. Otherwise it recommends **small** for a balance of Japanese recognition quality and CPU time. The check works before downloading a model and falls back to small if a tool is missing, fails, times out, or reports an unrecognized result. Vulkan, AMD, and Apple GPU support do not qualify for the turbo recommendation. Checks are cached for up to 30 seconds; changing the Whisper executable path triggers a new check.
The recommendation labels the model picker and explains the detected support. It does not change your configured model, current selection, external model path, or launcher's model choice. It also does not force a GPU backend during transcription. These are starting recommendations, not hardware benchmarks or guarantees that every model fits in available GPU memory. Tiny and base need less memory and usually finish sooner, with more recognition errors. Medium and large models favor accuracy but need more resources. Large-v3-turbo is optimized for speed compared with large-v3, with some accuracy tradeoff; actual performance depends on your CPU, GPU, whisper.cpp build, and audio.
The picker includes whisper.cpp's official multilingual tiny, base, small, medium, large-v1, large-v2, large-v3, and large-v3-turbo downloads, including their available quantized variants. Quantized models use less disk space and memory, with possible accuracy loss. English-only `.en` models are excluded. See the [upstream model list](https://github.com/ggml-org/whisper.cpp/blob/master/models/download-ggml-model.sh) and [Whisper's model guidance](https://github.com/openai/whisper#available-models-and-languages).
+4
View File
@@ -92,6 +92,10 @@ delay. It also works with no running
SubMiner app or mpv instance when you provide a file path. Omit the path to use the current
mpv file and selected audio track.
When the input matches the current mpv file, generation automatically uses an eligible embedded
or loaded external subtitle track as a timing reference, preferring English dialogue and skipping
tracks marked as signs, songs, or forced. See [timing references](/subtitle-generation#using-loaded-subtitles-as-timing-references).
```bash
subminer generate-subs video.mkv --download-model
subminer generate-subs video.mkv --model-path ~/models/ggml-medium.bin
+2
View File
@@ -32,6 +32,8 @@ 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/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. 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 local 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-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/config/` owns config definitions, defaults, loading, and resolution.
- `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel.
@@ -136,25 +136,28 @@ test('current mpv generation requires an identifiable selected audio track', asy
}
});
test('explicit local file leaves Japanese track selection to the shared generator', async () => {
test('explicit playing file leaves audio selection to the generator but inspects subtitle references', async () => {
const f = fixture();
await runGenerateSubtitlesCommand(f.context, f.deps);
assert.equal(f.generations[0]?.audioStreamIndex, undefined);
assert.equal(
f.commands.some((command) => command[1] === 'track-list'),
false,
true,
);
});
test('launcher does not load generated subtitles after mpv switches files', async () => {
const f = fixture(['generate-subs']);
let pathRequests = 0;
let changed = false;
f.deps.mpvCommand = async (_socket, command) => {
f.commands.push(command);
if (command[1] === 'path')
return ++pathRequests === 1 ? '/media/episode.mkv' : '/media/next.mkv';
if (command[1] === 'path') return changed ? '/media/next.mkv' : '/media/episode.mkv';
return [{ type: 'audio', selected: true, 'ff-index': 2 }];
};
f.deps.generate = async () => {
changed = true;
return '/media/episode.ja.srt';
};
await runGenerateSubtitlesCommand(f.context, f.deps);
assert.equal(
f.commands.some((command) => command[0] === 'sub-add'),
@@ -163,6 +166,86 @@ test('launcher does not load generated subtitles after mpv switches files', asyn
assert.match(f.output.join(''), /Saved Japanese subtitles/);
});
test('current mpv generation rejects tracks when playback changes during capture', async () => {
for (const nextMedia of ['/media/next.mkv', null]) {
const f = fixture(['generate-subs']);
let media: string | null = '/media/episode.mkv';
let modelChecks = 0;
f.deps.mpvCommand = async (_socket, command) => {
if (command[1] === 'path') return media;
if (command[1] === 'track-list') {
media = nextMedia;
return [{ type: 'audio', selected: true, 'ff-index': 99 }];
}
return undefined;
};
f.deps.resolveModel = async () => {
modelChecks += 1;
return { kind: 'external', path: '/models/model.bin' };
};
await assert.rejects(
runGenerateSubtitlesCommand(f.context, f.deps),
/mpv media.*Run generate-subs again/,
);
assert.equal(f.generations.length, 0);
assert.equal(modelChecks, 0);
}
});
test('explicit media or audio stream remains usable when the mpv snapshot changes', async () => {
for (const options of [['/media/episode.mkv'], ['--audio-stream', '7']]) {
const f = fixture(['generate-subs', ...options]);
let media = '/media/episode.mkv';
f.deps.mpvCommand = async (_socket, command) => {
if (command[1] === 'path') return media;
if (command[1] === 'track-list') {
media = '/media/next.mkv';
return [
{ type: 'audio', selected: true, 'ff-index': 99 },
{ type: 'sub', lang: 'eng', 'ff-index': 100 },
];
}
return undefined;
};
await runGenerateSubtitlesCommand(f.context, f.deps);
assert.equal(f.generations[0]?.mediaPath, '/media/episode.mkv');
assert.equal(
f.generations[0]?.audioStreamIndex,
options[0] === '--audio-stream' ? 7 : undefined,
);
assert.deepEqual(f.generations[0]?.references, []);
}
});
test('launcher captures loaded external references only for the matching media', async () => {
for (const matching of [true, false]) {
const f = fixture();
f.deps.mpvCommand = async (_socket, command) => {
if (command[1] === 'path') return matching ? '/media/episode.mkv' : '/media/other.mkv';
if (command[1] === 'working-directory') return '/mpv';
if (command[1] === 'track-list')
return [
{ type: 'sub', external: true, 'external-filename': 'episode.en.signs.ass' },
{ type: 'sub', external: true, 'external-filename': 'episode.en.srt' },
];
return undefined;
};
await runGenerateSubtitlesCommand(f.context, f.deps);
assert.deepEqual(
f.generations[0]?.references,
matching
? [
{
label: 'episode.en.srt',
delaySeconds: 0,
source: { kind: 'external', path: '/mpv/episode.en.srt' },
},
]
: [],
);
}
});
test('explicit managed model overrides external config and downloads before generation', async () => {
const f = fixture([
'generate-subs',
@@ -191,6 +274,69 @@ test('explicit managed model overrides external config and downloads before gene
assert.equal(f.generations.length, 1);
});
test('audio and subtitle timing use the initial mpv snapshot across model setup', async () => {
for (const changeDuring of ['resolve', 'download']) {
const f = fixture(['generate-subs', '--download-model']);
let changed = false;
let trackReads = 0;
f.deps.mpvCommand = async (_socket, command) => {
if (command[1] === 'path') return '/media/episode.mkv';
if (command[1] === 'track-list') {
trackReads += 1;
return [
{ type: 'audio', selected: true, 'ff-index': changed ? 3 : 2 },
{ type: 'sub', id: 1, title: 'English Full', lang: 'eng', 'ff-index': changed ? 5 : 4 },
];
}
if (command[1] === 'sid') return 1;
if (command[1] === 'sub-delay') return changed ? 9 : 1.5;
return undefined;
};
f.deps.resolveModel = async () => {
if (changeDuring === 'resolve') changed = true;
return { kind: 'missing', path: '/models/model.bin' };
};
f.deps.downloadModel = async () => {
changed = true;
return '/models/model.bin';
};
await runGenerateSubtitlesCommand(f.context, f.deps);
assert.equal(f.generations[0]?.audioStreamIndex, 2);
assert.deepEqual(f.generations[0]?.references, [
{
label: 'English Full',
delaySeconds: 1.5,
source: { kind: 'embedded', streamIndex: 4 },
},
]);
assert.equal(trackReads, 1);
}
});
test('explicit audio stream bypasses mpv audio selection while retaining subtitle references', async () => {
const f = fixture(['generate-subs', '--audio-stream', '7']);
f.deps.mpvCommand = async (_socket, command) => {
if (command[1] === 'path') return '/media/episode.mkv';
if (command[1] === 'track-list')
return [
{ type: 'audio', selected: true, external: true, 'ff-index': 0 },
{ type: 'sub', id: 2, title: 'English Full', lang: 'eng', 'ff-index': 4 },
];
if (command[1] === 'secondary-sid') return 2;
if (command[1] === 'secondary-sub-delay') return -0.5;
return undefined;
};
await runGenerateSubtitlesCommand(f.context, f.deps);
assert.equal(f.generations[0]?.audioStreamIndex, 7);
assert.deepEqual(f.generations[0]?.references, [
{
label: 'English Full',
delaySeconds: -0.5,
source: { kind: 'embedded', streamIndex: 4 },
},
]);
});
test('generation can run standalone and never loads subtitles into another video', async () => {
for (const playing of [null, '/media/different.mkv']) {
const f = fixture();
+30 -10
View File
@@ -8,6 +8,10 @@ import {
resolveSubtitleGenerationTools,
} from '../../src/core/services/subtitle-generation.js';
import { requireSubtitleGenerationTools } from '../../src/core/services/subtitle-generation-tools.js';
import {
readSubtitleGenerationReferences,
type SubtitleGenerationReference,
} from '../../src/core/services/subtitle-generation-reference.js';
import {
resolveSubtitleGenerationConfig,
type SubtitleGenerationProgress,
@@ -69,12 +73,9 @@ async function readMpvMedia(socketPath: string, command: GenerationCommandDeps['
return localMediaPath(media, workingDirectory);
}
async function readMpvAudioStream(
socketPath: string,
command: GenerationCommandDeps['mpvCommand'],
) {
const tracks = await command(socketPath, ['get_property', 'track-list'], 1000);
for (const track of Array.isArray(tracks) ? tracks : []) {
function selectedMpvAudioStream(value: unknown) {
const tracks: unknown[] = Array.isArray(value) ? value : [];
for (const track of tracks) {
if (
typeof track === 'object' &&
track !== null &&
@@ -160,11 +161,29 @@ export async function runGenerateSubtitlesCommand(
const mediaPath = options.mediaPath ? localMediaPath(options.mediaPath) : currentMedia;
if (!mediaPath)
throw new Error('Pass a local video file or open one in mpv before running generate-subs.');
// Capture audio and subtitle timing together before model setup can yield to playback changes.
const matchesCurrentMedia = currentMedia !== null && sameFile(currentMedia, mediaPath);
const tracks = matchesCurrentMedia
? await deps
.mpvCommand(context.mpvSocketPath, ['get_property', 'track-list'], 1000)
.catch(() => null)
: null;
let references: SubtitleGenerationReference[] = [];
if (matchesCurrentMedia) {
const candidates = await readSubtitleGenerationReferences(tracks, (name) =>
deps.mpvCommand(context.mpvSocketPath, ['get_property', name], 1000),
);
const stillPlaying = await readMpvMedia(context.mpvSocketPath, deps.mpvCommand).catch(
() => null,
);
if (stillPlaying && sameFile(stillPlaying, mediaPath)) references = candidates;
else if (!options.mediaPath && options.audioStreamIndex === undefined)
throw new Error(
'The current mpv media changed or could not be verified while reading tracks. Run generate-subs again.',
);
}
const audioStreamIndex =
options.audioStreamIndex ??
(!options.mediaPath
? await readMpvAudioStream(context.mpvSocketPath, deps.mpvCommand)
: undefined);
options.audioStreamIndex ?? (!options.mediaPath ? selectedMpvAudioStream(tracks) : undefined);
const onProgress = createGenerationProgressReporter(write);
// Missing executables fail here, before any model download starts.
requireSubtitleGenerationTools(await deps.resolveTools(config));
@@ -183,6 +202,7 @@ export async function runGenerateSubtitlesCommand(
modelDirectory,
mediaPath,
audioStreamIndex,
references,
outputPath: options.outputPath
? path.resolve(resolvePathMaybe(options.outputPath))
: undefined,
+18
View File
@@ -1527,3 +1527,21 @@ test('AnkiIntegration.formatMiscInfoPattern avoids leaking Jellyfin api_key quer
assert.equal(result, '[SubMiner] [Jellyfin/direct] Bocchi the Rock! - S01E02 (00:07:06)');
assert.equal(result.includes('api_key='), false);
});
test('Anki metadata rejects a credential-bearing media title before metadata arrives', () => {
const integration = new AnkiIntegration(
{ metadata: { pattern: '[SubMiner] %f | %F (%t)' } } as never,
{} as never,
{
currentVideoPath: 'https://jellyfin.example/Videos/item/stream?api_key=test-secret',
currentMediaTitle: 'stream?static=true&api_key=test-secret',
currentTimePos: 426,
send: () => true,
} as never,
);
const privateApi = integration as unknown as {
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
};
const result = privateApi.formatMiscInfoPattern('stream?api_key=test-secret', 426);
assert.equal(result, '[SubMiner] Unknown media | Unknown media (00:07:06)');
});
+6 -3
View File
@@ -32,6 +32,7 @@ import {
type MediaTimingReviewRequest,
} from './types/anki';
import { AiConfig } from './types/integrations';
import { sanitizeMediaTitle } from './shared/media-identity';
import type { KnownWordMaturityTier } from './types/subtitle';
import { MpvClient } from './types/runtime';
import { OPEN_ANKI_CARD_ACTION_ID } from './types/notification';
@@ -1165,11 +1166,13 @@ export class AnkiIntegration {
}
const videoFilename = extractFilenameFromMediaPath(mediaPath);
const resolvedMediaTitle = trimToNonEmptyString(mediaTitle);
const resolvedMediaTitle = sanitizeMediaTitle(mediaTitle);
const filenameWithExt =
(shouldPreferMediaTitleForMiscInfo(mediaPath, videoFilename)
? resolvedMediaTitle || videoFilename
: videoFilename || resolvedMediaTitle) || fallbackFilename;
? resolvedMediaTitle || 'Unknown media'
: sanitizeMediaTitle(videoFilename) || resolvedMediaTitle) ||
sanitizeMediaTitle(fallbackFilename) ||
'Unknown media';
const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, '');
const currentTimePos =
@@ -27,6 +27,83 @@ function createLogger() {
};
}
test('anilist retry queue migrates stream keys and rejects URL-derived searches', () => {
const queueFile = createTempQueueFile();
const loggerState = createLogger();
const key = 'https://example.com/Videos/item/stream?api_key=test-secret::2';
fs.writeFileSync(
queueFile,
JSON.stringify({
pending: [
{
key,
title: 'My Anime',
episode: 2,
createdAt: 1,
attemptCount: 0,
nextAttemptAt: 1,
lastError: null,
},
],
deadLetter: [],
}),
);
const queue = createAnilistUpdateQueue(queueFile, loggerState.logger);
assert.equal(queue.nextReady()?.key, 'jellyfin://example.com/item/item::2');
assert.equal(fs.readFileSync(queueFile, 'utf8').includes('test-secret'), false);
queue.enqueue('unsafe', 'stream?api_key=test-secret', 3);
assert.equal(queue.getSnapshot().pending, 1);
queue.markSuccess(key);
assert.equal(queue.getSnapshot().pending, 0);
});
test('anilist retry queue discards empty normalized identities on load and enqueue', () => {
const queueFile = createTempQueueFile();
const loggerState = createLogger();
const invalidKeys = [
'',
' ',
'::3',
'stream?api_key=secret::3',
'stream%3Fapi_key%3Dsecret::3',
'https://[invalid::3',
];
const item = {
title: 'My Anime',
episode: 3,
createdAt: 1,
attemptCount: 0,
nextAttemptAt: 1,
lastError: null,
};
const validKey = 'https://example.com/Videos/item/stream?api_key=secret::3';
fs.writeFileSync(
queueFile,
JSON.stringify({
pending: [...invalidKeys, validKey].map((key) => ({ ...item, key })),
deadLetter: invalidKeys.map((key) => ({ ...item, key })),
}),
);
const queue = createAnilistUpdateQueue(queueFile, loggerState.logger);
assert.deepEqual(queue.getSnapshot(), { pending: 1, ready: 1, deadLetter: 0 });
const persisted = fs.readFileSync(queueFile, 'utf8');
assert.deepEqual(JSON.parse(persisted), {
pending: [{ ...item, key: 'jellyfin://example.com/item/item::3' }],
deadLetter: [],
});
for (const key of invalidKeys) {
queue.enqueue(key, 'My Anime', 3);
queue.markFailure(key, 'invalid');
queue.markSuccess(key);
}
assert.equal(fs.readFileSync(queueFile, 'utf8'), persisted);
queue.markFailure(validKey, 'retry', 10);
assert.equal(queue.nextReady(30_010)?.attemptCount, 1);
queue.markSuccess(validKey);
queue.enqueue(validKey, 'My Anime', 3);
assert.equal(queue.nextReady()?.key, 'jellyfin://example.com/item/item::3');
});
test('anilist update queue enqueues, snapshots, and dequeues success', () => {
const queueFile = createTempQueueFile();
const loggerState = createLogger();
@@ -1,5 +1,13 @@
import * as fs from 'fs';
import { ensureDirForFile } from '../../../shared/fs-utils';
import { sanitizeMediaTitle, toMediaIdentityPath } from '../../../shared/media-identity';
function normalizeAnilistRetryKey(key: string): string {
const parts = key.match(/^(.*)::(\d+)$/s);
const identity = toMediaIdentityPath(parts ? (parts[1] ?? '') : key);
if (!identity) return '';
return parts ? `${identity}::${parts[2]}` : identity;
}
const INITIAL_BACKOFF_MS = 30_000;
const MAX_BACKOFF_MS = 6 * 60 * 60 * 1000;
@@ -105,6 +113,9 @@ export function createAnilistUpdateQueue(
isValidPersistedMediaId(item.mediaId) &&
(typeof item.lastError === 'string' || item.lastError === null),
)
.filter((item) => sanitizeMediaTitle(item.title) !== null)
.map((item) => ({ ...item, key: normalizeAnilistRetryKey(item.key) }))
.filter((item) => item.key !== '')
.slice(0, MAX_ITEMS);
deadLetter = parsedDeadLetter
.filter(
@@ -120,7 +131,11 @@ export function createAnilistUpdateQueue(
isValidPersistedMediaId(item.mediaId) &&
(typeof item.lastError === 'string' || item.lastError === null),
)
.filter((item) => sanitizeMediaTitle(item.title) !== null)
.map((item) => ({ ...item, key: normalizeAnilistRetryKey(item.key) }))
.filter((item) => item.key !== '')
.slice(0, MAX_ITEMS);
if (JSON.stringify({ pending, deadLetter }) !== JSON.stringify(parsed)) persist();
} catch (error) {
logger.error('Failed to load AniList retry queue.', error);
}
@@ -136,6 +151,9 @@ export function createAnilistUpdateQueue(
season: number | null = null,
mediaId: number | null = null,
): void {
if (!sanitizeMediaTitle(title)) return;
key = normalizeAnilistRetryKey(key);
if (!key) return;
const existing =
pending.find((item) => item.key === key) || deadLetter.find((item) => item.key === key);
if (existing) {
@@ -165,6 +183,8 @@ export function createAnilistUpdateQueue(
},
markSuccess(key: string): void {
key = normalizeAnilistRetryKey(key);
if (!key) return;
const before = pending.length;
pending = pending.filter((item) => item.key !== key);
if (pending.length !== before) {
@@ -173,6 +193,8 @@ export function createAnilistUpdateQueue(
},
markFailure(key: string, reason: string, nowMs: number = Date.now()): void {
key = normalizeAnilistRetryKey(key);
if (!key) return;
const item = pending.find((candidate) => candidate.key === key);
if (!item) {
return;
@@ -140,6 +140,62 @@ test('guessAnilistMediaInfo preserves useful guessit alternative title for ambig
});
});
test('guessAnilistMediaInfo uses the display title for authenticated streams', async () => {
const targets: string[] = [];
const result = await guessAnilistMediaInfo(
'https://jellyfin.example/Videos/item/stream?static=true&api_key=test-secret',
'My Anime S02E03',
{
runGuessit: async (target) => {
targets.push(target);
throw new Error('use fallback parser');
},
},
);
assert.deepEqual(targets, ['My Anime S02E03']);
assert.deepEqual(result, {
title: 'My Anime',
season: 2,
episode: 3,
source: 'fallback',
});
});
test('guessAnilistMediaInfo preserves slashes in display titles', async () => {
const title = 'Fate/stay night S01E02';
for (const mediaPath of [null, title, 'https://example.com/stream?api_key=test-secret']) {
const targets: string[] = [];
await guessAnilistMediaInfo(mediaPath, title, {
runGuessit: async (target) => {
targets.push(target);
return JSON.stringify({ title: 'Fate/stay night', season: 1, episode: 2 });
},
});
assert.deepEqual(targets, [title]);
}
});
test('guessAnilistMediaInfo never parses stream URLs or their query-bearing filenames', async () => {
const unsafeInputs = [
'https://jellyfin.example/Videos/item/stream?static=true&api_key=test-secret',
'stream?static=true&api_key=test-secret&MediaSourceId=item',
'https://user:test-secret@example.com/video.mkv',
];
for (const input of unsafeInputs) {
const targets: string[] = [];
const deps = {
runGuessit: async (target: string) => {
targets.push(target);
return JSON.stringify({ title: target });
},
};
assert.equal(await guessAnilistMediaInfo(input, null, deps), null);
assert.equal(await guessAnilistMediaInfo(null, input, deps), null);
assert.equal(await guessAnilistMediaInfo(input, input, deps), null);
assert.deepEqual(targets, []);
}
});
test('updateAnilistPostWatchProgress updates progress when behind', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
+5 -4
View File
@@ -2,6 +2,7 @@ import * as childProcess from 'child_process';
import * as path from 'path';
import { parseMediaInfo } from '../../../jimaku/utils';
import { resolveMediaLookupTarget, sanitizeMediaTitle } from '../../../shared/media-identity';
import type { AnilistRateLimiter } from './rate-limiter';
import { resolveAnilistSeasonMedia } from './season-resolver';
@@ -230,8 +231,9 @@ export async function guessAnilistMediaInfo(
mediaTitle: string | null,
deps: GuessAnilistMediaInfoDeps = { runGuessit },
): Promise<AnilistMediaGuess | null> {
const target = mediaPath ?? mediaTitle;
const guessitTarget = mediaPath ? path.basename(mediaPath) : mediaTitle;
const target = resolveMediaLookupTarget(mediaPath, mediaTitle);
if (!target) return null;
const guessitTarget = target === sanitizeMediaTitle(mediaTitle) ? target : path.basename(target);
if (guessitTarget && guessitTarget.trim().length > 0) {
try {
@@ -259,8 +261,7 @@ export async function guessAnilistMediaInfo(
}
}
const fallbackTarget = mediaPath ?? mediaTitle;
const parsed = parseMediaInfo(fallbackTarget);
const parsed = parseMediaInfo(target);
if (!parsed.title.trim()) {
return null;
}
@@ -116,6 +116,27 @@ function createExecutor(
return { execute, searches, relationLookups };
}
test('AniList refuses URL-derived search titles before making a request', async () => {
let requests = 0;
for (const title of [
'https://example.com/stream?api_key=test-secret',
'stream?static=true&api_key=test-secret',
'stream static true api key test secret',
]) {
const result = await resolveAnilistSeasonMedia(
{ title },
{
execute: async () => {
requests += 1;
throw new Error('must not send URL-derived searches');
},
},
);
assert.equal(result, null);
}
assert.equal(requests, 0);
});
test('stripSeasonSuffix drops release-name season markers', () => {
assert.equal(stripSeasonSuffix('Some Show Season 3'), 'Some Show');
assert.equal(stripSeasonSuffix('Some Show S3'), 'Some Show');
+5 -1
View File
@@ -1,3 +1,5 @@
import { sanitizeMediaTitle } from '../../../shared/media-identity';
/**
* AniList has no concept of "season N": sequels are separate media with their own
* titles (Zoku, Kan, 2nd Season, ...). Searching "<title> Season 3" therefore returns
@@ -348,7 +350,9 @@ export async function resolveAnilistSeasonMedia(
input: ResolveAnilistSeasonMediaInput,
deps: ResolveAnilistSeasonMediaDeps,
): Promise<AnilistSeasonResolution | null> {
const searchTitle = stripSeasonSuffix(input.title).trim() || input.title.trim();
const safeTitle = sanitizeMediaTitle(input.title);
if (!safeTitle) return null;
const searchTitle = stripSeasonSuffix(safeTitle).trim() || safeTitle;
if (!searchTitle) return null;
const season =
@@ -91,6 +91,21 @@ test('buildDiscordPresenceActivity shows media title regardless of style', () =>
}
});
test('buildDiscordPresenceActivity rejects stream URLs supplied as titles', () => {
for (const mediaTitle of [
'https://example.com/stream?api_key=test-secret',
'stream?api_key=test-secret',
]) {
const activity = buildDiscordPresenceActivity(baseConfig, {
...baseSnapshot,
mediaPath: 'https://example.com/stream?api_key=test-secret',
mediaTitle,
});
assert.equal(activity.details, 'Unknown media');
assert.equal(JSON.stringify(activity).includes('test-secret'), false);
}
});
test('buildDiscordPresenceActivity never falls back to remote stream URLs', () => {
const payload = buildDiscordPresenceActivity(baseConfig, {
...baseSnapshot,
+2 -1
View File
@@ -1,5 +1,6 @@
import type { DiscordPresenceStylePreset } from '../../types/integrations';
import type { ResolvedConfig } from '../../types';
import { sanitizeMediaTitle } from '../../shared/media-identity';
export interface DiscordPresenceSnapshot {
mediaTitle: string | null;
@@ -140,7 +141,7 @@ export function buildDiscordPresenceActivity(
const style = resolvePresenceStyle(config.presenceStyle);
const status = buildStatus(snapshot);
const title = sanitizeText(
snapshot.mediaTitle,
sanitizeMediaTitle(snapshot.mediaTitle),
fallbackTitleFromMediaPath(snapshot.mediaPath) || 'Unknown media',
);
const details =
@@ -3003,7 +3003,27 @@ test('startup repairs existing Jellyfin stream video links to metadata rows', as
const titledStreamUrl =
'http://jellyfin.local/Videos/item-10/stream?static=true&api_key=secret-token&MediaSourceId=ms-2';
tracker.handleMediaChange(titledStreamUrl, 'KonoSuba S01E06 Decision! Class Rep');
tracker.handleMediaTitleUpdate('stream?static=true&api_key=secret-token');
tracker.handleMediaChange(null, null);
// Safety must hold before metadata registration or a startup repair can run.
const liveDb = (tracker as unknown as { db: DatabaseSync }).db;
const persistedRows = liveDb.prepare('SELECT * FROM imm_videos').all();
assert.equal(JSON.stringify(persistedRows).includes('secret-token'), false);
assert.equal(JSON.stringify(persistedRows).includes('/stream'), false);
// Recreate the old on-disk representation to retain coverage of startup repair.
liveDb
.prepare(
'UPDATE imm_videos SET video_key = ?, source_url = ?, canonical_title = ? WHERE source_url = ?',
)
.run(
`remote:${streamUrl}`,
streamUrl,
'stream?static=true&api_key=secret-token',
'jellyfin://jellyfin.local/item/item-9',
);
liveDb
.prepare('UPDATE imm_videos SET video_key = ?, source_url = ? WHERE source_url = ?')
.run(`remote:${titledStreamUrl}`, titledStreamUrl, 'jellyfin://jellyfin.local/item/item-10');
tracker.recordJellyfinPlaybackMetadata({
mediaPath: 'http://jellyfin.local/Videos/item-9/stream?static=true&api_key=secret-token',
displayTitle: 'Frieren S01E09 Aura the Guillotine',
@@ -3105,6 +3125,90 @@ test('startup repairs existing Jellyfin stream video links to metadata rows', as
}
});
test('startup clears leaked parser metadata on safely titled anime without changing assignments', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
tracker.recordJellyfinPlaybackMetadata({
mediaPath: 'https://jellyfin.example/Videos/item/stream?api_key=test-secret',
displayTitle: 'My Anime S01E01',
itemTitle: 'Episode 1',
seriesTitle: 'My Anime',
seasonNumber: 1,
episodeNumber: 1,
itemId: 'item',
});
const db = (tracker as unknown as { db: DatabaseSync }).db;
db.prepare('UPDATE imm_anime SET metadata_json = ?').run(
JSON.stringify({
filename: 'stream?api_key=test-secret',
source: 'guessit',
}),
);
const before = db.prepare('SELECT video_id, anime_id FROM imm_videos').all();
tracker.destroy();
tracker = new Ctor({ dbPath });
const repairedDb = (tracker as unknown as { db: DatabaseSync }).db;
assert.deepEqual(repairedDb.prepare('SELECT video_id, anime_id FROM imm_videos').all(), before);
assert.deepEqual(
repairedDb.prepare('SELECT canonical_title, metadata_json FROM imm_anime').all(),
[{ canonical_title: 'My Anime Season 1', metadata_json: null }],
);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('Jellyfin metadata cleanup requires both an API key and a stream marker', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
const db = (tracker as unknown as { db: DatabaseSync }).db;
const timestamp = toDbTimestamp(trackerNowMs());
const cases = [
{ filename: 'stream?api_key=secret', leaked: true },
{ filename: '/STREAM?API_KEY=secret', leaked: true },
{ filename: '/Videos/item?api_key=secret', leaked: true },
{ filename: 'MediaSourceId=item api key secret', leaked: true },
{ filename: 'An API Key Story', leaked: false },
{ filename: 'api_key=ordinary-metadata', leaked: false },
{ filename: 'stream?quality=high', leaked: false },
{ filename: '/Videos/item', leaked: false },
{ filename: 'MediaSourceId=item', leaked: false },
];
for (const [index, entry] of cases.entries()) {
db.prepare(
`
INSERT INTO imm_anime (
normalized_title_key, canonical_title, metadata_json, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?)
`,
).run(
`show-${index}`,
`Show ${index}`,
JSON.stringify({ filename: entry.filename }),
timestamp,
timestamp,
);
}
repairJellyfinStreamVideoLinks(db);
assert.deepEqual(
db.prepare('SELECT metadata_json FROM imm_anime ORDER BY anime_id').all(),
cases.map(({ filename, leaked }) => ({
metadata_json: leaked ? null : JSON.stringify({ filename }),
})),
);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('Jellyfin link repair removes merged leaked anime rows and sanitizes orphan video titles', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
@@ -1,6 +1,7 @@
import path from 'node:path';
import * as fs from 'node:fs';
import { createLogger } from '../../logger';
import { sanitizeMediaTitle, toMediaIdentityPath } from '../../shared/media-identity';
import { MediaGenerator } from '../../media-generator';
import type { CoverArtFetcher } from './anilist/cover-art-fetcher';
import { getLocalVideoMetadata, guessAnimeVideoMetadata } from './immersion-tracker/metadata';
@@ -356,7 +357,7 @@ function normalizeMetadataInt(value: number | null | undefined): number | null {
function buildJellyfinStatsMediaPath(mediaPath: string, itemId: string): string {
const normalizedItemId = normalizeText(itemId);
if (!normalizedItemId) {
return mediaPath;
return toMediaIdentityPath(mediaPath);
}
try {
const parsed = new URL(mediaPath);
@@ -1520,11 +1521,11 @@ export class ImmersionTrackerService {
}
const displayTitle =
normalizeText(metadata.displayTitle) ||
normalizeText(metadata.itemTitle) ||
normalizeText(sanitizeMediaTitle(metadata.displayTitle)) ||
normalizeText(sanitizeMediaTitle(metadata.itemTitle)) ||
deriveCanonicalTitle(normalizedPath);
const itemTitle = normalizeText(metadata.itemTitle) || displayTitle;
const seriesTitle = normalizeText(metadata.seriesTitle);
const itemTitle = normalizeText(sanitizeMediaTitle(metadata.itemTitle)) || displayTitle;
const seriesTitle = normalizeText(sanitizeMediaTitle(metadata.seriesTitle));
const libraryTitle = seriesTitle || itemTitle;
const seasonNumber = normalizeMetadataInt(metadata.seasonNumber);
const episodeNumber = normalizeMetadataInt(metadata.episodeNumber);
@@ -1611,8 +1612,8 @@ export class ImmersionTrackerService {
const normalizedPath =
buildJellyfinMediaPathAliasCandidates(rawPath)
.map((alias) => this.mediaPathAliases.get(alias))
.find((alias): alias is string => Boolean(alias)) ?? rawPath;
const normalizedTitle = normalizeText(mediaTitle);
.find((alias): alias is string => Boolean(alias)) ?? toMediaIdentityPath(rawPath);
const normalizedTitle = normalizeText(sanitizeMediaTitle(mediaTitle));
this.logger.info(
`handleMediaChange called with path=${normalizedPath || '<empty>'} title=${normalizedTitle || '<empty>'}`,
);
@@ -1670,7 +1671,7 @@ export class ImmersionTrackerService {
handleMediaTitleUpdate(mediaTitle: string | null): void {
if (!this.sessionState) return;
const normalizedTitle = normalizeText(mediaTitle);
const normalizedTitle = normalizeText(sanitizeMediaTitle(mediaTitle));
if (!normalizedTitle) return;
this.currentVideoKey = normalizedTitle;
this.updateVideoTitleForActiveSession(normalizedTitle);
@@ -257,6 +257,30 @@ function repairLeakedJellyfinVideoParseMetadata(
return updated.changes;
}
function repairLeakedJellyfinAnimeParseMetadata(
db: DatabaseSync,
currentTimestamp: string,
): number {
const updated = db
.prepare(
`
UPDATE imm_anime
SET metadata_json = NULL, LAST_UPDATE_DATE = ?
WHERE (
metadata_json LIKE '%api_key=%'
OR lower(metadata_json) LIKE '%api key%'
) AND (
lower(metadata_json) LIKE '%stream?%'
OR lower(metadata_json) LIKE '%/stream?%'
OR lower(metadata_json) LIKE '%/videos/%'
OR lower(metadata_json) LIKE '%mediasourceid%'
)
`,
)
.run(currentTimestamp);
return updated.changes;
}
export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRepairSummary {
const candidates = db
.prepare(
@@ -290,7 +314,8 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe
const currentTimestamp = toDbTimestamp(nowMs());
const repaired =
repairLeakedJellyfinAnimeTitles(db, currentTimestamp) +
repairLeakedJellyfinVideoParseMetadata(db, currentTimestamp);
repairLeakedJellyfinVideoParseMetadata(db, currentTimestamp) +
repairLeakedJellyfinAnimeParseMetadata(db, currentTimestamp);
summary.repaired += repaired;
return summary;
}
@@ -422,6 +447,7 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe
}
summary.repaired += repairLeakedJellyfinAnimeTitles(db, currentTimestamp);
summary.repaired += repairLeakedJellyfinVideoParseMetadata(db, currentTimestamp);
summary.repaired += repairLeakedJellyfinAnimeParseMetadata(db, currentTimestamp);
db.exec('COMMIT');
} catch (error) {
db.exec('ROLLBACK');
@@ -147,6 +147,25 @@ test('getLocalVideoMetadata derives title and falls back to null hash on read er
assert.equal(hashFallbackMetadata.hashSha256, null);
});
test('stream stats parsing preserves display titles and never persists transport credentials', async () => {
const targets: string[] = [];
const parsed = await guessAnimeVideoMetadata(
'https://jellyfin.example/Videos/item/stream?api_key=test-secret',
'Fate/stay night S01E02',
{
runGuessit: async (target) => {
targets.push(target);
return JSON.stringify({ title: 'Fate/stay night', season: 1, episode: 2 });
},
},
);
assert.deepEqual(targets, ['Fate/stay night S01E02']);
assert.equal(parsed?.parsedBasename, 'Fate/stay night S01E02');
assert.equal(parsed?.parsedTitle, 'Fate/stay night');
assert.equal(JSON.stringify(parsed).includes('test-secret'), false);
assert.equal(JSON.stringify(parsed).includes('/stream'), false);
});
test('guessAnimeVideoMetadata uses guessit basename output first when available', async () => {
const seenTargets: string[] = [];
const parsed = await guessAnimeVideoMetadata(
@@ -3,6 +3,7 @@ import { spawn as nodeSpawn } from 'node:child_process';
import * as fs from 'node:fs';
import path from 'node:path';
import { parseMediaInfo } from '../../../jimaku/utils';
import { resolveMediaLookupTarget, sanitizeMediaTitle } from '../../../shared/media-identity';
import {
guessAnilistMediaInfo,
runGuessit,
@@ -184,6 +185,7 @@ export async function guessAnimeVideoMetadata(
mediaTitle: string | null,
deps: GuessAnimeVideoMetadataDeps = {},
): Promise<ParsedAnimeVideoGuess | null> {
const lookupTarget = resolveMediaLookupTarget(mediaPath, mediaTitle);
const parsed = await guessAnilistMediaInfo(mediaPath, mediaTitle, {
runGuessit: deps.runGuessit ?? runGuessit,
});
@@ -191,7 +193,12 @@ export async function guessAnimeVideoMetadata(
return null;
}
const parsedBasename = mediaPath ? path.basename(mediaPath) : null;
const parsedBasename =
lookupTarget === sanitizeMediaTitle(mediaTitle)
? lookupTarget
: lookupTarget
? path.basename(lookupTarget)
: null;
if (parsed.source === 'guessit') {
return {
parsedBasename,
@@ -207,7 +214,7 @@ export async function guessAnimeVideoMetadata(
};
}
const fallbackInfo = parseMediaInfo(mediaPath ?? mediaTitle);
const fallbackInfo = parseMediaInfo(lookupTarget);
return {
parsedBasename: parsedBasename ?? fallbackInfo.filename ?? null,
parsedTitle: parsed.title,
+4 -1
View File
@@ -1,4 +1,5 @@
import { MpvSubtitleRenderMetrics } from '../../types';
import { sanitizeMediaTitle } from '../../shared/media-identity';
export type MpvMessage = {
event?: string;
@@ -334,8 +335,10 @@ export async function dispatchMpvProtocolMessage(
} else if (msg.name === 'fullscreen') {
deps.emitFullscreenChange({ fullscreen: asBoolean(msg.data, false) });
} else if (msg.name === 'media-title') {
const title = typeof msg.data === 'string' ? sanitizeMediaTitle(msg.data) : null;
if (typeof msg.data === 'string' && msg.data.trim() && !title) return;
deps.emitMediaTitleChange({
title: typeof msg.data === 'string' ? msg.data.trim() : null,
title,
});
} else if (msg.name === 'path') {
const path = (msg.data as string) || '';
+15
View File
@@ -120,6 +120,21 @@ test('MpvIpcClient emits fullscreen property changes', async () => {
assert.deepEqual(events, [{ fullscreen: true }]);
});
test('MpvIpcClient ignores URL-derived titles without replacing known metadata', async () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
const titles: Array<string | null> = [];
client.on('media-title-change', ({ title }) => titles.push(title));
for (const data of [
'My Anime S01E02',
'https://example.com/stream?api_key=test-secret',
'stream?api_key=test-secret',
]) {
await invokeHandleMessage(client, { event: 'property-change', name: 'media-title', data });
}
assert.equal(client.currentMediaTitle, 'My Anime S01E02');
assert.deepEqual(titles, ['My Anime S01E02']);
});
test('MpvIpcClient clears cached media title when media path changes', async () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
@@ -0,0 +1,120 @@
import assert from 'node:assert/strict';
import { 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 { detectSubtitleGenerationAcceleration } from './subtitle-generation-acceleration';
async function fixture(run: (directory: string) => Promise<void>) {
const directory = await mkdtemp(path.join(tmpdir(), 'subtitle-acceleration-test-'));
try {
await run(directory);
} finally {
await rm(directory, { recursive: true, force: true });
}
}
async function executable(directory: string, name: string, body: string) {
const file = path.join(directory, name);
await writeFile(file, `#!${process.execPath}\n${body}`, { mode: 0o755 });
return file;
}
const cudaOutput =
'ggml_cuda_init: found 1 CUDA devices:\nwhisper_model_load: invalid model data (bad magic)\n';
test('NVIDIA and CUDA discovery work without downloading or loading a model', () =>
fixture(async (directory) => {
await executable(directory, 'nvidia-smi', 'console.log("NVIDIA Test GPU");');
const whisper = await executable(
directory,
'whisper-cli',
`const fs = require('node:fs');
const model = process.argv[process.argv.indexOf('-m') + 1];
if (fs.readFileSync(model).length !== 4) process.exit(1);
fs.writeFileSync(${JSON.stringify(path.join(directory, 'probe-path'))}, model);
process.stderr.write(${JSON.stringify(cudaOutput)}); process.exit(3);`,
);
assert.deepEqual(
await detectSubtitleGenerationAcceleration(
{ kind: 'found', path: whisper },
{ PATH: directory },
),
{ kind: 'nvidia-cuda', gpuName: 'NVIDIA Test GPU' },
);
const model = await readFile(path.join(directory, 'probe-path'), 'utf8');
await assert.rejects(readdir(path.dirname(model)), { code: 'ENOENT' });
}));
test('CPU-only, Vulkan-only, hidden CUDA devices and incomplete probes fall back safely', () =>
fixture(async (directory) => {
await executable(directory, 'nvidia-smi', 'console.log("NVIDIA Test GPU");');
for (const output of [
'usage: --no-gpu disable GPU\ninvalid model data (bad magic)',
'ggml_vulkan: Found 1 Vulkan devices\ninvalid model data (bad magic)',
'ggml_cuda_init: found 0 CUDA devices\ninvalid model data (bad magic)',
'ggml_cuda_init: found 1 CUDA devices\nCUDA error: driver initialization failed',
]) {
const whisper = await executable(
directory,
'whisper-cli',
`process.stderr.write(${JSON.stringify(output)}); process.exit(3);`,
);
assert.deepEqual(
await detectSubtitleGenerationAcceleration(
{ kind: 'found', path: whisper },
{ PATH: directory },
),
{ kind: 'unavailable' },
);
}
}));
test('missing tools, missing NVIDIA devices and driver errors do not recommend turbo', () =>
fixture(async (directory) => {
const whisper = await executable(
directory,
'whisper-cli',
`process.stderr.write(${JSON.stringify(cudaOutput)}); process.exit(3);`,
);
assert.deepEqual(
await detectSubtitleGenerationAcceleration(
{ kind: 'missing', message: 'Not installed' },
{ PATH: directory },
),
{ kind: 'unavailable' },
);
for (const driver of [
null,
'process.exit(0);',
'console.log("NVIDIA GPU"); process.exit(1);',
]) {
if (driver !== null) await executable(directory, 'nvidia-smi', driver);
assert.deepEqual(
await detectSubtitleGenerationAcceleration(
{ kind: 'found', path: whisper },
{ PATH: directory },
),
{ kind: 'unavailable' },
);
}
}));
test('a hung Whisper probe times out even if it printed a CUDA device', () =>
fixture(async (directory) => {
await executable(directory, 'nvidia-smi', 'console.log("NVIDIA Test GPU");');
const whisper = await executable(
directory,
'whisper-cli',
`process.stderr.write(${JSON.stringify(cudaOutput)}); setInterval(() => {}, 1000);`,
);
const started = Date.now();
assert.deepEqual(
await detectSubtitleGenerationAcceleration(
{ kind: 'found', path: whisper },
{ PATH: directory },
),
{ kind: 'unavailable' },
);
assert.ok(Date.now() - started < 6000);
}));
@@ -0,0 +1,62 @@
import { execFile } from 'node:child_process';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import type {
SubtitleGenerationAcceleration,
SubtitleGenerationToolStatus,
} from '../../shared/subtitle-generation';
function probe(command: string, args: string[], env: NodeJS.ProcessEnv) {
return new Promise<{ code: number; stdout: string; stderr: string } | null>((resolve) => {
execFile(
command,
args,
{ env, timeout: 3000, killSignal: 'SIGKILL', maxBuffer: 64 * 1024, windowsHide: true },
(error, stdout, stderr) => {
const code = error?.code ?? 0;
if (typeof code !== 'number' || error?.killed || error?.signal) {
resolve(null);
return;
}
resolve({ code, stdout, stderr });
},
);
});
}
/** Require both a working NVIDIA driver and CUDA device discovery in the selected Whisper binary. */
export async function detectSubtitleGenerationAcceleration(
whisper: SubtitleGenerationToolStatus,
env: NodeJS.ProcessEnv = process.env,
): Promise<SubtitleGenerationAcceleration> {
const unavailable: SubtitleGenerationAcceleration = { kind: 'unavailable' };
if (whisper.kind === 'missing') return unavailable;
let directory: string | undefined;
try {
const nvidia = await probe('nvidia-smi', ['--query-gpu=name', '--format=csv,noheader'], env);
const gpuName = nvidia?.stdout.trim().split(/\r?\n/)[0]?.trim();
if (nvidia?.code !== 0 || !gpuName) return unavailable;
directory = await mkdtemp(path.join(tmpdir(), 'subminer-cuda-check-'));
const model = path.join(directory, 'probe.bin');
await writeFile(model, Buffer.alloc(4));
// whisper.cpp discovers backends before checking model magic. This deliberately invalid
// local file stops before allocating a model or decoding audio, even on a fresh install.
const result = await probe(whisper.path, ['-m', model, '-f', model], env);
if (
result &&
result.code !== 0 &&
/ggml_cuda_init:\s+found\s+[1-9]\d*\s+CUDA devices?\b/i.test(result.stderr) &&
/invalid model data \(bad magic\)/i.test(result.stderr)
) {
return { kind: 'nvidia-cuda', gpuName };
}
return unavailable;
} catch {
// Detection is advisory; unsupported builds and driver failures must not block generation.
return unavailable;
} finally {
if (directory) await rm(directory, { recursive: true, force: true }).catch(() => {});
}
}
@@ -2,6 +2,24 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { appendSpeechChunkCues, splitSpeechPassages } from './subtitle-generation-chunks';
test('reference starts guide long cuts ahead of VAD without dropping unreferenced audio', () => {
const chunks = splitSpeechPassages(
[{ startSeconds: 0, endSeconds: 70 }],
[18],
[19],
[22, 43, 200],
);
assert.deepEqual(chunks, [
{ startSeconds: 0, endSeconds: 22.25 },
{ startSeconds: 21.75, endSeconds: 43.25 },
{ startSeconds: 42.75, endSeconds: 63.25 },
{ startSeconds: 62.75, endSeconds: 70 },
]);
assert.deepEqual(splitSpeechPassages([{ startSeconds: 0, endSeconds: 25 }], [], [], [10, 20]), [
{ startSeconds: 0, endSeconds: 25 },
]);
});
test('long coverage cuts at nearby speech starts instead of leaving a quiet lead-in', () => {
const chunks = splitSpeechPassages(
[{ startSeconds: 544.418, endSeconds: 581.581 }],
@@ -5,11 +5,12 @@ const CHUNK_CONTEXT_SECONDS = 0.25;
const WHISPER_WINDOW_SECONDS = 30;
const PAUSE_SEARCH_SECONDS = 5;
// Prefer detected speech starts, then quiet pauses. Context stays inside retained audio.
// Prefer subtitle timing hints, then detected speech starts and quiet pauses.
export function splitSpeechPassages(
passages: readonly SpeechPassage[],
pauses: readonly number[] = [],
speechStarts: readonly number[] = [],
referenceStarts: readonly number[] = [],
): SpeechPassage[] {
return passages.flatMap((passage) => {
if (passage.endSeconds - passage.startSeconds <= WHISPER_WINDOW_SECONDS)
@@ -22,17 +23,19 @@ export function splitSpeechPassages(
if (target < passage.endSeconds) {
// Starting in a long quiet lead-in can make Whisper place the next line
// several seconds early. A nearby VAD start gives the next chunk an anchor.
let nearestSpeechStart: number | undefined;
for (const time of speechStarts) {
const nearestStart = (starts: readonly number[]): number | undefined => {
let nearest: number | undefined;
for (const time of starts) {
if (
time >= target - PAUSE_SEARCH_SECONDS &&
time <= target + PAUSE_SEARCH_SECONDS &&
time < passage.endSeconds &&
(nearestSpeechStart === undefined ||
Math.abs(time - target) < Math.abs(nearestSpeechStart - target))
(nearest === undefined || Math.abs(time - target) < Math.abs(nearest - target))
)
nearestSpeechStart = time;
nearest = time;
}
return nearest;
};
let latestPause: number | undefined;
for (const time of pauses) {
if (
@@ -42,7 +45,7 @@ export function splitSpeechPassages(
)
latestPause = time;
}
end = nearestSpeechStart ?? latestPause ?? end;
end = nearestStart(referenceStarts) ?? nearestStart(speechStarts) ?? latestPause ?? end;
}
chunks.push({
startSeconds: Math.max(passage.startSeconds, boundary - CHUNK_CONTEXT_SECONDS),
@@ -13,6 +13,7 @@ import {
parseSpeechPassages,
speechPassageCues,
SPEECH_PASSAGE_SECONDS,
type SpeechPassage,
} from './subtitle-generation-speech';
import type { SubtitleCue } from './subtitle-cue-parser';
import { appendSpeechChunkCues, splitSpeechPassages } from './subtitle-generation-chunks';
@@ -21,13 +22,16 @@ import { findAudiblePassages, mergeSpeechPassages } from './subtitle-generation-
export async function transcribeSubtitleDialogue(input: {
config: SubtitleGenerationConfig;
tools: SubtitleGenerationToolPaths & { vad: string };
tools: SubtitleGenerationToolPaths;
referenceStarts?: readonly number[];
modelPath: string;
wavPath: string;
directory: string;
onProgress?: (progress: SubtitleGenerationProgress) => void;
signal?: AbortSignal;
}): Promise<string> {
let speech: SpeechPassage[] = [];
if (input.tools.vad !== null) {
const vadModelPath = expandSubtitleGenerationPath(input.config.vadModelPath);
await access(vadModelPath, constants.R_OK);
input.onProgress?.({ stage: 'transcribe', percent: 0, message: 'Finding spoken dialogue...' });
@@ -60,7 +64,8 @@ export async function transcribeSubtitleDialogue(input: {
segmentLines.push(line);
},
});
const speech = parseSpeechPassages(segmentLines.join('\n'));
speech = parseSpeechPassages(segmentLines.join('\n'));
}
input.onProgress?.({ stage: 'transcribe', percent: 0, message: 'Checking audio coverage...' });
const audible = await findAudiblePassages({
ffmpegPath: input.tools.ffmpeg,
@@ -82,6 +87,7 @@ export async function transcribeSubtitleDialogue(input: {
detected,
pauses,
speech.map((passage) => passage.startSeconds),
input.referenceStarts,
);
const cues: SubtitleCue[] = [];
for (const [index, passage] of passages.entries()) {
@@ -0,0 +1,137 @@
import assert from 'node:assert/strict';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
loadSubtitleGenerationReference,
readSubtitleGenerationReferences,
subtitleGenerationReferences,
} from './subtitle-generation-reference';
const embedded = { type: 'sub', codec: 'ass', 'ff-index': 2, lang: 'eng' };
test('references exclude signs, songs, forced, bitmap and generated tracks even when selected', () => {
const excluded = [
'Signs & Songs',
'SignsSongs',
'Signs/Songs',
'S&S',
'S+S',
'Forced',
'Karaoke',
'OP',
'ED',
'English lyrics',
'Generated Japanese',
];
assert.deepEqual(
subtitleGenerationReferences([
...excluded.map((title) => ({ ...embedded, title, selected: true })),
{ ...embedded, forced: true },
{ ...embedded, codec: 'hdmv_pgs_subtitle' },
{
...embedded,
title: 'English',
external: true,
'external-filename': '/subs/show.en.signs.ass',
},
{ ...embedded, title: 'English Full' },
]).map((reference) => reference.label),
['English Full'],
);
});
test('references rank English dialogue first and resolve loaded external files against mpv cwd', () => {
const refs = subtitleGenerationReferences(
[
{ ...embedded, title: 'French Full', lang: 'fra', selected: true },
{ ...embedded, title: 'English' },
{ type: 'sub', external: true, 'external-filename': 'subs/show.en.full.srt' },
{ type: 'sub', external: true, 'external-filename': 'https://example.com/en.srt' },
{ type: 'sub', 'ff-index': -1 },
null,
],
'/mpv',
);
assert.deepEqual(
refs.map((ref) => ref.label),
['show.en.full.srt', 'English', 'French Full'],
);
assert.deepEqual(refs[0]?.source, { kind: 'external', path: '/mpv/subs/show.en.full.srt' });
assert.deepEqual(
subtitleGenerationReferences([
{ type: 'sub', external: true, 'external-filename': 'relative.en.srt' },
]),
[],
);
});
test('reference discovery captures primary and secondary subtitle delays', async () => {
const properties: Record<string, unknown> = {
'working-directory': '/mpv',
sid: 1,
'secondary-sid': 2,
'sub-delay': 1.5,
'secondary-sub-delay': -2,
};
const refs = await readSubtitleGenerationReferences(
[
{ ...embedded, id: 1 },
{ ...embedded, id: 2 },
{ ...embedded, id: 3 },
],
async (name) => properties[name],
);
assert.deepEqual(
refs.map((ref) => ref.delaySeconds),
[1.5, -2, 0],
);
});
test('reference extraction retries unreadable tracks, restores audio offset and ignores marked lyrics', async () => {
const directory = await mkdtemp(path.join(tmpdir(), 'generation-reference-'));
try {
const ffmpegPath = path.join(directory, 'ffmpeg');
await writeFile(
ffmpegPath,
`#!${process.execPath}
const args = process.argv.slice(2);
if (args[args.indexOf('-map') + 1] === '0:2') process.exit(1);
require('node:fs').writeFileSync(args.at(-1), '1\\n00:00:10,000 --> 00:00:12,000\\nHello\\n\\n2\\n00:00:20,000 --> 00:00:22,000\\n♪ Song ♪\\n\\n3\\n00:00:30,000 --> 00:00:32,000\\nWorld\\n');
`,
{ mode: 0o755 },
);
const refs = subtitleGenerationReferences([{ ...embedded }, { ...embedded, 'ff-index': 3 }]);
const hints = await loadSubtitleGenerationReference({
references: refs,
mediaPath: '/video.mkv',
ffmpegPath,
directory,
audioOffset: 2.5,
});
assert.deepEqual(hints, [7.5, 27.5]);
assert.deepEqual(
await loadSubtitleGenerationReference({
references: refs.slice(0, 1),
mediaPath: '/video.mkv',
ffmpegPath,
directory,
audioOffset: 0,
}),
[],
);
await assert.rejects(
loadSubtitleGenerationReference({
references: refs,
mediaPath: '/video.mkv',
ffmpegPath,
directory,
audioOffset: 0,
signal: AbortSignal.abort(),
}),
);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
@@ -0,0 +1,169 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import type { SubtitleGenerationProgress } from '../../shared/subtitle-generation';
import { parseSrtCues } from './subtitle-cue-parser';
import { runSubtitleGenerationProcess } from './subtitle-generation-process';
export type SubtitleGenerationReference = {
label: string;
delaySeconds: number;
source: { kind: 'embedded'; streamIndex: number } | { kind: 'external'; path: string };
};
// Check both titles and filenames: releases often tag only one of them.
const EXCLUDED =
/(?:^|[^\p{L}\p{N}])(?:signs?(?:songs?)?|songs?|lyrics?|karaoke|forced|s[\s&+_-]*s|op|ed|opening|ending|generated)(?=$|[^\p{L}\p{N}])||/iu;
const TEXT_CODECS = new Set(['ass', 'ssa', 'subrip', 'srt', 'webvtt', 'mov_text', 'text']);
/** Rank loaded dialogue tracks, preferring English, then explicitly full tracks. */
export function subtitleGenerationReferences(
value: unknown,
workingDirectory?: string,
delays: ReadonlyMap<number, number> = new Map(),
): SubtitleGenerationReference[] {
if (!Array.isArray(value)) return [];
const tracks: unknown[] = value;
return tracks
.flatMap((track) => {
if (typeof track !== 'object' || track === null || !('type' in track) || track.type !== 'sub')
return [];
const title = 'title' in track && typeof track.title === 'string' ? track.title : '';
const filename =
'external-filename' in track && typeof track['external-filename'] === 'string'
? track['external-filename']
: '';
const name = `${title} ${path.basename(filename)}`;
if (('forced' in track && track.forced === true) || EXCLUDED.test(name)) return [];
if ('codec' in track && typeof track.codec === 'string' && !TEXT_CODECS.has(track.codec))
return [];
let source: SubtitleGenerationReference['source'];
if ('external' in track && track.external === true) {
let local = filename;
if (local.startsWith('file://')) {
try {
local = fileURLToPath(local);
} catch {
return [];
}
} else if (/^[a-z][a-z\d+.-]*:\/\//i.test(local)) return [];
if (!local || (!path.isAbsolute(local) && !workingDirectory)) return [];
if (!/\.(?:srt|ass|ssa|vtt)$/i.test(local)) return [];
source = { kind: 'external', path: path.resolve(workingDirectory ?? '.', local) };
} else {
if (
!('ff-index' in track) ||
typeof track['ff-index'] !== 'number' ||
!Number.isSafeInteger(track['ff-index']) ||
track['ff-index'] < 0
)
return [];
source = { kind: 'embedded', streamIndex: track['ff-index'] };
}
const language = 'lang' in track && typeof track.lang === 'string' ? track.lang : '';
const english =
/^(?:en|eng|english)(?:[-_]|$)/i.test(language) ||
/(?:^|[\s.\[(_-])(?:en|eng|english)(?=$|[\s.\])_-])/i.test(name);
const full = /\b(?:full|dialogue|dialog)\b/i.test(name);
const selected = 'selected' in track && track.selected === true;
const preferred = 'default' in track && track.default === true;
return [
{
reference: {
label:
title ||
path.basename(filename) ||
`${language || 'Subtitle'} stream ${source.kind === 'embedded' ? source.streamIndex : ''}`,
source,
delaySeconds:
'id' in track && typeof track.id === 'number' ? (delays.get(track.id) ?? 0) : 0,
},
score:
Number(english) * 100 + Number(full) * 20 + Number(selected) * 2 + Number(preferred),
},
];
})
.sort((a, b) => b.score - a.score)
.map(({ reference }) => reference);
}
/** Read mpv's path base and active subtitle delays while capturing timing references. */
export async function readSubtitleGenerationReferences(
tracks: unknown,
requestProperty: (name: string) => Promise<unknown>,
): Promise<SubtitleGenerationReference[]> {
const [directory, primary, secondary, primaryDelay, secondaryDelay] = await Promise.all(
['working-directory', 'sid', 'secondary-sid', 'sub-delay', 'secondary-sub-delay'].map((name) =>
requestProperty(name).catch(() => null),
),
);
const delays = new Map<number, number>();
for (const [id, delay] of [
[primary, primaryDelay],
[secondary, secondaryDelay],
]) {
if (typeof id === 'number' && typeof delay === 'number' && Number.isFinite(delay))
delays.set(id, delay);
}
return subtitleGenerationReferences(
tracks,
typeof directory === 'string' ? directory : undefined,
delays,
);
}
/** Reference timestamps are hints on the extracted audio timeline, never a coverage mask. */
export async function loadSubtitleGenerationReference(input: {
references: readonly SubtitleGenerationReference[];
mediaPath: string;
ffmpegPath: string;
directory: string;
audioOffset: number;
onProgress?: (progress: SubtitleGenerationProgress) => void;
signal?: AbortSignal;
}): Promise<number[]> {
for (const [index, reference] of input.references.entries()) {
input.signal?.throwIfAborted();
try {
const output = path.join(input.directory, `reference-${index}.srt`);
const embedded = reference.source.kind === 'embedded';
await runSubtitleGenerationProcess({
command: input.ffmpegPath,
args: [
'-nostdin',
'-hide_banner',
'-loglevel',
'error',
...(embedded ? ['-copyts', '-start_at_zero'] : []),
'-i',
reference.source.kind === 'external' ? reference.source.path : input.mediaPath,
'-map',
reference.source.kind === 'embedded' ? `0:${reference.source.streamIndex}` : '0:s:0',
'-c:s',
'srt',
output,
],
signal: input.signal,
});
const starts = parseSrtCues(await readFile(output, 'utf8'))
.filter((cue) => cue.text.trim() && !/[]/u.test(cue.text) && cue.endTime > cue.startTime)
.map((cue) => cue.startTime + reference.delaySeconds - input.audioOffset)
.filter((time) => Number.isFinite(time) && time >= 0);
if (starts.length === 0) continue;
input.onProgress?.({
stage: 'extract',
message: `Using subtitle timing reference: ${reference.label}`,
});
return [...new Set(starts)].sort((a, b) => a - b);
} catch {
input.signal?.throwIfAborted();
// An optional reference must not prevent transcription. Try the next loaded track.
}
}
if (input.references.length)
input.onProgress?.({
stage: 'extract',
message: 'No readable subtitle timing reference. Using audio timing.',
});
return [];
}
@@ -95,6 +95,52 @@ test('config parser accepts supported models and rejects unsafe threads and wron
assert.deepEqual(warnings, ['whisperPath', 'threads']);
});
test('generation uses reference cuts with and without VAD while preserving media offsets', () =>
fixture(async (directory) => {
const input = await generationFixture(directory);
const ffmpegPath = await executable(
directory,
'reference-ffmpeg',
`
const fs = require('node:fs');
const args = process.argv.slice(2);
fs.appendFileSync(${JSON.stringify(input.callsPath)}, JSON.stringify(args) + '\\n');
if (args.includes('-c:s')) {
fs.writeFileSync(args.at(-1), '1\\n00:00:24,500 --> 00:00:26,500\\nHello\\n\\n2\\n00:00:45,500 --> 00:00:47,500\\nWorld\\n');
} else if (args.at(-1) === '-') {
process.stdout.write('out_time_us=70000000\\nprogress=end\\n');
} else fs.writeFileSync(args.at(-1), 'wav');
`,
);
const vadPath = await executable(
directory,
'reference-vad',
"process.stdout.write('Detected 1 speech segments:\\nSpeech segment 0: start = 0.000, end = 7000.000\\n');",
);
const vadModelPath = path.join(directory, 'vad.bin');
await writeFile(vadModelPath, 'model');
for (const vad of [false, true]) {
await writeFile(input.callsPath, '');
const output = await generateJapaneseSubtitles({
...input,
config: { ...input.config, ffmpegPath, vadPath, vadModelPath: vad ? vadModelPath : '' },
references: [
{ label: 'English Full', delaySeconds: 0, source: { kind: 'embedded', streamIndex: 5 } },
],
});
const calls: string[][] = (await readFile(input.callsPath, 'utf8'))
.trim()
.split('\n')
.map((line) => JSON.parse(line));
const clips = calls.filter((args) => args.includes('-ss'));
assert.equal(clips[0]?.[clips[0].indexOf('-t') + 1], '22.25');
assert.equal(clips[1]?.[clips[1].indexOf('-ss') + 1], '21.75');
const srt = await readFile(output, 'utf8');
assert.match(srt, /00:00:03,500 --> 00:00:04,500/);
assert.match(srt, /00:00:25,250 --> 00:00:26,250/);
}
}));
test('external model path wins and invalid external models never fall back to download', () =>
fixture(async (directory) => {
const input = await generationFixture(directory);
+17 -2
View File
@@ -11,6 +11,10 @@ import { runSubtitleGenerationProcess } from './subtitle-generation-process';
import { publishSubtitleGenerationFile } from './subtitle-generation-files';
import { formatTimestamp } from './subtitle-generation-srt';
import { transcribeSubtitleDialogue } from './subtitle-generation-dialogue';
import {
loadSubtitleGenerationReference,
type SubtitleGenerationReference,
} from './subtitle-generation-reference';
import {
requireSubtitleGenerationTools,
resolveSubtitleGenerationTools,
@@ -174,6 +178,7 @@ export async function generateJapaneseSubtitles(input: {
modelDirectory: string;
mediaPath: string;
audioStreamIndex?: number;
references?: readonly SubtitleGenerationReference[];
outputPath?: string;
onProgress?: (progress: SubtitleGenerationProgress) => void;
signal?: AbortSignal;
@@ -257,11 +262,21 @@ export async function generateJapaneseSubtitles(input: {
percent: 0,
message: 'Generating Japanese subtitles...',
});
const referenceStarts = await loadSubtitleGenerationReference({
references: input.references ?? [],
mediaPath,
ffmpegPath: tools.ffmpeg,
directory: temporaryDirectory,
audioOffset: audio.offset,
onProgress: input.onProgress,
signal: input.signal,
});
let srt: string;
if (tools.vad !== null) {
if (tools.vad !== null || referenceStarts.length > 0) {
srt = await transcribeSubtitleDialogue({
config: input.config,
tools: { ...tools, vad: tools.vad },
tools,
referenceStarts,
modelPath: model.path,
wavPath,
directory: temporaryDirectory,
+7 -5
View File
@@ -1,4 +1,5 @@
import { updateCurrentMediaPath } from '../core/services';
import { sanitizeMediaTitle } from '../shared/media-identity';
import type { SubtitlePosition } from '../types';
@@ -52,17 +53,18 @@ export function createMediaRuntimeService(deps: MediaRuntimeDeps): MediaRuntimeS
updateCurrentMediaTitle(mediaTitle: unknown): void {
if (typeof mediaTitle === 'string') {
const sanitized = mediaTitle.trim();
deps.setCurrentMediaTitle(sanitized.length > 0 ? sanitized : null);
const sanitized = sanitizeMediaTitle(mediaTitle);
if (mediaTitle.trim() && !sanitized) return;
deps.setCurrentMediaTitle(sanitized);
return;
}
deps.setCurrentMediaTitle(null);
},
resolveMediaPathForJimaku(mediaPath: string | null): string | null {
return mediaPath && deps.isRemoteMediaPath(mediaPath) && deps.getCurrentMediaTitle()
? deps.getCurrentMediaTitle()
: mediaPath;
return mediaPath && deps.isRemoteMediaPath(mediaPath)
? sanitizeMediaTitle(deps.getCurrentMediaTitle())
: sanitizeMediaTitle(mediaPath);
},
};
}
@@ -9,6 +9,10 @@ import {
test('buildAnilistAttemptKey formats media and episode', () => {
assert.equal(buildAnilistAttemptKey('/tmp/video.mkv', 3), '/tmp/video.mkv::3');
assert.equal(
buildAnilistAttemptKey('https://example.com/Videos/item/stream?api_key=test-secret', 3),
'jellyfin://example.com/item/item::3',
);
});
test('rememberAnilistAttemptedUpdateKey evicts oldest beyond max size', () => {
@@ -17,6 +21,49 @@ test('rememberAnilistAttemptedUpdateKey evicts oldest beyond max size', () => {
assert.deepEqual(Array.from(set), ['b', 'c']);
});
test('post-watch rejects empty media identities before attempted keys or update side effects', async () => {
for (const mediaKey of [
' ',
'stream?api_key=secret',
'stream%3Fapi_key%3Dsecret',
'https://[invalid',
]) {
assert.equal(buildAnilistAttemptKey(mediaKey, 3), null);
const calls: string[] = [];
const unexpected = () => assert.fail('invalid identity reached update side effects');
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => false,
setInFlight: (value) => calls.push(`inflight:${value}`),
getResolvedConfig: () => ({}),
isAnilistTrackingEnabled: () => true,
getCurrentMediaKey: () => mediaKey,
hasMpvClient: () => true,
getTrackedMediaKey: () => mediaKey,
resetTrackedMedia: unexpected,
getWatchedSeconds: () => 1000,
maybeProbeAnilistDuration: async () => 1000,
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 3 }),
hasAttemptedUpdateKey: unexpected,
processNextAnilistRetryUpdate: unexpected,
refreshAnilistClientSecretState: unexpected,
enqueueRetry: unexpected,
markRetryFailure: unexpected,
markRetrySuccess: unexpected,
refreshRetryQueueState: unexpected,
updateAnilistPostWatchProgress: unexpected,
rememberAttemptedUpdateKey: unexpected,
showMpvOsd: unexpected,
logInfo: unexpected,
logWarn: unexpected,
minWatchSeconds: 600,
minWatchRatio: 0.85,
});
await handler();
await handler({ force: true });
assert.deepEqual(calls, ['inflight:true', 'inflight:false', 'inflight:true', 'inflight:false']);
}
});
test('createProcessNextAnilistRetryUpdateHandler handles successful retry', async () => {
const calls: string[] = [];
const handler = createProcessNextAnilistRetryUpdateHandler({
@@ -335,6 +382,7 @@ test('createMaybeRunAnilistPostWatchUpdateHandler notifies when retry already ha
const attemptedKeys = new Set<string>();
const mediaKey = '/tmp/video.mkv';
const attemptKey = buildAnilistAttemptKey(mediaKey, 1);
assert.ok(attemptKey);
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => false,
setInFlight: (value) => calls.push(`inflight:${value}`),
+5 -2
View File
@@ -1,4 +1,5 @@
import { isYoutubeMediaPath } from './youtube-playback';
import { toMediaIdentityPath } from '../../shared/media-identity';
type AnilistGuess = {
title: string;
@@ -31,8 +32,9 @@ type AnilistDurationProbeOptions = {
force?: boolean;
};
export function buildAnilistAttemptKey(mediaKey: string, episode: number): string {
return `${mediaKey}::${episode}`;
export function buildAnilistAttemptKey(mediaKey: string, episode: number): string | null {
const identity = toMediaIdentityPath(mediaKey);
return identity ? `${identity}::${episode}` : null;
}
export function rememberAnilistAttemptedUpdateKey(
@@ -214,6 +216,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
}
const attemptKey = buildAnilistAttemptKey(mediaKey, guess.episode);
if (!attemptKey) return;
if (deps.hasAttemptedUpdateKey(attemptKey)) {
return;
}
+3 -1
View File
@@ -1,3 +1,5 @@
import { toMediaIdentityPath } from '../../shared/media-identity';
type ResolvedConfigLike = {
immersionTracking?: {
dbPath?: string | null;
@@ -119,7 +121,7 @@ export function createImmersionMediaRuntime(deps: ImmersionMediaRuntimeDeps): {
const mediaState = await getCurrentMpvMediaStateForTracker();
if (mediaState.path) {
deps.logInfo(
`Seeded immersion tracker media state at attempt ${attempt + 1}/${attempts}: ${mediaState.path}`,
`Seeded immersion tracker media state at attempt ${attempt + 1}/${attempts}: ${toMediaIdentityPath(mediaState.path)}`,
);
tracker.handleMediaChange(mediaState.path, mediaState.title);
return;
@@ -103,6 +103,7 @@ test('playback handler drives mpv commands and playback state', async () => {
['set_property', 'sub-visibility', 'no'],
['set_property', 'secondary-sub-visibility', 'no'],
['script-message', 'subminer-managed-subtitles-loading'],
['set_property', 'force-media-title', 'Episode 1'],
[
'loadfile',
'https://stream.example/video.m3u8',
@@ -110,7 +111,6 @@ test('playback handler drives mpv commands and playback state', async () => {
-1,
'sid=no,secondary-sid=no,sub-auto=no,sub-visibility=no,secondary-sub-visibility=no,start=1.2',
],
['set_property', 'force-media-title', 'Episode 1'],
]);
assert.equal(scheduled.length, 0);
assert.equal(
@@ -437,6 +437,8 @@ test('playback handler publishes Jellyfin title before loading tokenized stream
assert.ok(titleIndex >= 0);
assert.ok(loadIndex >= 0);
assert.ok(titleIndex < loadIndex);
const mpvTitleIndex = timeline.indexOf('cmd:set_property:force-media-title');
assert.ok(mpvTitleIndex >= 0 && mpvTitleIndex < loadIndex);
assert.equal(timeline[titleIndex]?.includes('api_key'), false);
});
+2 -1
View File
@@ -221,11 +221,12 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
});
deps.setLastProgressAtMs(0);
deps.sendMpvCommand(['script-message', 'subminer-managed-subtitles-loading']);
// Set mpv's title before loadfile can emit a URL-derived media-title event.
deps.sendMpvCommand(['set_property', 'force-media-title', plan.title]);
deps.sendMpvCommand(['loadfile', playbackUrl, 'replace', -1, loadfileOptions]);
if (params.setQuitOnDisconnectArm !== false) {
deps.armQuitOnDisconnect();
}
deps.sendMpvCommand(['set_property', 'force-media-title', plan.title]);
await awaitBestEffortPlaybackHook(() =>
deps.preloadExternalSubtitles({
@@ -23,6 +23,7 @@ function fixture(overrides: Partial<SubtitleGenerationRuntimeDeps> = {}) {
getModelDirectory: () => '/models',
getMpvClient: () => client,
onProgress: () => {},
detectAcceleration: async () => ({ kind: 'unavailable' }),
resolveModel: async () => ({ kind: 'external', path: '/models/local.bin' }),
resolveTools: async (config) => ({
ffmpeg: { kind: 'found', path: '/usr/bin/ffmpeg' },
@@ -71,6 +72,27 @@ test('generation preserves the output without attaching it to a different video'
assert.deepEqual(subject.commands, []);
});
test('generation selects loaded dialogue references and excludes the signs track', async () => {
const subject = fixture({
generate: async (input) => {
assert.deepEqual(input.references, [
{ label: 'English Full', delaySeconds: 0, source: { kind: 'embedded', streamIndex: 5 } },
]);
return '/video/generated.srt';
},
});
const request = subject.client.requestProperty;
subject.client.requestProperty = async (name) =>
name === 'track-list'
? [
{ type: 'audio', selected: true, 'ff-index': 3 },
{ type: 'sub', lang: 'eng', title: 'Signs & Songs', 'ff-index': 4 },
{ type: 'sub', lang: 'eng', title: 'English Full', 'ff-index': 5 },
]
: request(name);
assert.equal((await subject.runtime.start()).ok, true);
});
test('mpv load failure still reports where the generated subtitles were saved', async () => {
const subject = fixture();
subject.client.request = async () => ({ error: 'loading failed' });
@@ -182,6 +204,40 @@ test('external model paths prevent managed selection, including unreadable overr
await assert.rejects(runtime.selectModel('medium'), /Clear Model Path/);
});
test('CUDA recommendations preserve selected and configured models and follow the Whisper path', async () => {
let whisperPath = '/cuda/whisper-cli';
const checked: string[] = [];
const subject = fixture({
getConfig: () => ({ ...DEFAULT_SUBTITLE_GENERATION_CONFIG, managedModel: 'medium' }),
resolveTools: async () => ({
ffmpeg: { kind: 'found', path: '/usr/bin/ffmpeg' },
ffprobe: { kind: 'found', path: '/usr/bin/ffprobe' },
whisper: { kind: 'found', path: whisperPath },
vad: null,
}),
detectAcceleration: async (whisper) => {
assert.equal(whisper.kind, 'found');
if (whisper.kind !== 'found') throw new Error('Expected a Whisper executable');
checked.push(whisper.path);
return whisper.path.startsWith('/cuda/')
? { kind: 'nvidia-cuda', gpuName: 'NVIDIA Test GPU' }
: { kind: 'unavailable' };
},
});
const initial = await subject.runtime.getStatus();
assert.deepEqual(initial.acceleration, { kind: 'nvidia-cuda', gpuName: 'NVIDIA Test GPU' });
assert.equal(initial.managedModel, 'medium');
const selected = await subject.runtime.selectModel('small');
assert.equal(selected.managedModel, 'small');
assert.equal(selected.acceleration.kind, 'nvidia-cuda');
assert.deepEqual(checked, ['/cuda/whisper-cli']);
whisperPath = '/cpu/whisper-cli';
const changed = await subject.runtime.getStatus();
assert.equal(changed.acceleration.kind, 'unavailable');
assert.equal(changed.managedModel, 'small');
assert.deepEqual(checked, ['/cuda/whisper-cli', '/cpu/whisper-cli']);
});
test('status reports the speech detector only while dialogue mode is on', async () => {
const { runtime } = fixture({
resolveVadModel: async () => ({ kind: 'managed', path: '/models/ggml-silero-v6.2.0.bin' }),
@@ -1,4 +1,6 @@
import path from 'node:path';
import { readSubtitleGenerationReferences } from '../../core/services/subtitle-generation-reference';
import { detectSubtitleGenerationAcceleration } from '../../core/services/subtitle-generation-acceleration';
import { SUBTITLE_GENERATION_VAD_MODEL } from '../../shared/subtitle-generation-vad-model';
import {
downloadSubtitleGenerationVadModel,
@@ -35,6 +37,7 @@ export interface SubtitleGenerationRuntimeDeps {
download?: typeof downloadSubtitleGenerationModel;
resolveModel?: typeof resolveSubtitleGenerationModel;
resolveTools?: typeof resolveSubtitleGenerationTools;
detectAcceleration?: typeof detectSubtitleGenerationAcceleration;
downloadVad?: typeof downloadSubtitleGenerationVadModel;
resolveVadModel?: typeof resolveSubtitleGenerationVadModel;
}
@@ -80,6 +83,13 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
let lastResult: SubtitleGenerationResult | null = null;
let selectedModel: SubtitleGenerationModelId | null = null;
let vadEnabled: boolean | null = null;
let accelerationCheck:
| {
path: string;
expires: number;
result: ReturnType<typeof detectSubtitleGenerationAcceleration>;
}
| undefined;
function getConfig(): SubtitleGenerationConfig {
const config = deps.getConfig();
return {
@@ -127,6 +137,20 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
async function getStatus(): Promise<SubtitleGenerationStatus> {
const config = getConfig();
const tools = await (deps.resolveTools ?? resolveSubtitleGenerationTools)(config);
const whisperPath = tools.whisper.kind === 'found' ? tools.whisper.path : '';
if (
!accelerationCheck ||
accelerationCheck.path !== whisperPath ||
(!controller && Date.now() >= accelerationCheck.expires)
) {
accelerationCheck = {
path: whisperPath,
expires: Date.now() + 30_000,
result: (deps.detectAcceleration ?? detectSubtitleGenerationAcceleration)(tools.whisper),
};
}
const acceleration = await accelerationCheck.result;
const model = await (deps.resolveModel ?? resolveSubtitleGenerationModel)(
config,
deps.getModelDirectory(),
@@ -142,7 +166,8 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
),
},
// Session toggles decide whether the speech detector executable is required.
tools: await (deps.resolveTools ?? resolveSubtitleGenerationTools)(config),
tools,
acceleration,
managedModel: config.managedModel,
externalModelPath: config.modelPath.trim() || null,
mediaPath,
@@ -214,7 +239,11 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
const mediaPath = await currentLocalMedia(client);
if (!client || !mediaPath)
throw new Error('Open a local video or audio file in mpv first.');
const audioStreamIndex = selectedAudioIndex(await client.requestProperty('track-list'));
const tracks = await client.requestProperty('track-list');
const audioStreamIndex = selectedAudioIndex(tracks);
const references = await readSubtitleGenerationReferences(tracks, (name) =>
client.requestProperty(name),
);
if ((await currentLocalMedia(client)) !== mediaPath)
throw new Error('The current media changed. Start generation again.');
signal.throwIfAborted();
@@ -223,6 +252,7 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
modelDirectory: deps.getModelDirectory(),
mediaPath,
audioStreamIndex,
references,
onProgress: report,
signal,
});
@@ -3,10 +3,25 @@ import test from 'node:test';
import {
describeGenerationModel,
describeGenerationProgress,
describeGenerationRecommendation,
describeGenerationTools,
describeGenerationVad,
} from './subtitle-generation-view';
test('only confirmed NVIDIA CUDA support recommends turbo', () => {
assert.deepEqual(describeGenerationRecommendation({ kind: 'unavailable' }), {
model: 'small',
text: 'NVIDIA CUDA acceleration was not confirmed. small is recommended.',
});
assert.deepEqual(
describeGenerationRecommendation({ kind: 'nvidia-cuda', gpuName: 'NVIDIA RTX 5070 Ti' }),
{
model: 'large-v3-turbo',
text: 'NVIDIA CUDA is available with NVIDIA RTX 5070 Ti. large-v3-turbo is recommended.',
},
);
});
test('missing tools block generation and list every install instruction', () => {
const found = { kind: 'found', path: '/usr/bin/tool' } as const;
assert.deepEqual(
@@ -1,11 +1,23 @@
import {
missingSubtitleGenerationTools,
recommendedSubtitleGenerationModel,
type SubtitleGenerationAcceleration,
type SubtitleGenerationModelStatus,
type SubtitleGenerationProgress,
type SubtitleGenerationTools,
} from '../../shared/subtitle-generation';
import type { SubtitleGenerationStatus } from '../../shared/subtitle-generation-ipc';
export function describeGenerationRecommendation(acceleration: SubtitleGenerationAcceleration) {
return {
model: recommendedSubtitleGenerationModel(acceleration),
text:
acceleration.kind === 'nvidia-cuda'
? `NVIDIA CUDA is available with ${acceleration.gpuName}. large-v3-turbo is recommended.`
: 'NVIDIA CUDA acceleration was not confirmed. small is recommended.',
};
}
export function describeGenerationTools(tools: SubtitleGenerationTools) {
const missing = missingSubtitleGenerationTools(tools);
if (missing.length > 0) return { ready: false, text: missing.join(' ') };
+10 -6
View File
@@ -1,7 +1,6 @@
import type { SubtitleGenerationProgress } from '../../shared/subtitle-generation';
import {
SUBTITLE_GENERATION_MODELS,
RECOMMENDED_SUBTITLE_GENERATION_MODEL,
formatSubtitleGenerationModelSize,
getSubtitleGenerationModel,
isSubtitleGenerationModelId,
@@ -16,6 +15,7 @@ import { createModalFocusGuard } from './modal-focus-guard';
import {
describeGenerationModel,
describeGenerationProgress,
describeGenerationRecommendation,
describeGenerationTools,
describeGenerationVad,
} from './subtitle-generation-view';
@@ -70,8 +70,7 @@ export function createSubtitleGenerationModal(
for (const model of SUBTITLE_GENERATION_MODELS) {
const option = document.createElement('option');
option.value = model.id;
const recommended = model.id === RECOMMENDED_SUBTITLE_GENERATION_MODEL ? ' (recommended)' : '';
option.textContent = `${model.id}${recommended} · ${formatSubtitleGenerationModelSize(model.size)}`;
option.textContent = `${model.id} · ${formatSubtitleGenerationModelSize(model.size)}`;
dom.modelSelect.append(option);
}
@@ -103,10 +102,15 @@ export function createSubtitleGenerationModal(
dom.modelPicker.classList.toggle('hidden', !snapshot || Boolean(snapshot.externalModelPath));
dom.modelSelect.disabled = busy || checking || !snapshot || Boolean(snapshot.externalModelPath);
if (snapshot) {
const recommendation = describeGenerationRecommendation(snapshot.acceleration);
for (const option of dom.modelSelect.options) {
if (!isSubtitleGenerationModelId(option.value)) continue;
const model = getSubtitleGenerationModel(option.value);
const recommended = model.id === recommendation.model ? ' (recommended)' : '';
option.textContent = `${model.id}${recommended} · ${formatSubtitleGenerationModelSize(model.size)}`;
}
dom.modelSelect.value = snapshot.managedModel;
dom.modelDescription.textContent = getSubtitleGenerationModel(
snapshot.managedModel,
).description;
dom.modelDescription.textContent = `${getSubtitleGenerationModel(snapshot.managedModel).description} ${recommendation.text}`;
}
dom.download.classList.toggle('hidden', !model?.download);
dom.download.textContent = snapshot
+37
View File
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { sanitizeMediaTitle, toMediaIdentityPath } from './media-identity';
test('media identity separates authenticated transport URLs from titles and stats keys', () => {
const url =
'https://user:password@jellyfin.example/Videos/item-1/stream?api_key=test-secret#token';
assert.equal(sanitizeMediaTitle(url), null);
assert.equal(sanitizeMediaTitle('stream?static=true&api_key=test-secret'), null);
assert.equal(sanitizeMediaTitle('stream static true api key test secret'), null);
assert.equal(sanitizeMediaTitle('stream%3Fapi_key%3Dtest-secret'), null);
assert.equal(sanitizeMediaTitle(' My Anime S01E02 '), 'My Anime S01E02');
assert.equal(toMediaIdentityPath(url), 'jellyfin://jellyfin.example/item/item-1');
assert.equal(
toMediaIdentityPath('https://example.com/base/Videos/item-1/master.m3u8?token=secret'),
'jellyfin://example.com/item/item-1',
);
assert.equal(toMediaIdentityPath('stream?api_key=test-secret'), '');
assert.equal(toMediaIdentityPath('/media/My Anime S01E02.mkv'), '/media/My Anime S01E02.mkv');
});
test('remote stats identities drop credentials while preserving YouTube video identity', () => {
assert.match(
toMediaIdentityPath('https://user:password@example.com/video.mkv?signature=secret#secret'),
/^https:\/\/example\.com\/video\.mkv#query-[a-f0-9]{64}$/,
);
assert.notEqual(
toMediaIdentityPath('https://example.com/video?id=1'),
toMediaIdentityPath('https://example.com/video?id=2'),
);
const identity = toMediaIdentityPath('https://example.com/video?id=1');
assert.equal(toMediaIdentityPath(identity), identity);
assert.equal(
toMediaIdentityPath('https://www.youtube.com/watch?v=video-id&token=secret'),
'https://www.youtube.com/watch?v=video-id',
);
});
+58
View File
@@ -0,0 +1,58 @@
import { createHash } from 'node:crypto';
const URL_SCHEME = /[a-z][a-z0-9+.-]*:\/\//i;
const QUERY_PAIR = /[?&][^=\s&#]+=/;
const CREDENTIAL_LABEL = /\b(?:api[_ -]?key|access[_ -]?token|x[_ -]?emby[_ -]?token)\b/i;
/** mpv can report the URL or its query-bearing basename before metadata arrives. */
export function sanitizeMediaTitle(value: string | null | undefined): string | null {
const title = value?.trim();
if (!title) return null;
let decoded = title;
try {
decoded = decodeURIComponent(title);
} catch {
// Ordinary titles can contain a literal percent sign.
}
if (URL_SCHEME.test(decoded) || QUERY_PAIR.test(decoded) || CREDENTIAL_LABEL.test(decoded)) {
return null;
}
return title;
}
export function resolveMediaLookupTarget(
mediaPath: string | null,
mediaTitle: string | null,
): string | null {
return sanitizeMediaTitle(mediaPath) ?? sanitizeMediaTitle(mediaTitle);
}
/** Persistent identity, never a URL to use for authenticated media retrieval. */
export function toMediaIdentityPath(mediaPath: string): string {
const value = mediaPath.trim();
if (!URL_SCHEME.test(value)) return sanitizeMediaTitle(value) ?? '';
try {
const url = new URL(value);
const jellyfinItem = url.pathname.match(
/\/Videos\/([^/]+)\/(?:stream(?:\.[^/]*)?|master\.m3u8)\/?$/i,
);
if (jellyfinItem) return `jellyfin://${url.host}/item/${jellyfinItem[1]}`;
url.username = '';
url.password = '';
const query = url.search;
const queryFingerprint = /^#query-[a-f0-9]{64}$/.test(url.hash) ? url.hash : '';
const youtubeId = /^(?:www\.|m\.)?youtube\.com$/i.test(url.hostname)
? url.searchParams.get('v')
: null;
url.search = '';
url.hash = queryFingerprint;
if (youtubeId) url.searchParams.set('v', youtubeId);
else if (query) {
// Different query-selected videos must not collapse into one stats entry.
url.hash = `query-${createHash('sha256').update(query).digest('hex')}`;
}
return url.toString();
} catch {
return '';
}
}
+2
View File
@@ -1,4 +1,5 @@
import type {
SubtitleGenerationAcceleration,
SubtitleGenerationConfig,
SubtitleGenerationModelStatus,
SubtitleGenerationProgress,
@@ -13,6 +14,7 @@ export interface SubtitleGenerationStatus {
model: SubtitleGenerationModelStatus;
vad: { enabled: boolean; model: SubtitleGenerationModelStatus };
tools: SubtitleGenerationTools;
acceleration: SubtitleGenerationAcceleration;
managedModel: SubtitleGenerationConfig['managedModel'];
externalModelPath: string | null;
mediaPath: string | null;
@@ -45,7 +45,7 @@ const MODEL_CATALOG = {
id: 'small',
size: 487601967,
sha256: '1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b',
description: 'Recommended starting point for accuracy and processing time.',
description: 'Balances accuracy and processing time with modest memory requirements.',
},
'small-q5_1': {
id: 'small-q5_1',
+10
View File
@@ -41,6 +41,16 @@ export type SubtitleGenerationToolStatus =
| { kind: 'found'; path: string }
| { kind: 'missing'; message: string };
export type SubtitleGenerationAcceleration =
| { kind: 'nvidia-cuda'; gpuName: string }
| { kind: 'unavailable' };
export function recommendedSubtitleGenerationModel(acceleration: SubtitleGenerationAcceleration) {
return acceleration.kind === 'nvidia-cuda'
? 'large-v3-turbo'
: RECOMMENDED_SUBTITLE_GENERATION_MODEL;
}
/** Executables generation depends on. `vad` is null unless dialogue mode is on. */
export interface SubtitleGenerationTools {
ffmpeg: SubtitleGenerationToolStatus;