fix(youtube): keep mpv queue when advancing to a queued video on Windows (#281)

This commit is contained in:
sudacode authored and GitHub committed 2026-10-02 22:13:35 -07:00
1 parent 138bfe9759
commit 727be5683a
5 files changed
+184 -4

No files matched your search

@@ -0,0 +1,4 @@
type: fixed
area: youtube
- Fixed Windows YouTube playback dropping the rest of mpv's queue when it advanced to a video queued from the YouTube browser window. The direct stream now replaces only that playlist entry.
@@ -298,3 +298,116 @@ test('prepare youtube playback accepts a fresh-start path change when the direct
'replace', '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 = { type YoutubePlaybackLaunchInput = {
url: string; 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; timeoutMs?: number;
pollIntervalMs?: 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 { function sendPlaybackPrepCommands(sendMpvCommand: (command: Array<string>) => void): void {
sendMpvCommand(['set_property', 'pause', 'yes']); sendMpvCommand(['set_property', 'pause', 'yes']);
sendMpvCommand(['set_property', 'sub-auto', 'no']); sendMpvCommand(['set_property', 'sub-auto', 'no']);
@@ -120,8 +171,15 @@ export function createPrepareYoutubePlaybackInMpvHandler(deps: YoutubePlaybackLa
// Keep polling; mpv can report the target path before tracks are ready. // Keep polling; mpv can report the target path before tracks are ready.
} }
} else { } else {
const sourceUrl = input.sourceUrl?.trim() ?? '';
const swapped =
sourceUrl !== '' &&
targetsSameYoutubeVideo(previousPath, sourceUrl) &&
(await swapCurrentPlaylistEntry(deps, targetUrl, sourceUrl));
if (!swapped) {
deps.sendMpvCommand(['loadfile', targetUrl, 'replace']); deps.sendMpvCommand(['loadfile', targetUrl, 'replace']);
} }
}
const deadline = now() + timeoutMs; const deadline = now() + timeoutMs;
while (now() < deadline) { while (now() < deadline) {
@@ -112,8 +112,8 @@ test('youtube playback runtime resolves the socket path lazily for windows start
calls.push(`wait-connected:${timeoutMs}`); calls.push(`wait-connected:${timeoutMs}`);
return true; return true;
}, },
prepareYoutubePlaybackInMpv: async ({ url }) => { prepareYoutubePlaybackInMpv: async ({ url, sourceUrl }) => {
calls.push(`prepare:${url}`); calls.push(`prepare:${url}:${sourceUrl}`);
return true; return true;
}, },
runYoutubePlaybackFlow: async ({ url }) => { 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'))); 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 () => { test('youtube playback runtime maps resolved windows streams back to their page urls', async () => {
+5 -2
View File
@@ -22,7 +22,7 @@ export type YoutubePlaybackRuntimeDeps = {
resolveYoutubePlaybackUrl: (url: string, format: string) => Promise<string>; resolveYoutubePlaybackUrl: (url: string, format: string) => Promise<string>;
launchWindowsMpv: (playbackUrl: string, args: string[]) => Promise<LaunchResult>; launchWindowsMpv: (playbackUrl: string, args: string[]) => Promise<LaunchResult>;
waitForYoutubeMpvConnected: (timeoutMs: number) => Promise<boolean>; 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>; startYoutubeMediaCache?: (url: string) => void | Promise<void>;
runYoutubePlaybackFlow: (request: { url: string }) => Promise<void>; runYoutubePlaybackFlow: (request: { url: string }) => Promise<void>;
logInfo: (message: string) => void; logInfo: (message: string) => void;
@@ -127,7 +127,10 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
}, 3000); }, 3000);
} }
const mediaReady = await deps.prepareYoutubePlaybackInMpv({ url: playbackUrl }); const mediaReady = await deps.prepareYoutubePlaybackInMpv({
url: playbackUrl,
sourceUrl: request.url,
});
if (!mediaReady) { if (!mediaReady) {
throw new Error('Timed out waiting for mpv to load the requested YouTube URL.'); throw new Error('Timed out waiting for mpv to load the requested YouTube URL.');
} }