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

@@ -689,6 +689,9 @@ class QueueController {
// The warm relay this app feeds (`<queue id>|<token>`), started once.
String _fedWarm = '';
// The link whose warm reader is being set up: its preload waits for it
// (it then reads that reader, not the site).
String? _feedingLink;
/// Feeds the server's warm relay of Up next's first item from here when
/// it names this member as its source (a link passed on, a file), or a
@@ -712,15 +715,22 @@ class QueueController {
coord.media.feeder.feedWarm(head.id, RelayMedia.single(FileRelayBytes(File(pick.path))));
} else if (pick is LinkMedia) {
_fedWarm = key;
_feedingLink = pick.url;
unawaited(() async {
try {
// The probe [_resolveWarm] keeps: no second yt-dlp run.
final probe = await coord.ytDlp.probe(pick.url, keep: _warmKeep).timeout(const Duration(seconds: 60));
final y = await coord.ytDlp.ytDlp;
if (_fedWarm != key) return;
coord.media.feeder.feedWarm(head.id, LinkRelayMedia(Uri.parse(pick.url), ytDlp: y, probe: probe));
// Read once for the preload and the warm relay.
await coord.media.feedWarmLink(head.id, pick.url, probe, y);
} catch (e) {
debugPrint('queue: feeding ${head.id}: $e');
} finally {
if (_feedingLink == pick.url) {
_feedingLink = null;
_syncPreload(coord.serverState.peek());
}
}
}());
}
@@ -814,6 +824,7 @@ class QueueController {
return;
}
if (!media.preloadEnabled) return;
if (want.url == _feedingLink) return;
final key = '${want.url}|${want.headers}';
final fresh = _preloadFresh;
// Asked for already: in flight, there, or failed (tried again with the
@@ -15,6 +15,7 @@ import 'app_events.dart';
import 'delivery.dart' show stateMediaRelay;
import 'media_plan.dart';
import 'relay_feeder.dart';
import 'shared_links.dart';
import 'switch_trace.dart';
export 'media_plan.dart';
@@ -37,7 +38,9 @@ final _log = createLogger('session');
// coordinator's report).
//
// Being a relay source ([RelayFeeder]) and the viewer's side of a source
// that drops ([recover]) live here too.
// that drops ([recover]) live here too. A link this member passes on is
// read from the site once ([SharedLinks]): its player opens the shared
// reader's loopback URL and the relay serves the same bytes.
/// What the coordinator does next after [MediaPipeline.switchTo]: a
/// Jellyfin item resolves and opens through [MediaPipeline.openJellyfin]
@@ -77,6 +80,10 @@ class MediaPipeline {
final PlaybackController playback;
final SwitchTrace trace;
late final RelayFeeder feeder;
/// The links this member passes on, read once for its player and the
/// relay.
final shared = SharedLinks();
MediaSession session;
/// The player could not open the current media (raw words: the
@@ -98,7 +105,10 @@ class MediaPipeline {
}) : playback = playback ?? PlaybackController(host),
trace = trace ?? SwitchTrace(),
session = session ?? MediaSession.none {
feeder = RelayFeeder(server: () => this.session.server(), notify: (e) => notify(e));
feeder = RelayFeeder(
server: () => this.session.server(),
notify: (e) => notify(e),
onWarmEnded: (queueId) => shared.dropWarm(queueId));
}
// ------------------------------------------------------------ players
@@ -202,6 +212,12 @@ class MediaPipeline {
/// stream opened with headers (a Jellyfin item's, with its token). Throws
/// a [ResolveFailure] in plain words, with the cookies used (D11).
Future<_Target> _resolve(String url, Map<String, String>? headers, {bool fresh = false}) async {
if (headers == null || headers.isEmpty) {
// A link this member passes on: the shared reader's loopback URL
// (it resolves an expired URL again itself, so [fresh] is moot).
final link = await shared.playerFor(url);
if (link != null) return _Target(link.source.playerUrl, const {}, subtitles: link.subtitles, shared: true);
}
if (!_isPage(url) || (headers != null && headers.isNotEmpty)) return _Target(url, headers ?? const {});
final l = links;
if (l == null) throw const ResolveFailure('no link resolver here');
@@ -343,6 +359,7 @@ class MediaPipeline {
};
}
if (headers != null && headers.isNotEmpty) return 'Jellyfin, own sign-in';
if (shared.has(url)) return 'link passed on, read once here';
return 'link, fetched here';
}
@@ -398,6 +415,7 @@ class MediaPipeline {
final name = (r.media?['name'] as String?) ?? '';
final queueId = stateMediaRelay(r)?.queueId ?? '';
final duration = (r.media?['duration'] as num?)?.toDouble() ?? 0;
if (source is! OwnLink) await shared.closeCurrent();
switch (source) {
case JellyfinSource():
return SwitchJellyfin(source);
@@ -407,8 +425,11 @@ class MediaPipeline {
announce: true, queueId: queueId, name: name, duration: duration);
await open(path);
case OwnLink(:final url, :final origin):
feeder.serve(r.mediaId, LinkRelayMedia(Uri.parse(url), ytDlp: origin.ytDlp, probe: origin.probe),
announce: true, queueId: queueId, name: name, duration: duration);
// Read from the site once: the relay serves what the player reads
// (pieces tracks keep the two fetches).
RelayMedia link() => LinkRelayMedia(Uri.parse(url), ytDlp: origin.ytDlp, probe: origin.probe);
final one = await shared.current(url, origin.probe, link, queueId: queueId);
feeder.serve(r.mediaId, one?.upload ?? link(), announce: true, queueId: queueId, name: name, duration: duration);
await open(url);
case RelayedSource(:final url, :final waiting, :final fetch, :final sourceId):
if (waiting) {
@@ -462,7 +483,8 @@ class MediaPipeline {
feeder.serve(r.mediaId, RelayMedia.single(FileRelayBytes(File(path))),
queueId: queueId, name: name, duration: duration);
case OwnLink(:final url, :final origin):
feeder.serve(r.mediaId, LinkRelayMedia(Uri.parse(url), ytDlp: origin.ytDlp, probe: origin.probe),
feeder.serve(r.mediaId,
shared.currentFor(url)?.upload ?? LinkRelayMedia(Uri.parse(url), ytDlp: origin.ytDlp, probe: origin.probe),
queueId: queueId, name: name, duration: duration);
case JellyfinSource(:final ref, :final origin):
if (origin == null || ownJellyfin == null) return;
@@ -538,6 +560,7 @@ class MediaPipeline {
_activeOrigin = null;
_jellyfinFeed = null;
feeder.stopAll();
unawaited(shared.closeAll());
}
/// The same media under a restarted server's numbering: the player keeps
@@ -558,6 +581,7 @@ class MediaPipeline {
_jellyfinFeed = null;
settledMediaId.value = -1;
feeder.stopAll();
unawaited(shared.closeAll());
}
// ----------------------------------------------------------- Jellyfin
@@ -738,6 +762,24 @@ class MediaPipeline {
String warmRelayUrl(QueueItem item, MediaRelay relay) =>
session.server().relayUrl(item.media.name.isEmpty ? item.title : item.media.name, item.id, relay.tracks);
/// Feeds the warm relay of Up next item [queueId], the link [url] this
/// member passes on, from one shared reader (pieces tracks: from their
/// own fetch, as [LinkRelayMedia]). A preload of [url] that reads the
/// site itself (it was prepared before the reader existed) is prepared
/// again on the reader, so the item is fetched once.
Future<void> feedWarmLink(String queueId, String url, LinkProbe probe, YtDlp ytDlp) async {
if (feeder.feedsWarm(queueId)) return;
RelayMedia link() => LinkRelayMedia(Uri.parse(url), ytDlp: ytDlp, probe: probe);
final one = await shared.warm(queueId, url, probe, link);
if (feeder.feedsWarm(queueId)) return;
feeder.feedWarm(queueId, one?.upload ?? link());
final pre = _pre;
if (one != null && pre != null && pre.url == url && pre.headers.isEmpty && !pre.shared && !pre.failed) {
_log.info('Up next ${logUrl(url)}: preparing it again on the shared reader');
unawaited(prepare(url, fresh: true));
}
}
/// Opens [url] early in a hidden player (paused at the start, muted, a
/// small cache, its own video output when the view is up), replacing any
/// other; completes once [need] seconds are buffered or the whole media
@@ -754,6 +796,7 @@ class MediaPipeline {
final pre = _pre = _Prepared(url, headers ?? const {});
try {
final target = await _resolve(url, headers, fresh: fresh);
pre.shared = target.shared;
if (!identical(_pre, pre)) {
pre.fail();
return false;
@@ -840,6 +883,7 @@ class MediaPipeline {
Future<void> dispose() async {
await release();
feeder.stopAll();
await shared.closeAll();
await playback.dispose();
}
}
@@ -864,6 +908,9 @@ class _Prepared {
StreamSubscription<PlayerLogLine>? logSub;
List<YtDlpSubtitle> subtitles = const [];
/// It reads a shared reader's loopback URL, not the site.
bool shared = false;
/// Completes once the media is handed to mpv (or it failed).
final opened = Completer<void>();
bool failed = false;
@@ -885,7 +932,10 @@ class _Target {
final String url;
final Map<String, String> headers;
final List<YtDlpSubtitle> subtitles;
const _Target(this.url, this.headers, {this.subtitles = const []});
/// A link this member passes on, read through its shared reader.
final bool shared;
const _Target(this.url, this.headers, {this.subtitles = const [], this.shared = false});
}
/// A page could not be resolved: why ([message], yt-dlp's words) and which
+20 -4
View File
@@ -17,7 +17,12 @@ import 'party.dart' show isPresent;
class RelayFeeder {
final ServerAddress Function() server;
final void Function(AppEvent event) notify;
RelayFeeder({required this.server, required this.notify});
/// The warm relay of Up next item `queueId` stopped without being
/// carried on as the media's (the server dropped it, or [stopWarm]).
final void Function(String queueId) onWarmEnded;
RelayFeeder({required this.server, required this.notify, void Function(String queueId)? onWarmEnded})
: onWarmEnded = onWarmEnded ?? ((_) {});
RelaySource? _source;
int _mediaId = -1;
@@ -33,6 +38,9 @@ class RelayFeeder {
/// relay and has opened them (empty otherwise).
List<RelayTrack> warmTracks(String queueId) => _warm?.queueId == queueId ? _warm!.src.tracks : const [];
/// This member feeds the warm relay of Up next item [queueId].
bool feedsWarm(String queueId) => _warm?.queueId == queueId;
/// Serves [media] for media [mediaId] on this member's relay channel.
/// The server only pulls while someone watches through it. When the
/// media came from Up next item [queueId] whose warm relay this member
@@ -111,18 +119,26 @@ class RelayFeeder {
await src.start();
} catch (e) {
debugPrint('warm relay source of $queueId failed: $e');
if (identical(_warm?.src, src)) _warm = null;
if (identical(_warm?.src, src)) {
_warm = null;
onWarmEnded(queueId);
}
return;
}
await src.done;
if (identical(_warm?.src, src)) _warm = null;
if (identical(_warm?.src, src)) {
_warm = null;
onWarmEnded(queueId);
}
}());
}
void stopWarm() {
final w = _warm;
_warm = null;
if (w != null) unawaited(w.src.close());
if (w == null) return;
unawaited(w.src.close());
onWarmEnded(w.queueId);
}
/// Leave, or a media this member no longer serves.
@@ -38,6 +38,7 @@ import 'notifier.dart';
import 'now_playing.dart';
import 'now_playing_info.dart';
import 'party.dart';
import 'shared_links.dart';
import 'status.dart';
import 'switch_trace.dart';
@@ -1279,11 +1280,21 @@ class SessionCoordinator {
// Up next's item this app already feeds (its warm relay): read.
final warm = queueId == null ? const <RelayTrack>[] : this.media.feeder.warmTracks(queueId);
final fed = warm.isNotEmpty;
final check = fed ? null : LinkRelayMedia(uri, ytDlp: y, probe: probe);
// Read once for the player and the relay (SharedLinks): the check's
// reader is the one the media then uses. Pieces tracks: a check of
// their own.
final share = !fed && SharedLinks.fits(probe);
final check = fed || share ? null : LinkRelayMedia(uri, ytDlp: y, probe: probe);
var size = 0;
final List<RelayTrackSpec> specs;
try {
final tracks = fed ? warm : await check!.open().timeout(const Duration(seconds: 15));
final tracks = fed
? warm
: share
? await (await this.media.shared.forSwap(url, LinkRelayMedia(uri, ytDlp: y, probe: probe), probe))
.upload
.open()
: await check!.open().timeout(const Duration(seconds: 15));
for (final t in tracks) {
// A playlist is read (DRM and live refused) but has no size.
final pieces = t.pieces;
@@ -0,0 +1,161 @@
import 'dart:async';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart' show SharedSource;
final _log = createLogger('session');
/// A link this member passes on, read from the site once ([SharedSource]):
/// its page URL, the reader, and the page's subtitles for the player.
class SharedLink {
final String url;
final SharedSource source;
final List<YtDlpSubtitle> subtitles;
SharedLink(this.url, this.source, {this.subtitles = const []});
}
/// The links this member passes on and reads once, by page URL: the one a
/// swap check read (until the swap's state takes it), Up next's warm one
/// (until the swap that puts it up takes it), and the current media's.
/// Each one's player opens [SharedSource.playerUrl] and its relay serves
/// [SharedSource.upload]. A page with a pieces track (HLS/DASH) is not
/// shared ([fits]): its player and relay keep their own fetches.
class SharedLinks {
SharedLink? _current;
({String queueId, SharedLink link})? _warm;
SharedLink? _pending;
Timer? _pendingTimer;
/// A link that can be shared: a media file, or a page whose relay tracks
/// are all plain files (one, or a video and an audio).
static bool fits(LinkProbe probe) =>
probe.relayProblem == null && (!probe.isPage || (probe.info?.tracks.every((t) => !t.format.isPieces) ?? false));
static List<YtDlpSubtitle> _subs(LinkProbe? probe) => probe?.info?.play?.subtitles ?? const [];
/// The swap check of a link this member passes on: a shared source
/// for [url] over [media], started (the tracks' sizes read). Kept for
/// the swap's state ([current]) for a minute, then dropped.
Future<SharedSource> forSwap(String url, RelayMedia media, LinkProbe probe,
{Duration timeout = const Duration(seconds: 15)}) async {
final s = SharedSource(media);
try {
await s.start().timeout(timeout);
} catch (_) {
unawaited(s.close());
rethrow;
}
_dropPending();
_pending = SharedLink(url, s, subtitles: _subs(probe));
_pendingTimer = Timer(const Duration(seconds: 60), _dropPending);
return s;
}
void _dropPending() {
_pendingTimer?.cancel();
_pendingTimer = null;
final p = _pending;
_pending = null;
if (p != null && !identical(p, _current)) unawaited(p.source.close());
}
/// The current media's shared source for [url]: the one it has, else
/// Up next's warm one for [queueId], else the swap check's, else a new
/// one over [media] (opened, its sizes read meanwhile). Null when the
/// link cannot be shared ([fits], or it does not open): the caller
/// keeps today's two fetches. Any other current one is closed.
Future<SharedSource?> current(String url, LinkProbe probe, RelayMedia Function() media,
{String queueId = ''}) async {
final had = _current;
if (had != null && had.url == url) return had.source;
await closeCurrent();
SharedLink? link;
final pending = _pending, warm = _warm;
if (warm != null && warm.link.url == url && queueId.isNotEmpty && warm.queueId == queueId) {
// The swap carries the warm relay's source on: the player reads the
// reader that source serves.
_warm = null;
link = warm.link;
if (pending != null && pending.url == url) _dropPending();
} else if (pending != null && pending.url == url) {
_pendingTimer?.cancel();
_pending = null;
link = pending;
} else if (fits(probe)) {
final s = SharedSource(media());
try {
await s.open();
} catch (e) {
_log.info('link ${logUrl(url)} not read once here: $e');
unawaited(s.close());
return null;
}
link = SharedLink(url, s, subtitles: _subs(probe));
}
_current = link;
return link?.source;
}
/// The current media's shared source when it reads [url].
SharedSource? currentFor(String url) => _current?.url == url ? _current!.source : null;
/// Up next item [queueId]'s shared source for [url] (fed to its warm
/// relay), made over [media] and opened; the one it has is kept. Null
/// when the link cannot be shared.
Future<SharedSource?> warm(String queueId, String url, LinkProbe probe, RelayMedia Function() media) async {
final had = _warm;
if (had != null && had.queueId == queueId && had.link.url == url) return had.link.source;
dropWarm();
if (!fits(probe)) return null;
final s = SharedSource(media());
final link = SharedLink(url, s, subtitles: _subs(probe));
_warm = (queueId: queueId, link: link);
try {
await s.open();
} catch (e) {
_log.info('Up next ${logUrl(url)} not read once here: $e');
if (identical(_warm?.link, link)) _warm = null;
unawaited(s.close());
return null;
}
return identical(_warm?.link, link) ? s : null;
}
/// The warm one goes (its relay ended, the item is no longer first);
/// with [queueId], only that item's.
void dropWarm([String? queueId]) {
final w = _warm;
if (w == null || (queueId != null && w.queueId != queueId)) return;
_warm = null;
unawaited(w.link.source.close());
}
/// What a player opens for [url] when it is shared here (the current
/// media's or the warm one), opened: null otherwise.
Future<SharedLink?> playerFor(String url) async {
final link = _current?.url == url ? _current : (_warm?.link.url == url ? _warm!.link : null);
if (link == null) return null;
try {
await link.source.open();
return link;
} catch (_) {
return null;
}
}
/// [url] is shared here (the current media's or the warm one).
bool has(String url) => _current?.url == url || _warm?.link.url == url;
Future<void> closeCurrent() async {
final c = _current;
_current = null;
if (c != null) await c.source.close();
}
/// Leave, a new session: everything goes.
Future<void> closeAll() async {
_dropPending();
dropWarm();
await closeCurrent();
}
}
@@ -343,6 +343,13 @@ void main() {
final relay = stateMediaRelay(a.serverState.peek()!);
expect(relay?.queueId, id, reason: 'the warm relay became the media\'s');
expect(relay?.fetch, mode == LinkRelay.server);
if (mode == LinkRelay.me) {
// Read once: alice's preload and the warm relay read one shared
// reader, and the swap carried it on as the media's.
final one = a.media.shared.currentFor(linkUrl);
expect(one, isNotNull, reason: 'alice reads the link once');
expect(one!.bytesIn, greaterThan(0));
}
if (preload) {
for (final x in all.values) {
expect(x.switchTrace.of(x.serverState.peek()!.mediaId), contains(SwitchTrace.preloaded),
+8 -2
View File
@@ -159,12 +159,12 @@ class _BareSource {
}
}
/// Serves one file with ranges; [requests] counts hits.
/// Serves one file with ranges; [requests] lists hits (user agent, URL).
Future<(HttpServer, List<String>)> _origin(String file) async {
final s = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final requests = <String>[];
s.listen((req) async {
requests.add(req.uri.toString());
requests.add('${req.headers.value('user-agent')} ${req.uri}');
final size = await File(file).length();
final r = resolveMediaRequest(rangeHeader: req.headers.value('range'), size: size, filename: 'v.mkv');
req.response.statusCode = r.status;
@@ -274,6 +274,12 @@ void main() {
expect(alice.serverState.peek()!.toJson().toString(), isNot(contains('SECRET')));
await playTogether([alice, bob]);
expect(server.hub.relay!.buffers.single.bytesToViewers, greaterThan(0));
// Read once: bob's player plays his shared reader's loopback, so
// every hit at the origin is that reader's (Dart's), none mpv's.
final one = bob.media.shared.currentFor(link);
expect(one, isNotNull);
expect(one!.bytesIn, greaterThan(0));
expect(hits.where((h) => !h.startsWith('Dart/')), isEmpty, reason: '$hits');
final before = hits.length;
expect(await alice.swapPicked(LinkMedia(link, relay: LinkRelay.server)), isNull);