fix(youtube): parse mpv EDL stream URLs with byte-length guards (#134)

This commit is contained in:
2026-07-02 01:37:37 -07:00
committed by GitHub
parent 359cb0a301
commit 36a3704815
9 changed files with 312 additions and 46 deletions
+4
View File
@@ -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.
-28
View File
@@ -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`.
@@ -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, '一行目 二行目');
+7 -4
View File
@@ -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 () => {
+56 -7
View File
@@ -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');
+3 -3
View File
@@ -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);
}
@@ -0,0 +1,3 @@
export function toMpvEdlValue(value: string): string {
return `%${Buffer.byteLength(value, 'utf8')}%${value}`;
}
+37
View File
@@ -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]);
});
+195
View File
@@ -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;
}