forked from Foxyz/sync_replay_project
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
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 []);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user