diff --git a/changes/273-launcher-windows-backend-and-ytdlp-bin.md b/changes/273-launcher-windows-backend-and-ytdlp-bin.md new file mode 100644 index 00000000..feea5692 --- /dev/null +++ b/changes/273-launcher-windows-backend-and-ytdlp-bin.md @@ -0,0 +1,5 @@ +type: fixed +area: launcher + +- Added `windows` as a recognized launcher backend option and auto-detection target on Windows. +- Honored `SUBMINER_YTDLP_BIN` consistently across YouTube playback URL resolution, track probing, subtitle downloads, and metadata probing. diff --git a/docs-site/architecture.md b/docs-site/architecture.md index 9708608e..bb701097 100644 --- a/docs-site/architecture.md +++ b/docs-site/architecture.md @@ -74,7 +74,7 @@ src/ handlers/ # Keyboard/mouse interaction modules modals/ # Jimaku/Kiku/subsync/runtime-options/session-help modals positioning/ # Subtitle position controller (drag-to-reposition) - window-trackers/ # Backend-specific tracker implementations (Hyprland, Sway, X11, macOS) + window-trackers/ # Backend-specific tracker implementations (Hyprland, Sway, X11, macOS, Windows) jimaku/ # Jimaku API integration helpers subsync/ # Subtitle sync (alass/ffsubsync) helpers subtitle/ # Subtitle processing utilities diff --git a/docs-site/launcher-script.md b/docs-site/launcher-script.md index f64ed628..d543227c 100644 --- a/docs-site/launcher-script.md +++ b/docs-site/launcher-script.md @@ -98,7 +98,7 @@ Use `subminer -h` for command-specific help. | `-T, --no-texthooker` | Disable texthooker server | | `-p, --profile` | mpv profile name (default: `subminer`) | | `-a, --args` | Pass additional mpv arguments as a quoted string | -| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`) | +| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`, `macos`, `windows`) | | `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) | | `--dev`, `--debug` | Enable app dev-mode (not tied to log level) | diff --git a/docs-site/mpv-plugin.md b/docs-site/mpv-plugin.md index 49400afb..90502281 100644 --- a/docs-site/mpv-plugin.md +++ b/docs-site/mpv-plugin.md @@ -79,7 +79,7 @@ texthooker_enabled=yes # Port for the texthooker server. texthooker_port=5174 -# Window manager backend: auto, hyprland, sway, x11, macos. +# Window manager backend: auto, hyprland, sway, x11, macos, windows. backend=auto # Start the overlay automatically when a file is loaded. diff --git a/launcher/config/args-normalizer.ts b/launcher/config/args-normalizer.ts index feb8b0e9..0c5627cb 100644 --- a/launcher/config/args-normalizer.ts +++ b/launcher/config/args-normalizer.ts @@ -49,10 +49,17 @@ function parseLogLevel(value: string): LogLevel { } function parseBackend(value: string): Backend { - if (value === 'auto' || value === 'hyprland' || value === 'x11' || value === 'macos') { + if ( + value === 'auto' || + value === 'hyprland' || + value === 'sway' || + value === 'x11' || + value === 'macos' || + value === 'windows' + ) { return value as Backend; } - fail(`Invalid backend: ${value} (must be auto, hyprland, x11, or macos)`); + fail(`Invalid backend: ${value} (must be auto, hyprland, sway, x11, macos, or windows)`); } function parseDictionaryTarget(value: string): string { diff --git a/launcher/config/cli-parser-builder.test.ts b/launcher/config/cli-parser-builder.test.ts index 110b9ad8..ada31119 100644 --- a/launcher/config/cli-parser-builder.test.ts +++ b/launcher/config/cli-parser-builder.test.ts @@ -17,20 +17,20 @@ test('resolveTopLevelCommand respects the app alias after root options', () => { }); test('parseCliPrograms keeps root options and target when no command is present', () => { - const result = parseCliPrograms(['--backend', 'x11', '/tmp/movie.mkv'], 'subminer'); + const result = parseCliPrograms(['--backend', 'windows', '/tmp/movie.mkv'], 'subminer'); - assert.equal(result.options.backend, 'x11'); + assert.equal(result.options.backend, 'windows'); assert.equal(result.rootTarget, '/tmp/movie.mkv'); assert.equal(result.invocations.appInvocation, null); }); test('parseCliPrograms routes app alias arguments through passthrough mode', () => { const result = parseCliPrograms( - ['--backend', 'macos', 'bin', '--anilist', '--log-level', 'debug'], + ['--backend', 'windows', 'bin', '--anilist', '--log-level', 'debug'], 'subminer', ); - assert.equal(result.options.backend, 'macos'); + assert.equal(result.options.backend, 'windows'); assert.deepEqual(result.invocations.appInvocation, { appArgs: ['--anilist', '--log-level', 'debug'], }); diff --git a/launcher/config/cli-parser-builder.ts b/launcher/config/cli-parser-builder.ts index d871c11f..6993e9bc 100644 --- a/launcher/config/cli-parser-builder.ts +++ b/launcher/config/cli-parser-builder.ts @@ -43,7 +43,7 @@ export interface CliInvocations { function applyRootOptions(program: Command): void { program - .option('-b, --backend ', 'Display backend') + .option('-b, --backend ', 'Display backend (auto, hyprland, sway, x11, macos, windows)') .option('-d, --directory ', 'Directory to browse') .option('-a, --args ', 'Pass arguments to MPV') .option('-r, --recursive', 'Search directories recursively') diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index 0c81cbaf..8f8eb983 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -8,6 +8,7 @@ import { EventEmitter } from 'node:events'; import type { Args } from './types'; import { cleanupPlaybackSession, + detectBackend, findAppBinary, launchAppCommandDetached, launchTexthookerOnly, @@ -56,6 +57,22 @@ function createTempSocketPath(): { dir: string; socketPath: string } { return { dir, socketPath: path.join(dir, 'mpv.sock') }; } +function withPlatform(platform: NodeJS.Platform, callback: () => T): T { + const originalDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform, + }); + + try { + return callback(); + } finally { + if (originalDescriptor) { + Object.defineProperty(process, 'platform', originalDescriptor); + } + } +} + test('mpv module exposes only canonical socket readiness helper', () => { assert.equal('waitForSocket' in mpvModule, false); }); @@ -102,6 +119,12 @@ test('parseMpvArgString preserves empty quoted tokens', () => { ]); }); +test('detectBackend resolves windows on win32 auto mode', () => { + withPlatform('win32', () => { + assert.equal(detectBackend('auto'), 'windows'); + }); +}); + test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', () => { const error = withProcessExitIntercept(() => { launchTexthookerOnly('/definitely-missing-subminer-binary', makeArgs()); diff --git a/launcher/mpv.ts b/launcher/mpv.ts index 5e4485af..781cf05b 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -227,6 +227,7 @@ export function makeTempDir(prefix: string): string { export function detectBackend(backend: Backend): Exclude { if (backend !== 'auto') return backend; + if (process.platform === 'win32') return 'windows'; if (process.platform === 'darwin') return 'macos'; const xdgCurrentDesktop = (process.env.XDG_CURRENT_DESKTOP || '').toLowerCase(); const xdgSessionDesktop = (process.env.XDG_SESSION_DESKTOP || '').toLowerCase(); diff --git a/launcher/types.ts b/launcher/types.ts index 598cc449..d67a1358 100644 --- a/launcher/types.ts +++ b/launcher/types.ts @@ -68,7 +68,7 @@ export const DEFAULT_MPV_SUBMINER_ARGS = [ ] as const; export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; -export type Backend = 'auto' | 'hyprland' | 'x11' | 'macos'; +export type Backend = 'auto' | 'hyprland' | 'sway' | 'x11' | 'macos' | 'windows'; export type JimakuLanguagePreference = 'ja' | 'en' | 'none'; export interface LauncherAiConfig { diff --git a/plugin/subminer.conf b/plugin/subminer.conf index 78c7ef6a..efaa06ef 100644 --- a/plugin/subminer.conf +++ b/plugin/subminer.conf @@ -18,7 +18,7 @@ texthooker_enabled=yes # Texthooker WebSocket port texthooker_port=5174 -# Window manager backend: auto, hyprland, sway, x11 +# Window manager backend: auto, hyprland, sway, x11, macos, windows # "auto" detects based on environment variables backend=auto diff --git a/src/core/services/youtube/metadata-probe.ts b/src/core/services/youtube/metadata-probe.ts index 2714fcc4..fa71f706 100644 --- a/src/core/services/youtube/metadata-probe.ts +++ b/src/core/services/youtube/metadata-probe.ts @@ -1,5 +1,6 @@ import { spawn } from 'node:child_process'; import type { YoutubeVideoMetadata } from '../immersion-tracker/types'; +import { getYoutubeYtDlpCommand } from './ytdlp-command'; const YOUTUBE_METADATA_PROBE_TIMEOUT_MS = 15_000; @@ -87,7 +88,7 @@ function pickChannelThumbnail(thumbnails: YtDlpThumbnail[] | undefined): string export async function probeYoutubeVideoMetadata( targetUrl: string, ): Promise { - const { stdout } = await runCapture('yt-dlp', [ + const { stdout } = await runCapture(getYoutubeYtDlpCommand(), [ '--dump-single-json', '--no-warnings', '--skip-download', diff --git a/src/core/services/youtube/playback-resolve.ts b/src/core/services/youtube/playback-resolve.ts index aac26f97..75fcab19 100644 --- a/src/core/services/youtube/playback-resolve.ts +++ b/src/core/services/youtube/playback-resolve.ts @@ -1,4 +1,5 @@ import { spawn } from 'node:child_process'; +import { getYoutubeYtDlpCommand } from './ytdlp-command'; const YOUTUBE_PLAYBACK_RESOLVE_TIMEOUT_MS = 15_000; const DEFAULT_PLAYBACK_FORMAT = 'b'; @@ -88,8 +89,7 @@ export async function resolveYoutubePlaybackUrl( targetUrl: string, format = DEFAULT_PLAYBACK_FORMAT, ): Promise { - const ytDlpCommand = process.env.SUBMINER_YTDLP_BIN?.trim() || 'yt-dlp'; - const { stdout } = await runCapture(ytDlpCommand, [ + const { stdout } = await runCapture(getYoutubeYtDlpCommand(), [ '--get-url', '--no-warnings', '-f', diff --git a/src/core/services/youtube/track-download.test.ts b/src/core/services/youtube/track-download.test.ts index 4f7ba11a..5a24bc1b 100644 --- a/src/core/services/youtube/track-download.test.ts +++ b/src/core/services/youtube/track-download.test.ts @@ -114,6 +114,39 @@ async function withFakeYtDlp( }); } +async function withFakeYtDlpCommand( + mode: 'both' | 'webp-only' | 'multi' | 'multi-primary-only-fail' | 'rolling-auto', + fn: (dir: string, binDir: string) => Promise, +): Promise { + return await withTempDir(async (root) => { + const binDir = path.join(root, 'bin'); + fs.mkdirSync(binDir, { recursive: true }); + makeFakeYtDlpScript(binDir); + + const originalPath = process.env.PATH; + const originalCommand = process.env.SUBMINER_YTDLP_BIN; + process.env.PATH = ''; + process.env.YTDLP_FAKE_MODE = mode; + process.env.SUBMINER_YTDLP_BIN = + process.platform === 'win32' ? path.join(binDir, 'yt-dlp.cmd') : path.join(binDir, 'yt-dlp'); + try { + return await fn(root, binDir); + } finally { + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + delete process.env.YTDLP_FAKE_MODE; + if (originalCommand === undefined) { + delete process.env.SUBMINER_YTDLP_BIN; + } else { + process.env.SUBMINER_YTDLP_BIN = originalCommand; + } + } + }); +} + async function withFakeYtDlpExpectations( expectations: Partial< Record<'YTDLP_EXPECT_AUTO_SUBS' | 'YTDLP_EXPECT_MANUAL_SUBS' | 'YTDLP_EXPECT_SUB_LANG', string> @@ -179,6 +212,29 @@ test('downloadYoutubeSubtitleTrack prefers subtitle files over later webp artifa }); }); +test('downloadYoutubeSubtitleTrack honors SUBMINER_YTDLP_BIN when yt-dlp is not on PATH', async () => { + if (process.platform === 'win32') { + return; + } + + await withFakeYtDlpCommand('both', async (root) => { + const result = await downloadYoutubeSubtitleTrack({ + targetUrl: 'https://www.youtube.com/watch?v=abc123', + outputDir: path.join(root, 'out'), + track: { + id: 'auto:ja-orig', + language: 'ja', + sourceLanguage: 'ja-orig', + kind: 'auto', + label: 'Japanese (auto)', + }, + }); + + assert.equal(path.extname(result.path), '.vtt'); + assert.match(path.basename(result.path), /^auto-ja-orig\./); + }); +}); + test('downloadYoutubeSubtitleTrack ignores stale subtitle files from prior runs', async () => { if (process.platform === 'win32') { return; diff --git a/src/core/services/youtube/track-download.ts b/src/core/services/youtube/track-download.ts index 8dac62ee..bd50e2c6 100644 --- a/src/core/services/youtube/track-download.ts +++ b/src/core/services/youtube/track-download.ts @@ -2,6 +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 { convertYoutubeTimedTextToVtt, isYoutubeTimedTextExtension, @@ -237,7 +238,7 @@ export async function downloadYoutubeSubtitleTrack(input: { }), ]; - await runCapture('yt-dlp', args); + await runCapture(getYoutubeYtDlpCommand(), args); const subtitlePath = pickLatestSubtitleFile(input.outputDir, prefix); if (!subtitlePath) { throw new Error(`No subtitle file was downloaded for ${input.track.sourceLanguage}`); @@ -281,7 +282,7 @@ export async function downloadYoutubeSubtitleTracks(input: { const includeManualSubs = input.tracks.some((track) => track.kind === 'manual'); const result = await runCaptureDetailed( - 'yt-dlp', + getYoutubeYtDlpCommand(), buildDownloadArgs({ targetUrl: input.targetUrl, outputTemplate, diff --git a/src/core/services/youtube/track-probe.test.ts b/src/core/services/youtube/track-probe.test.ts index 998aeaa7..e7e93e06 100644 --- a/src/core/services/youtube/track-probe.test.ts +++ b/src/core/services/youtube/track-probe.test.ts @@ -48,6 +48,37 @@ async function withFakeYtDlp( }); } +async function withFakeYtDlpCommand( + payload: unknown, + fn: () => Promise, + options: { rawScript?: boolean } = {}, +): Promise { + return await withTempDir(async (root) => { + const binDir = path.join(root, 'bin'); + fs.mkdirSync(binDir, { recursive: true }); + makeFakeYtDlpScript(binDir, payload, options.rawScript === true); + const originalPath = process.env.PATH; + const originalCommand = process.env.SUBMINER_YTDLP_BIN; + process.env.PATH = ''; + process.env.SUBMINER_YTDLP_BIN = + process.platform === 'win32' ? path.join(binDir, 'yt-dlp.cmd') : path.join(binDir, 'yt-dlp'); + try { + return await fn(); + } finally { + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + if (originalCommand === undefined) { + delete process.env.SUBMINER_YTDLP_BIN; + } else { + process.env.SUBMINER_YTDLP_BIN = originalCommand; + } + } + }); +} + test('probeYoutubeTracks prefers srv3 over vtt for automatic captions', async () => { await withFakeYtDlp( { @@ -69,6 +100,28 @@ test('probeYoutubeTracks prefers srv3 over vtt for automatic captions', async () ); }); +test('probeYoutubeTracks honors SUBMINER_YTDLP_BIN when yt-dlp is not on PATH', async () => { + if (process.platform === 'win32') { + return; + } + + await withFakeYtDlpCommand( + { + id: 'abc123', + title: 'Example', + subtitles: { + ja: [{ ext: 'vtt', url: 'https://example.com/ja.vtt', name: 'Japanese manual' }], + }, + }, + async () => { + const result = await probeYoutubeTracks('https://www.youtube.com/watch?v=abc123'); + assert.equal(result.videoId, 'abc123'); + assert.equal(result.tracks[0]?.downloadUrl, 'https://example.com/ja.vtt'); + assert.equal(result.tracks[0]?.fileExtension, 'vtt'); + }, + ); +}); + test('probeYoutubeTracks keeps preferring srt for manual captions', async () => { await withFakeYtDlp( { diff --git a/src/core/services/youtube/track-probe.ts b/src/core/services/youtube/track-probe.ts index 615c5e28..d3fe46d6 100644 --- a/src/core/services/youtube/track-probe.ts +++ b/src/core/services/youtube/track-probe.ts @@ -1,6 +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'; const YOUTUBE_TRACK_PROBE_TIMEOUT_MS = 15_000; @@ -111,7 +112,11 @@ function toTracks(entries: Record | undefined, kind: export type { YoutubeTrackOption }; export async function probeYoutubeTracks(targetUrl: string): Promise { - const { stdout } = await runCapture('yt-dlp', ['--dump-single-json', '--no-warnings', targetUrl]); + const { stdout } = await runCapture(getYoutubeYtDlpCommand(), [ + '--dump-single-json', + '--no-warnings', + 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 new file mode 100644 index 00000000..10996e15 --- /dev/null +++ b/src/core/services/youtube/ytdlp-command.ts @@ -0,0 +1,5 @@ +const DEFAULT_YTDLP_COMMAND = 'yt-dlp'; + +export function getYoutubeYtDlpCommand(): string { + return process.env.SUBMINER_YTDLP_BIN?.trim() || DEFAULT_YTDLP_COMMAND; +}