mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-21 05:01:26 -07:00
fix(youtube): keep mpv queue when advancing to a queued video on Windows (#281)
This commit is contained in:
1 parent
138bfe9759
commit
727be5683a
5 files changed
+185
-5
No files matched your search
@@ -298,3 +298,116 @@ test('prepare youtube playback accepts a fresh-start path change when the direct
|
||||
'replace',
|
||||
]);
|
||||
});
|
||||
|
||||
test('prepare youtube playback swaps a queued entry for its direct stream without clearing the playlist', async () => {
|
||||
const commands: Array<Array<string>> = [];
|
||||
const directUrl = 'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc';
|
||||
const observedPaths = ['https://www.youtube.com/watch?v=queued2', directUrl];
|
||||
let requestCount = 0;
|
||||
const prepare = createPrepareYoutubePlaybackInMpvHandler({
|
||||
requestPath: async () => {
|
||||
const value = observedPaths[Math.min(requestCount, observedPaths.length - 1)] ?? null;
|
||||
requestCount += 1;
|
||||
return value;
|
||||
},
|
||||
requestProperty: async (name) => {
|
||||
if (name === 'playlist') {
|
||||
return [
|
||||
{ filename: 'https://www.youtube.com/watch?v=queued1' },
|
||||
{ filename: 'https://www.youtube.com/watch?v=queued2', current: true, playing: true },
|
||||
{ filename: 'https://www.youtube.com/watch?v=queued3' },
|
||||
];
|
||||
}
|
||||
if (name === 'track-list') return [{ type: 'video', id: 1 }];
|
||||
return null;
|
||||
},
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
wait: createWaitStub(),
|
||||
});
|
||||
|
||||
const ok = await prepare({
|
||||
url: directUrl,
|
||||
sourceUrl: 'https://www.youtube.com/watch?v=queued2',
|
||||
timeoutMs: 1500,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
|
||||
assert.equal(ok, true);
|
||||
assert.deepEqual(commands.slice(4), [
|
||||
['loadfile', directUrl, 'append'],
|
||||
['playlist-move', '3', '2'],
|
||||
['playlist-remove', '1'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('prepare youtube playback replaces the playlist when the current entry is a different video', async () => {
|
||||
const commands: Array<Array<string>> = [];
|
||||
const directUrl = 'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc';
|
||||
let nowTick = 0;
|
||||
const prepare = createPrepareYoutubePlaybackInMpvHandler({
|
||||
requestPath: async () => 'https://www.youtube.com/watch?v=other',
|
||||
requestProperty: async (name) => {
|
||||
if (name === 'playlist') {
|
||||
return [
|
||||
{ filename: 'https://www.youtube.com/watch?v=other', current: true, playing: true },
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
wait: createWaitStub(),
|
||||
now: () => {
|
||||
nowTick += 100;
|
||||
return nowTick;
|
||||
},
|
||||
});
|
||||
|
||||
await prepare({
|
||||
url: directUrl,
|
||||
sourceUrl: 'https://www.youtube.com/watch?v=picked',
|
||||
timeoutMs: 200,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
|
||||
assert.deepEqual(commands.slice(4), [['loadfile', directUrl, 'replace']]);
|
||||
});
|
||||
|
||||
test('prepare youtube playback does not touch the playlist when the playing entry is another video', async () => {
|
||||
const commands: Array<Array<string>> = [];
|
||||
const directUrl = 'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc';
|
||||
let nowTick = 0;
|
||||
const prepare = createPrepareYoutubePlaybackInMpvHandler({
|
||||
// mpv's path still reports the queued video, but it is mid-advance: the selected entry moved
|
||||
// on and the playing entry is another queued video.
|
||||
requestPath: async () => 'https://www.youtube.com/watch?v=queued2',
|
||||
requestProperty: async (name) => {
|
||||
if (name === 'playlist') {
|
||||
return [
|
||||
{ filename: 'https://www.youtube.com/watch?v=queued1', playing: true },
|
||||
{ filename: 'https://www.youtube.com/watch?v=queued2', current: true },
|
||||
{ filename: 'https://www.youtube.com/watch?v=queued3' },
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
wait: createWaitStub(),
|
||||
now: () => {
|
||||
nowTick += 100;
|
||||
return nowTick;
|
||||
},
|
||||
});
|
||||
|
||||
await prepare({
|
||||
url: directUrl,
|
||||
sourceUrl: 'https://www.youtube.com/watch?v=queued2',
|
||||
timeoutMs: 200,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
commands.some((command) => command[0] === 'playlist-remove'),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(commands.slice(4), [['loadfile', directUrl, 'replace']]);
|
||||
});
|
||||
@@ -2,6 +2,11 @@ import { isYoutubeMediaPath } from './youtube-playback';
|
||||
|
||||
type YoutubePlaybackLaunchInput = {
|
||||
url: string;
|
||||
/**
|
||||
* YouTube page URL `url` was resolved from (Windows loads a direct stream URL). Lets a playlist
|
||||
* entry for the same video be swapped in place instead of replacing the whole playlist.
|
||||
*/
|
||||
sourceUrl?: string;
|
||||
timeoutMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
};
|
||||
@@ -81,6 +86,52 @@ function hasPlayableMediaTracks(trackListRaw: unknown): boolean {
|
||||
});
|
||||
}
|
||||
|
||||
/** Index of the entry mpv is actually playing, or null while it is between entries. */
|
||||
function findPlayingPlaylistEntry(playlist: unknown[]): { index: number; filename: string } | null {
|
||||
const index = playlist.findIndex(
|
||||
(entry) =>
|
||||
typeof entry === 'object' &&
|
||||
entry !== null &&
|
||||
(entry as Record<string, unknown>).playing === true,
|
||||
);
|
||||
if (index < 0) return null;
|
||||
const filename = (playlist[index] as Record<string, unknown>).filename;
|
||||
return typeof filename === 'string' ? { index, filename } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps mpv's playing playlist entry for `targetUrl` while keeping the rest of the playlist, e.g.
|
||||
* a queued YouTube watch URL replaced by its resolved direct stream on Windows. Uses only
|
||||
* long-standing playlist commands (no `loadfile insert-*`, which needs mpv 0.38+).
|
||||
* Returns false unless the playing entry is confirmed to be `sourceUrl`'s video, so the caller
|
||||
* falls back to `replace` instead of removing some other queued entry.
|
||||
*/
|
||||
async function swapCurrentPlaylistEntry(
|
||||
deps: YoutubePlaybackLaunchDeps,
|
||||
targetUrl: string,
|
||||
sourceUrl: string,
|
||||
): Promise<boolean> {
|
||||
if (!deps.requestProperty) return false;
|
||||
let playlist: unknown;
|
||||
try {
|
||||
// One snapshot so the position, count, and entry identity agree with each other.
|
||||
playlist = await deps.requestProperty('playlist');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!Array.isArray(playlist)) return false;
|
||||
const playing = findPlayingPlaylistEntry(playlist);
|
||||
if (!playing || !targetsSameYoutubeVideo(playing.filename, sourceUrl)) return false;
|
||||
|
||||
// The appended entry lands at index `count`; move it right after the playing entry, then remove
|
||||
// the playing entry so mpv advances onto it.
|
||||
const count = playlist.length;
|
||||
deps.sendMpvCommand(['loadfile', targetUrl, 'append']);
|
||||
deps.sendMpvCommand(['playlist-move', String(count), String(playing.index + 1)]);
|
||||
deps.sendMpvCommand(['playlist-remove', String(playing.index)]);
|
||||
return true;
|
||||
}
|
||||
|
||||
function sendPlaybackPrepCommands(sendMpvCommand: (command: Array<string>) => void): void {
|
||||
sendMpvCommand(['set_property', 'pause', 'yes']);
|
||||
sendMpvCommand(['set_property', 'sub-auto', 'no']);
|
||||
@@ -120,7 +171,14 @@ export function createPrepareYoutubePlaybackInMpvHandler(deps: YoutubePlaybackLa
|
||||
// Keep polling; mpv can report the target path before tracks are ready.
|
||||
}
|
||||
} else {
|
||||
deps.sendMpvCommand(['loadfile', targetUrl, 'replace']);
|
||||
const sourceUrl = input.sourceUrl?.trim() ?? '';
|
||||
const swapped =
|
||||
sourceUrl !== '' &&
|
||||
targetsSameYoutubeVideo(previousPath, sourceUrl) &&
|
||||
(await swapCurrentPlaylistEntry(deps, targetUrl, sourceUrl));
|
||||
if (!swapped) {
|
||||
deps.sendMpvCommand(['loadfile', targetUrl, 'replace']);
|
||||
}
|
||||
}
|
||||
|
||||
const deadline = now() + timeoutMs;
|
||||
|
||||
@@ -112,8 +112,8 @@ test('youtube playback runtime resolves the socket path lazily for windows start
|
||||
calls.push(`wait-connected:${timeoutMs}`);
|
||||
return true;
|
||||
},
|
||||
prepareYoutubePlaybackInMpv: async ({ url }) => {
|
||||
calls.push(`prepare:${url}`);
|
||||
prepareYoutubePlaybackInMpv: async ({ url, sourceUrl }) => {
|
||||
calls.push(`prepare:${url}:${sourceUrl}`);
|
||||
return true;
|
||||
},
|
||||
runYoutubePlaybackFlow: async ({ url }) => {
|
||||
@@ -143,6 +143,8 @@ test('youtube playback runtime resolves the socket path lazily for windows start
|
||||
});
|
||||
|
||||
assert.ok(calls.some((entry) => entry.includes('--input-ipc-server=/tmp/updated.sock')));
|
||||
// The page URL rides along so prepare can swap a queued entry for the direct stream in place.
|
||||
assert.ok(calls.includes('prepare:https://example.com/direct:https://youtu.be/demo'));
|
||||
});
|
||||
|
||||
test('youtube playback runtime maps resolved windows streams back to their page urls', async () => {
|
||||
|
||||
@@ -22,7 +22,7 @@ export type YoutubePlaybackRuntimeDeps = {
|
||||
resolveYoutubePlaybackUrl: (url: string, format: string) => Promise<string>;
|
||||
launchWindowsMpv: (playbackUrl: string, args: string[]) => Promise<LaunchResult>;
|
||||
waitForYoutubeMpvConnected: (timeoutMs: number) => Promise<boolean>;
|
||||
prepareYoutubePlaybackInMpv: (request: { url: string }) => Promise<boolean>;
|
||||
prepareYoutubePlaybackInMpv: (request: { url: string; sourceUrl: string }) => Promise<boolean>;
|
||||
startYoutubeMediaCache?: (url: string) => void | Promise<void>;
|
||||
runYoutubePlaybackFlow: (request: { url: string }) => Promise<void>;
|
||||
logInfo: (message: string) => void;
|
||||
@@ -127,7 +127,10 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
const mediaReady = await deps.prepareYoutubePlaybackInMpv({ url: playbackUrl });
|
||||
const mediaReady = await deps.prepareYoutubePlaybackInMpv({
|
||||
url: playbackUrl,
|
||||
sourceUrl: request.url,
|
||||
});
|
||||
if (!mediaReady) {
|
||||
throw new Error('Timed out waiting for mpv to load the requested YouTube URL.');
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user