From 4d65dec3400ffe3c4940a88fb51d1958e771f748 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 1 Aug 2026 22:56:28 -0700 Subject: [PATCH] fix(youtube): prevent playlist URLs from stalling yt-dlp probes (#180) --- changes/youtube-playlist-url-probe.md | 4 ++ docs-site/youtube-integration.md | 2 +- src/core/services/youtube/media-cache.ts | 4 +- src/core/services/youtube/metadata-probe.ts | 20 +++++++--- src/core/services/youtube/playback-resolve.ts | 17 +++++---- src/core/services/youtube/track-download.ts | 6 +-- src/core/services/youtube/track-probe.ts | 15 +++++--- src/core/services/youtube/ytdlp-command.ts | 7 ++++ .../youtube/ytdlp-single-video-args.test.ts | 38 +++++++++++++++++++ 9 files changed, 87 insertions(+), 26 deletions(-) create mode 100644 changes/youtube-playlist-url-probe.md create mode 100644 src/core/services/youtube/ytdlp-single-video-args.test.ts diff --git a/changes/youtube-playlist-url-probe.md b/changes/youtube-playlist-url-probe.md new file mode 100644 index 00000000..29a5695c --- /dev/null +++ b/changes/youtube-playlist-url-probe.md @@ -0,0 +1,4 @@ +type: fixed +area: youtube + +- Opening a YouTube video from a playlist URL (for example a Watch Later link with `list=`/`index=`) no longer times out while probing subtitle tracks, metadata, or the playback URL. diff --git a/docs-site/youtube-integration.md b/docs-site/youtube-integration.md index cdc2ae0d..ac1dbdb9 100644 --- a/docs-site/youtube-integration.md +++ b/docs-site/youtube-integration.md @@ -11,7 +11,7 @@ SubMiner auto-loads Japanese subtitles when you play a YouTube URL, giving you t When SubMiner detects a YouTube URL (or `ytsearch:` target), it pauses mpv at startup and runs a subtitle pipeline before resuming playback: -1. **Probe** --- `yt-dlp --dump-single-json` extracts all available subtitle tracks (manual uploads and auto-generated captions) along with video metadata. +1. **Probe** --- `yt-dlp --dump-single-json` extracts all available subtitle tracks (manual uploads and auto-generated captions) along with video metadata. Every yt-dlp call passes `--no-playlist`, so playlist links (for example a Watch Later URL with `list=`/`index=`) resolve to the single video instead of the whole playlist. 2. **Discover** --- Each track is normalized into a `YoutubeTrackOption` with language code, kind (`manual` or `auto`), display label, and direct download URL. 3. **Select** --- SubMiner picks the best primary track (Japanese, preferring manual over auto) and secondary track (English, preferring manual over auto). 4. **Download** --- Selected tracks are fetched via direct URL when available, falling back to `yt-dlp --write-subs` / `--write-auto-subs`. YouTube TimedText XML formats (`srv1`/`srv2`/`srv3`) are converted to VTT on the fly. Auto-generated VTT captions are normalized to remove rolling-caption duplication. diff --git a/src/core/services/youtube/media-cache.ts b/src/core/services/youtube/media-cache.ts index a2c9c151..2fb1ffca 100644 --- a/src/core/services/youtube/media-cache.ts +++ b/src/core/services/youtube/media-cache.ts @@ -6,7 +6,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import type { YoutubeMediaCacheMode } from '../../../types/integrations'; -import { getYoutubeYtDlpCommand } from './ytdlp-command'; +import { getYoutubeYtDlpCommand, YTDLP_SINGLE_VIDEO_ARG } from './ytdlp-command'; type MediaCacheSessionState = 'running' | 'ready' | 'failed'; @@ -88,7 +88,7 @@ function normalizeMaxHeight(maxHeight: number | undefined): number { function createYtDlpArgs(url: string, outputTemplate: string, maxHeight?: number): string[] { return [ - '--no-playlist', + YTDLP_SINGLE_VIDEO_ARG, '--no-warnings', '--force-ipv4', '--retries', diff --git a/src/core/services/youtube/metadata-probe.ts b/src/core/services/youtube/metadata-probe.ts index fa71f706..80f8f260 100644 --- a/src/core/services/youtube/metadata-probe.ts +++ b/src/core/services/youtube/metadata-probe.ts @@ -1,6 +1,6 @@ import { spawn } from 'node:child_process'; import type { YoutubeVideoMetadata } from '../immersion-tracker/types'; -import { getYoutubeYtDlpCommand } from './ytdlp-command'; +import { getYoutubeYtDlpCommand, YTDLP_SINGLE_VIDEO_ARG } from './ytdlp-command'; const YOUTUBE_METADATA_PROBE_TIMEOUT_MS = 15_000; @@ -85,15 +85,23 @@ function pickChannelThumbnail(thumbnails: YtDlpThumbnail[] | undefined): string return null; } -export async function probeYoutubeVideoMetadata( - targetUrl: string, -): Promise { - const { stdout } = await runCapture(getYoutubeYtDlpCommand(), [ +export function buildYoutubeMetadataProbeArgs(targetUrl: string): string[] { + return [ + YTDLP_SINGLE_VIDEO_ARG, '--dump-single-json', '--no-warnings', '--skip-download', targetUrl, - ]); + ]; +} + +export async function probeYoutubeVideoMetadata( + targetUrl: string, +): Promise { + const { stdout } = await runCapture( + getYoutubeYtDlpCommand(), + buildYoutubeMetadataProbeArgs(targetUrl), + ); let info: YtDlpYoutubeMetadata; try { info = JSON.parse(stdout) as YtDlpYoutubeMetadata; diff --git a/src/core/services/youtube/playback-resolve.ts b/src/core/services/youtube/playback-resolve.ts index 75fcab19..40d493b6 100644 --- a/src/core/services/youtube/playback-resolve.ts +++ b/src/core/services/youtube/playback-resolve.ts @@ -1,5 +1,5 @@ import { spawn } from 'node:child_process'; -import { getYoutubeYtDlpCommand } from './ytdlp-command'; +import { getYoutubeYtDlpCommand, YTDLP_SINGLE_VIDEO_ARG } from './ytdlp-command'; const YOUTUBE_PLAYBACK_RESOLVE_TIMEOUT_MS = 15_000; const DEFAULT_PLAYBACK_FORMAT = 'b'; @@ -85,17 +85,18 @@ function runCapture( }); } +export function buildYoutubePlaybackResolveArgs(targetUrl: string, format: string): string[] { + return [YTDLP_SINGLE_VIDEO_ARG, '--get-url', '--no-warnings', '-f', format, targetUrl]; +} + export async function resolveYoutubePlaybackUrl( targetUrl: string, format = DEFAULT_PLAYBACK_FORMAT, ): Promise { - const { stdout } = await runCapture(getYoutubeYtDlpCommand(), [ - '--get-url', - '--no-warnings', - '-f', - format, - targetUrl, - ]); + const { stdout } = await runCapture( + getYoutubeYtDlpCommand(), + buildYoutubePlaybackResolveArgs(targetUrl, format), + ); const playbackUrl = stdout .split(/\r?\n/) diff --git a/src/core/services/youtube/track-download.ts b/src/core/services/youtube/track-download.ts index bd50e2c6..83119539 100644 --- a/src/core/services/youtube/track-download.ts +++ b/src/core/services/youtube/track-download.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { spawn } from 'node:child_process'; import type { YoutubeTrackOption } from './track-probe'; -import { getYoutubeYtDlpCommand } from './ytdlp-command'; +import { getYoutubeYtDlpCommand, YTDLP_SINGLE_VIDEO_ARG } from './ytdlp-command'; import { convertYoutubeTimedTextToVtt, isYoutubeTimedTextExtension, @@ -126,14 +126,14 @@ function pickLatestSubtitleFileForLanguage( return candidates[0] ?? null; } -function buildDownloadArgs(input: { +export function buildDownloadArgs(input: { targetUrl: string; outputTemplate: string; sourceLanguages: string[]; includeAutoSubs: boolean; includeManualSubs: boolean; }): string[] { - const args = ['--skip-download', '--no-warnings']; + const args = [YTDLP_SINGLE_VIDEO_ARG, '--skip-download', '--no-warnings']; if (input.includeAutoSubs) { args.push('--write-auto-subs'); } diff --git a/src/core/services/youtube/track-probe.ts b/src/core/services/youtube/track-probe.ts index d3fe46d6..5b222fc5 100644 --- a/src/core/services/youtube/track-probe.ts +++ b/src/core/services/youtube/track-probe.ts @@ -1,7 +1,7 @@ import { spawn } from 'node:child_process'; import type { YoutubeTrackOption } from '../../../types'; import { formatYoutubeTrackLabel, normalizeYoutubeLangCode, type YoutubeTrackKind } from './labels'; -import { getYoutubeYtDlpCommand } from './ytdlp-command'; +import { getYoutubeYtDlpCommand, YTDLP_SINGLE_VIDEO_ARG } from './ytdlp-command'; const YOUTUBE_TRACK_PROBE_TIMEOUT_MS = 15_000; @@ -111,12 +111,15 @@ function toTracks(entries: Record | undefined, kind: export type { YoutubeTrackOption }; +export function buildYoutubeTrackProbeArgs(targetUrl: string): string[] { + return [YTDLP_SINGLE_VIDEO_ARG, '--dump-single-json', '--no-warnings', targetUrl]; +} + export async function probeYoutubeTracks(targetUrl: string): Promise { - const { stdout } = await runCapture(getYoutubeYtDlpCommand(), [ - '--dump-single-json', - '--no-warnings', - targetUrl, - ]); + const { stdout } = await runCapture( + getYoutubeYtDlpCommand(), + buildYoutubeTrackProbeArgs(targetUrl), + ); const trimmedStdout = stdout.trim(); if (!trimmedStdout) { throw new Error('yt-dlp returned empty output while probing subtitle tracks'); diff --git a/src/core/services/youtube/ytdlp-command.ts b/src/core/services/youtube/ytdlp-command.ts index a0731595..4285f4fc 100644 --- a/src/core/services/youtube/ytdlp-command.ts +++ b/src/core/services/youtube/ytdlp-command.ts @@ -4,6 +4,13 @@ import path from 'node:path'; const DEFAULT_YTDLP_COMMAND = 'yt-dlp'; const WINDOWS_YTDLP_COMMANDS = ['yt-dlp.cmd', 'yt-dlp.exe', 'yt-dlp']; +/** + * yt-dlp expands `list=`/`index=` URL params into the whole playlist unless told not to, which + * makes single-video extraction hang (e.g. a full Watch Later list) until our timeouts fire. + * Every yt-dlp invocation targeting one video must include this. + */ +export const YTDLP_SINGLE_VIDEO_ARG = '--no-playlist'; + function resolveFromPath(commandName: string): string | null { if (!process.env.PATH) { return null; diff --git a/src/core/services/youtube/ytdlp-single-video-args.test.ts b/src/core/services/youtube/ytdlp-single-video-args.test.ts new file mode 100644 index 00000000..b6bb10a9 --- /dev/null +++ b/src/core/services/youtube/ytdlp-single-video-args.test.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildYoutubeMetadataProbeArgs } from './metadata-probe'; +import { buildYoutubePlaybackResolveArgs } from './playback-resolve'; +import { buildDownloadArgs } from './track-download'; +import { buildYoutubeTrackProbeArgs } from './track-probe'; +import { YTDLP_SINGLE_VIDEO_ARG } from './ytdlp-command'; + +// Regression guard for issue #179: a `list=`/`index=` URL made yt-dlp enumerate the whole +// playlist (e.g. Watch Later) and blow past our 15s timeouts on every single-video call. +const PLAYLIST_URL = 'https://www.youtube.com/watch?v=LKfWC6CgFng&list=WL&index=3'; + +const cases: Array<{ name: string; args: string[] }> = [ + { name: 'track probe', args: buildYoutubeTrackProbeArgs(PLAYLIST_URL) }, + { name: 'metadata probe', args: buildYoutubeMetadataProbeArgs(PLAYLIST_URL) }, + { name: 'playback resolve', args: buildYoutubePlaybackResolveArgs(PLAYLIST_URL, 'b') }, + { + name: 'subtitle download', + args: buildDownloadArgs({ + targetUrl: PLAYLIST_URL, + outputTemplate: '/tmp/out.%(ext)s', + sourceLanguages: ['ja'], + includeAutoSubs: true, + includeManualSubs: false, + }), + }, +]; + +test('YTDLP_SINGLE_VIDEO_ARG is the yt-dlp flag that disables playlist expansion', () => { + assert.equal(YTDLP_SINGLE_VIDEO_ARG, '--no-playlist'); +}); + +for (const { name, args } of cases) { + test(`${name} passes --no-playlist for playlist-scoped URLs`, () => { + assert.ok(args.includes('--no-playlist'), `${name} args: ${args.join(' ')}`); + assert.equal(args.at(-1), PLAYLIST_URL); + }); +}