mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-07 01:08:54 -07:00
fix(youtube): parse mpv EDL stream URLs with byte-length guards (#134)
This commit is contained in:
@@ -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.
|
||||||
@@ -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 test from 'node:test';
|
||||||
|
|
||||||
import { CardCreationService } from './card-creation';
|
import { CardCreationService } from './card-creation';
|
||||||
|
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||||
import type { MediaInput } from '../media-generator';
|
import type { MediaInput } from '../media-generator';
|
||||||
import type { AnkiConnectConfig } from '../types/anki';
|
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 imagePaths: string[] = [];
|
||||||
const recordMediaPath = (mediaInput: MediaInput): string =>
|
const recordMediaPath = (mediaInput: MediaInput): string =>
|
||||||
typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
|
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 = [
|
const edlSource = [
|
||||||
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
|
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
|
||||||
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
|
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
|
||||||
'!global_tags,title=test',
|
'!global_tags,title=test',
|
||||||
].join(';');
|
].join(';');
|
||||||
|
|
||||||
@@ -354,8 +357,8 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
|
|||||||
|
|
||||||
await service.updateLastAddedFromClipboard('一行目\n\n二行目');
|
await service.updateLastAddedFromClipboard('一行目\n\n二行目');
|
||||||
|
|
||||||
assert.deepEqual(audioPaths, ['https://audio.example/videoplayback?mime=audio%2Fwebm']);
|
assert.deepEqual(audioPaths, [audioUrl]);
|
||||||
assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']);
|
assert.deepEqual(imagePaths, [videoUrl]);
|
||||||
assert.equal(storedMedia.length, 2);
|
assert.equal(storedMedia.length, 2);
|
||||||
assert.equal(updatedFields.length, 1);
|
assert.equal(updatedFields.length, 1);
|
||||||
assert.equal(updatedFields[0]?.Sentence, '一行目 二行目');
|
assert.equal(updatedFields[0]?.Sentence, '一行目 二行目');
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { CardCreationService } from './card-creation';
|
import { CardCreationService } from './card-creation';
|
||||||
|
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||||
import type { MediaInput } from '../media-generator';
|
import type { MediaInput } from '../media-generator';
|
||||||
import type { AnkiConnectConfig } from '../types/anki';
|
import type { AnkiConnectConfig } from '../types/anki';
|
||||||
|
|
||||||
@@ -290,9 +291,11 @@ test('CardCreationService uses stream-open-filename for remote media generation'
|
|||||||
const imagePaths: string[] = [];
|
const imagePaths: string[] = [];
|
||||||
const recordMediaPath = (mediaInput: MediaInput): string =>
|
const recordMediaPath = (mediaInput: MediaInput): string =>
|
||||||
typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
|
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 = [
|
const edlSource = [
|
||||||
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
|
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
|
||||||
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
|
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
|
||||||
'!global_tags,title=test',
|
'!global_tags,title=test',
|
||||||
].join(';');
|
].join(';');
|
||||||
|
|
||||||
@@ -397,8 +400,8 @@ test('CardCreationService uses stream-open-filename for remote media generation'
|
|||||||
const created = await service.createSentenceCard('テスト', 0, 1);
|
const created = await service.createSentenceCard('テスト', 0, 1);
|
||||||
|
|
||||||
assert.equal(created, true);
|
assert.equal(created, true);
|
||||||
assert.deepEqual(audioPaths, ['https://audio.example/videoplayback?mime=audio%2Fwebm']);
|
assert.deepEqual(audioPaths, [audioUrl]);
|
||||||
assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']);
|
assert.deepEqual(imagePaths, [videoUrl]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('CardCreationService does not use mpv stream indexes for ready cached YouTube media', async () => {
|
test('CardCreationService does not use mpv stream indexes for ready cached YouTube media', async () => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import * as mediaSource from './media-source';
|
import * as mediaSource from './media-source';
|
||||||
|
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||||
|
|
||||||
const { resolveMediaGenerationInputPath } = mediaSource;
|
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 () => {
|
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 = [
|
const edlSource = [
|
||||||
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
|
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
|
||||||
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
|
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
|
||||||
'!global_tags,title=test',
|
'!global_tags,title=test',
|
||||||
].join(';');
|
].join(';');
|
||||||
|
|
||||||
@@ -74,8 +77,52 @@ test('resolveMediaGenerationInputPath unwraps mpv edl source for audio and video
|
|||||||
'video',
|
'video',
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(audioResult, 'https://audio.example/videoplayback?mime=audio%2Fwebm');
|
assert.equal(audioResult, audioUrl);
|
||||||
assert.equal(videoResult, 'https://video.example/videoplayback?mime=video%2Fmp4');
|
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 () => {
|
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;
|
).resolveMediaGenerationInput;
|
||||||
assert.equal(typeof resolver, 'function');
|
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 = [
|
const edlSource = [
|
||||||
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
|
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
|
||||||
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
|
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
|
||||||
].join(';');
|
].join(';');
|
||||||
|
|
||||||
const result = await resolver!(
|
const result = await resolver!(
|
||||||
@@ -117,7 +166,7 @@ test('resolveMediaGenerationInput returns single-stream metadata for mpv EDL URL
|
|||||||
'audio',
|
'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?.singleResolvedStream, true);
|
||||||
assert.equal(result?.inputOptions?.reconnect, true);
|
assert.equal(result?.inputOptions?.reconnect, true);
|
||||||
assert.equal(result?.inputOptions?.userAgent, 'Mozilla/5.0');
|
assert.equal(result?.inputOptions?.userAgent, 'Mozilla/5.0');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { isRemoteMediaPath } from '../jimaku/utils';
|
import { isRemoteMediaPath } from '../jimaku/utils';
|
||||||
import type { MediaInput, MediaInputOptions } from '../media-input';
|
import type { MediaInput, MediaInputOptions } from '../media-input';
|
||||||
import type { MpvClient } from '../types/runtime';
|
import type { MpvClient } from '../types/runtime';
|
||||||
|
import { extractFileUrlsFromMpvEdlSource } from './mpv-edl';
|
||||||
|
|
||||||
export type MediaGenerationKind = 'audio' | 'video';
|
export type MediaGenerationKind = 'audio' | 'video';
|
||||||
export type MediaGenerationInputSource =
|
export type MediaGenerationInputSource =
|
||||||
@@ -73,9 +74,8 @@ function normalizeHeaderName(value: string): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractUrlsFromMpvEdlSource(source: string): string[] {
|
function extractUrlsFromMpvEdlSource(source: string): string[] {
|
||||||
const matches = source.matchAll(/%\d+%(https?:\/\/.*?)(?=;!new_stream|;!global_tags|$)/gms);
|
return extractFileUrlsFromMpvEdlSource(source)
|
||||||
return [...matches]
|
.map((value) => trimToNonEmptyString(value))
|
||||||
.map((match) => trimToNonEmptyString(match[1]))
|
|
||||||
.filter((value): value is string => value !== null);
|
.filter((value): value is string => value !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function toMpvEdlValue(value: string): string {
|
||||||
|
return `%${Buffer.byteLength(value, 'utf8')}%${value}`;
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user