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

+1
View File
@@ -5,6 +5,7 @@ export 'src/clock_sync.dart';
export 'src/config.dart';
export 'src/protocol.dart';
export 'src/relay.dart';
export 'src/relay_pieces.dart';
export 'src/link_probe.dart';
export 'src/yt_dlp.dart';
export 'src/cookies.dart';
+91 -16
View File
@@ -5,6 +5,7 @@ import 'dart:typed_data';
import 'constants.dart';
import 'relay.dart';
import 'relay_pieces.dart';
import 'yt_dlp.dart';
// What a pasted link is: a media file (range-readable as is) or a web page
@@ -71,12 +72,8 @@ String _unescape(String s) => s
String notAVideoText(String? ytDlpError) =>
ytDlpError == null ? 'Not a video' : "Not a video (and yt-dlp couldn't read it: $ytDlpError)";
/// The text for a page with no video file a relay can read.
const noRelayFormatText = 'This page has no video file to pass on (only streaming playlists). '
"Choose 'Everyone fetches it' instead.";
/// The text for a live stream.
const liveStreamText = "Live streams can't be passed on. Choose 'Everyone fetches it' instead.";
/// The text for a page with no video a relay can read.
const noRelayFormatText = "This page has no video a relay can read. Choose 'Everyone fetches it' instead.";
class LinkProbeException implements Exception {
final String message;
@@ -190,18 +187,22 @@ Future<String> _readText(HttpClientResponse res) async {
return utf8.decode(out.takeBytes(), allowMalformed: true);
}
/// A page's relay tracks (see [YtDlpInfo.tracks]: one file, or video +
/// audio), range-read with yt-dlp's headers in GETs of the format's
/// `http_chunk_size`. Format URLs expire: on 403/410 from any track the
/// page is resolved again once for all tracks (same formats, same sizes)
/// and the read goes on from where it stopped.
/// A page's relay tracks (see [YtDlpInfo.tracks]: one file, video +
/// audio, or segmented playlists). A byte track is range-read with
/// yt-dlp's headers in GETs of the format's `http_chunk_size`; a pieces
/// track's playlist (or DASH fragments) is read on first use, then each
/// piece is one GET. Format URLs expire: on 403/410 from any track the page
/// is resolved again once for all tracks (same format ids, same sizes and
/// piece counts) and the read goes on from where it stopped.
class PageRelayMedia implements RelayMedia {
final Uri page;
final YtDlp ytDlp;
List<YtDlpTrack>? _picked;
List<HttpRelayBytes?> _inner = const [];
List<UpstreamPieces?> _pieces = const [];
List<RelayTrack>? _tracks;
final _sizes = <int, int>{};
final _http = HttpClient();
var _gen = 0;
Future<void>? _refreshing;
PageRelayMedia(this.page, this.ytDlp, {List<YtDlpTrack>? tracks}) : _picked = tracks;
@@ -218,7 +219,13 @@ class PageRelayMedia implements RelayMedia {
final picked = _picked ??= (await _info()).tracks;
if (picked.isEmpty) throw const LinkProbeException(noRelayFormatText);
_inner = List.filled(picked.length, null, growable: false);
return _tracks = [for (var i = 0; i < picked.length; i++) RelayTrack(picked[i].kind, _PageTrack(this, i))];
_pieces = List.filled(picked.length, null, growable: false);
return _tracks = [
for (var i = 0; i < picked.length; i++)
picked[i].format.isPieces
? RelayTrack.pieces(picked[i].kind, _PagePieces(this, i))
: RelayTrack(picked[i].kind, _PageTrack(this, i)),
];
}
HttpRelayBytes _open(int i) => _inner[i] ??= () {
@@ -227,26 +234,45 @@ class PageRelayMedia implements RelayMedia {
headers: f.headers, chunkSize: f.chunkSize ?? RelayConstants.relayUpstreamChunkBytes);
}();
/// Reads track [i]'s playlist (or DASH fragments) into pieces.
Future<UpstreamPieces> _loadPieces(YtDlpFormat f) async {
final frags = f.fragments;
if (f.protocol == 'http_dash_segments') {
if (frags == null) throw const RelayPiecesException('no DASH fragments');
return dashPieces(frags);
}
return fetchHlsPlaylist(_http, f.url, f.headers);
}
static bool _expired(Object e) => e is UpstreamHttpException && (e.status == 403 || e.status == 410);
/// Resolves the page again: the same format ids with the same sizes,
/// else the video changed.
/// Resolves the page again: the same format ids with the same sizes and
/// piece shapes, else the video changed.
Future<void> _refresh(int gen) {
if (gen != _gen) return Future.value();
return _refreshing ??= () async {
try {
const changed = LinkProbeException('the video changed on the page');
final info = await _info();
final next = <YtDlpTrack>[];
for (final t in _picked!) {
final f = info.formats[t.format.id];
if (f == null) throw const LinkProbeException('the video changed on the page');
if (f == null || f.isPieces != t.format.isPieces) throw changed;
next.add((kind: t.kind, format: f));
}
final pieces = List<UpstreamPieces?>.filled(next.length, null, growable: false);
for (var i = 0; i < next.length; i++) {
final had = _pieces[i];
if (had == null) continue;
final p = pieces[i] = await _loadPieces(next[i].format);
if (!p.list.sameShape(had.list)) throw changed;
}
final old = _inner;
_picked = next;
_pieces = pieces;
_inner = List.filled(next.length, null, growable: false);
for (final e in _sizes.entries) {
if (await _open(e.key).size() != e.value) throw const LinkProbeException('the video changed on the page');
if (await _open(e.key).size() != e.value) throw changed;
}
_gen++;
for (final b in old) {
@@ -271,6 +297,19 @@ class PageRelayMedia implements RelayMedia {
}
}
Future<UpstreamPieces> _piecesOf(int i) async {
final known = _pieces[i];
if (known != null) return known;
final gen = _gen;
try {
return _pieces[i] = await _loadPieces(_picked![i].format);
} catch (e) {
if (!_expired(e)) rethrow;
await _refresh(gen);
return _pieces[i] ??= await _loadPieces(_picked![i].format);
}
}
Stream<List<int>> _read(int i, int start, int end) async* {
var pos = start;
var retried = false;
@@ -290,11 +329,31 @@ class PageRelayMedia implements RelayMedia {
}
}
/// Piece [index] of track [i]; on expiry once more after a re-resolve
/// (nothing was sent yet: an expired URL fails before its first byte).
Stream<List<int>> _readPiece(int i, int index) async* {
var retried = false;
while (true) {
final gen = _gen;
final p = await _piecesOf(i);
if (index >= p.sources.length) throw RangeError.index(index, p.sources, 'piece');
try {
yield* readPiece(_http, p.sources[index], _picked![i].format.headers);
return;
} catch (e) {
if (retried || !_expired(e)) rethrow;
retried = true;
await _refresh(gen);
}
}
}
@override
Future<void> close() async {
for (final b in _inner) {
await b?.close();
}
_http.close(force: true);
}
}
@@ -314,6 +373,22 @@ class _PageTrack implements RelayBytes {
Future<void> close() async {}
}
class _PagePieces implements RelayPieces {
final PageRelayMedia media;
final int index;
_PagePieces(this.media, this.index);
@override
Future<RelayPieceList> list() async => (await media._piecesOf(index)).list;
@override
Stream<List<int>> read(int piece) => media._readPiece(index, piece);
/// The media closes its tracks.
@override
Future<void> close() async {}
}
/// A link of either kind, probed on first [open] (or given [probe]): a
/// file as one track ([HttpRelayBytes]), a page as its picked formats
/// ([PageRelayMedia]). Throws [LinkProbeException] with the reason when it
+41 -12
View File
@@ -144,46 +144,75 @@ class HelloMedia {
}
}
/// One track of a relayed media: [kind] `video` or `audio`; a byte stream
/// (a Range-readable file) unless [pieces] (a segmented playlist relayed
/// piece by piece, served as a media playlist). JSON `{"kind":"video"}`,
/// `{"kind":"audio","as":"pieces"}`.
class RelayTrackSpec {
final String kind;
final bool pieces;
const RelayTrackSpec(this.kind, {this.pieces = false});
Map<String, Object?> toJson() => {'kind': kind, if (pieces) 'as': 'pieces'};
/// Null when [j] is not a track entry this side knows.
static RelayTrackSpec? fromJson(Object? j) {
if (j is! Map || !MediaRelay.trackKinds.contains(j['kind'])) return null;
final as = j['as'];
if (as != null && as != 'bytes' && as != 'pieces') return null;
return RelayTrackSpec(j['kind'] as String, pieces: as == 'pieces');
}
@override
bool operator ==(Object other) => other is RelayTrackSpec && other.kind == kind && other.pieces == pieces;
@override
int get hashCode => Object.hash(kind, pieces);
@override
String toString() => pieces ? '$kind pieces' : kind;
}
/// Relayed media: the bytes go through the server's buffer (`/r/...`).
/// Either from the [source] member's machine (it dials the relay channel),
/// or, with [fetch], the server reads the swap's link itself. A swap sends
/// `{}` or `{"fetch":true}`; the server fills in [source] (the swapper's
/// member id, empty with [fetch]) and whether bytes can flow ([live]).
/// [tracks]: the kinds (`video`, `audio`) of a media relayed as several
/// tracks played together; empty for one file. A member swap names them,
/// the server fills them in for [fetch] once it has read the link.
/// [tracks]: the tracks of a media relayed as several played together, or
/// as one segmented playlist; empty for one file. A member swap names
/// them, the server fills them in for [fetch] once it has read the link.
class MediaRelay {
final String source;
final bool fetch;
final bool live;
final List<String> tracks;
final List<RelayTrackSpec> tracks;
const MediaRelay({this.source = '', this.fetch = false, this.live = false, this.tracks = const []});
static const trackKinds = {'video', 'audio'};
static const maxTracks = 4;
MediaRelay copyWith({bool? live, List<String>? tracks}) =>
/// [tracks] as sent: several, or one pieces track; else one file.
static bool validTracks(List<RelayTrackSpec> t) =>
t.length <= maxTracks && (t.length > 1 || (t.length == 1 && t.single.pieces));
MediaRelay copyWith({bool? live, List<RelayTrackSpec>? tracks}) =>
MediaRelay(source: source, fetch: fetch, live: live ?? this.live, tracks: tracks ?? this.tracks);
Map<String, Object?> toJson() => {
if (source.isNotEmpty) 'source': source,
if (fetch) 'fetch': true,
if (live) 'live': true,
if (tracks.length > 1) 'tracks': [for (final k in tracks) {'kind': k}],
if (validTracks(tracks)) 'tracks': [for (final t in tracks) t.toJson()],
};
/// Unknown kinds, a single entry or too many make it one file.
/// Unknown kinds, a single byte track or too many make it one file.
factory MediaRelay.fromJson(Map<String, Object?> json) {
final t = json['tracks'];
final kinds = t is List
? [for (final e in t) e is Map && e['kind'] is String ? e['kind'] as String : '']
: <String>[];
final ok = kinds.length > 1 && kinds.length <= maxTracks && kinds.every(trackKinds.contains);
final specs = t is List ? [for (final e in t) RelayTrackSpec.fromJson(e)] : const <RelayTrackSpec?>[];
final ok = !specs.contains(null) && validTracks(specs.cast<RelayTrackSpec>());
return MediaRelay(
source: json['source'] is String ? json['source'] as String : '',
fetch: json['fetch'] == true,
live: json['live'] == true,
tracks: ok ? List.unmodifiable(kinds) : const []);
tracks: ok ? List.unmodifiable(specs.cast<RelayTrackSpec>()) : const []);
}
}
+59 -21
View File
@@ -7,20 +7,26 @@ import 'dart:typed_data';
import 'constants.dart';
import 'logging.dart';
import 'protocol.dart';
import 'relay_pieces.dart';
// The relay channel: a member's machine (the source) serves the current
// media's bytes to the server, which buffers them for every viewer (`/r/...`).
// The source dials out (works behind NAT/CGNAT) on its own WebSocket, so
// video bytes never queue behind `/sync` messages. A media is one track (a
// file) or several (a page's separate video and audio).
// file) or several (a page's separate video and audio); a track is a
// byte stream or pieces (a segmented playlist, see relay_pieces.dart).
//
// source -> server {"t":"info","size":N} first frame, one track
// {"t":"info","tracks":[{"kind":"video","size":N},...]}
// {"t":"info","tracks":[{"kind":"video","as":"pieces",
// "pieces":[{"init":true},{"d":6.0,"map":0},...]}]}
// binary [u32 BE pull id][bytes] in order per pull
// {"t":"end","id":1} pull complete
// {"t":"fail","id":1,"reason":"..."} cannot read
// server -> source {"t":"pull","id":1,"track":0,"start":0,"end":N}
// bytes start..end
// {"t":"pull","id":2,"track":0,"piece":17} one piece, whole
// {"t":"ack","id":1,"n":65536} n bytes arrived
// {"t":"cancel","id":1} stop this pull
//
@@ -37,11 +43,14 @@ String relayUrlName(String name) => Uri.encodeComponent(name.replaceAll(RegExp(r
/// What a viewer opens for relayed media at [base]
/// (`http://<host>/r/<sid>/<token>/<mediaId>`): `<base>/<name>` for one
/// track, else an mpv EDL playing `<base>/<i>/<name>` of every track
/// together (see [mpvEdl]).
String relayViewerUrl(String base, String name, {int tracks = 1}) {
/// file; with [tracks], each track's URL (`<base>/<i>/<name>`, a pieces
/// track's playlist `<base>/<i>/<name>.m3u8`), several played together as
/// an mpv EDL (see [mpvEdl]).
String relayViewerUrl(String base, String name, {List<RelayTrackSpec> tracks = const []}) {
final n = relayUrlName(name);
return tracks <= 1 ? '$base/$n' : mpvEdl([for (var i = 0; i < tracks; i++) '$base/$i/$n']);
if (tracks.isEmpty) return '$base/$n';
final urls = [for (var i = 0; i < tracks.length; i++) '$base/$i/$n${tracks[i].pieces ? '.m3u8' : ''}'];
return urls.length == 1 ? urls.single : mpvEdl(urls);
}
/// An mpv `edl://` URL playing [urls] as the streams of one file (a video
@@ -244,14 +253,40 @@ class RelayWindow {
}
}
/// One byte stream of a relayed media: [kind] is `video` or `audio` when
/// a media has several, empty for a single file.
/// Where a source reads a pieces track from (see relay_pieces.dart).
abstract interface class RelayPieces {
/// The track's shape; a playlist is fetched on the first call. Throws
/// [RelayPiecesException] when it cannot be relayed (DRM, live).
Future<RelayPieceList> list();
/// Piece [index], whole, in order. Cancelling stops reading.
Stream<List<int>> read(int index);
Future<void> close();
}
/// One track of a relayed media: [kind] is `video` or `audio` when a
/// media has several (or is pieces), empty for a single file. Read as
/// [bytes] (a Range-readable stream) or as [pieces].
class RelayTrack {
final String kind;
final RelayBytes bytes;
const RelayTrack(this.kind, this.bytes);
final RelayBytes? bytes;
final RelayPieces? pieces;
const RelayTrack(this.kind, RelayBytes this.bytes) : pieces = null;
const RelayTrack.pieces(this.kind, RelayPieces this.pieces) : bytes = null;
RelayTrackSpec get spec => RelayTrackSpec(kind.isEmpty ? 'video' : kind, pieces: pieces != null);
}
/// The wire's `relay.tracks` for [tracks]: empty for one file.
List<RelayTrackSpec> relayTrackSpecs(List<RelayTrack> tracks) =>
tracks.length == 1 && tracks.single.pieces == null ? const [] : [for (final t in tracks) t.spec];
/// A track's `info` entry: its kind and size, or its pieces.
Future<Map<String, Object?>> relayTrackInfo(RelayTrack t) async => t.pieces != null
? {...t.spec.toJson(), ...(await t.pieces!.list()).toJson()}
: {'kind': t.kind, 'size': await t.bytes!.size()};
/// What a relay serves: one track, or several played together (a page's
/// separate video and audio).
abstract interface class RelayMedia {
@@ -317,16 +352,11 @@ class RelaySource {
/// when it cannot be read.
Future<void> start() async {
final tracks = await media.open();
final sizes = [for (final t in tracks) await t.bytes.size()];
final infos = [for (final t in tracks) await relayTrackInfo(t)];
_tracks = tracks;
unawaited(_loop({
't': 'info',
if (tracks.length == 1)
'size': sizes.single
else
'tracks': [
for (var i = 0; i < tracks.length; i++) {'kind': tracks[i].kind, 'size': sizes[i]},
],
if (relayTrackSpecs(tracks).isEmpty) 'size': infos.single['size'] else 'tracks': infos,
}));
}
@@ -373,12 +403,20 @@ class RelaySource {
if (id is! int) return;
switch (j['t']) {
case 'pull':
final start = j['start'], end = j['end'], track = j['track'] ?? 0;
if (start is! int || end is! int || start < 0 || end < start) return;
final start = j['start'], end = j['end'], track = j['track'] ?? 0, piece = j['piece'];
if (track is! int || track < 0 || track >= _tracks.length) return;
final t = _tracks[track];
final Stream<List<int>> Function() read;
if (piece is int) {
if (t.pieces == null || piece < 0) return;
read = () => t.pieces!.read(piece);
} else {
if (start is! int || end is! int || start < 0 || end < start || t.bytes == null) return;
read = () => t.bytes!.read(start, end);
}
final p = _Pull(id);
_pulls[id] = p;
unawaited(_serve(ws, p, _tracks[track].bytes, start, end));
unawaited(_serve(ws, p, read));
case 'ack':
final p = _pulls[id];
final n = j['n'];
@@ -409,8 +447,8 @@ class RelaySource {
_pulls.clear();
}
Future<void> _serve(WebSocket ws, _Pull p, RelayBytes bytes, int start, int end) async {
final it = p.it = StreamIterator(bytes.read(start, end));
Future<void> _serve(WebSocket ws, _Pull p, Stream<List<int>> Function() read) async {
final it = p.it = StreamIterator(read());
final pending = BytesBuilder(copy: false);
const frame = RelayConstants.relayFrameBytes;
try {
+385
View File
@@ -0,0 +1,385 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'relay.dart';
// Pieces tracks: a segmented stream (an HLS media playlist, or yt-dlp's
// DASH fragments) relayed piece by piece. A piece is one upstream GET
// served whole: a segment, an init section (`EXT-X-MAP`) or an AES-128
// key. The fetcher (the source member, or the server for a link it reads
// itself) parses the upstream playlist into a [RelayPieceList] (no URLs);
// the server turns that into the VOD media playlist viewers open, whose
// entries are its own `p/<i>` piece URLs (see [RelayPieceList.playlist]).
/// Why a segmented stream cannot be relayed.
class RelayPiecesException implements Exception {
final String message;
const RelayPiecesException(this.message);
@override
String toString() => message;
}
/// The text for DRM-protected media.
const drmText = "This video is DRM-protected; it can't be relayed.";
/// The text for a live stream.
const liveStreamText = "Live streams can't be passed on. Choose 'Everyone fetches it' instead.";
enum RelayPieceRole { segment, init, key }
/// One piece of a [RelayPieceList]. A segment has its duration [d], the
/// init piece [map] and AES-128 key piece [key] (with an explicit [iv])
/// that apply to it, and [disc] when an `EXT-X-DISCONTINUITY` precedes it.
class RelayPiece {
final RelayPieceRole role;
final double d;
final int? map, key;
final String? iv;
final bool disc;
const RelayPiece.segment(this.d, {this.map, this.key, this.iv, this.disc = false})
: role = RelayPieceRole.segment;
const RelayPiece.init()
: role = RelayPieceRole.init,
d = 0,
map = null,
key = null,
iv = null,
disc = false;
const RelayPiece.key()
: role = RelayPieceRole.key,
d = 0,
map = null,
key = null,
iv = null,
disc = false;
bool get isSegment => role == RelayPieceRole.segment;
Map<String, Object?> toJson() => switch (role) {
RelayPieceRole.init => {'init': true},
RelayPieceRole.key => {'key': true},
RelayPieceRole.segment => {
'd': d,
if (map != null) 'map': map,
if (key != null) 'key': key,
if (iv != null) 'iv': iv,
if (disc) 'disc': true,
},
};
}
final _ivRe = RegExp(r'^0[xX][0-9a-fA-F]{1,32}$');
/// The shape of a pieces track: what the server needs to serve it (no
/// upstream URLs). Sent in the relay channel's `info` (see PROTOCOL.md).
class RelayPieceList {
final List<RelayPiece> pieces;
/// `EXT-X-MEDIA-SEQUENCE` of the first segment (AES-128 without an
/// explicit IV uses the segment's sequence number).
final int seq;
const RelayPieceList(this.pieces, {this.seq = 0});
static const maxPieces = 200000;
int get length => pieces.length;
double get duration => pieces.fold(0.0, (a, p) => a + p.d);
/// Same piece count and roles (a re-resolve or a reconnect of the same
/// media).
bool sameShape(RelayPieceList o) {
if (o.length != length) return false;
for (var i = 0; i < length; i++) {
if (o.pieces[i].role != pieces[i].role) return false;
}
return true;
}
Map<String, Object?> toJson() => {
if (seq != 0) 'seq': seq,
'pieces': [for (final p in pieces) p.toJson()],
};
/// Parses and checks a track entry's `seq` / `pieces`; null when
/// malformed (an index out of range or of the wrong role, a bad IV).
static RelayPieceList? fromJson(Map j) {
final raw = j['pieces'];
final seq = j['seq'] ?? 0;
if (raw is! List || raw.isEmpty || raw.length > maxPieces || seq is! int || seq < 0) return null;
final roles = <RelayPieceRole>[];
for (final e in raw) {
if (e is! Map) return null;
roles.add(e['init'] == true
? RelayPieceRole.init
: e['key'] == true
? RelayPieceRole.key
: RelayPieceRole.segment);
}
int? ref(Object? v, RelayPieceRole want) {
if (v == null) return null;
if (v is! int || v < 0 || v >= roles.length || roles[v] != want) throw const FormatException();
return v;
}
final out = <RelayPiece>[];
try {
for (var i = 0; i < raw.length; i++) {
final e = raw[i] as Map;
switch (roles[i]) {
case RelayPieceRole.init:
out.add(const RelayPiece.init());
case RelayPieceRole.key:
out.add(const RelayPiece.key());
case RelayPieceRole.segment:
final d = e['d'], iv = e['iv'];
if (d is! num || !d.isFinite || d < 0 || d > 3600) return null;
if (iv != null && (iv is! String || !_ivRe.hasMatch(iv))) return null;
out.add(RelayPiece.segment(d.toDouble(),
map: ref(e['map'], RelayPieceRole.init),
key: ref(e['key'], RelayPieceRole.key),
iv: iv as String?,
disc: e['disc'] == true));
}
}
} on FormatException {
return null;
}
if (!out.any((p) => p.isSegment)) return null;
return RelayPieceList(List.unmodifiable(out), seq: seq);
}
/// The VOD media playlist viewers open: every entry is `p/<i>`
/// (relative to the playlist's URL), init and key pieces included.
String playlist() {
final segs = pieces.where((p) => p.isSegment);
final target = max(1, segs.map((p) => p.d).fold(0.0, max).ceil());
final b = StringBuffer()
..writeln('#EXTM3U')
..writeln('#EXT-X-VERSION:6')
..writeln('#EXT-X-TARGETDURATION:$target')
..writeln('#EXT-X-MEDIA-SEQUENCE:$seq')
..writeln('#EXT-X-PLAYLIST-TYPE:VOD');
int? map, key;
String? iv;
for (var i = 0; i < pieces.length; i++) {
final p = pieces[i];
if (!p.isSegment) continue;
if (p.disc) b.writeln('#EXT-X-DISCONTINUITY');
if (p.map != null && p.map != map) b.writeln('#EXT-X-MAP:URI="p/${p.map}"');
if (p.key != key || p.iv != iv) {
b.writeln(p.key == null
? '#EXT-X-KEY:METHOD=NONE'
: '#EXT-X-KEY:METHOD=AES-128,URI="p/${p.key}"${p.iv == null ? '' : ',IV=${p.iv}'}');
}
map = p.map;
key = p.key;
iv = p.iv;
b
..writeln('#EXTINF:${p.d.toStringAsFixed(6)},')
..writeln('p/$i');
}
b.writeln('#EXT-X-ENDLIST');
return b.toString();
}
}
/// Where the fetcher reads piece `i`: one GET of [url], of [length] bytes
/// at [offset] when it is a byte range of a bigger file.
typedef PieceSource = ({Uri url, int? offset, int? length});
/// A parsed upstream stream: the shape plus where each piece lives.
typedef UpstreamPieces = ({RelayPieceList list, List<PieceSource> sources});
Map<String, String> _attrs(String s) {
final out = <String, String>{};
for (final m in RegExp(r'([A-Z0-9-]+)=("[^"]*"|[^,]*)').allMatches(s)) {
var v = m.group(2)!;
if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) v = v.substring(1, v.length - 1);
out[m.group(1)!] = v;
}
return out;
}
/// An HLS media playlist at [base] as pieces: every segment, `EXT-X-MAP`
/// and AES-128 `EXT-X-KEY` becomes a piece; `EXT-X-BYTERANGE` (and a MAP's
/// `BYTERANGE`) is folded into the piece (the offset defaults to the end
/// of the previous range of the same URI). Throws [RelayPiecesException]
/// for DRM (SAMPLE-AES, `skd:`, any non-identity key format), a live
/// playlist (no `EXT-X-ENDLIST`), a master playlist or no segments.
UpstreamPieces parseHlsPlaylist(String text, Uri base) {
final lines = const LineSplitter().convert(text).map((l) => l.trim()).toList();
if (lines.isEmpty || !lines.first.startsWith('#EXTM3U')) {
throw const RelayPiecesException('not an HLS playlist');
}
final pieces = <RelayPiece>[];
final sources = <PieceSource>[];
final lastEnd = <String, int>{};
final shared = <String, int>{}; // init/key piece by "url offset length"
var seq = 0;
var ended = false;
double? extinf;
int? brLen, brOff, map, key;
String? iv;
var disc = false;
int add(RelayPiece p, Uri url, int? len, int? off) {
if (len != null) {
off ??= lastEnd['$url'] ?? 0;
lastEnd['$url'] = off + len;
}
pieces.add(p);
sources.add((url: url, offset: len == null ? null : off, length: len));
return pieces.length - 1;
}
int sharedPiece(RelayPiece p, String uri, (int, int?)? range) {
final url = base.resolve(uri);
if (!url.isScheme('http') && !url.isScheme('https')) throw const RelayPiecesException(drmText);
final k = '${p.role} $url ${range?.$2} ${range?.$1}';
return shared[k] ??= add(p, url, range?.$1, range?.$2);
}
(int, int?) range(String v) {
final parts = v.split('@');
final len = int.tryParse(parts[0]);
final off = parts.length > 1 ? int.tryParse(parts[1]) : null;
if (len == null || len <= 0 || (parts.length > 1 && (off == null || off < 0))) {
throw const RelayPiecesException('bad byte range in the playlist');
}
return (len, off);
}
for (final line in lines) {
if (line.isEmpty) continue;
if (!line.startsWith('#')) {
final url = base.resolve(line);
if (!url.isScheme('http') && !url.isScheme('https')) {
throw const RelayPiecesException('the playlist has a segment that is not http(s)');
}
add(RelayPiece.segment(extinf ?? 0, map: map, key: key, iv: iv, disc: disc), url, brLen, brOff);
extinf = null;
brLen = brOff = null;
disc = false;
continue;
}
final colon = line.indexOf(':');
final tag = colon < 0 ? line : line.substring(0, colon);
final value = colon < 0 ? '' : line.substring(colon + 1);
switch (tag) {
case '#EXT-X-STREAM-INF':
throw const RelayPiecesException('a master playlist, not a media playlist');
case '#EXT-X-I-FRAMES-ONLY':
throw const RelayPiecesException('an I-frame playlist');
case '#EXT-X-SESSION-KEY':
throw const RelayPiecesException(drmText);
case '#EXT-X-MEDIA-SEQUENCE':
seq = int.tryParse(value) ?? 0;
case '#EXT-X-ENDLIST':
ended = true;
case '#EXT-X-DISCONTINUITY':
disc = true;
case '#EXTINF':
extinf = double.tryParse(value.split(',').first.trim());
case '#EXT-X-BYTERANGE':
(brLen, brOff) = range(value);
case '#EXT-X-MAP':
final a = _attrs(value);
final uri = a['URI'];
if (uri == null) throw const RelayPiecesException('EXT-X-MAP without a URI');
map = sharedPiece(const RelayPiece.init(), uri, a['BYTERANGE'] == null ? null : range(a['BYTERANGE']!));
case '#EXT-X-KEY':
final a = _attrs(value);
final method = (a['METHOD'] ?? '').toUpperCase();
if (method == 'NONE') {
key = null;
iv = null;
continue;
}
final format = a['KEYFORMAT'];
final uri = a['URI'] ?? '';
if (method != 'AES-128' || (format != null && format != 'identity') || uri.isEmpty) {
throw const RelayPiecesException(drmText);
}
final v = a['IV'];
if (v != null && !_ivRe.hasMatch(v)) throw const RelayPiecesException('bad IV in the playlist');
key = sharedPiece(const RelayPiece.key(), uri, null);
iv = v;
}
}
if (!ended) throw const RelayPiecesException(liveStreamText);
if (!pieces.any((p) => p.isSegment)) throw const RelayPiecesException('the playlist has no segments');
if (pieces.length > RelayPieceList.maxPieces) throw const RelayPiecesException('the playlist is too long');
return (list: RelayPieceList(List.unmodifiable(pieces), seq: seq), sources: List.unmodifiable(sources));
}
/// yt-dlp's DASH `fragments` (`http_dash_segments`) as pieces: a first
/// fragment without a duration is the init section, every other one a
/// segment (so no MPD parser of our own).
UpstreamPieces dashPieces(List<({Uri url, double? duration})> fragments) {
if (fragments.isEmpty) throw const RelayPiecesException('no fragments');
final pieces = <RelayPiece>[];
final sources = <PieceSource>[];
int? map;
for (var i = 0; i < fragments.length; i++) {
final f = fragments[i];
if (i == 0 && f.duration == null) {
map = 0;
pieces.add(const RelayPiece.init());
} else {
pieces.add(RelayPiece.segment(f.duration ?? 0, map: map));
}
sources.add((url: f.url, offset: null, length: null));
}
if (!pieces.any((p) => p.isSegment)) throw const RelayPiecesException('no fragments');
return (list: RelayPieceList(List.unmodifiable(pieces)), sources: List.unmodifiable(sources));
}
/// Fetches an HLS media playlist with [headers] and parses it
/// ([parseHlsPlaylist]).
Future<UpstreamPieces> fetchHlsPlaylist(HttpClient client, Uri url, Map<String, String> headers) async {
final req = await client.getUrl(url).timeout(const Duration(seconds: 20));
headers.forEach(req.headers.set);
final res = await req.close().timeout(const Duration(seconds: 20));
if (res.statusCode != 200) {
await res.drain<void>().catchError((_) {});
throw UpstreamHttpException(res.statusCode, url);
}
final text = await res.transform(utf8.decoder).join().timeout(const Duration(seconds: 30));
// Relative entries resolve against where the playlist ended up.
final at = res.redirects.isEmpty ? url : url.resolveUri(res.redirects.last.location);
return parseHlsPlaylist(text, at);
}
/// Reads one piece: a GET of its URL (with its byte range), whole.
Stream<List<int>> readPiece(HttpClient client, PieceSource s, Map<String, String> headers) async* {
final req = await client.getUrl(s.url).timeout(const Duration(seconds: 20));
headers.forEach(req.headers.set);
req.headers.set(HttpHeaders.acceptEncodingHeader, 'identity');
final off = s.offset, len = s.length;
if (len != null) req.headers.set(HttpHeaders.rangeHeader, 'bytes=${off ?? 0}-${(off ?? 0) + len - 1}');
final res = await req.close().timeout(const Duration(seconds: 20));
if (res.statusCode != 200 && res.statusCode != 206) {
await res.drain<void>().catchError((_) {});
throw UpstreamHttpException(res.statusCode, s.url);
}
if (len == null) {
yield* res;
return;
}
// A server ignoring Range sends the whole file: cut the piece out.
var skip = res.statusCode == 200 ? (off ?? 0) : 0;
var left = len;
await for (final c in res) {
var from = 0;
if (skip > 0) {
from = min(skip, c.length);
skip -= from;
}
final take = min(c.length - from, left);
if (take > 0) yield from == 0 && take == c.length ? c : c.sublist(from, from + take);
left -= take;
if (left <= 0) return;
}
if (left > 0) throw HttpException('upstream ended early', uri: s.url);
}
+105 -28
View File
@@ -25,7 +25,8 @@ class YtDlpException implements Exception {
}
/// One playable format of a resolved page: a URL and the headers yt-dlp
/// says to send with it.
/// says to send with it. A segmented format ([isPieces]) is an HLS media
/// playlist at [url], or DASH [fragments].
class YtDlpFormat {
final String id;
final Uri url;
@@ -35,8 +36,23 @@ class YtDlpFormat {
/// yt-dlp's `downloader_options.http_chunk_size`: the longest GET the
/// site serves at full speed.
final int? chunkSize;
/// yt-dlp's `protocol`: `https`/`http` (a file), `m3u8_native`/`m3u8`
/// (an HLS media playlist), `http_dash_segments` ([fragments]).
final String protocol;
/// DASH fragments (a first one without a duration is the init section).
final List<({Uri url, double? duration})>? fragments;
const YtDlpFormat(
{required this.id, required this.url, this.headers = const {}, this.height, this.ext = '', this.chunkSize});
{required this.id,
required this.url,
this.headers = const {},
this.height,
this.ext = '',
this.chunkSize,
this.protocol = 'https',
this.fragments});
/// Relayed as pieces (see relay_pieces.dart), not as a byte stream.
bool get isPieces => _piecesProtocols.contains(protocol);
static YtDlpFormat _of(Map f) {
final headers = f['http_headers'];
@@ -50,10 +66,34 @@ class YtDlpFormat {
height: h == 0 ? null : h,
ext: '${f['ext']}',
chunkSize: chunk is num && chunk > 0 ? chunk.toInt() : null,
protocol: '${f['protocol']}',
fragments: f['protocol'] == 'http_dash_segments' ? _fragments(f) : null,
);
}
/// yt-dlp's `fragments` (`url`, or `path` under `fragment_base_url`),
/// or null when one is not plain http(s).
static List<({Uri url, double? duration})>? _fragments(Map f) {
final raw = f['fragments'];
final base = Uri.tryParse('${f['fragment_base_url'] ?? f['url'] ?? ''}');
if (raw is! List || raw.isEmpty) return null;
final out = <({Uri url, double? duration})>[];
for (final fr in raw) {
if (fr is! Map) return null;
final u = fr['url'] is String
? Uri.tryParse(fr['url'] as String)
: fr['path'] is String && base != null
? base.resolve(fr['path'] as String)
: null;
if (u == null || (!u.isScheme('http') && !u.isScheme('https'))) return null;
out.add((url: u, duration: fr['duration'] is num ? (fr['duration'] as num).toDouble() : null));
}
return out;
}
}
const _piecesProtocols = {'m3u8_native', 'm3u8', 'http_dash_segments'};
/// A format a relay plays as one of a media's tracks ([kind] `video` or
/// `audio`; empty for a single file holding both).
typedef YtDlpTrack = ({String kind, YtDlpFormat format});
@@ -69,11 +109,11 @@ class YtDlpInfo {
final String? thumbnail;
/// A live stream (never relayed).
final bool isLive;
/// What a relay range-reads (see [pickTracks]): one file, or a video and
/// an audio file; empty when the page has neither (only streaming
/// playlists, or live).
/// What a relay reads (see [pickTracks]): one file, a video and an
/// audio file, or segmented playlists; empty when there is nothing (or
/// live).
final List<YtDlpTrack> tracks;
/// Every plain http(s) format by id, to find [tracks] again after a
/// Every relayable format by id, to find [tracks] again after a
/// re-resolve.
final Map<String, YtDlpFormat> formats;
const YtDlpInfo({
@@ -110,7 +150,7 @@ class YtDlpInfo {
tracks: isLive ? const [] : pickTracks(formats),
formats: {
for (final f in formats)
if (_httpUrl(f) && f is Map) '${f['format_id'] ?? ''}': YtDlpFormat._of(f),
if ((_httpUrl(f) || _piecesUrl(f)) && f is Map) '${f['format_id'] ?? ''}': YtDlpFormat._of(f),
},
);
}
@@ -129,6 +169,16 @@ bool _httpUrl(Object? f) {
return uri != null && (uri.isScheme('http') || uri.isScheme('https'));
}
/// A segmented format (HLS playlist or DASH fragments) over http(s), not
/// DRM-protected (the playlist's keys are checked when it is read).
bool _piecesUrl(Object? f) {
if (f is! Map || f['has_drm'] == true || !_piecesProtocols.contains(f['protocol'])) return false;
final url = f['url'];
final uri = url is String ? Uri.tryParse(url) : null;
if (uri == null || (!uri.isScheme('http') && !uri.isScheme('https'))) return false;
return f['protocol'] != 'http_dash_segments' || YtDlpFormat._fragments(f) != null;
}
int _height(Map f) => f['height'] is num ? (f['height'] as num).toInt() : 0;
double _tbr(Map f) =>
f['tbr'] is num ? (f['tbr'] as num).toDouble() : (f['abr'] is num ? (f['abr'] as num).toDouble() : 0);
@@ -180,19 +230,22 @@ int _videoCodecRank(Map f) {
/// shortest above), HEVC before AV1 before VP9 before h264 (smallest
/// first, see [_videoCodecRank]), then bitrate. Audio: the original language, not a `-drc` variant, m4a/aac
/// before others (opus), then bitrate.
(YtDlpFormat, YtDlpFormat)? pickSplit(List<Object?> formats, {int maxHeight = 1080}) {
final video = [
for (final f in formats)
if (_httpUrl(f) && (f as Map)['acodec'] == 'none' && f['vcodec'] is String && f['vcodec'] != 'none' &&
_singleFileExts.contains(f['ext']))
f,
];
final audio = [
for (final f in formats)
if (_httpUrl(f) && (f as Map)['vcodec'] == 'none' && f['acodec'] is String && f['acodec'] != 'none' &&
_audioExts.contains(f['ext']))
f,
];
(YtDlpFormat, YtDlpFormat)? pickSplit(List<Object?> formats, {int maxHeight = 1080}) => _pickPair(
[
for (final f in formats)
if (_httpUrl(f) && (f as Map)['acodec'] == 'none' && f['vcodec'] is String && f['vcodec'] != 'none' &&
_singleFileExts.contains(f['ext']))
f,
],
[
for (final f in formats)
if (_httpUrl(f) && (f as Map)['vcodec'] == 'none' && f['acodec'] is String && f['acodec'] != 'none' &&
_audioExts.contains(f['ext']))
f,
],
maxHeight);
(YtDlpFormat, YtDlpFormat)? _pickPair(List<Map> video, List<Map> audio, int maxHeight) {
if (video.isEmpty || audio.isEmpty) return null;
video.sort((a, b) {
final h = _byHeight(_height(a), _height(b), maxHeight);
@@ -213,20 +266,44 @@ int _videoCodecRank(Map f) {
return (YtDlpFormat._of(video.first), YtDlpFormat._of(audio.first));
}
/// The pair if its video is taller than [single] (by the [maxHeight] rule).
bool _pairWins((YtDlpFormat, YtDlpFormat)? pair, YtDlpFormat? single, int maxHeight) =>
pair != null &&
(single == null ||
(single.height != null &&
pair.$1.height != null &&
_byHeight(pair.$1.height!, single.height!, maxHeight) < 0));
/// Segmented formats (HLS / DASH): one muxed playlist (video and audio
/// not known to be absent) unless a video-only + audio-only pair is
/// taller; the same height, codec and audio rules as files.
List<YtDlpTrack> pickPieces(List<Object?> formats, {int maxHeight = 1080}) {
final ok = [for (final f in formats) if (_piecesUrl(f)) f as Map];
final muxed = [for (final f in ok) if (f['vcodec'] != 'none' && f['acodec'] != 'none') f]..sort((a, b) {
final h = _byHeight(_height(a), _height(b), maxHeight);
return h != 0 ? h : _tbr(b).compareTo(_tbr(a));
});
final pair = _pickPair([for (final f in ok) if (f['acodec'] == 'none' && f['vcodec'] != 'none') f],
[for (final f in ok) if (f['vcodec'] == 'none' && f['acodec'] != 'none') f], maxHeight);
final single = muxed.isEmpty ? null : YtDlpFormat._of(muxed.first);
if (_pairWins(pair, single, maxHeight)) {
return [(kind: 'video', format: pair!.$1), (kind: 'audio', format: pair.$2)];
}
return single == null ? const [] : [(kind: 'video', format: single)];
}
/// What a relay reads of [formats]: the single file ([pickSingleFile])
/// unless a video + audio pair ([pickSplit]) is taller (both by the
/// [maxHeight] rule); empty when there is neither.
/// [maxHeight] rule); with neither, segmented playlists ([pickPieces]);
/// empty when there is nothing.
List<YtDlpTrack> pickTracks(List<Object?> formats, {int maxHeight = 1080}) {
final single = pickSingleFile(formats, maxHeight: maxHeight);
final split = pickSplit(formats, maxHeight: maxHeight);
if (split != null &&
(single == null ||
(single.height != null &&
split.$1.height != null &&
_byHeight(split.$1.height!, single.height!, maxHeight) < 0))) {
return [(kind: 'video', format: split.$1), (kind: 'audio', format: split.$2)];
if (_pairWins(split, single, maxHeight)) {
return [(kind: 'video', format: split!.$1), (kind: 'audio', format: split.$2)];
}
return single == null ? const [] : [(kind: '', format: single)];
if (single != null) return [(kind: '', format: single)];
return pickPieces(formats, maxHeight: maxHeight);
}
/// The official standalone build for this platform, or null if there is
+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([