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:
1 parent
05f9e794a9
commit
fe5d6c3b3f
18 files changed
+1593
-207
No files matched your search
@@ -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'},
|
||||
]);
|
||||
});
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
Reference in new issue
Block a user