From 5f3c3871d358ce138d369820edccc910de6ac0fe Mon Sep 17 00:00:00 2001 From: "Autumn (Bee)" Date: Mon, 6 Apr 2026 14:07:56 +0900 Subject: [PATCH] [codex] Prefer unlabeled external sidecars for local playback (#46) Co-authored-by: bee --- changes/43-local-subtitle-sidecar.md | 4 ++ .../runtime/local-subtitle-selection.test.ts | 69 +++++++++++++++++++ src/main/runtime/local-subtitle-selection.ts | 24 ++++++- 3 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 changes/43-local-subtitle-sidecar.md diff --git a/changes/43-local-subtitle-sidecar.md b/changes/43-local-subtitle-sidecar.md new file mode 100644 index 00000000..4b666895 --- /dev/null +++ b/changes/43-local-subtitle-sidecar.md @@ -0,0 +1,4 @@ +type: fixed +area: launcher + +Local playback now promotes a single unlabeled external subtitle sidecar to the primary slot instead of leaving mpv's embedded English auto-selection in place. diff --git a/src/main/runtime/local-subtitle-selection.test.ts b/src/main/runtime/local-subtitle-selection.test.ts index 2ae42f91..a03dba0b 100644 --- a/src/main/runtime/local-subtitle-selection.test.ts +++ b/src/main/runtime/local-subtitle-selection.test.ts @@ -15,6 +15,11 @@ const mixedLanguageTrackList = [ { type: 'sub', id: 12, lang: 'ja', title: 'ja.srt', external: true }, ]; +const unlabeledExternalSidecarTrackList = [ + { type: 'sub', id: 1, lang: 'eng', title: 'English ASS', external: false, selected: true }, + { type: 'sub', id: 2, title: 'srt', external: true }, +]; + test('resolveManagedLocalSubtitleSelection prefers default Japanese primary and English secondary tracks', () => { const result = resolveManagedLocalSubtitleSelection({ trackList: mixedLanguageTrackList, @@ -37,6 +42,31 @@ test('resolveManagedLocalSubtitleSelection respects configured language override assert.equal(result.secondaryTrackId, 12); }); +test('resolveManagedLocalSubtitleSelection promotes a single unlabeled external sidecar to primary', () => { + const result = resolveManagedLocalSubtitleSelection({ + trackList: unlabeledExternalSidecarTrackList, + primaryLanguages: [], + secondaryLanguages: [], + }); + + assert.equal(result.primaryTrackId, 2); + assert.equal(result.secondaryTrackId, 1); +}); + +test('resolveManagedLocalSubtitleSelection does not guess between multiple unlabeled external sidecars', () => { + const result = resolveManagedLocalSubtitleSelection({ + trackList: [ + ...unlabeledExternalSidecarTrackList, + { type: 'sub', id: 3, title: 'subrip', external: true }, + ], + primaryLanguages: [], + secondaryLanguages: [], + }); + + assert.equal(result.primaryTrackId, null); + assert.equal(result.secondaryTrackId, 1); +}); + test('managed local subtitle selection runtime applies preferred tracks once for a local media path', async () => { const commands: Array> = []; const scheduled: Array<() => void> = []; @@ -75,3 +105,42 @@ test('managed local subtitle selection runtime applies preferred tracks once for ['set_property', 'secondary-sid', 11], ]); }); + +test('managed local subtitle selection runtime promotes a single unlabeled external sidecar over embedded english', async () => { + const commands: Array> = []; + const scheduled: Array<() => void> = []; + + const runtime = createManagedLocalSubtitleSelectionRuntime({ + getCurrentMediaPath: () => '/videos/example.mkv', + getMpvClient: () => + ({ + connected: true, + requestProperty: async (name: string) => { + if (name === 'track-list') { + return unlabeledExternalSidecarTrackList; + } + throw new Error(`Unexpected property: ${name}`); + }, + }) as never, + getPrimarySubtitleLanguages: () => [], + getSecondarySubtitleLanguages: () => [], + sendMpvCommand: (command) => { + commands.push(command); + }, + schedule: (callback) => { + scheduled.push(callback); + return 1 as never; + }, + clearScheduled: () => {}, + }); + + runtime.handleMediaPathChange('/videos/example.mkv'); + scheduled.shift()?.(); + await new Promise((resolve) => setTimeout(resolve, 0)); + runtime.handleSubtitleTrackListChange(unlabeledExternalSidecarTrackList); + + assert.deepEqual(commands, [ + ['set_property', 'sid', 2], + ['set_property', 'secondary-sid', 1], + ]); +}); diff --git a/src/main/runtime/local-subtitle-selection.ts b/src/main/runtime/local-subtitle-selection.ts index f929cb2d..7594327f 100644 --- a/src/main/runtime/local-subtitle-selection.ts +++ b/src/main/runtime/local-subtitle-selection.ts @@ -90,6 +90,10 @@ function isLikelyHearingImpaired(title: string): boolean { return HEARING_IMPAIRED_PATTERN.test(title); } +function isUnlabeledExternalTrack(track: NormalizedSubtitleTrack): boolean { + return track.external && normalizeYoutubeLangCode(track.lang).length === 0; +} + function pickBestTrackId( tracks: NormalizedSubtitleTrack[], preferredLanguages: string[], @@ -126,6 +130,19 @@ function pickBestTrackId( }; } +function pickSingleUnlabeledExternalTrackId( + tracks: NormalizedSubtitleTrack[], + excludeId: number | null = null, +): number | null { + const fallbackCandidates = tracks.filter( + (track) => track.id !== excludeId && isUnlabeledExternalTrack(track), + ); + if (fallbackCandidates.length !== 1) { + return null; + } + return fallbackCandidates[0]?.id ?? null; +} + export function resolveManagedLocalSubtitleSelection(input: { trackList: unknown[] | null; primaryLanguages: string[]; @@ -146,12 +163,13 @@ export function resolveManagedLocalSubtitleSelection(input: { ); const primary = pickBestTrackId(tracks, preferredPrimaryLanguages); - const secondary = pickBestTrackId(tracks, preferredSecondaryLanguages, primary.trackId); + const primaryTrackId = primary.trackId ?? pickSingleUnlabeledExternalTrackId(tracks); + const secondary = pickBestTrackId(tracks, preferredSecondaryLanguages, primaryTrackId); return { - primaryTrackId: primary.trackId, + primaryTrackId, secondaryTrackId: secondary.trackId, - hasPrimaryMatch: primary.hasMatch, + hasPrimaryMatch: primary.hasMatch || primaryTrackId !== null, hasSecondaryMatch: secondary.hasMatch, }; }