diff --git a/changes/youtube-stream-edl-options.md b/changes/youtube-stream-edl-options.md new file mode 100644 index 00000000..14b9eda9 --- /dev/null +++ b/changes/youtube-stream-edl-options.md @@ -0,0 +1,4 @@ +type: fixed +area: youtube + +- Fixed direct YouTube stream media extraction by parsing mpv EDL stream URLs with their byte-length guards, preventing trailing EDL segment options from corrupting signed googlevideo URLs and causing ffmpeg 403 errors. diff --git a/release/release-notes.md b/release/release-notes.md deleted file mode 100644 index 9a568127..00000000 --- a/release/release-notes.md +++ /dev/null @@ -1,28 +0,0 @@ -## Highlights - -### Fixed - -- **YouTube Background Cache:** Fixed Windows background media cache startup for YouTube URLs opened directly in mpv. - - Resolved stream URLs are now tracked even when mpv still exposes the original YouTube playlist entry. - - Queued Anki media updates can append audio and images after the cache finishes instead of staying text-only. - -- **YouTube Subtitle Picker Notifications:** Manual subtitle picker requests now show immediate status while SubMiner probes tracks and opens the modal. - - Subtitle download progress is replaced with a transient success notification after tracks load. - -## What's Changed - -- feat(youtube): notify on manual picker open and show success after track load by @ksyasuda in #133 -- fix(youtube): recover source URL for background media cache on direct mpv open by @ksyasuda in #132 - -## Installation - -See the README and docs/installation guide for full setup steps. - -## Assets - -- Linux: `SubMiner.AppImage` -- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip` -- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip` -- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher - -Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`. diff --git a/src/anki-integration/card-creation-manual-update.test.ts b/src/anki-integration/card-creation-manual-update.test.ts index ed771731..f426888d 100644 --- a/src/anki-integration/card-creation-manual-update.test.ts +++ b/src/anki-integration/card-creation-manual-update.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { CardCreationService } from './card-creation'; +import { toMpvEdlValue } from './mpv-edl-test-utils'; import type { MediaInput } from '../media-generator'; import type { AnkiConnectConfig } from '../types/anki'; @@ -269,9 +270,11 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote const imagePaths: string[] = []; const recordMediaPath = (mediaInput: MediaInput): string => typeof mediaInput === 'string' ? mediaInput : mediaInput.path; + const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm'; + const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4'; const edlSource = [ - 'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm', - '!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4', + `edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`, + `!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`, '!global_tags,title=test', ].join(';'); @@ -354,8 +357,8 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote await service.updateLastAddedFromClipboard('一行目\n\n二行目'); - assert.deepEqual(audioPaths, ['https://audio.example/videoplayback?mime=audio%2Fwebm']); - assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']); + assert.deepEqual(audioPaths, [audioUrl]); + assert.deepEqual(imagePaths, [videoUrl]); assert.equal(storedMedia.length, 2); assert.equal(updatedFields.length, 1); assert.equal(updatedFields[0]?.Sentence, '一行目 二行目'); diff --git a/src/anki-integration/card-creation.test.ts b/src/anki-integration/card-creation.test.ts index 80b84939..02394e0a 100644 --- a/src/anki-integration/card-creation.test.ts +++ b/src/anki-integration/card-creation.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { CardCreationService } from './card-creation'; +import { toMpvEdlValue } from './mpv-edl-test-utils'; import type { MediaInput } from '../media-generator'; import type { AnkiConnectConfig } from '../types/anki'; @@ -290,9 +291,11 @@ test('CardCreationService uses stream-open-filename for remote media generation' const imagePaths: string[] = []; const recordMediaPath = (mediaInput: MediaInput): string => typeof mediaInput === 'string' ? mediaInput : mediaInput.path; + const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm'; + const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4'; const edlSource = [ - 'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm', - '!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4', + `edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`, + `!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`, '!global_tags,title=test', ].join(';'); @@ -397,8 +400,8 @@ test('CardCreationService uses stream-open-filename for remote media generation' const created = await service.createSentenceCard('テスト', 0, 1); assert.equal(created, true); - assert.deepEqual(audioPaths, ['https://audio.example/videoplayback?mime=audio%2Fwebm']); - assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']); + assert.deepEqual(audioPaths, [audioUrl]); + assert.deepEqual(imagePaths, [videoUrl]); }); test('CardCreationService does not use mpv stream indexes for ready cached YouTube media', async () => { diff --git a/src/anki-integration/media-source.test.ts b/src/anki-integration/media-source.test.ts index 9c32a88f..a3c49871 100644 --- a/src/anki-integration/media-source.test.ts +++ b/src/anki-integration/media-source.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import * as mediaSource from './media-source'; +import { toMpvEdlValue } from './mpv-edl-test-utils'; const { resolveMediaGenerationInputPath } = mediaSource; @@ -53,9 +54,11 @@ test('resolveMediaGenerationInputPath prefers stream-open-filename for remote me }); test('resolveMediaGenerationInputPath unwraps mpv edl source for audio and video', async () => { + const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm'; + const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4'; const edlSource = [ - 'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm', - '!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4', + `edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`, + `!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`, '!global_tags,title=test', ].join(';'); @@ -74,8 +77,52 @@ test('resolveMediaGenerationInputPath unwraps mpv edl source for audio and video 'video', ); - assert.equal(audioResult, 'https://audio.example/videoplayback?mime=audio%2Fwebm'); - assert.equal(videoResult, 'https://video.example/videoplayback?mime=video%2Fmp4'); + assert.equal(audioResult, audioUrl); + assert.equal(videoResult, videoUrl); +}); + +test('resolveMediaGenerationInputPath strips mpv edl segment options from unwrapped streams', async () => { + const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm'; + const signedVideoUrl = + 'https://rr1---sn.example.googlevideo.com/videoplayback?mime=video%2Fmp4&mn=sn-a,sn-b&lsig=abc%3D'; + const edlSource = [ + `edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`, + `!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(signedVideoUrl)},title=clip,length=73,timestamps=chapters`, + '!global_tags,title=test', + ].join(';'); + + const result = await resolveMediaGenerationInputPath( + { + currentVideoPath: 'https://www.youtube.com/watch?v=abc123', + requestProperty: async () => edlSource, + }, + 'video', + ); + + assert.equal(result, signedVideoUrl); +}); + +test('resolveMediaGenerationInputPath ignores length-guarded URLs in mpv edl headers', async () => { + const initUrl = 'https://init.example/init.mp4'; + const audioUrl = 'https://audio.example/stream'; + const videoUrl = 'https://video.example/stream'; + const edlSource = [ + `edl://!mp4_dash,init=${toMpvEdlValue(initUrl)}`, + '!new_stream', + toMpvEdlValue(audioUrl), + '!new_stream', + toMpvEdlValue(videoUrl), + ].join(';'); + + const audioResult = await resolveMediaGenerationInputPath( + { + currentVideoPath: 'https://www.youtube.com/watch?v=abc123', + requestProperty: async () => edlSource, + }, + 'audio', + ); + + assert.equal(audioResult, audioUrl); }); test('resolveMediaGenerationInputPath falls back to currentVideoPath when stream-open-filename fails', async () => { @@ -97,9 +144,11 @@ test('resolveMediaGenerationInput returns single-stream metadata for mpv EDL URL ).resolveMediaGenerationInput; assert.equal(typeof resolver, 'function'); + const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm'; + const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4'; const edlSource = [ - 'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm', - '!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4', + `edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`, + `!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`, ].join(';'); const result = await resolver!( @@ -117,7 +166,7 @@ test('resolveMediaGenerationInput returns single-stream metadata for mpv EDL URL 'audio', ); - assert.equal(result?.path, 'https://audio.example/videoplayback?mime=audio%2Fwebm'); + assert.equal(result?.path, audioUrl); assert.equal(result?.singleResolvedStream, true); assert.equal(result?.inputOptions?.reconnect, true); assert.equal(result?.inputOptions?.userAgent, 'Mozilla/5.0'); diff --git a/src/anki-integration/media-source.ts b/src/anki-integration/media-source.ts index c8e2bbfa..8f511bc9 100644 --- a/src/anki-integration/media-source.ts +++ b/src/anki-integration/media-source.ts @@ -1,6 +1,7 @@ import { isRemoteMediaPath } from '../jimaku/utils'; import type { MediaInput, MediaInputOptions } from '../media-input'; import type { MpvClient } from '../types/runtime'; +import { extractFileUrlsFromMpvEdlSource } from './mpv-edl'; export type MediaGenerationKind = 'audio' | 'video'; export type MediaGenerationInputSource = @@ -73,9 +74,8 @@ function normalizeHeaderName(value: string): string | null { } function extractUrlsFromMpvEdlSource(source: string): string[] { - const matches = source.matchAll(/%\d+%(https?:\/\/.*?)(?=;!new_stream|;!global_tags|$)/gms); - return [...matches] - .map((match) => trimToNonEmptyString(match[1])) + return extractFileUrlsFromMpvEdlSource(source) + .map((value) => trimToNonEmptyString(value)) .filter((value): value is string => value !== null); } diff --git a/src/anki-integration/mpv-edl-test-utils.ts b/src/anki-integration/mpv-edl-test-utils.ts new file mode 100644 index 00000000..812f262a --- /dev/null +++ b/src/anki-integration/mpv-edl-test-utils.ts @@ -0,0 +1,3 @@ +export function toMpvEdlValue(value: string): string { + return `%${Buffer.byteLength(value, 'utf8')}%${value}`; +} diff --git a/src/anki-integration/mpv-edl.test.ts b/src/anki-integration/mpv-edl.test.ts new file mode 100644 index 00000000..e712b6b9 --- /dev/null +++ b/src/anki-integration/mpv-edl.test.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { extractFileUrlsFromMpvEdlSource } from './mpv-edl'; +import { toMpvEdlValue } from './mpv-edl-test-utils'; + +test('extractFileUrlsFromMpvEdlSource honors length-guarded file values', () => { + const url = + 'https://rr1---sn.example.googlevideo.com/videoplayback?mime=video%2Fmp4&mn=sn-a,sn-b&lsig=abc%3D'; + const source = `edl://!new_stream;${toMpvEdlValue(url)},title=clip,length=73`; + + assert.deepEqual(extractFileUrlsFromMpvEdlSource(source), [url]); +}); + +test('extractFileUrlsFromMpvEdlSource reads file parameters', () => { + const initUrl = 'https://init.example/init.mp4'; + const fileUrl = 'https://video.example/videoplayback?mime=video%2Fmp4'; + const source = `edl://!mp4_dash,init=${toMpvEdlValue(initUrl)};file=${toMpvEdlValue( + fileUrl, + )},length=42`; + + assert.deepEqual(extractFileUrlsFromMpvEdlSource(source), [fileUrl]); +}); + +test('extractFileUrlsFromMpvEdlSource aggregates file URLs across entries', () => { + const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm'; + const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4'; + const source = [ + 'edl://!new_stream', + toMpvEdlValue(audioUrl), + '!new_stream', + `file=${toMpvEdlValue(videoUrl)},length=50`, + '!global_tags,title=test', + ].join(';'); + + assert.deepEqual(extractFileUrlsFromMpvEdlSource(source), [audioUrl, videoUrl]); +}); diff --git a/src/anki-integration/mpv-edl.ts b/src/anki-integration/mpv-edl.ts new file mode 100644 index 00000000..d397113f --- /dev/null +++ b/src/anki-integration/mpv-edl.ts @@ -0,0 +1,195 @@ +const EDL_URI_PREFIX = 'edl://'; + +const BYTE_COMMA = ','.charCodeAt(0); +const BYTE_CR = '\r'.charCodeAt(0); +const BYTE_EQUALS = '='.charCodeAt(0); +const BYTE_EXCLAMATION = '!'.charCodeAt(0); +const BYTE_LF = '\n'.charCodeAt(0); +const BYTE_PERCENT = '%'.charCodeAt(0); +const BYTE_SEMICOLON = ';'.charCodeAt(0); + +function isDigitByte(value: number | undefined): value is number { + return value !== undefined && value >= 48 && value <= 57; +} + +function isEntrySeparator(value: number | undefined): boolean { + return value === BYTE_SEMICOLON || value === BYTE_LF || value === BYTE_CR; +} + +function isParamSeparator(value: number | undefined): boolean { + return value === BYTE_COMMA || isEntrySeparator(value); +} + +function decodeBytes(buffer: Buffer, start: number, end: number): string { + return buffer.subarray(start, end).toString('utf8'); +} + +function isHttpUrl(value: string): boolean { + return /^https?:\/\//i.test(value); +} + +function toEdlDataBuffer(source: string): Buffer { + const data = source.startsWith(EDL_URI_PREFIX) ? source.slice(EDL_URI_PREFIX.length) : source; + return Buffer.from(data, 'utf8'); +} + +function parseLengthGuardedValue( + buffer: Buffer, + position: number, +): { value: string; end: number } | null { + if (buffer[position] !== BYTE_PERCENT) { + return null; + } + + let cursor = position + 1; + if (!isDigitByte(buffer[cursor])) { + return null; + } + + let byteLength = 0; + while (true) { + const digit = buffer[cursor]; + if (!isDigitByte(digit)) { + break; + } + byteLength = byteLength * 10 + (digit - 48); + cursor += 1; + } + + if (buffer[cursor] !== BYTE_PERCENT) { + return null; + } + + const valueStart = cursor + 1; + const valueEnd = valueStart + byteLength; + if (valueEnd > buffer.length) { + return null; + } + + return { + value: decodeBytes(buffer, valueStart, valueEnd), + end: valueEnd, + }; +} + +function skipEntrySeparators(buffer: Buffer, position: number): number { + let cursor = position; + while (cursor < buffer.length && isEntrySeparator(buffer[cursor])) { + cursor += 1; + } + return cursor; +} + +function skipEntry(buffer: Buffer, position: number): number { + let cursor = position; + while (cursor < buffer.length) { + const guardedValue = parseLengthGuardedValue(buffer, cursor); + if (guardedValue) { + cursor = guardedValue.end; + continue; + } + if (isEntrySeparator(buffer[cursor])) { + break; + } + cursor += 1; + } + return cursor; +} + +function parseRawValue(buffer: Buffer, position: number): { value: string; end: number } { + let cursor = position; + while ( + cursor < buffer.length && + !isParamSeparator(buffer[cursor]) && + buffer[cursor] !== BYTE_EXCLAMATION + ) { + cursor += 1; + } + return { + value: decodeBytes(buffer, position, cursor), + end: cursor, + }; +} + +function parseParamValue(buffer: Buffer, position: number): { value: string; end: number } { + return parseLengthGuardedValue(buffer, position) ?? parseRawValue(buffer, position); +} + +function parseOptionalParamName( + buffer: Buffer, + position: number, +): { name: string | null; valueStart: number } { + let cursor = position; + while ( + cursor < buffer.length && + !isParamSeparator(buffer[cursor]) && + buffer[cursor] !== BYTE_PERCENT && + buffer[cursor] !== BYTE_EXCLAMATION + ) { + if (buffer[cursor] === BYTE_EQUALS) { + return { + name: decodeBytes(buffer, position, cursor), + valueStart: cursor + 1, + }; + } + cursor += 1; + } + + return { name: null, valueStart: position }; +} + +function parseSegmentEntry(buffer: Buffer, position: number): { urls: string[]; end: number } { + const urls: string[] = []; + let cursor = position; + let unnamedParamIndex = 0; + + while (cursor < buffer.length && !isEntrySeparator(buffer[cursor])) { + const { name, valueStart } = parseOptionalParamName(buffer, cursor); + const value = parseParamValue(buffer, valueStart); + const lowerName = name?.toLowerCase() ?? null; + const isFileParam = lowerName === 'file' || (lowerName === null && unnamedParamIndex === 0); + + if (isFileParam && isHttpUrl(value.value)) { + urls.push(value.value); + } + + if (lowerName === null) { + unnamedParamIndex += 1; + } + + cursor = value.end; + if (buffer[cursor] === BYTE_COMMA) { + cursor += 1; + continue; + } + if (!isEntrySeparator(buffer[cursor])) { + cursor = skipEntry(buffer, cursor); + } + } + + return { urls, end: cursor }; +} + +export function extractFileUrlsFromMpvEdlSource(source: string): string[] { + const buffer = toEdlDataBuffer(source); + const urls: string[] = []; + let cursor = 0; + + while (cursor < buffer.length) { + cursor = skipEntrySeparators(buffer, cursor); + if (cursor >= buffer.length) { + break; + } + + if (buffer[cursor] === BYTE_EXCLAMATION) { + cursor = skipEntry(buffer, cursor); + continue; + } + + const segment = parseSegmentEntry(buffer, cursor); + urls.push(...segment.urls); + cursor = segment.end; + } + + return urls; +}