Read once: a link passed on is fetched from the site once, for the putter's player and the relay

Before, the member passing a page link on downloaded it twice, independently:
their mpv straight from the CDN and the relay's pulls again, with a
different format pick and without yt-dlp's cookies. The two drifted (the
putter played on while viewers stalled, or the reverse) and doubled the
connections the CDN saw. Now one SharedSource per track reads the site
once (resuming after cuts, backing off on 412/429/5xx and silence) into a
bounded buffer (64 MiB ahead, 16 behind) that serves both the putter's
mpv through a loopback endpoint and the server's pulls; the putter plays
the relay's own format pick, and the relay sends yt-dlp's cookies.
Up next's warm relay uses the same reader. Pieces tracks (HLS/DASH) keep
two fetches for now. Measured in the opt-in e2e: half the CDN traffic, no
412s under a lockout CDN, viewer seeks 0.29 s instead of 4 s there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-18 02:05:03 +03:00
1 parent 7715dc479a
commit ec0849eb01
19 files changed
+1894 -101

No files matched your search

+82 -11
View File
@@ -158,11 +158,13 @@ class _Pull {
}
class _Viewer {
final String name;
int pos = 0;
int open = 0; // requests in flight
int newest = 0; // sequence of its newest request
(int slice, int req)? blocking; // where its newest request waits
DateTime lastActive = DateTime.now();
_Viewer(this.name);
}
/// The server side of the relay for one media: an in-memory buffer of
@@ -181,6 +183,9 @@ class _Viewer {
/// - Only a viewer's newest request moves its position or asks the feed
/// for anything; older ones still get bytes once they are buffered, and
/// stop holding the feed once superseded (a seek elsewhere).
/// - [together]: two viewers kept within [ahead] of each other (see the
/// field).
/// - Read-ahead never fetches a slice that would be the next one evicted.
/// Everything lives in memory: dropping the buffer is the cleanup.
class RelayBuffer implements TrackBuffer {
/// Set by the relay when a media's tracks share the budget.
@@ -190,11 +195,22 @@ class RelayBuffer implements TrackBuffer {
final Duration waitLimit;
/// Pull ids; the tracks of one media share one sequence (one channel).
final int Function() _newId;
/// Two viewers that read one stream at about one position (the source's
/// own player and its upload to the server, see [SharedSource]) and
/// must not pull the feed two ways: while both have a request open, a
/// read of the one more than [ahead] past the other waits without
/// asking the feed for anything (and without the wait limit), and its
/// position does not count for read-ahead or eviction. So one
/// sequential feed serves both, from one window, instead of handing
/// over at every slice boundary. Pinned slices are always read. Null:
/// every viewer drives on its own (the server's viewers).
final (String, String)? together;
RelayBuffer({
this.ahead = RelayConstants.relayAheadBytes,
this.back = RelayConstants.relayBackBytes,
this.pin = RelayConstants.relayPinBytes,
this.waitLimit = const Duration(seconds: RelayConstants.relayWaitSecs),
this.together,
int Function()? nextId,
}) : _newId = nextId ?? _counter();
@@ -213,6 +229,8 @@ class RelayBuffer implements TrackBuffer {
// The driving requests waiting on each blocked slice.
final _blockedBy = <int, Set<int>>{};
final _viewers = <String, _Viewer>{};
// Reads held back by [together], woken when a position moves.
var _moved = Completer<void>();
@override
FeedClock clock = FeedClock();
_Pull? _pullNow;
@@ -282,12 +300,30 @@ class RelayBuffer implements TrackBuffer {
_waiters.clear();
if (!_ready.isCompleted) _ready.complete();
_wake();
_wakeHeld();
}
void _wake() {
if (!_kick.isCompleted) _kick.complete();
}
void _wakeHeld() {
if (together == null) return;
final m = _moved;
_moved = Completer<void>();
m.complete();
}
/// [v] is one of [together] and more than [ahead] past the other while
/// that one has a request open: it does not drive.
bool _held(_Viewer v) {
final t = together;
if (t == null) return false;
final other = v.name == t.$1 ? t.$2 : (v.name == t.$2 ? t.$1 : null);
final o = other == null ? null : _viewers[other];
return o != null && o.open > 0 && v.pos > o.pos + ahead;
}
// ------------------------------------------------------------ feed input
@override
@@ -314,6 +350,8 @@ class RelayBuffer implements TrackBuffer {
_slices[s] = pl.takeSlice();
// A whole slice came: the source works again.
_failures = 0;
// A long pull stays within the budget as it goes.
_evict();
}
// Whole, or more of it: whoever waits on it reads on.
for (final c in _waiters.remove(s) ?? const <Completer<void>>[]) {
@@ -362,7 +400,24 @@ class RelayBuffer implements TrackBuffer {
return true;
}
if (_slices.containsKey(next)) return true;
return !_wanted(next);
if (!_wanted(next)) return true;
// Read-ahead nobody waits on stops where the next slice would be the
// next one evicted (else a full buffer fetches and drops it in turn).
final s = next - 1;
return !_blocked.containsKey(s) && !_blocked.containsKey(next) && !_roomFor(next);
}
/// Slice [s] can come in without being the next one evicted: the
/// buffer has room, or holds a slice farther from the viewers.
bool _roomFor(int s) {
if ((_slices.length + 1) * slice <= ahead + back + 2 * pin) return true;
final at = _positions();
final d = _distance(s, at);
if (d < 0) return true;
for (final c in _slices.keys) {
if (!_waiters.containsKey(c) && _distance(c, at) > d) return true;
}
return false;
}
void _endPull(_Pull pl) {
@@ -374,8 +429,8 @@ class RelayBuffer implements TrackBuffer {
Iterable<_Viewer> get _active {
final now = DateTime.now();
return _viewers.values
.where((v) => v.open > 0 || now.difference(v.lastActive) < const Duration(seconds: 60));
return _viewers.values.where(
(v) => (v.open > 0 || now.difference(v.lastActive) < const Duration(seconds: 60)) && !_held(v));
}
bool _pinned(int s) => s * slice < pin || s * slice >= size - pin;
@@ -388,13 +443,17 @@ class RelayBuffer implements TrackBuffer {
return off >= pos.reduce(min) - back && off <= pos.reduce(max) + ahead;
}
int _distance(int s) {
/// The active viewers' positions, for [_distance].
List<int> _positions() => [for (final v in _active) v.pos];
/// How far slice [s] is from the viewers at [positions]; -1 pinned.
int _distance(int s, List<int> positions) {
if (_pinned(s)) return -1;
final off = s * slice;
var d = 1 << 62;
for (final v in _active) {
for (final p in positions) {
// Behind a viewer costs more than ahead (it was already watched).
d = min(d, off >= v.pos ? off - v.pos : (v.pos - off) * 4);
d = min(d, off >= p ? off - p : (p - off) * 4);
}
return d;
}
@@ -403,9 +462,11 @@ class RelayBuffer implements TrackBuffer {
final limit = ahead + back + 2 * pin;
var total = _slices.length * slice;
if (total <= limit) return;
final order = _slices.keys.toList()..sort((a, b) => _distance(b).compareTo(_distance(a)));
final at = _positions();
final dist = {for (final s in _slices.keys) s: _distance(s, at)};
final order = dist.keys.toList()..sort((a, b) => dist[b]!.compareTo(dist[a]!));
for (final s in order) {
if (total <= limit || _distance(s) < 0) break;
if (total <= limit || dist[s]! < 0) break;
if (_waiters.containsKey(s)) continue;
_slices.remove(s);
total -= slice;
@@ -427,7 +488,7 @@ class RelayBuffer implements TrackBuffer {
final starts = active.map((v) => v.pos).toList()..sort();
for (final p in starts) {
for (var s = p ~/ slice; s * slice <= p + ahead && s < _nSlices; s++) {
if (!_slices.containsKey(s)) return s;
if (!_slices.containsKey(s)) return _roomFor(s) ? s : null;
}
}
return null;
@@ -481,7 +542,7 @@ class RelayBuffer implements TrackBuffer {
/// request is its viewer's newest ([driving] says while it still is), so
/// it may move the feed.
Future<Uint8List?> _waitBytes(_Viewer v, int s, int from, int req, bool Function() driving) async {
final deadline = DateTime.now().add(waitLimit);
var deadline = DateTime.now().add(waitLimit);
try {
while (true) {
final whole = _slices[s];
@@ -492,6 +553,13 @@ class RelayBuffer implements TrackBuffer {
if (part != null) return part;
}
if (_disposed || DateTime.now().isAfter(deadline)) return null;
if (!_pinned(s) && driving() && _held(v)) {
// Too far past its partner: waits for it, asking nothing.
_unblock(s, req);
deadline = DateTime.now().add(waitLimit);
await _moved.future.timeout(const Duration(seconds: 2), onTimeout: () {});
continue;
}
if (driving()) {
_blocked.putIfAbsent(s, () => DateTime.now().microsecondsSinceEpoch);
_blockedBy.putIfAbsent(s, () => {}).add(req);
@@ -539,7 +607,7 @@ class RelayBuffer implements TrackBuffer {
/// viewer went away) ends it; a slice that never arrives ends it with an
/// error after [waitLimit]; the buffer's [dispose] with [RelayEnded].
Stream<Uint8List> read(String viewer, int start, int end, {void Function(int n)? onSent}) async* {
final v = _viewers.putIfAbsent(viewer, _Viewer.new);
final v = _viewers.putIfAbsent(viewer, () => _Viewer(viewer));
final tail = start >= size - pin;
final me = ++_reqSeq;
if (!tail) {
@@ -553,6 +621,7 @@ class RelayBuffer implements TrackBuffer {
v.open++;
v.lastActive = DateTime.now();
_wake();
_wakeHeld();
try {
var off = start;
while (off <= end) {
@@ -569,10 +638,12 @@ class RelayBuffer implements TrackBuffer {
v.lastActive = DateTime.now();
if (!tail && v.newest == me) v.pos = off;
_wake();
_wakeHeld();
}
} finally {
v.open--;
v.lastActive = DateTime.now();
_wakeHeld();
}
}
}
+76 -62
View File
@@ -592,74 +592,88 @@ void mountRelayRoutes(
app.get('/r/:sid/:token/:mediaId/:seg/p/:piece', (final req) => viewer(req, tracked: true, piece: true));
}
/// A relayed byte range written on the raw connection: the status line
/// and headers go out with the first body bytes, or after [headerWait]
/// without them (dart:io would hold them until body bytes come, and an
/// ffmpeg whose request gets no headers within its 5 s timeout has lost
/// the file's size: every later seek fails with "size -38"). Then the
/// body as the buffer has it; a read waiting on missing bytes keeps the
/// connection open meanwhile. Always `connection: close` and closed after
/// the body: ffmpeg asks for close, and one that is not told so reuses the
/// A relayed byte range on the raw connection ([writeRelayRange]).
Hijack _rangeResponse(int status, Map<String, String> headers, int length, Stream<Uint8List>? body,
{required void Function(Object e) onBroke}) =>
Hijack((ch) => writeRelayRange(ch.stream, ch.sink, status, headers, length, body, onBroke: onBroke));
/// Writes a relayed byte range on a raw connection ([input] / [out]: the
/// server's `/r` answers, and the source's own loopback endpoint in
/// shared_source.dart): the status line and headers go out with the
/// first body bytes, or after [headerWait] without them (an HTTP stack
/// would hold them until body bytes come, and an ffmpeg whose request
/// gets no headers within its 5 s timeout has lost the file's size:
/// every later seek fails with "size -38"). Then the body as the buffer
/// has it; a read waiting on missing bytes keeps the connection open
/// meanwhile. Always `connection: close` and closed after the body:
/// ffmpeg asks for close, and one that is not told so reuses the
/// connection the server closed for its next seek and fails it the same
/// way. A relay that ends ([RelayEnded]) closes the connection plainly;
/// any other failure ([onBroke]) resets it. The client going away cancels
/// the read.
Hijack _rangeResponse(int status, Map<String, String> headers, int length, Stream<Uint8List>? body,
{required void Function(Object e) onBroke, Duration headerWait = const Duration(milliseconds: RelayConstants.relayHeaderWaitMs)}) {
return Hijack((ch) async {
final out = ch.sink;
final head = StringBuffer('HTTP/1.1 $status ${status == 206 ? 'Partial Content' : 'OK'}\r\n')
..write('date: ${HttpDate.format(DateTime.now())}\r\n');
headers.forEach((k, v) {
if (k != 'content-length' && k != 'connection') head.write('$k: $v\r\n');
});
head.write('content-length: $length\r\nconnection: close\r\n\r\n');
final it = body == null ? null : StreamIterator(body);
// A read cancelled while it waits ends with what it was waiting for
// (its relay ending, say): moot then.
Future<void> stop() async => it?.cancel().catchError((_) {});
// The client closing (a seek elsewhere, the player gone) ends the read.
final input = ch.stream.listen(null,
onDone: () => unawaited(stop()), onError: (_) => unawaited(stop()), cancelOnError: true);
var reset = false;
// The first bytes go out with the headers when they come in time.
Future<bool>? first = it?.moveNext();
if (first != null) {
await Future.any([first.then((_) {}, onError: (_) {}), Future<void>.delayed(headerWait)]);
}
try {
out.add(latin1.encode('$head'));
if (out is IOSink) await out.flush();
while (it != null) {
final bool more;
try {
final next = first ?? it.moveNext();
first = null;
more = await next;
} catch (e) {
onBroke(e);
reset = e is! RelayEnded;
break;
}
if (!more) break;
out.add(it.current);
if (out is IOSink) await out.flush();
}
} catch (_) {
// The client went away mid-write: nothing to say.
reset = true;
} finally {
await input.cancel().catchError((_) {});
await stop();
if (reset && out is Socket) {
out.destroy();
} else {
await out.close().catchError((_) {});
}
}
/// the read. Never throws.
Future<void> writeRelayRange(Stream<List<int>> input, StreamSink<List<int>> out, int status,
Map<String, String> headers, int length, Stream<Uint8List>? body,
{required void Function(Object e) onBroke,
Duration headerWait = const Duration(milliseconds: RelayConstants.relayHeaderWaitMs)}) async {
final head = StringBuffer('HTTP/1.1 $status ${_reason(status)}\r\n')
..write('date: ${HttpDate.format(DateTime.now())}\r\n');
headers.forEach((k, v) {
if (k != 'content-length' && k != 'connection') head.write('$k: $v\r\n');
});
head.write('content-length: $length\r\nconnection: close\r\n\r\n');
final it = body == null ? null : StreamIterator(body);
// A read cancelled while it waits ends with what it was waiting for
// (its relay ending, say): moot then.
Future<void> stop() async => it?.cancel().catchError((_) {});
// The client closing (a seek elsewhere, the player gone) ends the read.
final listening = input.listen(null,
onDone: () => unawaited(stop()), onError: (_) => unawaited(stop()), cancelOnError: true);
var reset = false;
// The first bytes go out with the headers when they come in time.
Future<bool>? first = it?.moveNext();
if (first != null) {
await Future.any([first.then((_) {}, onError: (_) {}), Future<void>.delayed(headerWait)]);
}
try {
out.add(latin1.encode('$head'));
if (out is IOSink) await out.flush();
while (it != null) {
final bool more;
try {
final next = first ?? it.moveNext();
first = null;
more = await next;
} catch (e) {
onBroke(e);
reset = e is! RelayEnded;
break;
}
if (!more) break;
out.add(it.current);
if (out is IOSink) await out.flush();
}
} catch (_) {
// The client went away mid-write: nothing to say.
reset = true;
} finally {
await listening.cancel().catchError((_) {});
await stop();
if (reset && out is Socket) {
out.destroy();
} else {
await out.close().catchError((_) {});
}
}
}
String _reason(int status) => switch (status) {
200 => 'OK',
206 => 'Partial Content',
404 => 'Not Found',
416 => 'Range Not Satisfiable',
_ => 'Status',
};
Response _plain(int status) => Response(status, body: Body.fromString('status $status'));
final _readFailThrottle = LogThrottle(burst: 3);
+381
View File
@@ -0,0 +1,381 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:sync_relay_core/core.dart';
import 'media_endpoint.dart' show resolveMediaRequest;
import 'relay_buffer.dart';
import 'relay_routes.dart' show relayBudgets, writeRelayRange;
// The single-fetch source: the member who passes a link on reads it from
// the site once. Per track, one [RelayBuffer] filled by one sequential
// upstream reader ([LocalRelayFeed] over [ResumingBytes]) serves two
// readers: this app's own player, through a loopback HTTP endpoint
// ([SharedSource.playerUrl]), and the server's pulls
// ([SharedSource.upload], handed to the relay's [RelaySource]). The two
// are kept within one window ([RelayBuffer.together]). Byte tracks only:
// a page with pieces tracks (HLS/DASH) keeps the two fetches.
final _log = createLogger('relay');
/// Upstream errors worth another try after a pause: a cut connection, a
/// timeout, a 5xx, and the refusals a CDN gives a client it thinks is too
/// busy (412, 429). 403/410 (an expired URL) is the page's job
/// ([PageRelayMedia] resolves again once); anything else is final.
bool retryableUpstream(Object e) {
if (e is UpstreamHttpException) return e.status == 412 || e.status == 429 || e.status >= 500;
return e is HttpException || e is SocketException || e is TimeoutException;
}
/// [inner] read with resume: a read that breaks goes on from the byte it
/// reached. After a cut that brought bytes it goes on after 100 ms; after
/// an answer without a byte (a refusal, a dead connection) after
/// [backoff] (attempt n, reset by any byte). A read that brings nothing
/// for [stall] is cut and resumed the same way. It fails only on a final
/// error or after [giveUp] without a byte. Cancelling stops it at once,
/// also during a pause.
class ResumingBytes implements RelayBytes {
final RelayBytes inner;
final Duration Function(int attempt) backoff;
final Duration giveUp;
final Duration stall;
ResumingBytes(this.inner,
{Duration Function(int attempt)? backoff,
this.giveUp = const Duration(seconds: 60),
this.stall = const Duration(seconds: 20)})
: backoff = backoff ?? defaultBackoff;
static final _rnd = Random();
/// 0.5 s doubling to 8 s, with up to 25% jitter.
static Duration defaultBackoff(int attempt) {
final ms = min(8000, 500 * (1 << min(attempt, 4)));
return Duration(milliseconds: ms + _rnd.nextInt(ms ~/ 4 + 1));
}
/// Resumes so far, for logs and tests.
int retries = 0;
/// Bytes read from [inner], for logs and tests.
int bytesIn = 0;
@override
Future<int> size() async {
var attempt = 0;
final since = DateTime.now();
while (true) {
try {
return await inner.size();
} catch (e) {
if (!retryableUpstream(e) || DateTime.now().difference(since) > giveUp) rethrow;
retries++;
await Future<void>.delayed(backoff(attempt++));
}
}
}
@override
Stream<List<int>> read(int start, int end) {
late final StreamController<List<int>> out;
var cancelled = false;
StreamSubscription<List<int>>? sub;
Completer<void>? pause;
Future<void> run() async {
var pos = start, attempt = 0;
var lastByte = DateTime.now();
while (!cancelled && pos <= end) {
var got = 0;
final done = Completer<Object?>();
Timer? quiet;
void watch() {
quiet?.cancel();
quiet = Timer(stall, () {
if (done.isCompleted) return;
unawaited(sub?.cancel().catchError((_) {}));
done.complete(TimeoutException('upstream sent nothing', stall));
});
}
watch();
sub = inner.read(pos, end).listen((c) {
if (done.isCompleted) return;
pos += c.length;
got += c.length;
bytesIn += c.length;
lastByte = DateTime.now();
watch();
out.add(c);
}, onError: (Object e) {
if (!done.isCompleted) done.complete(e);
}, onDone: () {
if (!done.isCompleted) done.complete(null);
}, cancelOnError: true);
final err = await done.future;
quiet?.cancel();
sub = null;
if (cancelled) return;
if (err == null) {
if (pos <= end) continue; // ended short without an error: go on
break;
}
if (!retryableUpstream(err) || DateTime.now().difference(lastByte) > giveUp) {
out.addError(err);
break;
}
// A cut after some bytes goes on almost at once (what mpv's
// reconnect does); an answer without a byte backs off, doubling.
if (got > 0) attempt = 0;
final wait = got > 0 ? const Duration(milliseconds: 100) : backoff(attempt++);
retries++;
_log.fine('upstream read broke at $pos ($err); again in ${wait.inMilliseconds} ms');
final p = pause = Completer<void>();
final t = Timer(wait, () {
if (!p.isCompleted) p.complete();
});
await p.future;
t.cancel();
pause = null;
}
if (!cancelled) await out.close();
}
out = StreamController<List<int>>(
onListen: () => unawaited(run()),
onPause: () => sub?.pause(),
onResume: () => sub?.resume(),
onCancel: () async {
cancelled = true;
final p = pause;
if (p != null && !p.isCompleted) p.complete();
await sub?.cancel().catchError((_) {});
},
);
return out.stream;
}
@override
Future<void> close() => inner.close();
}
/// A media a [SharedSource] cannot share (a pieces track): the caller
/// keeps the player and the relay on their own fetches.
class SharedSourceUnsupported implements Exception {
final String reason;
const SharedSourceUnsupported(this.reason);
@override
String toString() => reason;
}
/// One track of a [SharedSource]: its buffer and its single upstream
/// reader.
class SharedTrack {
final String kind;
final int size;
final RelayBuffer buffer;
final LocalRelayFeed feed;
final ResumingBytes upstream;
SharedTrack(this.kind, this.size, this.buffer, this.feed, this.upstream);
}
/// A media read from upstream once and served to this app's player and to
/// the relay. [open] opens it (a page's tracks, the loopback endpoint) and
/// starts reading the tracks' sizes; [ready] completes with them. The
/// player can open [playerUrl] right after [open]: the endpoint waits for
/// the sizes. Nothing is read upstream while neither reader asks.
class SharedSource {
final RelayMedia media;
/// The local buffer's budgets (all tracks together, split by size like
/// the server's, [relayBudgets]). It only has to hold what the player
/// and the upload have not both read yet: they read within [ahead] of
/// each other ([RelayBuffer.together]).
final int ahead, back;
final Duration Function(int attempt)? backoff;
/// A read waiting for missing bytes gives up after this.
final Duration waitLimit;
SharedSource(this.media,
{this.ahead = RelayConstants.sharedAheadBytes,
this.back = RelayConstants.sharedBackBytes,
this.backoff,
this.waitLimit = const Duration(seconds: RelayConstants.relayWaitSecs)});
/// The tracks, once [ready].
final tracks = <SharedTrack>[];
List<RelayTrack> _upstream = const [];
HttpServer? _http;
final _secret = List.generate(16, (_) => Random.secure().nextInt(16).toRadixString(16)).join();
Future<void>? _opening;
final _ready = Completer<void>();
var _closed = false;
/// Opens the media and binds the loopback endpoint; the sizes are read
/// meanwhile ([ready]). Throws [SharedSourceUnsupported] for a media
/// with pieces tracks, or why the media cannot be opened.
Future<void> open() => _opening ??= _open();
Future<void> _open() async {
final upstream = await media.open();
if (upstream.any((t) => t.bytes == null)) {
throw const SharedSourceUnsupported('a segmented stream (HLS/DASH) is not shared');
}
_upstream = upstream;
final http = _http = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
http.listen((req) => unawaited(_serve(req)), onError: (_) {});
// Nobody may be waiting on [ready] yet: its error is not unhandled.
_ready.future.ignore();
unawaited(_readSizes());
}
Future<void> _readSizes() async {
final sw = Stopwatch()..start();
try {
final wrapped = [for (final t in _upstream) ResumingBytes(t.bytes!, backoff: backoff)];
final sizes = await Future.wait([for (final w in wrapped) w.size()]);
if (_closed) throw const RelayEnded();
final budgets = relayBudgets(sizes, ahead: ahead, back: back);
var ids = 0;
for (var i = 0; i < _upstream.length; i++) {
final b = RelayBuffer(
ahead: budgets[i].$1,
back: budgets[i].$2,
waitLimit: waitLimit,
together: (playerViewer, uploadViewer),
nextId: () => ++ids);
final f = LocalRelayFeed(RelayTrack(_upstream[i].kind, wrapped[i]), b);
b.attach(f, sizes[i]);
tracks.add(SharedTrack(_upstream[i].kind, sizes[i], b, f, wrapped[i]));
}
_log.info('shared source: ${tracks.map((t) => '${t.kind.isEmpty ? 'file' : t.kind} ${t.size} bytes').join(' + ')} '
'in ${sw.elapsedMilliseconds} ms, the player on loopback port ${_http?.port}');
_ready.complete();
} catch (e, st) {
_ready.completeError(e, st);
}
}
/// Completes once every track's size is known; throws why it could not
/// be read.
Future<void> get ready => _ready.future;
/// [open], then [ready].
Future<void> start() async {
await open();
await ready;
}
/// The two readers' names in each track's [RelayBuffer].
static const playerViewer = 'player', uploadViewer = 'upload';
/// What this app's own player opens (after [open]): one loopback URL, or
/// an mpv EDL of one per track. No headers: the site's stay in this
/// process.
String get playerUrl {
final http = _http;
if (http == null) throw StateError('shared source not open');
final urls = [for (var i = 0; i < _upstream.length; i++) 'http://127.0.0.1:${http.port}/$_secret/$i'];
return urls.length == 1 ? urls.single : mpvEdl(urls);
}
/// What [RelaySource] serves: the same buffers, read as the viewer
/// `upload`. Closing it leaves the shared source open (the player may
/// still read it).
RelayMedia get upload => _Upload(this);
/// Upstream reads resumed so far, all tracks (for logs and tests).
int get resumes => tracks.fold(0, (a, t) => a + t.upstream.retries);
/// Bytes read upstream so far, all tracks.
int get bytesIn => tracks.fold(0, (a, t) => a + t.upstream.bytesIn);
/// Bytes the local buffers hold now.
int get cachedBytes => tracks.fold(0, (a, t) => a + t.buffer.cachedBytes);
Future<void> _serve(HttpRequest req) async {
final res = req.response;
Future<void> plain(int status, [Map<String, String> headers = const {}]) async {
res.statusCode = status;
headers.forEach(res.headers.set);
res.headers.set(HttpHeaders.connectionHeader, 'close');
await res.close().catchError((_) {});
}
final seg = req.uri.pathSegments;
final i = seg.length == 2 && seg[0] == _secret ? int.tryParse(seg[1]) : null;
if (i == null || i < 0 || i >= _upstream.length || (req.method != 'GET' && req.method != 'HEAD')) {
return plain(404);
}
try {
await ready.timeout(waitLimit);
} catch (_) {
return plain(_closed ? 404 : 502);
}
if (_closed) return plain(404);
final t = tracks[i];
final r = resolveMediaRequest(
rangeHeader: req.headers.value(HttpHeaders.rangeHeader), size: t.size, filename: 'track');
if (r.status != 200 && r.status != 206) return plain(r.status, r.headers);
final end = r.end ?? t.size - 1;
final Socket socket;
try {
socket = await res.detachSocket(writeHeaders: false);
} catch (_) {
return;
}
// The server's own answer code: headers in time, connection: close,
// a read that breaks resets the connection (the player reconnects).
await writeRelayRange(socket, socket, r.status, r.headers, end - r.start + 1,
req.method == 'HEAD' ? null : t.buffer.read(playerViewer, r.start, end),
onBroke: (e) => _log.fine('shared source: the player\'s read of track $i broke: $e'));
}
Future<void> close() async {
if (_closed) return;
_closed = true;
await _http?.close(force: true);
for (final t in tracks) {
t.buffer.dispose();
await t.feed.close();
}
if (tracks.isNotEmpty) _log.info('shared source closed: $bytesIn bytes read upstream, $resumes resumes');
await media.close();
}
}
class _Upload implements RelayMedia {
final SharedSource src;
_Upload(this.src);
@override
Future<List<RelayTrack>> open() async {
await src.open();
return [for (var i = 0; i < src._upstream.length; i++) RelayTrack(src._upstream[i].kind, _BufferBytes(src, i))];
}
/// The shared source owns the upstream.
@override
Future<void> close() async {}
}
class _BufferBytes implements RelayBytes {
final SharedSource src;
final int i;
_BufferBytes(this.src, this.i);
@override
Future<int> size() async {
await src.ready;
return src.tracks[i].size;
}
@override
Stream<List<int>> read(int start, int end) async* {
await src.ready;
if (src._closed) throw const RelayEnded();
yield* src.tracks[i].buffer.read(SharedSource.uploadViewer, start, end);
}
@override
Future<void> close() async {}
}