mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-20 17:01:24 -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
@@ -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,7 +171,14 @@ 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 {
|
||||||
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;
|
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}`);
|
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 () => {
|
||||||
|
|||||||
@@ -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.');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user