Relay HLS/DASH-only pages as pieces tracks (protocol 11)

A track can be served as pieces: the server writes a VOD playlist whose
entries are its own piece URLs (segments, init maps, AES-128 keys; byte
ranges folded in; yt-dlp's DASH fragments become the same list). A piece
buffer per track fills like the byte buffer. Picks a single file, then a
split byte pair, then pieces. DRM, live, master playlists are refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-09 23:35:31 +03:00
1 parent 05f9e794a9
commit fe5d6c3b3f
18 files changed
+1593 -207

No files matched your search

+130
View File
@@ -0,0 +1,130 @@
// Pieces tracks: HLS playlist parsing (byte ranges folded, init and key
// pieces, refusals), the synthesized playlist, and the track wire.
import 'package:sync_relay_core/core.dart';
import 'package:test/test.dart';
void main() {
final base = Uri.parse('https://cdn.example/v/index.m3u8');
test('byte ranges fold into pieces; MAP, AES-128 key and sequence carry over to the served playlist', () {
final p = parseHlsPlaylist('''
#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:5
#EXT-X-MAP:URI="main.mp4",BYTERANGE="700@0"
#EXT-X-KEY:METHOD=AES-128,URI="https://keys.example/k1",IV=0x00000000000000000000000000000001
#EXTINF:6.0,
#EXT-X-BYTERANGE:1000@700
main.mp4
#EXTINF:5.5,
#EXT-X-BYTERANGE:2000
main.mp4
#EXT-X-KEY:METHOD=NONE
#EXT-X-DISCONTINUITY
#EXTINF:4,
seg/3.ts?tok=a
#EXT-X-ENDLIST
''', base);
expect([for (final s in p.sources) (s.url.toString(), s.offset, s.length)], [
('https://cdn.example/v/main.mp4', 0, 700),
('https://keys.example/k1', null, null),
('https://cdn.example/v/main.mp4', 700, 1000),
('https://cdn.example/v/main.mp4', 1700, 2000), // offset: end of the previous range
('https://cdn.example/v/seg/3.ts?tok=a', null, null),
]);
// Through the wire and back: same shape, same playlist.
final list = RelayPieceList.fromJson(p.list.toJson())!;
expect(list.sameShape(p.list), isTrue);
expect(list.playlist(), '''
#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:5
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-MAP:URI="p/0"
#EXT-X-KEY:METHOD=AES-128,URI="p/1",IV=0x00000000000000000000000000000001
#EXTINF:6.000000,
p/2
#EXTINF:5.500000,
p/3
#EXT-X-DISCONTINUITY
#EXT-X-KEY:METHOD=NONE
#EXTINF:4.000000,
p/4
#EXT-X-ENDLIST
''');
});
test('refused: DRM, live, master; a malformed piece list', () {
String pl(String body, {bool end = true}) => '#EXTM3U\n$body\n#EXTINF:4,\na.ts\n${end ? '#EXT-X-ENDLIST' : ''}';
Matcher refused(String text) =>
throwsA(isA<RelayPiecesException>().having((e) => e.message, 'message', text));
expect(() => parseHlsPlaylist(pl('#EXT-X-KEY:METHOD=SAMPLE-AES,URI="skd://x"'), base), refused(drmText));
expect(() => parseHlsPlaylist(pl('#EXT-X-KEY:METHOD=AES-128,URI="skd://x"'), base), refused(drmText));
expect(
() => parseHlsPlaylist(
pl('#EXT-X-KEY:METHOD=AES-128,URI="k",KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"'), base),
refused(drmText));
expect(() => parseHlsPlaylist(pl(''), base).list, returnsNormally);
expect(() => parseHlsPlaylist(pl('', end: false), base), refused(liveStreamText));
expect(() => parseHlsPlaylist('#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1\nv.m3u8\n', base),
throwsA(isA<RelayPiecesException>()));
expect(
RelayPieceList.fromJson({
'pieces': [
{'d': 4.0, 'map': 0},
],
}),
isNull,
reason: 'map must name an init piece');
expect(
RelayPieceList.fromJson({
'pieces': [
{'d': 4.0, 'iv': '0x1"\n#EXT'},
],
}),
isNull);
});
test('track wire: bytes entries as before, pieces with "as"; one pieces track is enough', () {
final two = MediaRelay.fromJson({
'source': 'm2',
'tracks': [
{'kind': 'video', 'as': 'pieces'},
{'kind': 'audio', 'as': 'pieces'},
],
});
expect(two.tracks, const [RelayTrackSpec('video', pieces: true), RelayTrackSpec('audio', pieces: true)]);
expect(two.toJson()['tracks'], [
{'kind': 'video', 'as': 'pieces'},
{'kind': 'audio', 'as': 'pieces'},
]);
final one = MediaRelay.fromJson({
'tracks': [
{'kind': 'video', 'as': 'pieces'},
],
});
expect(one.tracks.single.pieces, isTrue);
// One byte track is one file; an unknown "as" drops the list.
expect(
MediaRelay.fromJson({
'tracks': [
{'kind': 'video'},
],
}).tracks,
isEmpty);
expect(
MediaRelay.fromJson({
'tracks': [
{'kind': 'video', 'as': 'frames'},
{'kind': 'audio'},
],
}).tracks,
isEmpty);
expect(const MediaRelay(tracks: [RelayTrackSpec('video'), RelayTrackSpec('audio')]).toJson()['tracks'], [
{'kind': 'video'},
{'kind': 'audio'},
]);
});
}
+33 -3
View File
@@ -101,7 +101,8 @@ void main() {
final single = _f('22', 'https', 'avc1', 'mp4a', 720);
expect(pickTracks([single, ...youtube]).map((x) => x.kind), ['video', 'audio']);
expect(pickTracks([_f('18', 'https', 'avc1', 'mp4a', 1080), ...youtube]).single.format.id, '18');
expect(pickTracks([_f('96', 'm3u8_native', 'avc1', 'mp4a', 1080)]), isEmpty);
expect(pickTracks([_f('96', 'm3u8_native', 'avc1', 'mp4a', 1080)]).single.format.isPieces, isTrue);
expect(pickTracks([_f('96', 'rtmp', 'avc1', 'mp4a', 1080)]), isEmpty);
final live = YtDlpInfo.parse({'title': 'x', 'is_live': true, 'formats': youtube});
expect(live.isLive, isTrue);
expect(live.tracks, isEmpty);
@@ -117,8 +118,10 @@ void main() {
test('viewer URL: one track plain, several an mpv EDL with %length% framing', () {
expect(relayViewerUrl('http://h:1/r/default/t/3', 'a/b c'), 'http://h:1/r/default/t/3/a_b%20c');
const v = 'http://h:1/r/default/t/3/0/x', a = 'http://h:1/r/default/t/3/1/x';
expect(relayViewerUrl('http://h:1/r/default/t/3', 'x', tracks: 2),
expect(relayViewerUrl('http://h:1/r/default/t/3', 'x', tracks: const [RelayTrackSpec('video'), RelayTrackSpec('audio')]),
'edl://!no_clip;%${v.length}%$v;!new_stream;!no_clip;%${a.length}%$a');
expect(relayViewerUrl('http://h:1/r/default/t/3', 'x', tracks: const [RelayTrackSpec('video', pieces: true)]),
'http://h:1/r/default/t/3/0/x.m3u8');
expect(mpvEdl(['http://x/a;b,c']), 'edl://!no_clip;%14%http://x/a;b,c');
});
@@ -191,7 +194,7 @@ void main() {
expect(probe.title, startsWith('a girl.'));
expect(probe.relayProblem, isNull);
final media = LinkRelayMedia(url, ytDlp: y, probe: probe);
final bytes = (await media.open()).single.bytes;
final bytes = (await media.open()).single.bytes!;
final size = await bytes.size();
var n = 0;
await for (final c in bytes.read(0, size - 1)) {
@@ -201,6 +204,33 @@ void main() {
expect(n, size);
}, timeout: const Timeout(Duration(minutes: 3)), skip: Platform.environment['YTDLP_LIVE'] == '1' ? false : 'YTDLP_LIVE not set');
test('pieces only without a file: the tweet\'s HLS video + audio; DASH fragments; muxed HLS', () {
final tweet = [
_f('hls-audio-128000-Audio', 'm3u8_native', 'none', null, null, tbr: 128),
_f('hls-430', 'm3u8_native', 'avc1.64001F', 'none', 598, tbr: 430),
];
expect([for (final t in pickTracks(tweet)) (t.kind, t.format.id, t.format.isPieces)],
[('video', 'hls-430', true), ('audio', 'hls-audio-128000-Audio', true)]);
// A byte file (even a shorter one) comes first.
expect(pickTracks([...tweet, _f('http-832', 'https', null, null, 360)]).single.format.id, 'http-832');
final dash = _f('v', 'http_dash_segments', 'avc1', 'none', 1080, extra: {
'fragment_base_url': 'https://cdn.example/b/',
'fragments': [
{'path': 'init.mp4'},
{'path': 's1.m4s', 'duration': 4.0},
{'url': 'https://other.example/s2.m4s', 'duration': 2.5},
],
});
final t = pickTracks([dash, _f('a', 'http_dash_segments', 'none', 'mp4a', null, extra: {'fragments': [{'url': 'https://cdn.example/a', 'duration': 6.5}]})]);
final p = dashPieces(t.first.format.fragments!);
expect(p.sources.map((s) => '${s.url}'), ['https://cdn.example/b/init.mp4', 'https://cdn.example/b/s1.m4s', 'https://other.example/s2.m4s']);
expect((p.list.pieces.first.role, p.list.pieces[2].map, p.list.duration), (RelayPieceRole.init, 0, 6.5));
// A muxed playlist wins unless the pair is taller.
final muxed = _f('m', 'm3u8_native', 'avc1', 'mp4a', 720);
expect(pickTracks([muxed, ...tweet]).single.format.id, 'm');
expect(pickTracks([muxed, ...tweet]).single.kind, 'video');
});
test('codec order: HEVC, then AV1, then VP9, then h264 at the same height', () {
final audio = _f('140', 'https', 'none', 'mp4a.40.2', null);
String pick(List<String> codecs) => pickSplit([