mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-25 00:11:26 -07:00
fix: refine youtube subtitle startup binding
This commit is contained in:
@@ -43,7 +43,9 @@ test('youtube flow can open a manual picker session and load the selected subtit
|
|||||||
[secondaryTrack.id, '/tmp/manual-en.vtt'],
|
[secondaryTrack.id, '/tmp/manual-en.vtt'],
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
acquireYoutubeSubtitleTrack: async ({ track }) => ({ path: `/tmp/${track.id}.vtt` }),
|
acquireYoutubeSubtitleTrack: async ({ track }) => ({
|
||||||
|
path: `/tmp/${track.id.replace(/[^a-z0-9_-]+/gi, '-')}.vtt`,
|
||||||
|
}),
|
||||||
retimeYoutubePrimaryTrack: async ({ primaryPath }) => `${primaryPath}.retimed`,
|
retimeYoutubePrimaryTrack: async ({ primaryPath }) => `${primaryPath}.retimed`,
|
||||||
openPicker: async (payload) => {
|
openPicker: async (payload) => {
|
||||||
openedPayloads.push(payload);
|
openedPayloads.push(payload);
|
||||||
@@ -146,11 +148,13 @@ test('youtube flow can open a manual picker session and load the selected subtit
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
commands.some(
|
commands.every(
|
||||||
(command) =>
|
(command) =>
|
||||||
command[0] === 'set_property' &&
|
!(
|
||||||
command[1] === 'secondary-sub-visibility' &&
|
command[0] === 'set_property' &&
|
||||||
command[2] === 'yes',
|
command[1] === 'secondary-sub-visibility' &&
|
||||||
|
command[2] === 'yes'
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
assert.deepEqual(refreshedSidebarSources, ['/tmp/auto-ja-orig.vtt.retimed']);
|
assert.deepEqual(refreshedSidebarSources, ['/tmp/auto-ja-orig.vtt.retimed']);
|
||||||
@@ -161,6 +165,7 @@ test('youtube flow retries secondary after partial batch subtitle failure', asyn
|
|||||||
const acquireSingleCalls: string[] = [];
|
const acquireSingleCalls: string[] = [];
|
||||||
const commands: Array<Array<string | number>> = [];
|
const commands: Array<Array<string | number>> = [];
|
||||||
const waits: number[] = [];
|
const waits: number[] = [];
|
||||||
|
let secondaryTrackAdded = false;
|
||||||
|
|
||||||
const runtime = createYoutubeFlowRuntime({
|
const runtime = createYoutubeFlowRuntime({
|
||||||
probeYoutubeTracks: async () => ({
|
probeYoutubeTracks: async () => ({
|
||||||
@@ -190,29 +195,47 @@ test('youtube flow retries secondary after partial batch subtitle failure', asyn
|
|||||||
resumeMpv: () => {},
|
resumeMpv: () => {},
|
||||||
sendMpvCommand: (command) => {
|
sendMpvCommand: (command) => {
|
||||||
commands.push(command);
|
commands.push(command);
|
||||||
|
if (
|
||||||
|
command[0] === 'sub-add' &&
|
||||||
|
command[1] === '/tmp/manual:en.vtt' &&
|
||||||
|
command[2] === 'cached'
|
||||||
|
) {
|
||||||
|
secondaryTrackAdded = true;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
requestMpvProperty: async (name) => {
|
requestMpvProperty: async (name) => {
|
||||||
if (name === 'sub-text') {
|
if (name === 'sub-text') {
|
||||||
return '字幕です';
|
return '字幕です';
|
||||||
}
|
}
|
||||||
return [
|
return secondaryTrackAdded
|
||||||
{
|
? [
|
||||||
type: 'sub',
|
{
|
||||||
id: 5,
|
type: 'sub',
|
||||||
lang: 'ja-orig',
|
id: 5,
|
||||||
title: 'primary',
|
lang: 'ja-orig',
|
||||||
external: true,
|
title: 'primary',
|
||||||
'external-filename': '/tmp/auto-ja-orig.vtt',
|
external: true,
|
||||||
},
|
'external-filename': '/tmp/auto-ja-orig.vtt',
|
||||||
{
|
},
|
||||||
type: 'sub',
|
{
|
||||||
id: 6,
|
type: 'sub',
|
||||||
lang: 'en',
|
id: 6,
|
||||||
title: 'secondary',
|
lang: 'en',
|
||||||
external: true,
|
title: 'secondary',
|
||||||
'external-filename': '/tmp/manual:en.vtt',
|
external: true,
|
||||||
},
|
'external-filename': '/tmp/manual:en.vtt',
|
||||||
];
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
type: 'sub',
|
||||||
|
id: 5,
|
||||||
|
lang: 'ja-orig',
|
||||||
|
title: 'primary',
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/auto-ja-orig.vtt',
|
||||||
|
},
|
||||||
|
];
|
||||||
},
|
},
|
||||||
refreshCurrentSubtitle: () => {},
|
refreshCurrentSubtitle: () => {},
|
||||||
startTokenizationWarmups: async () => {},
|
startTokenizationWarmups: async () => {},
|
||||||
@@ -371,7 +394,9 @@ test('youtube flow retries secondary subtitle selection until mpv reports the ex
|
|||||||
[primaryTrack.id, '/tmp/auto-ja-orig.vtt'],
|
[primaryTrack.id, '/tmp/auto-ja-orig.vtt'],
|
||||||
[secondaryTrack.id, '/tmp/manual-en.vtt'],
|
[secondaryTrack.id, '/tmp/manual-en.vtt'],
|
||||||
]),
|
]),
|
||||||
acquireYoutubeSubtitleTrack: async ({ track }) => ({ path: `/tmp/${track.id}.vtt` }),
|
acquireYoutubeSubtitleTrack: async ({ track }) => ({
|
||||||
|
path: `/tmp/${track.id.replace(/[^a-z0-9_-]+/gi, '-')}.vtt`,
|
||||||
|
}),
|
||||||
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
|
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
|
||||||
openPicker: async (payload) => {
|
openPicker: async (payload) => {
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
@@ -410,7 +435,7 @@ test('youtube flow retries secondary subtitle selection until mpv reports the ex
|
|||||||
type: 'sub',
|
type: 'sub',
|
||||||
id: 6,
|
id: 6,
|
||||||
lang: 'en',
|
lang: 'en',
|
||||||
title: 'English',
|
title: 'manual-en.vtt',
|
||||||
external: true,
|
external: true,
|
||||||
'external-filename': null,
|
'external-filename': null,
|
||||||
},
|
},
|
||||||
@@ -448,3 +473,673 @@ test('youtube flow retries secondary subtitle selection until mpv reports the ex
|
|||||||
);
|
);
|
||||||
assert.ok(waits.includes(100));
|
assert.ok(waits.includes(100));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('youtube flow reuses the matching existing manual secondary track instead of a loose language match', async () => {
|
||||||
|
const commands: Array<Array<string | number>> = [];
|
||||||
|
let selectedSecondarySid: number | null = null;
|
||||||
|
|
||||||
|
const runtime = createYoutubeFlowRuntime({
|
||||||
|
probeYoutubeTracks: async () => ({
|
||||||
|
videoId: 'video123',
|
||||||
|
title: 'Video 123',
|
||||||
|
tracks: [
|
||||||
|
primaryTrack,
|
||||||
|
{
|
||||||
|
...secondaryTrack,
|
||||||
|
id: 'manual:en',
|
||||||
|
sourceLanguage: 'en',
|
||||||
|
kind: 'manual',
|
||||||
|
title: 'manual-en.vtt',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
acquireYoutubeSubtitleTracks: async () =>
|
||||||
|
new Map<string, string>([
|
||||||
|
[primaryTrack.id, '/tmp/auto-ja-orig.vtt'],
|
||||||
|
[secondaryTrack.id, '/tmp/manual-en.vtt'],
|
||||||
|
]),
|
||||||
|
acquireYoutubeSubtitleTrack: async ({ track }) => ({
|
||||||
|
path: `/tmp/${track.id.replace(/[^a-z0-9_-]+/gi, '-')}.vtt`,
|
||||||
|
}),
|
||||||
|
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
|
||||||
|
openPicker: async (payload) => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void runtime.resolveActivePicker({
|
||||||
|
sessionId: payload.sessionId,
|
||||||
|
action: 'use-selected',
|
||||||
|
primaryTrackId: primaryTrack.id,
|
||||||
|
secondaryTrackId: 'manual:en',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
pauseMpv: () => {},
|
||||||
|
resumeMpv: () => {},
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
commands.push(command);
|
||||||
|
if (
|
||||||
|
command[0] === 'set_property' &&
|
||||||
|
command[1] === 'secondary-sid' &&
|
||||||
|
typeof command[2] === 'number'
|
||||||
|
) {
|
||||||
|
selectedSecondarySid = command[2];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
requestMpvProperty: async (name) => {
|
||||||
|
if (name === 'sub-text') {
|
||||||
|
return '字幕です';
|
||||||
|
}
|
||||||
|
if (name === 'sid') {
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
if (name === 'secondary-sid') {
|
||||||
|
return selectedSecondarySid;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'sub',
|
||||||
|
id: 5,
|
||||||
|
lang: 'ja-orig',
|
||||||
|
title: 'auto-ja-orig.vtt',
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/auto-ja-orig.vtt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'sub',
|
||||||
|
id: 6,
|
||||||
|
lang: 'en',
|
||||||
|
title: 'English',
|
||||||
|
external: true,
|
||||||
|
'external-filename': null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'sub',
|
||||||
|
id: 8,
|
||||||
|
lang: 'en',
|
||||||
|
title: 'manual-en.vtt',
|
||||||
|
external: true,
|
||||||
|
'external-filename': null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
refreshCurrentSubtitle: () => {},
|
||||||
|
startTokenizationWarmups: async () => {},
|
||||||
|
waitForTokenizationReady: async () => {},
|
||||||
|
waitForAnkiReady: async () => {},
|
||||||
|
wait: async () => {},
|
||||||
|
waitForPlaybackWindowReady: async () => {},
|
||||||
|
waitForOverlayGeometryReady: async () => {},
|
||||||
|
focusOverlayWindow: () => {},
|
||||||
|
showMpvOsd: () => {},
|
||||||
|
reportSubtitleFailure: () => {
|
||||||
|
throw new Error('authoritative secondary bind should not report failure');
|
||||||
|
},
|
||||||
|
warn: (message) => {
|
||||||
|
throw new Error(message);
|
||||||
|
},
|
||||||
|
log: () => {},
|
||||||
|
getYoutubeOutputDir: () => '/tmp',
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.openManualPicker({ url: 'https://example.com' });
|
||||||
|
|
||||||
|
assert.equal(selectedSecondarySid, 8);
|
||||||
|
assert.ok(
|
||||||
|
commands.some(
|
||||||
|
(command) =>
|
||||||
|
command[0] === 'set_property' && command[1] === 'secondary-sid' && command[2] === 8,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('youtube flow leaves non-authoritative youtube subtitle tracks untouched after authoritative tracks bind', async () => {
|
||||||
|
const commands: Array<Array<string | number>> = [];
|
||||||
|
let selectedPrimarySid: number | null = null;
|
||||||
|
let selectedSecondarySid: number | null = null;
|
||||||
|
|
||||||
|
const runtime = createYoutubeFlowRuntime({
|
||||||
|
probeYoutubeTracks: async () => ({
|
||||||
|
videoId: 'video123',
|
||||||
|
title: 'Video 123',
|
||||||
|
tracks: [primaryTrack, secondaryTrack],
|
||||||
|
}),
|
||||||
|
acquireYoutubeSubtitleTracks: async () =>
|
||||||
|
new Map<string, string>([
|
||||||
|
[primaryTrack.id, '/tmp/manual-ja.ja.srt'],
|
||||||
|
[secondaryTrack.id, '/tmp/manual-en.en.srt'],
|
||||||
|
]),
|
||||||
|
acquireYoutubeSubtitleTrack: async ({ track }) => ({
|
||||||
|
path: `/tmp/${track.id.replace(/[^a-z0-9_-]+/gi, '-')}.vtt`,
|
||||||
|
}),
|
||||||
|
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
|
||||||
|
openPicker: async (payload) => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void runtime.resolveActivePicker({
|
||||||
|
sessionId: payload.sessionId,
|
||||||
|
action: 'use-selected',
|
||||||
|
primaryTrackId: primaryTrack.id,
|
||||||
|
secondaryTrackId: secondaryTrack.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
pauseMpv: () => {},
|
||||||
|
resumeMpv: () => {},
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
commands.push(command);
|
||||||
|
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
|
||||||
|
selectedPrimarySid = command[2];
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
command[0] === 'set_property' &&
|
||||||
|
command[1] === 'secondary-sid' &&
|
||||||
|
typeof command[2] === 'number'
|
||||||
|
) {
|
||||||
|
selectedSecondarySid = command[2];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
requestMpvProperty: async (name) => {
|
||||||
|
if (name === 'sub-text') {
|
||||||
|
return '字幕です';
|
||||||
|
}
|
||||||
|
if (name === 'sid') {
|
||||||
|
return selectedPrimarySid;
|
||||||
|
}
|
||||||
|
if (name === 'secondary-sid') {
|
||||||
|
return selectedSecondarySid;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ type: 'sub', id: 1, lang: 'en', title: 'English', external: true, 'external-filename': null },
|
||||||
|
{ type: 'sub', id: 2, lang: 'ja', title: 'Japanese', external: true, 'external-filename': null },
|
||||||
|
{ type: 'sub', id: 3, lang: 'ja-en', title: 'Japanese from English', external: true, 'external-filename': null },
|
||||||
|
{ type: 'sub', id: 4, lang: 'ja-ja', title: 'Japanese from Japanese', external: true, 'external-filename': null },
|
||||||
|
{ type: 'sub', id: 5, lang: 'ja-orig', title: 'auto-ja-orig.vtt', external: true, 'external-filename': '/tmp/auto-ja-orig.vtt' },
|
||||||
|
{ type: 'sub', id: 6, lang: 'en', title: 'manual-en.en.srt', external: true, 'external-filename': '/tmp/manual-en.en.srt' },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
refreshCurrentSubtitle: () => {},
|
||||||
|
startTokenizationWarmups: async () => {},
|
||||||
|
waitForTokenizationReady: async () => {},
|
||||||
|
waitForAnkiReady: async () => {},
|
||||||
|
wait: async () => {},
|
||||||
|
waitForPlaybackWindowReady: async () => {},
|
||||||
|
waitForOverlayGeometryReady: async () => {},
|
||||||
|
focusOverlayWindow: () => {},
|
||||||
|
showMpvOsd: () => {},
|
||||||
|
reportSubtitleFailure: () => {
|
||||||
|
throw new Error('authoritative bind should not report failure');
|
||||||
|
},
|
||||||
|
warn: (message) => {
|
||||||
|
throw new Error(message);
|
||||||
|
},
|
||||||
|
log: () => {},
|
||||||
|
getYoutubeOutputDir: () => '/tmp',
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.openManualPicker({ url: 'https://example.com' });
|
||||||
|
|
||||||
|
assert.equal(commands.some((command) => command[0] === 'sub-remove'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('youtube flow reuses existing manual youtube subtitle tracks when both requested languages already exist', async () => {
|
||||||
|
const commands: Array<Array<string | number>> = [];
|
||||||
|
let selectedPrimarySid: number | null = null;
|
||||||
|
let selectedSecondarySid: number | null = null;
|
||||||
|
const refreshedSidebarSources: string[] = [];
|
||||||
|
|
||||||
|
const runtime = createYoutubeFlowRuntime({
|
||||||
|
probeYoutubeTracks: async () => ({
|
||||||
|
videoId: 'video123',
|
||||||
|
title: 'Video 123',
|
||||||
|
tracks: [
|
||||||
|
{ ...primaryTrack, id: 'manual:ja', sourceLanguage: 'ja', kind: 'manual', title: 'Japanese' },
|
||||||
|
{ ...secondaryTrack, id: 'manual:en', sourceLanguage: 'en', kind: 'manual', title: 'English' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
acquireYoutubeSubtitleTracks: async () => {
|
||||||
|
throw new Error('should not batch download when both manual tracks already exist in mpv');
|
||||||
|
},
|
||||||
|
acquireYoutubeSubtitleTrack: async ({ track }) => {
|
||||||
|
if (track.language === 'ja') {
|
||||||
|
return { path: '/tmp/manual-ja.ja.srt' };
|
||||||
|
}
|
||||||
|
throw new Error('should not download secondary track when manual english already exists');
|
||||||
|
},
|
||||||
|
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
|
||||||
|
openPicker: async (payload) => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void runtime.resolveActivePicker({
|
||||||
|
sessionId: payload.sessionId,
|
||||||
|
action: 'use-selected',
|
||||||
|
primaryTrackId: 'manual:ja',
|
||||||
|
secondaryTrackId: 'manual:en',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
pauseMpv: () => {},
|
||||||
|
resumeMpv: () => {},
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
commands.push(command);
|
||||||
|
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
|
||||||
|
selectedPrimarySid = command[2];
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
command[0] === 'set_property' &&
|
||||||
|
command[1] === 'secondary-sid' &&
|
||||||
|
typeof command[2] === 'number'
|
||||||
|
) {
|
||||||
|
selectedSecondarySid = command[2];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
requestMpvProperty: async (name) => {
|
||||||
|
if (name === 'sub-text') {
|
||||||
|
return '字幕です';
|
||||||
|
}
|
||||||
|
if (name === 'sid') {
|
||||||
|
return selectedPrimarySid;
|
||||||
|
}
|
||||||
|
if (name === 'secondary-sid') {
|
||||||
|
return selectedSecondarySid;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ type: 'sub', id: 1, lang: 'en', title: 'English', external: true, 'external-filename': null },
|
||||||
|
{ type: 'sub', id: 2, lang: 'ja', title: 'Japanese', external: true, 'external-filename': null },
|
||||||
|
{ type: 'sub', id: 3, lang: 'ja-en', title: 'Japanese from English', external: true, 'external-filename': null },
|
||||||
|
{ type: 'sub', id: 4, lang: 'ja-ja', title: 'Japanese from Japanese', external: true, 'external-filename': null },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
refreshCurrentSubtitle: () => {},
|
||||||
|
refreshSubtitleSidebarSource: async (sourcePath) => {
|
||||||
|
refreshedSidebarSources.push(sourcePath);
|
||||||
|
},
|
||||||
|
startTokenizationWarmups: async () => {},
|
||||||
|
waitForTokenizationReady: async () => {},
|
||||||
|
waitForAnkiReady: async () => {},
|
||||||
|
wait: async () => {},
|
||||||
|
waitForPlaybackWindowReady: async () => {},
|
||||||
|
waitForOverlayGeometryReady: async () => {},
|
||||||
|
focusOverlayWindow: () => {},
|
||||||
|
showMpvOsd: () => {},
|
||||||
|
reportSubtitleFailure: () => {
|
||||||
|
throw new Error('existing manual tracks should not report failure');
|
||||||
|
},
|
||||||
|
warn: (message) => {
|
||||||
|
throw new Error(message);
|
||||||
|
},
|
||||||
|
log: () => {},
|
||||||
|
getYoutubeOutputDir: () => '/tmp',
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.openManualPicker({ url: 'https://example.com' });
|
||||||
|
|
||||||
|
assert.equal(selectedPrimarySid, 2);
|
||||||
|
assert.equal(selectedSecondarySid, 1);
|
||||||
|
assert.equal(commands.some((command) => command[0] === 'sub-add'), false);
|
||||||
|
assert.deepEqual(refreshedSidebarSources, ['/tmp/manual-ja.ja.srt']);
|
||||||
|
assert.equal(commands.some((command) => command[0] === 'sub-remove'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('youtube flow waits for manual youtube tracks to appear before falling back to injected copies', async () => {
|
||||||
|
const commands: Array<Array<string | number>> = [];
|
||||||
|
let selectedPrimarySid: number | null = null;
|
||||||
|
let selectedSecondarySid: number | null = null;
|
||||||
|
let trackListReads = 0;
|
||||||
|
|
||||||
|
const runtime = createYoutubeFlowRuntime({
|
||||||
|
probeYoutubeTracks: async () => ({
|
||||||
|
videoId: 'video123',
|
||||||
|
title: 'Video 123',
|
||||||
|
tracks: [
|
||||||
|
{ ...primaryTrack, id: 'manual:ja', sourceLanguage: 'ja', kind: 'manual', title: 'Japanese' },
|
||||||
|
{ ...secondaryTrack, id: 'manual:en', sourceLanguage: 'en', kind: 'manual', title: 'English' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
acquireYoutubeSubtitleTracks: async () => {
|
||||||
|
throw new Error('should not batch download when manual tracks appear after startup');
|
||||||
|
},
|
||||||
|
acquireYoutubeSubtitleTrack: async ({ track }) => {
|
||||||
|
if (track.language === 'ja') {
|
||||||
|
return { path: '/tmp/manual-ja.ja.srt' };
|
||||||
|
}
|
||||||
|
throw new Error('should not download secondary track when manual english appears in mpv');
|
||||||
|
},
|
||||||
|
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
|
||||||
|
openPicker: async (payload) => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void runtime.resolveActivePicker({
|
||||||
|
sessionId: payload.sessionId,
|
||||||
|
action: 'use-selected',
|
||||||
|
primaryTrackId: 'manual:ja',
|
||||||
|
secondaryTrackId: 'manual:en',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
pauseMpv: () => {},
|
||||||
|
resumeMpv: () => {},
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
commands.push(command);
|
||||||
|
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
|
||||||
|
selectedPrimarySid = command[2];
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
command[0] === 'set_property' &&
|
||||||
|
command[1] === 'secondary-sid' &&
|
||||||
|
typeof command[2] === 'number'
|
||||||
|
) {
|
||||||
|
selectedSecondarySid = command[2];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
requestMpvProperty: async (name) => {
|
||||||
|
if (name === 'sub-text') {
|
||||||
|
return '字幕です';
|
||||||
|
}
|
||||||
|
if (name === 'sid') {
|
||||||
|
return selectedPrimarySid;
|
||||||
|
}
|
||||||
|
if (name === 'secondary-sid') {
|
||||||
|
return selectedSecondarySid;
|
||||||
|
}
|
||||||
|
trackListReads += 1;
|
||||||
|
if (trackListReads === 1) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ type: 'sub', id: 1, lang: 'en', title: 'English', external: true, 'external-filename': null },
|
||||||
|
{ type: 'sub', id: 2, lang: 'ja', title: 'Japanese', external: true, 'external-filename': null },
|
||||||
|
{ type: 'sub', id: 3, lang: 'ja-en', title: 'Japanese from English', external: true, 'external-filename': null },
|
||||||
|
{ type: 'sub', id: 4, lang: 'ja-ja', title: 'Japanese from Japanese', external: true, 'external-filename': null },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
refreshCurrentSubtitle: () => {},
|
||||||
|
startTokenizationWarmups: async () => {},
|
||||||
|
waitForTokenizationReady: async () => {},
|
||||||
|
waitForAnkiReady: async () => {},
|
||||||
|
wait: async () => {},
|
||||||
|
waitForPlaybackWindowReady: async () => {},
|
||||||
|
waitForOverlayGeometryReady: async () => {},
|
||||||
|
focusOverlayWindow: () => {},
|
||||||
|
showMpvOsd: () => {},
|
||||||
|
reportSubtitleFailure: () => {
|
||||||
|
throw new Error('delayed manual tracks should not report failure');
|
||||||
|
},
|
||||||
|
warn: (message) => {
|
||||||
|
throw new Error(message);
|
||||||
|
},
|
||||||
|
log: () => {},
|
||||||
|
getYoutubeOutputDir: () => '/tmp',
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.openManualPicker({ url: 'https://example.com' });
|
||||||
|
|
||||||
|
assert.equal(selectedPrimarySid, 2);
|
||||||
|
assert.equal(selectedSecondarySid, 1);
|
||||||
|
assert.equal(commands.some((command) => command[0] === 'sub-add'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('youtube flow reuses manual youtube tracks even when mpv exposes external filenames', async () => {
|
||||||
|
const commands: Array<Array<string | number>> = [];
|
||||||
|
let selectedPrimarySid: number | null = null;
|
||||||
|
let selectedSecondarySid: number | null = null;
|
||||||
|
|
||||||
|
const runtime = createYoutubeFlowRuntime({
|
||||||
|
probeYoutubeTracks: async () => ({
|
||||||
|
videoId: 'video123',
|
||||||
|
title: 'Video 123',
|
||||||
|
tracks: [
|
||||||
|
{
|
||||||
|
id: 'manual:ja',
|
||||||
|
language: 'ja',
|
||||||
|
sourceLanguage: 'ja',
|
||||||
|
kind: 'manual',
|
||||||
|
title: 'Japanese',
|
||||||
|
label: 'Japanese',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'manual:en',
|
||||||
|
language: 'en',
|
||||||
|
sourceLanguage: 'en',
|
||||||
|
kind: 'manual',
|
||||||
|
title: 'English',
|
||||||
|
label: 'English',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
acquireYoutubeSubtitleTracks: async () => {
|
||||||
|
throw new Error('should not batch-download when existing manual tracks are reusable');
|
||||||
|
},
|
||||||
|
acquireYoutubeSubtitleTrack: async ({ track }) => {
|
||||||
|
if (track.id === 'manual:ja') {
|
||||||
|
return { path: '/tmp/manual-ja.ja.srt' };
|
||||||
|
}
|
||||||
|
throw new Error('should not download secondary track when existing manual english track is reusable');
|
||||||
|
},
|
||||||
|
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
|
||||||
|
openPicker: async () => false,
|
||||||
|
pauseMpv: () => {},
|
||||||
|
resumeMpv: () => {},
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
commands.push(command);
|
||||||
|
if (command[0] === 'set_property' && command[1] === 'sid') {
|
||||||
|
selectedPrimarySid = Number(command[2]);
|
||||||
|
}
|
||||||
|
if (command[0] === 'set_property' && command[1] === 'secondary-sid') {
|
||||||
|
selectedSecondarySid = Number(command[2]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
requestMpvProperty: async (name) => {
|
||||||
|
if (name === 'track-list') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'sub',
|
||||||
|
id: 1,
|
||||||
|
lang: 'en',
|
||||||
|
title: 'English',
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/mpv-ytdl-track-en.vtt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'sub',
|
||||||
|
id: 2,
|
||||||
|
lang: 'ja',
|
||||||
|
title: 'Japanese',
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/mpv-ytdl-track-ja.vtt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'sub',
|
||||||
|
id: 3,
|
||||||
|
lang: 'ja-en',
|
||||||
|
title: 'Japanese from English',
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/mpv-ytdl-track-ja-en.vtt',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (name === 'sid') {
|
||||||
|
return selectedPrimarySid;
|
||||||
|
}
|
||||||
|
if (name === 'secondary-sid') {
|
||||||
|
return selectedSecondarySid;
|
||||||
|
}
|
||||||
|
if (name === 'sub-text') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
refreshCurrentSubtitle: () => {},
|
||||||
|
refreshSubtitleSidebarSource: async () => {},
|
||||||
|
startTokenizationWarmups: async () => {},
|
||||||
|
waitForTokenizationReady: async () => {},
|
||||||
|
waitForAnkiReady: async () => {},
|
||||||
|
wait: async () => {},
|
||||||
|
waitForPlaybackWindowReady: async () => {},
|
||||||
|
waitForOverlayGeometryReady: async () => {},
|
||||||
|
focusOverlayWindow: () => {},
|
||||||
|
showMpvOsd: () => {},
|
||||||
|
reportSubtitleFailure: (message) => {
|
||||||
|
throw new Error(message);
|
||||||
|
},
|
||||||
|
warn: (message) => {
|
||||||
|
throw new Error(message);
|
||||||
|
},
|
||||||
|
log: () => {},
|
||||||
|
getYoutubeOutputDir: () => '/tmp',
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.runYoutubePlaybackFlow({
|
||||||
|
url: 'https://example.com/watch?v=video123',
|
||||||
|
mode: 'download',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(selectedPrimarySid, 2);
|
||||||
|
assert.equal(selectedSecondarySid, 1);
|
||||||
|
assert.equal(commands.some((command) => command[0] === 'sub-add'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('youtube flow falls back to existing auto secondary track when auto secondary download fails', async () => {
|
||||||
|
const commands: Array<Array<string | number>> = [];
|
||||||
|
let selectedPrimarySid: number | null = null;
|
||||||
|
let selectedSecondarySid: number | null = null;
|
||||||
|
let primaryTrackAdded = false;
|
||||||
|
|
||||||
|
const runtime = createYoutubeFlowRuntime({
|
||||||
|
probeYoutubeTracks: async () => ({
|
||||||
|
videoId: 'video123',
|
||||||
|
title: 'Video 123',
|
||||||
|
tracks: [
|
||||||
|
{
|
||||||
|
id: 'auto:ja-orig',
|
||||||
|
language: 'ja-orig',
|
||||||
|
sourceLanguage: 'ja-orig',
|
||||||
|
kind: 'auto',
|
||||||
|
title: 'Japanese (Original)',
|
||||||
|
label: 'Japanese (Original) (auto)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'auto:en',
|
||||||
|
language: 'en',
|
||||||
|
sourceLanguage: 'en',
|
||||||
|
kind: 'auto',
|
||||||
|
title: 'English',
|
||||||
|
label: 'English (auto)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
acquireYoutubeSubtitleTracks: async () =>
|
||||||
|
new Map<string, string>([['auto:ja-orig', '/tmp/auto-ja-orig.ja-orig.vtt']]),
|
||||||
|
acquireYoutubeSubtitleTrack: async ({ track }) => {
|
||||||
|
if (track.id === 'auto:en') {
|
||||||
|
throw new Error('HTTP 429 while downloading en');
|
||||||
|
}
|
||||||
|
return { path: '/tmp/auto-ja-orig.ja-orig.vtt' };
|
||||||
|
},
|
||||||
|
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
|
||||||
|
openPicker: async () => false,
|
||||||
|
pauseMpv: () => {},
|
||||||
|
resumeMpv: () => {},
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
commands.push(command);
|
||||||
|
if (
|
||||||
|
command[0] === 'sub-add' &&
|
||||||
|
command[1] === '/tmp/auto-ja-orig.ja-orig.vtt' &&
|
||||||
|
command[2] === 'select'
|
||||||
|
) {
|
||||||
|
primaryTrackAdded = true;
|
||||||
|
}
|
||||||
|
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
|
||||||
|
selectedPrimarySid = command[2];
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
command[0] === 'set_property' &&
|
||||||
|
command[1] === 'secondary-sid' &&
|
||||||
|
typeof command[2] === 'number'
|
||||||
|
) {
|
||||||
|
selectedSecondarySid = command[2];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
requestMpvProperty: async (name) => {
|
||||||
|
if (name === 'sub-text') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (name === 'sid') {
|
||||||
|
return selectedPrimarySid;
|
||||||
|
}
|
||||||
|
if (name === 'secondary-sid') {
|
||||||
|
return selectedSecondarySid;
|
||||||
|
}
|
||||||
|
return primaryTrackAdded
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: 'sub',
|
||||||
|
id: 1,
|
||||||
|
lang: 'en',
|
||||||
|
title: 'English',
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/mpv-auto-en.vtt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'sub',
|
||||||
|
id: 3,
|
||||||
|
lang: 'ja-orig',
|
||||||
|
title: 'Japanese (Original)',
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/mpv-auto-ja-orig.vtt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'sub',
|
||||||
|
id: 4,
|
||||||
|
lang: 'ja-orig',
|
||||||
|
title: 'auto-ja-orig.ja-orig.vtt',
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/auto-ja-orig.ja-orig.vtt',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
type: 'sub',
|
||||||
|
id: 1,
|
||||||
|
lang: 'en',
|
||||||
|
title: 'English',
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/mpv-auto-en.vtt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'sub',
|
||||||
|
id: 3,
|
||||||
|
lang: 'ja-orig',
|
||||||
|
title: 'Japanese (Original)',
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/mpv-auto-ja-orig.vtt',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
refreshCurrentSubtitle: () => {},
|
||||||
|
refreshSubtitleSidebarSource: async () => {},
|
||||||
|
startTokenizationWarmups: async () => {},
|
||||||
|
waitForTokenizationReady: async () => {},
|
||||||
|
waitForAnkiReady: async () => {},
|
||||||
|
wait: async () => {},
|
||||||
|
waitForPlaybackWindowReady: async () => {},
|
||||||
|
waitForOverlayGeometryReady: async () => {},
|
||||||
|
focusOverlayWindow: () => {},
|
||||||
|
showMpvOsd: () => {},
|
||||||
|
reportSubtitleFailure: (message) => {
|
||||||
|
throw new Error(message);
|
||||||
|
},
|
||||||
|
warn: (message) => {
|
||||||
|
throw new Error(message);
|
||||||
|
},
|
||||||
|
log: () => {},
|
||||||
|
getYoutubeOutputDir: () => '/tmp',
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.runYoutubePlaybackFlow({
|
||||||
|
url: 'https://example.com/watch?v=video123',
|
||||||
|
mode: 'download',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(selectedPrimarySid, 4);
|
||||||
|
assert.equal(selectedSecondarySid, 1);
|
||||||
|
});
|
||||||
|
|||||||
@@ -175,10 +175,113 @@ function normalizeTrackListEntry(track: Record<string, unknown>): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function matchesTitleBasename(title: string, basename: string): boolean {
|
||||||
|
const normalizedTitle = title.trim();
|
||||||
|
return normalizedTitle.length > 0 && path.basename(normalizedTitle) === basename;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLikelyTranslatedYoutubeTrack(entry: { lang: string; title: string }): boolean {
|
||||||
|
const normalizedTitle = entry.title.trim().toLowerCase();
|
||||||
|
if (normalizedTitle.includes(' from ')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return /-[a-z]{2,}(?:-[a-z0-9]+)?$/i.test(entry.lang.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchExistingManualYoutubeTrackId(
|
||||||
|
trackListRaw: unknown,
|
||||||
|
trackOption: YoutubeTrackOption,
|
||||||
|
excludeId: number | null = null,
|
||||||
|
): number | null {
|
||||||
|
if (!Array.isArray(trackListRaw)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedTitle = trackOption.title?.trim().toLowerCase() || '';
|
||||||
|
const expectedLanguages = new Set(
|
||||||
|
[trackOption.language, trackOption.sourceLanguage]
|
||||||
|
.map((value) => value.trim().toLowerCase())
|
||||||
|
.filter((value) => value.length > 0),
|
||||||
|
);
|
||||||
|
const tracks = trackListRaw
|
||||||
|
.filter(
|
||||||
|
(track): track is Record<string, unknown> => Boolean(track) && typeof track === 'object',
|
||||||
|
)
|
||||||
|
.filter((track) => track.type === 'sub')
|
||||||
|
.map(normalizeTrackListEntry)
|
||||||
|
.filter((track) => track.external && track.id !== null && track.id !== excludeId)
|
||||||
|
.filter((track) => !isLikelyTranslatedYoutubeTrack(track));
|
||||||
|
|
||||||
|
const exactTitleMatch = tracks.find(
|
||||||
|
(track) =>
|
||||||
|
expectedTitle.length > 0 &&
|
||||||
|
track.title.trim().toLowerCase() === expectedTitle &&
|
||||||
|
expectedLanguages.has(track.lang.trim().toLowerCase()),
|
||||||
|
);
|
||||||
|
if (exactTitleMatch?.id !== null && exactTitleMatch?.id !== undefined) {
|
||||||
|
return exactTitleMatch.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectedTitle.length === 0) {
|
||||||
|
const languageOnlyMatch = tracks.find((track) =>
|
||||||
|
expectedLanguages.has(track.lang.trim().toLowerCase()),
|
||||||
|
);
|
||||||
|
if (languageOnlyMatch?.id !== null && languageOnlyMatch?.id !== undefined) {
|
||||||
|
return languageOnlyMatch.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchExistingYoutubeTrackId(
|
||||||
|
trackListRaw: unknown,
|
||||||
|
trackOption: YoutubeTrackOption,
|
||||||
|
excludeId: number | null = null,
|
||||||
|
): number | null {
|
||||||
|
if (!Array.isArray(trackListRaw)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedTitle = trackOption.title?.trim().toLowerCase() || '';
|
||||||
|
const expectedLanguages = new Set(
|
||||||
|
[trackOption.language, trackOption.sourceLanguage]
|
||||||
|
.map((value) => value.trim().toLowerCase())
|
||||||
|
.filter((value) => value.length > 0),
|
||||||
|
);
|
||||||
|
const tracks = trackListRaw
|
||||||
|
.filter(
|
||||||
|
(track): track is Record<string, unknown> => Boolean(track) && typeof track === 'object',
|
||||||
|
)
|
||||||
|
.filter((track) => track.type === 'sub')
|
||||||
|
.map(normalizeTrackListEntry)
|
||||||
|
.filter((track) => track.external && track.id !== null && track.id !== excludeId);
|
||||||
|
|
||||||
|
const exactTitleMatch = tracks.find(
|
||||||
|
(track) =>
|
||||||
|
expectedTitle.length > 0 &&
|
||||||
|
track.title.trim().toLowerCase() === expectedTitle &&
|
||||||
|
expectedLanguages.has(track.lang.trim().toLowerCase()),
|
||||||
|
);
|
||||||
|
if (exactTitleMatch?.id !== null && exactTitleMatch?.id !== undefined) {
|
||||||
|
return exactTitleMatch.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectedTitle.length === 0) {
|
||||||
|
const languageOnlyMatch = tracks.find((track) =>
|
||||||
|
expectedLanguages.has(track.lang.trim().toLowerCase()),
|
||||||
|
);
|
||||||
|
if (languageOnlyMatch?.id !== null && languageOnlyMatch?.id !== undefined) {
|
||||||
|
return languageOnlyMatch.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function matchExternalTrackId(
|
function matchExternalTrackId(
|
||||||
trackListRaw: unknown,
|
trackListRaw: unknown,
|
||||||
filePath: string,
|
filePath: string,
|
||||||
trackOption: YoutubeTrackOption,
|
|
||||||
excludeId: number | null = null,
|
excludeId: number | null = null,
|
||||||
): number | null {
|
): number | null {
|
||||||
if (!Array.isArray(trackListRaw)) {
|
if (!Array.isArray(trackListRaw)) {
|
||||||
@@ -209,16 +312,9 @@ function matchExternalTrackId(
|
|||||||
return basenameMatch.id;
|
return basenameMatch.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const languageMatch = externalTracks.find((track) => track.lang === trackOption.sourceLanguage);
|
const titleMatch = externalTracks.find((track) => matchesTitleBasename(track.title, basename));
|
||||||
if (languageMatch?.id !== null && languageMatch?.id !== undefined) {
|
if (titleMatch?.id !== null && titleMatch?.id !== undefined) {
|
||||||
return languageMatch.id;
|
return titleMatch.id;
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedLanguageMatch = externalTracks.find(
|
|
||||||
(track) => track.lang === trackOption.language,
|
|
||||||
);
|
|
||||||
if (normalizedLanguageMatch?.id !== null && normalizedLanguageMatch?.id !== undefined) {
|
|
||||||
return normalizedLanguageMatch.id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -226,43 +322,61 @@ function matchExternalTrackId(
|
|||||||
|
|
||||||
async function injectDownloadedSubtitles(
|
async function injectDownloadedSubtitles(
|
||||||
deps: YoutubeFlowDeps,
|
deps: YoutubeFlowDeps,
|
||||||
primaryTrack: YoutubeTrackOption,
|
primarySelection: {
|
||||||
primaryPath: string,
|
track: YoutubeTrackOption;
|
||||||
|
existingTrackId: number | null;
|
||||||
|
injectedPath: string | null;
|
||||||
|
},
|
||||||
secondaryTrack: YoutubeTrackOption | null,
|
secondaryTrack: YoutubeTrackOption | null,
|
||||||
secondaryPath: string | null,
|
secondarySelection: {
|
||||||
|
existingTrackId: number | null;
|
||||||
|
injectedPath: string | null;
|
||||||
|
} | null,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
deps.sendMpvCommand(['set_property', 'sub-delay', 0]);
|
deps.sendMpvCommand(['set_property', 'sub-delay', 0]);
|
||||||
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
||||||
deps.sendMpvCommand(['set_property', 'secondary-sid', 'no']);
|
deps.sendMpvCommand(['set_property', 'secondary-sid', 'no']);
|
||||||
deps.sendMpvCommand([
|
if (primarySelection.injectedPath) {
|
||||||
'sub-add',
|
|
||||||
primaryPath,
|
|
||||||
'select',
|
|
||||||
path.basename(primaryPath),
|
|
||||||
primaryTrack.sourceLanguage,
|
|
||||||
]);
|
|
||||||
if (secondaryPath && secondaryTrack) {
|
|
||||||
deps.sendMpvCommand([
|
deps.sendMpvCommand([
|
||||||
'sub-add',
|
'sub-add',
|
||||||
secondaryPath,
|
primarySelection.injectedPath,
|
||||||
|
'select',
|
||||||
|
path.basename(primarySelection.injectedPath),
|
||||||
|
primarySelection.track.sourceLanguage,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (secondarySelection?.injectedPath && secondaryTrack) {
|
||||||
|
deps.sendMpvCommand([
|
||||||
|
'sub-add',
|
||||||
|
secondarySelection.injectedPath,
|
||||||
'cached',
|
'cached',
|
||||||
path.basename(secondaryPath),
|
path.basename(secondarySelection.injectedPath),
|
||||||
secondaryTrack.sourceLanguage,
|
secondaryTrack.sourceLanguage,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
let trackListRaw: unknown = null;
|
let trackListRaw: unknown = await deps.requestMpvProperty('track-list');
|
||||||
let primaryTrackId: number | null = null;
|
let primaryTrackId: number | null = primarySelection.existingTrackId;
|
||||||
let secondaryTrackId: number | null = null;
|
let secondaryTrackId: number | null = secondarySelection?.existingTrackId ?? null;
|
||||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||||
await deps.wait(attempt === 0 ? 150 : 100);
|
if (attempt > 0 || primarySelection.injectedPath || secondarySelection?.injectedPath) {
|
||||||
trackListRaw = await deps.requestMpvProperty('track-list');
|
await deps.wait(attempt === 0 ? 150 : 100);
|
||||||
primaryTrackId = matchExternalTrackId(trackListRaw, primaryPath, primaryTrack);
|
trackListRaw = await deps.requestMpvProperty('track-list');
|
||||||
secondaryTrackId =
|
}
|
||||||
secondaryPath && secondaryTrack
|
if (primaryTrackId === null && primarySelection.injectedPath) {
|
||||||
? matchExternalTrackId(trackListRaw, secondaryPath, secondaryTrack, primaryTrackId)
|
primaryTrackId = matchExternalTrackId(trackListRaw, primarySelection.injectedPath);
|
||||||
: null;
|
}
|
||||||
if (primaryTrackId !== null && (!secondaryPath || secondaryTrackId !== null)) {
|
if (secondarySelection?.injectedPath && secondaryTrack && secondaryTrackId === null) {
|
||||||
|
secondaryTrackId = matchExternalTrackId(
|
||||||
|
trackListRaw,
|
||||||
|
secondarySelection.injectedPath,
|
||||||
|
primaryTrackId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
primaryTrackId !== null &&
|
||||||
|
(!secondaryTrack || secondarySelection === null || secondaryTrackId !== null)
|
||||||
|
) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -276,20 +390,25 @@ async function injectDownloadedSubtitles(
|
|||||||
deps.sendMpvCommand(['set_property', 'sub-visibility', 'yes']);
|
deps.sendMpvCommand(['set_property', 'sub-visibility', 'yes']);
|
||||||
} else {
|
} else {
|
||||||
deps.warn(
|
deps.warn(
|
||||||
`Unable to bind downloaded primary subtitle track in mpv: ${path.basename(primaryPath)}`,
|
`Unable to bind downloaded primary subtitle track in mpv: ${
|
||||||
|
primarySelection.injectedPath ? path.basename(primarySelection.injectedPath) : primarySelection.track.label
|
||||||
|
}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (secondaryPath && secondaryTrack) {
|
if (secondaryTrack && secondarySelection) {
|
||||||
if (secondaryTrackId !== null) {
|
if (secondaryTrackId !== null) {
|
||||||
await ensureSubtitleTrackSelection({
|
await ensureSubtitleTrackSelection({
|
||||||
deps,
|
deps,
|
||||||
property: 'secondary-sid',
|
property: 'secondary-sid',
|
||||||
targetId: secondaryTrackId,
|
targetId: secondaryTrackId,
|
||||||
});
|
});
|
||||||
deps.sendMpvCommand(['set_property', 'secondary-sub-visibility', 'yes']);
|
|
||||||
} else {
|
} else {
|
||||||
deps.warn(
|
deps.warn(
|
||||||
`Unable to bind downloaded secondary subtitle track in mpv: ${path.basename(secondaryPath)}`,
|
`Unable to bind downloaded secondary subtitle track in mpv: ${
|
||||||
|
secondarySelection.injectedPath
|
||||||
|
? path.basename(secondarySelection.injectedPath)
|
||||||
|
: secondaryTrack.label
|
||||||
|
}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -304,7 +423,7 @@ async function injectDownloadedSubtitles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
deps.showMpvOsd(
|
deps.showMpvOsd(
|
||||||
secondaryPath && secondaryTrack ? 'Primary and secondary subtitles loaded.' : 'Subtitles loaded.',
|
secondaryTrack ? 'Primary and secondary subtitles loaded.' : 'Subtitles loaded.',
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -455,33 +574,134 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
|||||||
osdProgress.setMessage('Downloading subtitles...');
|
osdProgress.setMessage('Downloading subtitles...');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const acquired = await acquireSelectedTracks({
|
let initialTrackListRaw: unknown = null;
|
||||||
targetUrl: input.url,
|
let existingPrimaryTrackId: number | null = null;
|
||||||
outputDir: input.outputDir,
|
let existingSecondaryTrackId: number | null = null;
|
||||||
primaryTrack: input.primaryTrack,
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||||
secondaryTrack: input.secondaryTrack,
|
if (attempt > 0) {
|
||||||
secondaryFailureLabel: input.secondaryFailureLabel,
|
await deps.wait(attempt === 1 ? 150 : 100);
|
||||||
});
|
}
|
||||||
const resolvedPrimaryPath = await deps.retimeYoutubePrimaryTrack({
|
initialTrackListRaw = await deps.requestMpvProperty('track-list');
|
||||||
targetUrl: input.url,
|
existingPrimaryTrackId =
|
||||||
primaryTrack: input.primaryTrack,
|
input.primaryTrack.kind === 'manual'
|
||||||
primaryPath: acquired.primaryPath,
|
? matchExistingManualYoutubeTrackId(initialTrackListRaw, input.primaryTrack)
|
||||||
secondaryTrack: input.secondaryTrack,
|
: null;
|
||||||
secondaryPath: acquired.secondaryPath,
|
existingSecondaryTrackId =
|
||||||
});
|
input.secondaryTrack?.kind === 'manual'
|
||||||
|
? matchExistingManualYoutubeTrackId(
|
||||||
|
initialTrackListRaw,
|
||||||
|
input.secondaryTrack,
|
||||||
|
existingPrimaryTrackId,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const primaryReady = input.primaryTrack.kind !== 'manual' || existingPrimaryTrackId !== null;
|
||||||
|
const secondaryReady =
|
||||||
|
!input.secondaryTrack ||
|
||||||
|
input.secondaryTrack.kind !== 'manual' ||
|
||||||
|
existingSecondaryTrackId !== null;
|
||||||
|
if (primaryReady && secondaryReady) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let primarySidebarPath: string;
|
||||||
|
let primaryInjectedPath: string | null = null;
|
||||||
|
let secondaryInjectedPath: string | null = null;
|
||||||
|
|
||||||
|
if (existingPrimaryTrackId !== null) {
|
||||||
|
primarySidebarPath = (
|
||||||
|
await deps.acquireYoutubeSubtitleTrack({
|
||||||
|
targetUrl: input.url,
|
||||||
|
outputDir: input.outputDir,
|
||||||
|
track: input.primaryTrack,
|
||||||
|
})
|
||||||
|
).path;
|
||||||
|
} else if (existingSecondaryTrackId !== null || !input.secondaryTrack) {
|
||||||
|
primaryInjectedPath = (
|
||||||
|
await deps.acquireYoutubeSubtitleTrack({
|
||||||
|
targetUrl: input.url,
|
||||||
|
outputDir: input.outputDir,
|
||||||
|
track: input.primaryTrack,
|
||||||
|
})
|
||||||
|
).path;
|
||||||
|
primarySidebarPath = await deps.retimeYoutubePrimaryTrack({
|
||||||
|
targetUrl: input.url,
|
||||||
|
primaryTrack: input.primaryTrack,
|
||||||
|
primaryPath: primaryInjectedPath,
|
||||||
|
secondaryTrack: input.secondaryTrack,
|
||||||
|
secondaryPath: null,
|
||||||
|
});
|
||||||
|
primaryInjectedPath = primarySidebarPath;
|
||||||
|
} else {
|
||||||
|
const acquired = await acquireSelectedTracks({
|
||||||
|
targetUrl: input.url,
|
||||||
|
outputDir: input.outputDir,
|
||||||
|
primaryTrack: input.primaryTrack,
|
||||||
|
secondaryTrack: existingSecondaryTrackId === null ? input.secondaryTrack : null,
|
||||||
|
secondaryFailureLabel: input.secondaryFailureLabel,
|
||||||
|
});
|
||||||
|
primarySidebarPath = await deps.retimeYoutubePrimaryTrack({
|
||||||
|
targetUrl: input.url,
|
||||||
|
primaryTrack: input.primaryTrack,
|
||||||
|
primaryPath: acquired.primaryPath,
|
||||||
|
secondaryTrack: input.secondaryTrack,
|
||||||
|
secondaryPath: acquired.secondaryPath,
|
||||||
|
});
|
||||||
|
primaryInjectedPath = primarySidebarPath;
|
||||||
|
secondaryInjectedPath = acquired.secondaryPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.secondaryTrack && existingSecondaryTrackId === null && secondaryInjectedPath === null) {
|
||||||
|
try {
|
||||||
|
secondaryInjectedPath = (
|
||||||
|
await deps.acquireYoutubeSubtitleTrack({
|
||||||
|
targetUrl: input.url,
|
||||||
|
outputDir: input.outputDir,
|
||||||
|
track: input.secondaryTrack,
|
||||||
|
})
|
||||||
|
).path;
|
||||||
|
} catch (error) {
|
||||||
|
const fallbackExistingSecondaryTrackId =
|
||||||
|
input.secondaryTrack.kind === 'auto'
|
||||||
|
? matchExistingYoutubeTrackId(
|
||||||
|
initialTrackListRaw,
|
||||||
|
input.secondaryTrack,
|
||||||
|
existingPrimaryTrackId,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
if (fallbackExistingSecondaryTrackId !== null) {
|
||||||
|
existingSecondaryTrackId = fallbackExistingSecondaryTrackId;
|
||||||
|
} else {
|
||||||
|
deps.warn(
|
||||||
|
`${input.secondaryFailureLabel}: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
deps.showMpvOsd('Loading subtitles...');
|
deps.showMpvOsd('Loading subtitles...');
|
||||||
const refreshedActiveSubtitle = await injectDownloadedSubtitles(
|
const refreshedActiveSubtitle = await injectDownloadedSubtitles(
|
||||||
deps,
|
deps,
|
||||||
input.primaryTrack,
|
{
|
||||||
resolvedPrimaryPath,
|
track: input.primaryTrack,
|
||||||
|
existingTrackId: existingPrimaryTrackId,
|
||||||
|
injectedPath: primaryInjectedPath,
|
||||||
|
},
|
||||||
input.secondaryTrack,
|
input.secondaryTrack,
|
||||||
acquired.secondaryPath,
|
input.secondaryTrack
|
||||||
|
? {
|
||||||
|
existingTrackId: existingSecondaryTrackId,
|
||||||
|
injectedPath: secondaryInjectedPath,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
if (!refreshedActiveSubtitle) {
|
if (!refreshedActiveSubtitle) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await deps.refreshSubtitleSidebarSource?.(resolvedPrimaryPath);
|
await deps.refreshSubtitleSidebarSource?.(primarySidebarPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
deps.warn(
|
deps.warn(
|
||||||
`Failed to refresh parsed subtitle cues for sidebar: ${
|
`Failed to refresh parsed subtitle cues for sidebar: ${
|
||||||
|
|||||||
Reference in New Issue
Block a user