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:
1 parent
7715dc479a
commit
ec0849eb01
19 files changed
+1849
-56
No files matched your search
@@ -689,6 +689,9 @@ class QueueController {
|
|||||||
|
|
||||||
// The warm relay this app feeds (`<queue id>|<token>`), started once.
|
// The warm relay this app feeds (`<queue id>|<token>`), started once.
|
||||||
String _fedWarm = '';
|
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
|
/// 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
|
/// 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))));
|
coord.media.feeder.feedWarm(head.id, RelayMedia.single(FileRelayBytes(File(pick.path))));
|
||||||
} else if (pick is LinkMedia) {
|
} else if (pick is LinkMedia) {
|
||||||
_fedWarm = key;
|
_fedWarm = key;
|
||||||
|
_feedingLink = pick.url;
|
||||||
unawaited(() async {
|
unawaited(() async {
|
||||||
try {
|
try {
|
||||||
// The probe [_resolveWarm] keeps: no second yt-dlp run.
|
// 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 probe = await coord.ytDlp.probe(pick.url, keep: _warmKeep).timeout(const Duration(seconds: 60));
|
||||||
final y = await coord.ytDlp.ytDlp;
|
final y = await coord.ytDlp.ytDlp;
|
||||||
if (_fedWarm != key) return;
|
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) {
|
} catch (e) {
|
||||||
debugPrint('queue: feeding ${head.id}: $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;
|
return;
|
||||||
}
|
}
|
||||||
if (!media.preloadEnabled) return;
|
if (!media.preloadEnabled) return;
|
||||||
|
if (want.url == _feedingLink) return;
|
||||||
final key = '${want.url}|${want.headers}';
|
final key = '${want.url}|${want.headers}';
|
||||||
final fresh = _preloadFresh;
|
final fresh = _preloadFresh;
|
||||||
// Asked for already: in flight, there, or failed (tried again with the
|
// 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 'delivery.dart' show stateMediaRelay;
|
||||||
import 'media_plan.dart';
|
import 'media_plan.dart';
|
||||||
import 'relay_feeder.dart';
|
import 'relay_feeder.dart';
|
||||||
|
import 'shared_links.dart';
|
||||||
import 'switch_trace.dart';
|
import 'switch_trace.dart';
|
||||||
|
|
||||||
export 'media_plan.dart';
|
export 'media_plan.dart';
|
||||||
@@ -37,7 +38,9 @@ final _log = createLogger('session');
|
|||||||
// coordinator's report).
|
// coordinator's report).
|
||||||
//
|
//
|
||||||
// Being a relay source ([RelayFeeder]) and the viewer's side of a source
|
// 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
|
/// What the coordinator does next after [MediaPipeline.switchTo]: a
|
||||||
/// Jellyfin item resolves and opens through [MediaPipeline.openJellyfin]
|
/// Jellyfin item resolves and opens through [MediaPipeline.openJellyfin]
|
||||||
@@ -77,6 +80,10 @@ class MediaPipeline {
|
|||||||
final PlaybackController playback;
|
final PlaybackController playback;
|
||||||
final SwitchTrace trace;
|
final SwitchTrace trace;
|
||||||
late final RelayFeeder feeder;
|
late final RelayFeeder feeder;
|
||||||
|
|
||||||
|
/// The links this member passes on, read once for its player and the
|
||||||
|
/// relay.
|
||||||
|
final shared = SharedLinks();
|
||||||
MediaSession session;
|
MediaSession session;
|
||||||
|
|
||||||
/// The player could not open the current media (raw words: the
|
/// The player could not open the current media (raw words: the
|
||||||
@@ -98,7 +105,10 @@ class MediaPipeline {
|
|||||||
}) : playback = playback ?? PlaybackController(host),
|
}) : playback = playback ?? PlaybackController(host),
|
||||||
trace = trace ?? SwitchTrace(),
|
trace = trace ?? SwitchTrace(),
|
||||||
session = session ?? MediaSession.none {
|
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
|
// ------------------------------------------------------------ players
|
||||||
@@ -202,6 +212,12 @@ class MediaPipeline {
|
|||||||
/// stream opened with headers (a Jellyfin item's, with its token). Throws
|
/// stream opened with headers (a Jellyfin item's, with its token). Throws
|
||||||
/// a [ResolveFailure] in plain words, with the cookies used (D11).
|
/// a [ResolveFailure] in plain words, with the cookies used (D11).
|
||||||
Future<_Target> _resolve(String url, Map<String, String>? headers, {bool fresh = false}) async {
|
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 {});
|
if (!_isPage(url) || (headers != null && headers.isNotEmpty)) return _Target(url, headers ?? const {});
|
||||||
final l = links;
|
final l = links;
|
||||||
if (l == null) throw const ResolveFailure('no link resolver here');
|
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 (headers != null && headers.isNotEmpty) return 'Jellyfin, own sign-in';
|
||||||
|
if (shared.has(url)) return 'link passed on, read once here';
|
||||||
return 'link, fetched here';
|
return 'link, fetched here';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,6 +415,7 @@ class MediaPipeline {
|
|||||||
final name = (r.media?['name'] as String?) ?? '';
|
final name = (r.media?['name'] as String?) ?? '';
|
||||||
final queueId = stateMediaRelay(r)?.queueId ?? '';
|
final queueId = stateMediaRelay(r)?.queueId ?? '';
|
||||||
final duration = (r.media?['duration'] as num?)?.toDouble() ?? 0;
|
final duration = (r.media?['duration'] as num?)?.toDouble() ?? 0;
|
||||||
|
if (source is! OwnLink) await shared.closeCurrent();
|
||||||
switch (source) {
|
switch (source) {
|
||||||
case JellyfinSource():
|
case JellyfinSource():
|
||||||
return SwitchJellyfin(source);
|
return SwitchJellyfin(source);
|
||||||
@@ -407,8 +425,11 @@ class MediaPipeline {
|
|||||||
announce: true, queueId: queueId, name: name, duration: duration);
|
announce: true, queueId: queueId, name: name, duration: duration);
|
||||||
await open(path);
|
await open(path);
|
||||||
case OwnLink(:final url, :final origin):
|
case OwnLink(:final url, :final origin):
|
||||||
feeder.serve(r.mediaId, LinkRelayMedia(Uri.parse(url), ytDlp: origin.ytDlp, probe: origin.probe),
|
// Read from the site once: the relay serves what the player reads
|
||||||
announce: true, queueId: queueId, name: name, duration: duration);
|
// (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);
|
await open(url);
|
||||||
case RelayedSource(:final url, :final waiting, :final fetch, :final sourceId):
|
case RelayedSource(:final url, :final waiting, :final fetch, :final sourceId):
|
||||||
if (waiting) {
|
if (waiting) {
|
||||||
@@ -462,7 +483,8 @@ class MediaPipeline {
|
|||||||
feeder.serve(r.mediaId, RelayMedia.single(FileRelayBytes(File(path))),
|
feeder.serve(r.mediaId, RelayMedia.single(FileRelayBytes(File(path))),
|
||||||
queueId: queueId, name: name, duration: duration);
|
queueId: queueId, name: name, duration: duration);
|
||||||
case OwnLink(:final url, :final origin):
|
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);
|
queueId: queueId, name: name, duration: duration);
|
||||||
case JellyfinSource(:final ref, :final origin):
|
case JellyfinSource(:final ref, :final origin):
|
||||||
if (origin == null || ownJellyfin == null) return;
|
if (origin == null || ownJellyfin == null) return;
|
||||||
@@ -538,6 +560,7 @@ class MediaPipeline {
|
|||||||
_activeOrigin = null;
|
_activeOrigin = null;
|
||||||
_jellyfinFeed = null;
|
_jellyfinFeed = null;
|
||||||
feeder.stopAll();
|
feeder.stopAll();
|
||||||
|
unawaited(shared.closeAll());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The same media under a restarted server's numbering: the player keeps
|
/// The same media under a restarted server's numbering: the player keeps
|
||||||
@@ -558,6 +581,7 @@ class MediaPipeline {
|
|||||||
_jellyfinFeed = null;
|
_jellyfinFeed = null;
|
||||||
settledMediaId.value = -1;
|
settledMediaId.value = -1;
|
||||||
feeder.stopAll();
|
feeder.stopAll();
|
||||||
|
unawaited(shared.closeAll());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------- Jellyfin
|
// ----------------------------------------------------------- Jellyfin
|
||||||
@@ -738,6 +762,24 @@ class MediaPipeline {
|
|||||||
String warmRelayUrl(QueueItem item, MediaRelay relay) =>
|
String warmRelayUrl(QueueItem item, MediaRelay relay) =>
|
||||||
session.server().relayUrl(item.media.name.isEmpty ? item.title : item.media.name, item.id, relay.tracks);
|
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
|
/// 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
|
/// small cache, its own video output when the view is up), replacing any
|
||||||
/// other; completes once [need] seconds are buffered or the whole media
|
/// other; completes once [need] seconds are buffered or the whole media
|
||||||
@@ -754,6 +796,7 @@ class MediaPipeline {
|
|||||||
final pre = _pre = _Prepared(url, headers ?? const {});
|
final pre = _pre = _Prepared(url, headers ?? const {});
|
||||||
try {
|
try {
|
||||||
final target = await _resolve(url, headers, fresh: fresh);
|
final target = await _resolve(url, headers, fresh: fresh);
|
||||||
|
pre.shared = target.shared;
|
||||||
if (!identical(_pre, pre)) {
|
if (!identical(_pre, pre)) {
|
||||||
pre.fail();
|
pre.fail();
|
||||||
return false;
|
return false;
|
||||||
@@ -840,6 +883,7 @@ class MediaPipeline {
|
|||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
await release();
|
await release();
|
||||||
feeder.stopAll();
|
feeder.stopAll();
|
||||||
|
await shared.closeAll();
|
||||||
await playback.dispose();
|
await playback.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -864,6 +908,9 @@ class _Prepared {
|
|||||||
StreamSubscription<PlayerLogLine>? logSub;
|
StreamSubscription<PlayerLogLine>? logSub;
|
||||||
List<YtDlpSubtitle> subtitles = const [];
|
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).
|
/// Completes once the media is handed to mpv (or it failed).
|
||||||
final opened = Completer<void>();
|
final opened = Completer<void>();
|
||||||
bool failed = false;
|
bool failed = false;
|
||||||
@@ -885,7 +932,10 @@ class _Target {
|
|||||||
final String url;
|
final String url;
|
||||||
final Map<String, String> headers;
|
final Map<String, String> headers;
|
||||||
final List<YtDlpSubtitle> subtitles;
|
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
|
/// A page could not be resolved: why ([message], yt-dlp's words) and which
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ import 'party.dart' show isPresent;
|
|||||||
class RelayFeeder {
|
class RelayFeeder {
|
||||||
final ServerAddress Function() server;
|
final ServerAddress Function() server;
|
||||||
final void Function(AppEvent event) notify;
|
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;
|
RelaySource? _source;
|
||||||
int _mediaId = -1;
|
int _mediaId = -1;
|
||||||
@@ -33,6 +38,9 @@ class RelayFeeder {
|
|||||||
/// relay and has opened them (empty otherwise).
|
/// relay and has opened them (empty otherwise).
|
||||||
List<RelayTrack> warmTracks(String queueId) => _warm?.queueId == queueId ? _warm!.src.tracks : const [];
|
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.
|
/// Serves [media] for media [mediaId] on this member's relay channel.
|
||||||
/// The server only pulls while someone watches through it. When the
|
/// The server only pulls while someone watches through it. When the
|
||||||
/// media came from Up next item [queueId] whose warm relay this member
|
/// media came from Up next item [queueId] whose warm relay this member
|
||||||
@@ -111,18 +119,26 @@ class RelayFeeder {
|
|||||||
await src.start();
|
await src.start();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('warm relay source of $queueId failed: $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;
|
return;
|
||||||
}
|
}
|
||||||
await src.done;
|
await src.done;
|
||||||
if (identical(_warm?.src, src)) _warm = null;
|
if (identical(_warm?.src, src)) {
|
||||||
|
_warm = null;
|
||||||
|
onWarmEnded(queueId);
|
||||||
|
}
|
||||||
}());
|
}());
|
||||||
}
|
}
|
||||||
|
|
||||||
void stopWarm() {
|
void stopWarm() {
|
||||||
final w = _warm;
|
final w = _warm;
|
||||||
_warm = null;
|
_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.
|
/// Leave, or a media this member no longer serves.
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import 'notifier.dart';
|
|||||||
import 'now_playing.dart';
|
import 'now_playing.dart';
|
||||||
import 'now_playing_info.dart';
|
import 'now_playing_info.dart';
|
||||||
import 'party.dart';
|
import 'party.dart';
|
||||||
|
import 'shared_links.dart';
|
||||||
import 'status.dart';
|
import 'status.dart';
|
||||||
import 'switch_trace.dart';
|
import 'switch_trace.dart';
|
||||||
|
|
||||||
@@ -1279,11 +1280,21 @@ class SessionCoordinator {
|
|||||||
// Up next's item this app already feeds (its warm relay): read.
|
// Up next's item this app already feeds (its warm relay): read.
|
||||||
final warm = queueId == null ? const <RelayTrack>[] : this.media.feeder.warmTracks(queueId);
|
final warm = queueId == null ? const <RelayTrack>[] : this.media.feeder.warmTracks(queueId);
|
||||||
final fed = warm.isNotEmpty;
|
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;
|
var size = 0;
|
||||||
final List<RelayTrackSpec> specs;
|
final List<RelayTrackSpec> specs;
|
||||||
try {
|
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) {
|
for (final t in tracks) {
|
||||||
// A playlist is read (DRM and live refused) but has no size.
|
// A playlist is read (DRM and live refused) but has no size.
|
||||||
final pieces = t.pieces;
|
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()!);
|
final relay = stateMediaRelay(a.serverState.peek()!);
|
||||||
expect(relay?.queueId, id, reason: 'the warm relay became the media\'s');
|
expect(relay?.queueId, id, reason: 'the warm relay became the media\'s');
|
||||||
expect(relay?.fetch, mode == LinkRelay.server);
|
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) {
|
if (preload) {
|
||||||
for (final x in all.values) {
|
for (final x in all.values) {
|
||||||
expect(x.switchTrace.of(x.serverState.peek()!.mediaId), contains(SwitchTrace.preloaded),
|
expect(x.switchTrace.of(x.serverState.peek()!.mediaId), contains(SwitchTrace.preloaded),
|
||||||
|
|||||||
@@ -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 {
|
Future<(HttpServer, List<String>)> _origin(String file) async {
|
||||||
final s = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
final s = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
final requests = <String>[];
|
final requests = <String>[];
|
||||||
s.listen((req) async {
|
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 size = await File(file).length();
|
||||||
final r = resolveMediaRequest(rangeHeader: req.headers.value('range'), size: size, filename: 'v.mkv');
|
final r = resolveMediaRequest(rangeHeader: req.headers.value('range'), size: size, filename: 'v.mkv');
|
||||||
req.response.statusCode = r.status;
|
req.response.statusCode = r.status;
|
||||||
@@ -274,6 +274,12 @@ void main() {
|
|||||||
expect(alice.serverState.peek()!.toJson().toString(), isNot(contains('SECRET')));
|
expect(alice.serverState.peek()!.toJson().toString(), isNot(contains('SECRET')));
|
||||||
await playTogether([alice, bob]);
|
await playTogether([alice, bob]);
|
||||||
expect(server.hub.relay!.buffers.single.bytesToViewers, greaterThan(0));
|
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;
|
final before = hits.length;
|
||||||
expect(await alice.swapPicked(LinkMedia(link, relay: LinkRelay.server)), isNull);
|
expect(await alice.swapPicked(LinkMedia(link, relay: LinkRelay.server)), isNull);
|
||||||
|
|||||||
+78
-9
@@ -638,8 +638,9 @@ ready.
|
|||||||
can, so this is Android's plug-in point).
|
can, so this is Android's plug-in point).
|
||||||
- Resolve: a Jellyfin item through this app's account (or the plan
|
- Resolve: a Jellyfin item through this app's account (or the plan
|
||||||
resolved ahead for Up next, `warmJellyfin`), else the server's login,
|
resolved ahead for Up next, `warmJellyfin`), else the server's login,
|
||||||
else the member passing it on, else blocked (`openJellyfin`); a web
|
else the member passing it on, else blocked (`openJellyfin`); a link
|
||||||
page through the app's own yt-dlp (`LinkResolver.probe`: yt-dlp's
|
this member passes on through its shared reader's loopback URL ("Read
|
||||||
|
once" under "Relay"); a web page through the app's own yt-dlp (`LinkResolver.probe`: yt-dlp's
|
||||||
stream URLs, an EDL of video and audio, the CDN's headers with its
|
stream URLs, an EDL of video and audio, the CDN's headers with its
|
||||||
cookies, the subtitles; see "yt-dlp"), failing in plain words with the
|
cookies, the subtitles; see "yt-dlp"), failing in plain words with the
|
||||||
cookies that were used (`ResolveFailure`, D11); a relay not fed yet is
|
cookies that were used (`ResolveFailure`, D11); a relay not fed yet is
|
||||||
@@ -681,6 +682,10 @@ ready.
|
|||||||
- Being a relay source (`RelayFeeder`, `session/relay_feeder.dart`): the
|
- Being a relay source (`RelayFeeder`, `session/relay_feeder.dart`): the
|
||||||
current media's channel and Up next's warm relay (see "Relay"), with
|
current media's channel and Up next's warm relay (see "Relay"), with
|
||||||
the step-1 re-dial (a source rides through this app's own drop). A
|
the step-1 re-dial (a source rides through this app's own drop). A
|
||||||
|
link it passes on is read once for its player and the relay
|
||||||
|
(`SharedLinks`, `session/shared_links.dart`; "Read once" under
|
||||||
|
"Relay"): the swap check's reader, else Up next's warm one, else a new
|
||||||
|
one; it lives while the media does (a sit-out keeps it for the relay). A
|
||||||
Jellyfin item this member put up is passed on only once someone
|
Jellyfin item this member put up is passed on only once someone
|
||||||
present needs it (`jellyfinRelayNeeded`: the server's login does not
|
present needs it (`jellyfinRelayNeeded`: the server's login does not
|
||||||
cover it and a present member does not play that server with its own
|
cover it and a present member does not play that server with its own
|
||||||
@@ -840,8 +845,8 @@ and URL, played together by viewers as an mpv EDL
|
|||||||
`relayViewerUrl` / `mpvEdl`). media_kit opens an `edl://` URL unchanged
|
`relayViewerUrl` / `mpvEdl`). media_kit opens an `edl://` URL unchanged
|
||||||
(it writes it to a playlist and `loadlist`s it) and mpv keeps both
|
(it writes it to a playlist and `loadlist`s it) and mpv keeps both
|
||||||
streams in sync across seeks; one URL keeps the app's "one media URL"
|
streams in sync across seeks; one URL keeps the app's "one media URL"
|
||||||
path (sync, swap, reopen) as is. The source member plays the page
|
path (sync, swap, reopen) as is. The source member's own player reads
|
||||||
itself (its own resolve, see "yt-dlp") like any other link.
|
the same tracks through its shared reader ("Read once" below).
|
||||||
|
|
||||||
A page with only segmented streams (HLS `m3u8_native`, DASH
|
A page with only segmented streams (HLS `m3u8_native`, DASH
|
||||||
`http_dash_segments`, e.g. X's HLS, generic `.m3u8`/`.mpd`) is relayed as
|
`http_dash_segments`, e.g. X's HLS, generic `.m3u8`/`.mpd`) is relayed as
|
||||||
@@ -873,6 +878,64 @@ How bytes move:
|
|||||||
this machine). Viewers only ever talk HTTP to the server, so the source
|
this machine). Viewers only ever talk HTTP to the server, so the source
|
||||||
never sees mpv's request pattern. The tracks of a media share the
|
never sees mpv's request pattern. The tracks of a media share the
|
||||||
channel and its flow control (they compete for the same uplink).
|
channel and its flow control (they compete for the same uplink).
|
||||||
|
- **Read once** (a link passed on: `SharedSource`,
|
||||||
|
`packages/server/lib/src/shared_source.dart`, kept by the app's
|
||||||
|
`SharedLinks`): the source reads each track from the site once. Per
|
||||||
|
track a `RelayBuffer` on the source (64 MiB ahead and 16 MiB behind
|
||||||
|
for all tracks, split by size like the server's, plus the pins:
|
||||||
|
`sharedAheadBytes` / `sharedBackBytes`), filled by one sequential
|
||||||
|
upstream reader, serves two readers: the source's own player, through
|
||||||
|
a loopback endpoint (`http://127.0.0.1:<port>/<random secret>/<track>`,
|
||||||
|
an mpv EDL for several tracks, no headers: the site's stay in the
|
||||||
|
reader) answered with the server's own byte-answer code
|
||||||
|
(`writeRelayRange`, below: headers within 4 s, `connection: close`),
|
||||||
|
and the server's pulls (`SharedSource.upload`, served by the usual
|
||||||
|
`RelaySource`). The player opens at once; the endpoint waits for the
|
||||||
|
tracks' sizes. The two readers are kept within one window
|
||||||
|
(`RelayBuffer.together`): while both have a request open, the one more
|
||||||
|
than the track's `ahead` past the other waits without asking upstream,
|
||||||
|
so one sequential read serves both instead of handing over at every
|
||||||
|
slice. The server's read-ahead thus stops about 64 MiB past the
|
||||||
|
source's player, and a source whose upload lags holds its own player
|
||||||
|
there (viewers would wait at that point anyway); either reader alone
|
||||||
|
reads freely, pinned slices always come. A seek (shared, so both
|
||||||
|
readers go together) is a new upstream read there, as on the server.
|
||||||
|
Upstream (`ResumingBytes`): a cut resumes from the byte it reached
|
||||||
|
after 100 ms; an answer without a byte (412, 429, 5xx, a refused or
|
||||||
|
dead connection, nothing for 20 s) backs off 0.5 s doubling to 8 s;
|
||||||
|
60 s without a byte fails the read, and the buffer's own backoff asks
|
||||||
|
again. 403/410/412 first resolve the page again (see "yt-dlp"). The
|
||||||
|
source's player plays the relay's tracks, so the source and the
|
||||||
|
viewers watch one format, and the reader sends the headers and cookies
|
||||||
|
yt-dlp gave for it. The putter's swap check is this reader (its sizes
|
||||||
|
are the media's: no second read); an Up next link passed on feeds its
|
||||||
|
warm relay from its own reader, the preload player reads its loopback
|
||||||
|
URL (one prepared on the site before the reader existed is prepared
|
||||||
|
again), and the swap carries both on. Reload video opens the loopback
|
||||||
|
URL again (the reader resolves an expired page itself). Not read once:
|
||||||
|
a page with a pieces track (HLS/DASH, X's): its player plays yt-dlp's
|
||||||
|
pick from the site and the relay reads the pieces itself, two fetches;
|
||||||
|
a file passed on (disk) and a Jellyfin item (see "Media pipeline")
|
||||||
|
keep their own paths. Measured (server `single_fetch_e2e`, opt-in: a
|
||||||
|
real server and `RelaySource`, mpv 0.41 with the app's pins on
|
||||||
|
loopback, a fake CDN with Bilibili's Referer rule and two tracks:
|
||||||
|
60 s of 720p h264 at 4 Mbit/s and 128 kbit/s aac; 20 s of play, then
|
||||||
|
a seek to 45 s and back to 5 s):
|
||||||
|
|
||||||
|
| CDN / source | First frame: source, viewer | Viewer stalls | Viewer seek to 45 s | CDN requests (player + relay) | CDN MiB | 412s |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| clean, two fetches | 0.22 s, 0.46 s | 0 | 0.27 s | 8 (2 + 6) | 51.8 | 0 |
|
||||||
|
| clean, read once | 0.27 s, 0.33 s | 0 | 0.29 s | 6 (0 + 6) | 25.9 | 0 |
|
||||||
|
| cuts every 0 to 2 MiB, two fetches | 0.22 s, 0.38 s | 1 | 0.27 s | 64 (28 + 36) | 55.9 | 0 |
|
||||||
|
| cuts, read once (29 resumes) | 0.22 s, 0.27 s | 0 | 0.27 s | 33 (0 + 33) | 25.9 | 0 |
|
||||||
|
| cuts + 412 lockout (> 40 requests in 10 s), two fetches | 0.21 s, 0.39 s | 1 | 4.03 s | 49 (23 + 26) | 35.3 | 6 |
|
||||||
|
| cuts + lockout, read once | 0.26 s, 0.33 s | 0 | 0.29 s | 33 (0 + 33) | 25.9 | 0 |
|
||||||
|
| 768 KiB/s per connection, two fetches | 0.40 s, 0.59 s | 1 | 0.80 s | 12 (5 + 7) | 34.0 | 0 |
|
||||||
|
| 768 KiB/s, read once | 0.40 s, 0.47 s | 1 | 0.76 s | 7 (0 + 7) | 16.9 | 0 |
|
||||||
|
|
||||||
|
The source's own seeks took about what the viewer's did; back to 5 s
|
||||||
|
took 0.25 to 0.29 s everywhere. The source's player stalled only on
|
||||||
|
the slow CDN (once, in both modes).
|
||||||
- Upstream GETs (`HttpRelayBytes.read`) are split into back-to-back
|
- Upstream GETs (`HttpRelayBytes.read`) are split into back-to-back
|
||||||
ranges of at most yt-dlp's `http_chunk_size` for the format, else
|
ranges of at most yt-dlp's `http_chunk_size` for the format, else
|
||||||
10 MiB (`relayUpstreamChunkBytes`); a buffer pull stays one read.
|
10 MiB (`relayUpstreamChunkBytes`); a buffer pull stays one read.
|
||||||
@@ -918,7 +981,9 @@ How bytes move:
|
|||||||
source's upstream dropping, a CDN refusing) is followed by the next
|
source's upstream dropping, a CDN refusing) is followed by the next
|
||||||
one after 250 ms, doubling up to 8 s (`relayRetryDelay`), back to none
|
one after 250 ms, doubling up to 8 s (`relayRetryDelay`), back to none
|
||||||
once a slice or a pull completes: a failing CDN is not asked again
|
once a slice or a pull completes: a failing CDN is not asked again
|
||||||
every round trip (that drew Bilibili's 412/403). A viewer reads the
|
every round trip (that drew Bilibili's 412/403). Read-ahead never
|
||||||
|
fetches the slice that would be evicted next, and a long pull evicts
|
||||||
|
as its slices come, so a buffer stays within its budget. A viewer reads the
|
||||||
slice being filled as its bytes arrive, not once it is whole (mpv's
|
slice being filled as its bytes arrive, not once it is whole (mpv's
|
||||||
demuxer, blocked in a read, takes a seek only once that read returns).
|
demuxer, blocked in a read, takes a seek only once that read returns).
|
||||||
Nothing is fetched while nobody watches through the relay (a Jellyfin
|
Nothing is fetched while nobody watches through the relay (a Jellyfin
|
||||||
@@ -1126,7 +1191,8 @@ all three link modes. Nothing is bundled or installed by hand.
|
|||||||
format's `http_headers` plus its `cookies` as a Cookie header; the
|
format's `http_headers` plus its `cookies` as a Cookie header; the
|
||||||
requested subtitles but Bilibili's danmaku, an XML comment feed); and
|
requested subtitles but Bilibili's danmaku, an XML comment feed); and
|
||||||
what a relay reads (`pickTracks`), each format with its URL,
|
what a relay reads (`pickTracks`), each format with its URL,
|
||||||
`http_headers` and `http_chunk_size`. For a relay only plain http(s)
|
`http_headers` plus its `cookies` as a Cookie header (what a player
|
||||||
|
sends) and `http_chunk_size`. For a relay only plain http(s)
|
||||||
formats without DRM count:
|
formats without DRM count:
|
||||||
- the best single file (`pickSingleFile`: video and audio not `none`, a
|
- the best single file (`pickSingleFile`: video and audio not `none`, a
|
||||||
video container, tallest up to 1080p, else the shortest above), unless
|
video container, tallest up to 1080p, else the shortest above), unless
|
||||||
@@ -1176,11 +1242,14 @@ all three link modes. Nothing is bundled or installed by hand.
|
|||||||
track's playlist must be readable, else the swap is refused ("Live
|
track's playlist must be readable, else the swap is refused ("Live
|
||||||
streams can't be passed on", DRM, or nothing a relay can read) with a
|
streams can't be passed on", DRM, or nothing a relay can read) with a
|
||||||
hint to use "Everyone fetches it". The swap names the tracks; its relay
|
hint to use "Everyone fetches it". The swap names the tracks; its relay
|
||||||
source reads them with yt-dlp's headers (`PageRelayMedia`). On
|
source reads them with yt-dlp's headers and cookies (`PageRelayMedia`),
|
||||||
|
and the putter's own player plays those same tracks through the one
|
||||||
|
reader ("Read once" under "Relay"), not yt-dlp's `requested_formats`:
|
||||||
|
the putter sees the relay's pick (HEVC first) like the viewers. On
|
||||||
403/410/412 (expired format URLs, 6 h on YouTube; signed segment URLs;
|
403/410/412 (expired format URLs, 6 h on YouTube; signed segment URLs;
|
||||||
Bilibili's 412) from any track it resolves the page again once for all
|
Bilibili's 412) from any track it resolves the page again once for all
|
||||||
tracks (not again within 60 s: a further refusal fails the read and the
|
tracks (not again within 60 s: a further refusal fails the read and is
|
||||||
server's backoff asks later), finds the same
|
backed off), finds the same
|
||||||
format ids with the same sizes and piece counts and roles (playlists
|
format ids with the same sizes and piece counts and roles (playlists
|
||||||
re-read and re-mapped by index; else "the video changed on the page")
|
re-read and re-mapped by index; else "the video changed on the page")
|
||||||
and goes on from where it stopped. Format URLs are bound to the
|
and goes on from where it stopped. Format URLs are bound to the
|
||||||
|
|||||||
+11
-3
@@ -94,7 +94,10 @@ Three suites, run from each package's folder:
|
|||||||
- `cd packages/server && dart test` (well under a minute): live servers
|
- `cd packages/server && dart test` (well under a minute): live servers
|
||||||
on loopback: auth and version refusal, identity, takeover and grace
|
on loopback: auth and version refusal, identity, takeover and grace
|
||||||
(`presence_test` on a fake clock), media and relay byte serving with
|
(`presence_test` on a fake clock), media and relay byte serving with
|
||||||
source drops, Jellyfin relay against a fake, queue (and the shared
|
source drops, the read-once source (`shared_source_test`: its loopback
|
||||||
|
range answers, a cut and resume with backoff, seeks of either reader,
|
||||||
|
the two kept within one window, its memory bound), Jellyfin relay
|
||||||
|
against a fake, queue (and the shared
|
||||||
countdown on a fake clock), session log,
|
countdown on a fake clock), session log,
|
||||||
isolate `serve`, sharing (`share_test`: a listener attached and detached
|
isolate `serve`, sharing (`share_test`: a listener attached and detached
|
||||||
under the same members and Up next, the host key standing in for the
|
under the same members and Up next, the host key standing in for the
|
||||||
@@ -114,7 +117,10 @@ Three suites, run from each package's folder:
|
|||||||
|
|
||||||
Opt-in, skipped unless set: `JELLYFIN_TEST_*` (core `jellyfin_live` and
|
Opt-in, skipped unless set: `JELLYFIN_TEST_*` (core `jellyfin_live` and
|
||||||
server `jellyfin_relay_live` against a real Jellyfin; read their headers),
|
server `jellyfin_relay_live` against a real Jellyfin; read their headers),
|
||||||
`YTDLP_LIVE=1` (downloads yt-dlp and resolves a real tweet). Everything
|
`YTDLP_LIVE=1` (downloads yt-dlp and resolves a real tweet),
|
||||||
|
`WT_SINGLE_FETCH=1` (server `single_fetch_e2e`: a link passed on read
|
||||||
|
once against twice, two real mpv against a fake CDN that cuts and locks
|
||||||
|
out; minutes; `WT_SF_SCENARIOS`, `WT_SF_PLAY` narrow it). Everything
|
||||||
else is offline: Jellyfin and link hosts are local fakes.
|
else is offline: Jellyfin and link hosts are local fakes.
|
||||||
|
|
||||||
Real-mpv tests: `sync_playback_e2e`, `coord_presence` (drops through a
|
Real-mpv tests: `sync_playback_e2e`, `coord_presence` (drops through a
|
||||||
@@ -129,7 +135,9 @@ stand-in yt-dlp and a Referer-checking CDN: the EDL, the headers and
|
|||||||
cookies, subtitles without danmaku, a 403, the D11 error),
|
cookies, subtitles without danmaku, a 403, the D11 error),
|
||||||
`media_switch_e2e` (commands during a switch wait and apply once, no mpv
|
`media_switch_e2e` (commands during a switch wait and apply once, no mpv
|
||||||
errors), `relay_e2e` (a dedicated server with no file of its
|
errors), `relay_e2e` (a dedicated server with no file of its
|
||||||
own: a guest's file, links passed on or fetched by the server, a late
|
own: a guest's file, links passed on (read once: the source's player on
|
||||||
|
its loopback reader, nothing from mpv at the origin) or fetched by the
|
||||||
|
server, a late
|
||||||
source, a source that drops and resumes; a seek past what a slow source
|
source, a source that drops and resumes; a seek past what a slow source
|
||||||
has sent, timed; a source that drops while a viewer seeks past
|
has sent, timed; a source that drops while a viewer seeks past
|
||||||
the buffer and another joins: no seek loop, "Waiting for", all resume
|
the buffer and another joins: no seek loop, "Waiting for", all resume
|
||||||
|
|||||||
@@ -170,6 +170,17 @@ seek or a new link; the start of a freshly put-up link was never
|
|||||||
measured on its own. Levers if it still shows: start the window from
|
measured on its own. Levers if it still shows: start the window from
|
||||||
the last rate, prefetch from the source as soon as a link goes up,
|
the last rate, prefetch from the source as soon as a link goes up,
|
||||||
a smaller gate buffer when only the relay is behind.
|
a smaller gate buffer when only the relay is behind.
|
||||||
|
Left of the read-once source (ARCHITECTURE "Relay", "Read once"): a
|
||||||
|
human check against real Bilibili and YouTube (the 412 storm gone, the
|
||||||
|
putter's picture the relay's format, a Windows putter's loopback), and
|
||||||
|
the source's first frame and seeks over a 12 Mbit/s uplink
|
||||||
|
(`tool/shaper`): a putter whose upload lags now holds its own player
|
||||||
|
about 64 MiB past it. Not built: pieces tracks (HLS/DASH, X's) are
|
||||||
|
still fetched twice (the putter's player from the site, the relay's
|
||||||
|
pieces on their own; the same idea needs a `PieceBuffer` and a loopback
|
||||||
|
playlist); Jellyfin items a member passes on are still streamed twice
|
||||||
|
when the relay is needed. The e2e's media (26 MiB) fits the putter's
|
||||||
|
buffer whole, so the 64 + 16 MiB bound is only unit-tested.
|
||||||
Left of step 4 (library): a human check of the Files library on the
|
Left of step 4 (library): a human check of the Files library on the
|
||||||
real folder (`/mnt/torrents/Torrents/Binux/`: Bleach over three folders
|
real folder (`/mnt/torrents/Torrents/Binux/`: Bleach over three folders
|
||||||
with 66–107 missing, SAO S3 Alicization with OP/ED, Re.Zero S04E17, the
|
with 66–107 missing, SAO S3 Alicization with OP/ED, Re.Zero S04E17, the
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ abstract class RelayConstants {
|
|||||||
/// first bytes, under mpv's 5 s `network-timeout`: a header that never
|
/// first bytes, under mpv's 5 s `network-timeout`: a header that never
|
||||||
/// comes in time costs ffmpeg the file's size for good.
|
/// comes in time costs ffmpeg the file's size for good.
|
||||||
static const int relayHeaderWaitMs = 4000;
|
static const int relayHeaderWaitMs = 4000;
|
||||||
|
/// The source's own buffer of a link it reads once for its player and
|
||||||
|
/// the upload (`SharedSource`), all tracks together besides the pins:
|
||||||
|
/// the two read within [sharedAheadBytes] of each other.
|
||||||
|
static const int sharedAheadBytes = 64 << 20;
|
||||||
|
static const int sharedBackBytes = 16 << 20;
|
||||||
/// The warm relay (Up next's first relayed item, before it goes up)
|
/// The warm relay (Up next's first relayed item, before it goes up)
|
||||||
/// keeps this much ahead of and behind its viewers' preloads, besides
|
/// keeps this much ahead of and behind its viewers' preloads, besides
|
||||||
/// the pins: about what a preload player reads ahead (48 MiB), never the
|
/// the pins: about what a preload player reads ahead (48 MiB), never the
|
||||||
|
|||||||
@@ -65,15 +65,21 @@ class YtDlpFormat {
|
|||||||
/// Relayed as pieces (see relay_pieces.dart), not as a byte stream.
|
/// Relayed as pieces (see relay_pieces.dart), not as a byte stream.
|
||||||
bool get isPieces => _piecesProtocols.contains(protocol);
|
bool get isPieces => _piecesProtocols.contains(protocol);
|
||||||
|
|
||||||
|
/// From one of yt-dlp's formats: its `http_headers` plus its `cookies`
|
||||||
|
/// as a Cookie header, the same a player gets ([YtDlpPlay.parse]).
|
||||||
static YtDlpFormat _of(Map f) {
|
static YtDlpFormat _of(Map f) {
|
||||||
final headers = f['http_headers'];
|
final headers = f['http_headers'];
|
||||||
final opts = f['downloader_options'];
|
final opts = f['downloader_options'];
|
||||||
final chunk = opts is Map ? opts['http_chunk_size'] : null;
|
final chunk = opts is Map ? opts['http_chunk_size'] : null;
|
||||||
final h = f['height'] is num ? (f['height'] as num).toInt() : 0;
|
final h = f['height'] is num ? (f['height'] as num).toInt() : 0;
|
||||||
|
final cookie = cookieHeaderOf(f['cookies']);
|
||||||
return YtDlpFormat(
|
return YtDlpFormat(
|
||||||
id: '${f['format_id'] ?? ''}',
|
id: '${f['format_id'] ?? ''}',
|
||||||
url: Uri.parse(f['url'] as String),
|
url: Uri.parse(f['url'] as String),
|
||||||
headers: headers is Map ? {for (final e in headers.entries) '${e.key}': '${e.value}'} : const {},
|
headers: {
|
||||||
|
if (headers is Map) for (final e in headers.entries) '${e.key}': '${e.value}',
|
||||||
|
if (cookie.isNotEmpty) 'Cookie': cookie,
|
||||||
|
},
|
||||||
height: h == 0 ? null : h,
|
height: h == 0 ? null : h,
|
||||||
ext: '${f['ext']}',
|
ext: '${f['ext']}',
|
||||||
chunkSize: chunk is num && chunk > 0 ? chunk.toInt() : null,
|
chunkSize: chunk is num && chunk > 0 ? chunk.toInt() : null,
|
||||||
|
|||||||
@@ -262,6 +262,25 @@ void main() {
|
|||||||
expect(pick(['avc1.640033', 'vp09.00.40.08']), 'v1');
|
expect(pick(['avc1.640033', 'vp09.00.40.08']), 'v1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('what a relay reads sends what a player sends: each format\'s headers with its cookies', () {
|
||||||
|
const cookies = 'SESSDATA=abc; Domain=.bilibili.com; Path=/; Secure; Expires=1999999999; buvid3=xyz; Domain=.bilibili.com';
|
||||||
|
Map<String, Object?> f(String id, String vcodec, String acodec, int? height) => {
|
||||||
|
'format_id': id, 'url': 'https://cdn/$id.m4s', 'protocol': 'https', 'ext': 'mp4',
|
||||||
|
'vcodec': vcodec, 'acodec': acodec, 'height': height,
|
||||||
|
'http_headers': {'Referer': 'https://www.bilibili.com/', 'User-Agent': 'UA'},
|
||||||
|
'cookies': cookies,
|
||||||
|
};
|
||||||
|
final v = f('30080', 'avc1.640032', 'none', 1080), a = f('30280', 'none', 'mp4a.40.2', null);
|
||||||
|
final info = YtDlpInfo.parse({'title': 'Page', 'formats': [v, a], 'requested_formats': [v, a]});
|
||||||
|
const want = {'Referer': 'https://www.bilibili.com/', 'User-Agent': 'UA', 'Cookie': 'SESSDATA=abc; buvid3=xyz'};
|
||||||
|
expect([for (final t in info.tracks) t.format.id], ['30080', '30280']);
|
||||||
|
for (final t in info.tracks) {
|
||||||
|
expect(t.format.headers, want, reason: t.kind);
|
||||||
|
}
|
||||||
|
expect(info.play!.headers, want);
|
||||||
|
expect(info.formats['30080']!.describe(), isNot(contains('SESSDATA')), reason: 'the log names headers only');
|
||||||
|
});
|
||||||
|
|
||||||
test('what a player opens: yt-dlp\'s pick, an EDL for video + audio, headers with the cookies, subtitles but danmaku', () {
|
test('what a player opens: yt-dlp\'s pick, an EDL for video + audio, headers with the cookies, subtitles but danmaku', () {
|
||||||
final split = YtDlpInfo.parse({
|
final split = YtDlpInfo.parse({
|
||||||
'title': 'Page',
|
'title': 'Page',
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ export 'src/relay_buffer.dart';
|
|||||||
export 'src/relay_routes.dart';
|
export 'src/relay_routes.dart';
|
||||||
export 'src/relay_stats.dart';
|
export 'src/relay_stats.dart';
|
||||||
export 'src/session_log.dart';
|
export 'src/session_log.dart';
|
||||||
|
export 'src/shared_source.dart';
|
||||||
@@ -158,11 +158,13 @@ class _Pull {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _Viewer {
|
class _Viewer {
|
||||||
|
final String name;
|
||||||
int pos = 0;
|
int pos = 0;
|
||||||
int open = 0; // requests in flight
|
int open = 0; // requests in flight
|
||||||
int newest = 0; // sequence of its newest request
|
int newest = 0; // sequence of its newest request
|
||||||
(int slice, int req)? blocking; // where its newest request waits
|
(int slice, int req)? blocking; // where its newest request waits
|
||||||
DateTime lastActive = DateTime.now();
|
DateTime lastActive = DateTime.now();
|
||||||
|
_Viewer(this.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The server side of the relay for one media: an in-memory buffer of
|
/// 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
|
/// - 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
|
/// for anything; older ones still get bytes once they are buffered, and
|
||||||
/// stop holding the feed once superseded (a seek elsewhere).
|
/// 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.
|
/// Everything lives in memory: dropping the buffer is the cleanup.
|
||||||
class RelayBuffer implements TrackBuffer {
|
class RelayBuffer implements TrackBuffer {
|
||||||
/// Set by the relay when a media's tracks share the budget.
|
/// Set by the relay when a media's tracks share the budget.
|
||||||
@@ -190,11 +195,22 @@ class RelayBuffer implements TrackBuffer {
|
|||||||
final Duration waitLimit;
|
final Duration waitLimit;
|
||||||
/// Pull ids; the tracks of one media share one sequence (one channel).
|
/// Pull ids; the tracks of one media share one sequence (one channel).
|
||||||
final int Function() _newId;
|
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({
|
RelayBuffer({
|
||||||
this.ahead = RelayConstants.relayAheadBytes,
|
this.ahead = RelayConstants.relayAheadBytes,
|
||||||
this.back = RelayConstants.relayBackBytes,
|
this.back = RelayConstants.relayBackBytes,
|
||||||
this.pin = RelayConstants.relayPinBytes,
|
this.pin = RelayConstants.relayPinBytes,
|
||||||
this.waitLimit = const Duration(seconds: RelayConstants.relayWaitSecs),
|
this.waitLimit = const Duration(seconds: RelayConstants.relayWaitSecs),
|
||||||
|
this.together,
|
||||||
int Function()? nextId,
|
int Function()? nextId,
|
||||||
}) : _newId = nextId ?? _counter();
|
}) : _newId = nextId ?? _counter();
|
||||||
|
|
||||||
@@ -213,6 +229,8 @@ class RelayBuffer implements TrackBuffer {
|
|||||||
// The driving requests waiting on each blocked slice.
|
// The driving requests waiting on each blocked slice.
|
||||||
final _blockedBy = <int, Set<int>>{};
|
final _blockedBy = <int, Set<int>>{};
|
||||||
final _viewers = <String, _Viewer>{};
|
final _viewers = <String, _Viewer>{};
|
||||||
|
// Reads held back by [together], woken when a position moves.
|
||||||
|
var _moved = Completer<void>();
|
||||||
@override
|
@override
|
||||||
FeedClock clock = FeedClock();
|
FeedClock clock = FeedClock();
|
||||||
_Pull? _pullNow;
|
_Pull? _pullNow;
|
||||||
@@ -282,12 +300,30 @@ class RelayBuffer implements TrackBuffer {
|
|||||||
_waiters.clear();
|
_waiters.clear();
|
||||||
if (!_ready.isCompleted) _ready.complete();
|
if (!_ready.isCompleted) _ready.complete();
|
||||||
_wake();
|
_wake();
|
||||||
|
_wakeHeld();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _wake() {
|
void _wake() {
|
||||||
if (!_kick.isCompleted) _kick.complete();
|
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
|
// ------------------------------------------------------------ feed input
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -314,6 +350,8 @@ class RelayBuffer implements TrackBuffer {
|
|||||||
_slices[s] = pl.takeSlice();
|
_slices[s] = pl.takeSlice();
|
||||||
// A whole slice came: the source works again.
|
// A whole slice came: the source works again.
|
||||||
_failures = 0;
|
_failures = 0;
|
||||||
|
// A long pull stays within the budget as it goes.
|
||||||
|
_evict();
|
||||||
}
|
}
|
||||||
// Whole, or more of it: whoever waits on it reads on.
|
// Whole, or more of it: whoever waits on it reads on.
|
||||||
for (final c in _waiters.remove(s) ?? const <Completer<void>>[]) {
|
for (final c in _waiters.remove(s) ?? const <Completer<void>>[]) {
|
||||||
@@ -362,7 +400,24 @@ class RelayBuffer implements TrackBuffer {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (_slices.containsKey(next)) 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) {
|
void _endPull(_Pull pl) {
|
||||||
@@ -374,8 +429,8 @@ class RelayBuffer implements TrackBuffer {
|
|||||||
|
|
||||||
Iterable<_Viewer> get _active {
|
Iterable<_Viewer> get _active {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
return _viewers.values
|
return _viewers.values.where(
|
||||||
.where((v) => v.open > 0 || now.difference(v.lastActive) < const Duration(seconds: 60));
|
(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;
|
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;
|
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;
|
if (_pinned(s)) return -1;
|
||||||
final off = s * slice;
|
final off = s * slice;
|
||||||
var d = 1 << 62;
|
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).
|
// 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;
|
return d;
|
||||||
}
|
}
|
||||||
@@ -403,9 +462,11 @@ class RelayBuffer implements TrackBuffer {
|
|||||||
final limit = ahead + back + 2 * pin;
|
final limit = ahead + back + 2 * pin;
|
||||||
var total = _slices.length * slice;
|
var total = _slices.length * slice;
|
||||||
if (total <= limit) return;
|
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) {
|
for (final s in order) {
|
||||||
if (total <= limit || _distance(s) < 0) break;
|
if (total <= limit || dist[s]! < 0) break;
|
||||||
if (_waiters.containsKey(s)) continue;
|
if (_waiters.containsKey(s)) continue;
|
||||||
_slices.remove(s);
|
_slices.remove(s);
|
||||||
total -= slice;
|
total -= slice;
|
||||||
@@ -427,7 +488,7 @@ class RelayBuffer implements TrackBuffer {
|
|||||||
final starts = active.map((v) => v.pos).toList()..sort();
|
final starts = active.map((v) => v.pos).toList()..sort();
|
||||||
for (final p in starts) {
|
for (final p in starts) {
|
||||||
for (var s = p ~/ slice; s * slice <= p + ahead && s < _nSlices; s++) {
|
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;
|
return null;
|
||||||
@@ -481,7 +542,7 @@ class RelayBuffer implements TrackBuffer {
|
|||||||
/// request is its viewer's newest ([driving] says while it still is), so
|
/// request is its viewer's newest ([driving] says while it still is), so
|
||||||
/// it may move the feed.
|
/// it may move the feed.
|
||||||
Future<Uint8List?> _waitBytes(_Viewer v, int s, int from, int req, bool Function() driving) async {
|
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 {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
final whole = _slices[s];
|
final whole = _slices[s];
|
||||||
@@ -492,6 +553,13 @@ class RelayBuffer implements TrackBuffer {
|
|||||||
if (part != null) return part;
|
if (part != null) return part;
|
||||||
}
|
}
|
||||||
if (_disposed || DateTime.now().isAfter(deadline)) return null;
|
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()) {
|
if (driving()) {
|
||||||
_blocked.putIfAbsent(s, () => DateTime.now().microsecondsSinceEpoch);
|
_blocked.putIfAbsent(s, () => DateTime.now().microsecondsSinceEpoch);
|
||||||
_blockedBy.putIfAbsent(s, () => {}).add(req);
|
_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
|
/// viewer went away) ends it; a slice that never arrives ends it with an
|
||||||
/// error after [waitLimit]; the buffer's [dispose] with [RelayEnded].
|
/// error after [waitLimit]; the buffer's [dispose] with [RelayEnded].
|
||||||
Stream<Uint8List> read(String viewer, int start, int end, {void Function(int n)? onSent}) async* {
|
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 tail = start >= size - pin;
|
||||||
final me = ++_reqSeq;
|
final me = ++_reqSeq;
|
||||||
if (!tail) {
|
if (!tail) {
|
||||||
@@ -553,6 +621,7 @@ class RelayBuffer implements TrackBuffer {
|
|||||||
v.open++;
|
v.open++;
|
||||||
v.lastActive = DateTime.now();
|
v.lastActive = DateTime.now();
|
||||||
_wake();
|
_wake();
|
||||||
|
_wakeHeld();
|
||||||
try {
|
try {
|
||||||
var off = start;
|
var off = start;
|
||||||
while (off <= end) {
|
while (off <= end) {
|
||||||
@@ -569,10 +638,12 @@ class RelayBuffer implements TrackBuffer {
|
|||||||
v.lastActive = DateTime.now();
|
v.lastActive = DateTime.now();
|
||||||
if (!tail && v.newest == me) v.pos = off;
|
if (!tail && v.newest == me) v.pos = off;
|
||||||
_wake();
|
_wake();
|
||||||
|
_wakeHeld();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
v.open--;
|
v.open--;
|
||||||
v.lastActive = DateTime.now();
|
v.lastActive = DateTime.now();
|
||||||
|
_wakeHeld();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -592,23 +592,30 @@ void mountRelayRoutes(
|
|||||||
app.get('/r/:sid/:token/:mediaId/:seg/p/:piece', (final req) => viewer(req, tracked: true, piece: true));
|
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
|
/// A relayed byte range on the raw connection ([writeRelayRange]).
|
||||||
/// and headers go out with the first body bytes, or after [headerWait]
|
Hijack _rangeResponse(int status, Map<String, String> headers, int length, Stream<Uint8List>? body,
|
||||||
/// without them (dart:io would hold them until body bytes come, and an
|
{required void Function(Object e) onBroke}) =>
|
||||||
/// ffmpeg whose request gets no headers within its 5 s timeout has lost
|
Hijack((ch) => writeRelayRange(ch.stream, ch.sink, status, headers, length, body, onBroke: onBroke));
|
||||||
/// 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
|
/// Writes a relayed byte range on a raw connection ([input] / [out]: the
|
||||||
/// connection open meanwhile. Always `connection: close` and closed after
|
/// server's `/r` answers, and the source's own loopback endpoint in
|
||||||
/// the body: ffmpeg asks for close, and one that is not told so reuses the
|
/// 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
|
/// connection the server closed for its next seek and fails it the same
|
||||||
/// way. A relay that ends ([RelayEnded]) closes the connection plainly;
|
/// way. A relay that ends ([RelayEnded]) closes the connection plainly;
|
||||||
/// any other failure ([onBroke]) resets it. The client going away cancels
|
/// any other failure ([onBroke]) resets it. The client going away cancels
|
||||||
/// the read.
|
/// the read. Never throws.
|
||||||
Hijack _rangeResponse(int status, Map<String, String> headers, int length, Stream<Uint8List>? body,
|
Future<void> writeRelayRange(Stream<List<int>> input, StreamSink<List<int>> out, int status,
|
||||||
{required void Function(Object e) onBroke, Duration headerWait = const Duration(milliseconds: RelayConstants.relayHeaderWaitMs)}) {
|
Map<String, String> headers, int length, Stream<Uint8List>? body,
|
||||||
return Hijack((ch) async {
|
{required void Function(Object e) onBroke,
|
||||||
final out = ch.sink;
|
Duration headerWait = const Duration(milliseconds: RelayConstants.relayHeaderWaitMs)}) async {
|
||||||
final head = StringBuffer('HTTP/1.1 $status ${status == 206 ? 'Partial Content' : 'OK'}\r\n')
|
final head = StringBuffer('HTTP/1.1 $status ${_reason(status)}\r\n')
|
||||||
..write('date: ${HttpDate.format(DateTime.now())}\r\n');
|
..write('date: ${HttpDate.format(DateTime.now())}\r\n');
|
||||||
headers.forEach((k, v) {
|
headers.forEach((k, v) {
|
||||||
if (k != 'content-length' && k != 'connection') head.write('$k: $v\r\n');
|
if (k != 'content-length' && k != 'connection') head.write('$k: $v\r\n');
|
||||||
@@ -619,7 +626,7 @@ Hijack _rangeResponse(int status, Map<String, String> headers, int length, Strea
|
|||||||
// (its relay ending, say): moot then.
|
// (its relay ending, say): moot then.
|
||||||
Future<void> stop() async => it?.cancel().catchError((_) {});
|
Future<void> stop() async => it?.cancel().catchError((_) {});
|
||||||
// The client closing (a seek elsewhere, the player gone) ends the read.
|
// The client closing (a seek elsewhere, the player gone) ends the read.
|
||||||
final input = ch.stream.listen(null,
|
final listening = input.listen(null,
|
||||||
onDone: () => unawaited(stop()), onError: (_) => unawaited(stop()), cancelOnError: true);
|
onDone: () => unawaited(stop()), onError: (_) => unawaited(stop()), cancelOnError: true);
|
||||||
var reset = false;
|
var reset = false;
|
||||||
// The first bytes go out with the headers when they come in time.
|
// The first bytes go out with the headers when they come in time.
|
||||||
@@ -649,7 +656,7 @@ Hijack _rangeResponse(int status, Map<String, String> headers, int length, Strea
|
|||||||
// The client went away mid-write: nothing to say.
|
// The client went away mid-write: nothing to say.
|
||||||
reset = true;
|
reset = true;
|
||||||
} finally {
|
} finally {
|
||||||
await input.cancel().catchError((_) {});
|
await listening.cancel().catchError((_) {});
|
||||||
await stop();
|
await stop();
|
||||||
if (reset && out is Socket) {
|
if (reset && out is Socket) {
|
||||||
out.destroy();
|
out.destroy();
|
||||||
@@ -657,9 +664,16 @@ Hijack _rangeResponse(int status, Map<String, String> headers, int length, Strea
|
|||||||
await out.close().catchError((_) {});
|
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'));
|
Response _plain(int status) => Response(status, body: Body.fromString('status $status'));
|
||||||
|
|
||||||
final _readFailThrottle = LogThrottle(burst: 3);
|
final _readFailThrottle = LogThrottle(burst: 3);
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
import 'package:sync_relay_server/server.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
// The single-fetch source (SharedSource): one upstream reader per track
|
||||||
|
// feeds this app's player (a loopback endpoint) and the upload to the
|
||||||
|
// server. Its loopback answers, a cut and a resume, seeks of either
|
||||||
|
// reader, the two kept within one window, and the memory bound.
|
||||||
|
|
||||||
|
const _mib = 1 << 20;
|
||||||
|
|
||||||
|
/// An in-memory upstream: every read logged by its start, at most
|
||||||
|
/// [rate] bytes per second when set.
|
||||||
|
class _Upstream implements RelayBytes {
|
||||||
|
final Uint8List data;
|
||||||
|
final int? rate;
|
||||||
|
final reads = <int>[];
|
||||||
|
var open = 0, maxOpen = 0, bytes = 0;
|
||||||
|
_Upstream(this.data, {this.rate});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> size() async => data.length;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<int>> read(int start, int end) async* {
|
||||||
|
reads.add(start);
|
||||||
|
open++;
|
||||||
|
maxOpen = max(maxOpen, open);
|
||||||
|
try {
|
||||||
|
const chunk = 64 * 1024;
|
||||||
|
for (var pos = start; pos <= end; pos += chunk) {
|
||||||
|
final n = min(chunk, end - pos + 1);
|
||||||
|
if (rate != null) await Future<void>.delayed(Duration(microseconds: n * 1000000 ~/ rate!));
|
||||||
|
bytes += n;
|
||||||
|
yield Uint8List.sublistView(data, pos, pos + n);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
open--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() async {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List _bytes(int n) => Uint8List.fromList(List.generate(n, (i) => (i * 7 + (i >> 16)) % 251));
|
||||||
|
|
||||||
|
Future<(HttpClientResponse, List<int>)> _get(String url, {String? range, String method = 'GET'}) async {
|
||||||
|
final c = HttpClient();
|
||||||
|
try {
|
||||||
|
final req = await c.openUrl(method, Uri.parse(url));
|
||||||
|
if (range != null) req.headers.set(HttpHeaders.rangeHeader, range);
|
||||||
|
final res = await req.close();
|
||||||
|
final body = <int>[];
|
||||||
|
await for (final b in res) {
|
||||||
|
body.addAll(b);
|
||||||
|
}
|
||||||
|
return (res, body);
|
||||||
|
} finally {
|
||||||
|
c.close(force: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<int>> _readAll(Stream<List<int>> s) async => [for (final c in await s.toList()) ...c];
|
||||||
|
|
||||||
|
/// The upload's track [i] (what the relay source reads).
|
||||||
|
Future<RelayBytes> _upload(SharedSource s, [int i = 0]) async => (await s.upload.open())[i].bytes!;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('loopback endpoint', () {
|
||||||
|
late SharedSource src;
|
||||||
|
late Uint8List data;
|
||||||
|
setUp(() async {
|
||||||
|
data = _bytes(3 * _mib + 123);
|
||||||
|
src = SharedSource(RelayMedia.single(_Upstream(data)));
|
||||||
|
await src.start();
|
||||||
|
});
|
||||||
|
tearDown(() => src.close());
|
||||||
|
|
||||||
|
test('answers ranges like the server (200, 206, suffix, 416), connection: close', () async {
|
||||||
|
final url = src.playerUrl;
|
||||||
|
expect(url, startsWith('http://127.0.0.1:'));
|
||||||
|
|
||||||
|
var (res, body) = await _get(url);
|
||||||
|
expect(res.statusCode, 200);
|
||||||
|
expect(res.contentLength, data.length);
|
||||||
|
expect(res.headers.value('accept-ranges'), 'bytes');
|
||||||
|
expect(res.headers.value('connection'), 'close');
|
||||||
|
expect(body, data);
|
||||||
|
|
||||||
|
(res, body) = await _get(url, range: 'bytes=1000-1999');
|
||||||
|
expect(res.statusCode, 206);
|
||||||
|
expect(res.headers.value('content-range'), 'bytes 1000-1999/${data.length}');
|
||||||
|
expect(body, data.sublist(1000, 2000));
|
||||||
|
|
||||||
|
(res, body) = await _get(url, range: 'bytes=-50');
|
||||||
|
expect(res.statusCode, 206);
|
||||||
|
expect(body, data.sublist(data.length - 50));
|
||||||
|
|
||||||
|
(res, body) = await _get(url, range: 'bytes=${2 * _mib}-');
|
||||||
|
expect(res.statusCode, 206);
|
||||||
|
expect(body, data.sublist(2 * _mib));
|
||||||
|
|
||||||
|
(res, body) = await _get(url, range: 'bytes=${data.length}-');
|
||||||
|
expect(res.statusCode, 416);
|
||||||
|
expect(res.headers.value('content-range'), 'bytes */${data.length}');
|
||||||
|
|
||||||
|
(res, body) = await _get(url, method: 'HEAD');
|
||||||
|
expect(res.statusCode, 200);
|
||||||
|
expect(res.contentLength, data.length);
|
||||||
|
expect(body, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a wrong secret or track is 404', () async {
|
||||||
|
final u = Uri.parse(src.playerUrl);
|
||||||
|
expect((await _get(u.replace(path: '/nope/0').toString())).$1.statusCode, 404);
|
||||||
|
expect((await _get(u.replace(path: '${u.pathSegments[0]}/1').toString())).$1.statusCode, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two tracks: an EDL of one loopback URL per track, no headers', () async {
|
||||||
|
final two = SharedSource(_TwoTracks(_Upstream(_bytes(_mib)), _Upstream(_bytes(_mib ~/ 2))));
|
||||||
|
addTearDown(two.close);
|
||||||
|
await two.open();
|
||||||
|
final url = two.playerUrl;
|
||||||
|
expect(url, startsWith('edl://'));
|
||||||
|
expect(RegExp(r'http://127\.0\.0\.1:\d+/[0-9a-f]+/[01]').allMatches(url).length, 2);
|
||||||
|
expect([for (final t in await two.upload.open()) t.kind], ['video', 'audio']);
|
||||||
|
expect(await (await _upload(two, 1)).size(), _mib ~/ 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the player can open before the sizes are read: the endpoint waits for them', () async {
|
||||||
|
final slow = _SlowSize(_Upstream(data));
|
||||||
|
final s = SharedSource(RelayMedia.single(slow));
|
||||||
|
addTearDown(s.close);
|
||||||
|
await s.open();
|
||||||
|
final pending = _get(s.playerUrl, range: 'bytes=0-99');
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
slow.go.complete();
|
||||||
|
final (res, body) = await pending;
|
||||||
|
expect(res.statusCode, 206);
|
||||||
|
expect(body, data.sublist(0, 100));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('ResumingBytes', () {
|
||||||
|
late HttpServer cdn;
|
||||||
|
late Uint8List data;
|
||||||
|
// Per request: cut after this many bytes (null: whole), or answer
|
||||||
|
// this status.
|
||||||
|
late List<Object?> script;
|
||||||
|
var requests = 0;
|
||||||
|
setUp(() async {
|
||||||
|
data = _bytes(_mib + 77);
|
||||||
|
requests = 0;
|
||||||
|
cdn = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
|
cdn.listen((req) async {
|
||||||
|
final step = requests < script.length ? script[requests] : null;
|
||||||
|
requests++;
|
||||||
|
final m = RegExp(r'bytes=(\d+)-(\d+)').firstMatch(req.headers.value('range') ?? '')!;
|
||||||
|
final start = int.parse(m[1]!), end = int.parse(m[2]!);
|
||||||
|
if (step is String) {
|
||||||
|
req.response.statusCode = int.parse(step);
|
||||||
|
await req.response.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
req.response
|
||||||
|
..statusCode = 206
|
||||||
|
..headers.set('content-range', 'bytes $start-$end/${data.length}')
|
||||||
|
..contentLength = end - start + 1;
|
||||||
|
final s = await req.response.detachSocket();
|
||||||
|
final cut = step as int?;
|
||||||
|
if (cut == -1) {
|
||||||
|
await s.flush();
|
||||||
|
return; // headers only, then silence
|
||||||
|
}
|
||||||
|
final stop = cut == null ? end + 1 : min(end + 1, start + cut);
|
||||||
|
s.add(data.sublist(start, stop));
|
||||||
|
await s.flush();
|
||||||
|
if (stop <= end) {
|
||||||
|
s.destroy();
|
||||||
|
} else {
|
||||||
|
await s.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
tearDown(() => cdn.close(force: true));
|
||||||
|
|
||||||
|
HttpRelayBytes http() => HttpRelayBytes(Uri.parse('http://127.0.0.1:${cdn.port}/v.mp4'), chunkSize: 0);
|
||||||
|
Duration quick(int _) => const Duration(milliseconds: 20);
|
||||||
|
|
||||||
|
test('a cut goes on from the byte it reached', () async {
|
||||||
|
script = [100000, 300000, 5];
|
||||||
|
final r = ResumingBytes(http(), backoff: quick);
|
||||||
|
expect(await _readAll(r.read(0, data.length - 1)), data);
|
||||||
|
expect(r.retries, 3);
|
||||||
|
expect(requests, 4);
|
||||||
|
expect(r.bytesIn, data.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('412, 429 and 5xx back off and try again; 403 and 404 are final', () async {
|
||||||
|
script = ['412', '429', '503', null];
|
||||||
|
final r = ResumingBytes(http(), backoff: quick);
|
||||||
|
expect(await _readAll(r.read(10, 5009)), data.sublist(10, 5010));
|
||||||
|
expect(r.retries, 3);
|
||||||
|
|
||||||
|
for (final status in ['403', '404']) {
|
||||||
|
script = [status];
|
||||||
|
requests = 0;
|
||||||
|
final f = ResumingBytes(http(), backoff: quick);
|
||||||
|
await expectLater(_readAll(f.read(0, 99)),
|
||||||
|
throwsA(isA<UpstreamHttpException>().having((e) => e.status, 'status', int.parse(status))));
|
||||||
|
expect(requests, 1, reason: status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an answer that goes silent is cut after the stall limit and resumed', () async {
|
||||||
|
script = [-1, null];
|
||||||
|
final r = ResumingBytes(http(), backoff: quick, stall: const Duration(milliseconds: 300));
|
||||||
|
final sw = Stopwatch()..start();
|
||||||
|
expect(await _readAll(r.read(0, 199999)), data.sublist(0, 200000));
|
||||||
|
expect(r.retries, 1);
|
||||||
|
expect(sw.elapsedMilliseconds, greaterThanOrEqualTo(250));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancelling stops it during a pause', () async {
|
||||||
|
script = ['503', '503', '503'];
|
||||||
|
final r = ResumingBytes(http(), backoff: (_) => const Duration(seconds: 30));
|
||||||
|
final sub = r.read(0, 99).listen((_) {});
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
final sw = Stopwatch()..start();
|
||||||
|
await sub.cancel();
|
||||||
|
expect(sw.elapsedMilliseconds, lessThan(1000));
|
||||||
|
expect(requests, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('the shared buffer: seeks, one window, memory', () {
|
||||||
|
test('cut upstream: both readers get every byte through one resuming reader', () async {
|
||||||
|
final data = _bytes(6 * _mib);
|
||||||
|
final up = _Cutting(data, every: 700 * 1024);
|
||||||
|
final s = SharedSource(RelayMedia.single(up), backoff: (_) => const Duration(milliseconds: 10));
|
||||||
|
addTearDown(s.close);
|
||||||
|
await s.start();
|
||||||
|
final upload = await _upload(s);
|
||||||
|
final results = await Future.wait([
|
||||||
|
_get(s.playerUrl).then((r) => r.$2),
|
||||||
|
_readAll(upload.read(0, data.length - 1)),
|
||||||
|
]);
|
||||||
|
expect(results[0], data);
|
||||||
|
expect(results[1], data);
|
||||||
|
expect(s.resumes, greaterThan(3));
|
||||||
|
expect(up.maxOpen, 1, reason: 'one upstream reader');
|
||||||
|
expect(up.bytes, lessThan(data.length + 3 * _mib), reason: 'each byte read about once');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the player seeks far ahead and back: the reader follows it, the head is kept', () async {
|
||||||
|
final data = _bytes(40 * _mib);
|
||||||
|
final up = _Upstream(data);
|
||||||
|
final s = SharedSource(RelayMedia.single(up), ahead: 4 * _mib, back: _mib);
|
||||||
|
addTearDown(s.close);
|
||||||
|
await s.start();
|
||||||
|
expect((await _get(s.playerUrl, range: 'bytes=0-${_mib - 1}')).$2, data.sublist(0, _mib));
|
||||||
|
final before = up.reads.length;
|
||||||
|
final at = 30 * _mib + 12345;
|
||||||
|
expect((await _get(s.playerUrl, range: 'bytes=$at-${at + _mib - 1}')).$2, data.sublist(at, at + _mib));
|
||||||
|
final seekReads = up.reads.sublist(before);
|
||||||
|
expect(seekReads.where((r) => r >= 30 * _mib - RelayConstants.relaySliceBytes && r <= at), isNotEmpty,
|
||||||
|
reason: 'a new upstream read at the seek: $seekReads');
|
||||||
|
final again = up.reads.length;
|
||||||
|
expect((await _get(s.playerUrl, range: 'bytes=1000-1999')).$2, data.sublist(1000, 2000));
|
||||||
|
expect(up.reads.sublist(again).where((r) => r < RelayConstants.relayPinBytes), isEmpty,
|
||||||
|
reason: 'the head is pinned: no upstream read for it');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('player and upload reading together share one sequential read (no hand-overs)', () async {
|
||||||
|
final data = _bytes(24 * _mib);
|
||||||
|
final up = _Upstream(data, rate: 60 * _mib);
|
||||||
|
final s = SharedSource(RelayMedia.single(up));
|
||||||
|
addTearDown(s.close);
|
||||||
|
await s.start();
|
||||||
|
final upload = await _upload(s);
|
||||||
|
final out = await Future.wait([
|
||||||
|
_get(s.playerUrl).then((r) => r.$2),
|
||||||
|
_readAll(upload.read(0, data.length - 1)),
|
||||||
|
]);
|
||||||
|
expect(out[0], data);
|
||||||
|
expect(out[1], data);
|
||||||
|
// The tail pin, the head, then one sequential read (a slice-boundary
|
||||||
|
// hand-over between the two would be a new read per 256 KiB).
|
||||||
|
expect(up.reads.length, lessThanOrEqualTo(6), reason: '${up.reads}');
|
||||||
|
expect(up.bytes, lessThanOrEqualTo(data.length + 2 * RelayConstants.relayPinBytes));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the upload does not run ahead of a reading player by more than the window; free once the player stops', () async {
|
||||||
|
final data = _bytes(48 * _mib);
|
||||||
|
final up = _Upstream(data);
|
||||||
|
final s = SharedSource(RelayMedia.single(up), ahead: 4 * _mib, back: _mib);
|
||||||
|
addTearDown(s.close);
|
||||||
|
await s.start();
|
||||||
|
final buffer = s.tracks.single.buffer;
|
||||||
|
// The player has a request open at 0 and stops reading after its
|
||||||
|
// first bytes (mpv with a full cache).
|
||||||
|
final player = StreamIterator(buffer.read(SharedSource.playerViewer, 0, data.length - 1));
|
||||||
|
expect(await player.moveNext(), isTrue);
|
||||||
|
final upload = await _upload(s);
|
||||||
|
final far = 30 * _mib;
|
||||||
|
final got = <int>[];
|
||||||
|
final sub = upload.read(far, far + _mib - 1).listen(got.addAll);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 600));
|
||||||
|
expect(got, isEmpty, reason: 'held: 30 MiB is past the player + 4 MiB');
|
||||||
|
expect(up.reads.where((r) => r >= 20 * _mib && r < data.length - RelayConstants.relayPinBytes), isEmpty,
|
||||||
|
reason: 'nothing asked upstream for it: ${up.reads}');
|
||||||
|
await player.cancel();
|
||||||
|
final until = DateTime.now().add(const Duration(seconds: 5));
|
||||||
|
while (got.length < _mib && DateTime.now().isBefore(until)) {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
}
|
||||||
|
await sub.cancel();
|
||||||
|
expect(got, data.sublist(far, far + _mib));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the player is held the same way when the upload lags; the tail is always served', () async {
|
||||||
|
final data = _bytes(48 * _mib);
|
||||||
|
final up = _Upstream(data);
|
||||||
|
final s = SharedSource(RelayMedia.single(up), ahead: 4 * _mib, back: _mib);
|
||||||
|
addTearDown(s.close);
|
||||||
|
await s.start();
|
||||||
|
final buffer = s.tracks.single.buffer;
|
||||||
|
final upload = StreamIterator((await _upload(s)).read(0, data.length - 1));
|
||||||
|
expect(await upload.moveNext(), isTrue);
|
||||||
|
// The tail (an mp4 index) is pinned: never held.
|
||||||
|
final tail = data.length - 1000;
|
||||||
|
expect((await _get(s.playerUrl, range: 'bytes=$tail-')).$2, data.sublist(tail));
|
||||||
|
final got = <int>[];
|
||||||
|
final sub = buffer.read(SharedSource.playerViewer, 20 * _mib, 21 * _mib - 1).listen(got.addAll);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 600));
|
||||||
|
expect(got, isEmpty);
|
||||||
|
await upload.cancel();
|
||||||
|
final until = DateTime.now().add(const Duration(seconds: 5));
|
||||||
|
while (got.length < _mib && DateTime.now().isBefore(until)) {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
}
|
||||||
|
await sub.cancel();
|
||||||
|
expect(got.length, _mib);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('memory stays within the budget while the player reads the whole media', () async {
|
||||||
|
final data = _bytes(64 * _mib);
|
||||||
|
final up = _Upstream(data);
|
||||||
|
const ahead = 8 * _mib, back = 2 * _mib;
|
||||||
|
final s = SharedSource(RelayMedia.single(up), ahead: ahead, back: back);
|
||||||
|
addTearDown(s.close);
|
||||||
|
await s.start();
|
||||||
|
final buffer = s.tracks.single.buffer;
|
||||||
|
var peak = 0;
|
||||||
|
var n = 0;
|
||||||
|
await for (final c in buffer.read(SharedSource.playerViewer, 0, data.length - 1)) {
|
||||||
|
n += c.length;
|
||||||
|
peak = max(peak, buffer.cachedBytes);
|
||||||
|
}
|
||||||
|
expect(n, data.length);
|
||||||
|
expect(peak, lessThanOrEqualTo(ahead + back + 2 * RelayConstants.relayPinBytes + RelayConstants.relaySliceBytes));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('closing under open reads ends them plainly', () async {
|
||||||
|
final data = _bytes(8 * _mib);
|
||||||
|
final s = SharedSource(RelayMedia.single(_Upstream(data, rate: _mib)));
|
||||||
|
await s.start();
|
||||||
|
final upload = await _upload(s);
|
||||||
|
final errors = <Object>[];
|
||||||
|
final sub = upload.read(0, data.length - 1).listen((_) {}, onError: errors.add);
|
||||||
|
final player = _get(s.playerUrl).then((r) => r.$2.length, onError: (_) => -1);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||||
|
await s.close();
|
||||||
|
await player;
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
|
await sub.cancel();
|
||||||
|
expect(errors.every((e) => e is RelayEnded), isTrue, reason: '$errors');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TwoTracks implements RelayMedia {
|
||||||
|
final RelayBytes v, a;
|
||||||
|
_TwoTracks(this.v, this.a);
|
||||||
|
@override
|
||||||
|
Future<List<RelayTrack>> open() async => [RelayTrack('video', v), RelayTrack('audio', a)];
|
||||||
|
@override
|
||||||
|
Future<void> close() async {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SlowSize implements RelayBytes {
|
||||||
|
final RelayBytes inner;
|
||||||
|
final go = Completer<void>();
|
||||||
|
_SlowSize(this.inner);
|
||||||
|
@override
|
||||||
|
Future<int> size() async {
|
||||||
|
await go.future;
|
||||||
|
return inner.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<int>> read(int start, int end) => inner.read(start, end);
|
||||||
|
@override
|
||||||
|
Future<void> close() => inner.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An upstream whose every read is cut after [every] bytes.
|
||||||
|
class _Cutting extends _Upstream {
|
||||||
|
final int every;
|
||||||
|
_Cutting(super.data, {required this.every});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<int>> read(int start, int end) async* {
|
||||||
|
var n = 0;
|
||||||
|
await for (final c in super.read(start, end)) {
|
||||||
|
if (n + c.length > every) throw const HttpException('Connection closed while receiving data');
|
||||||
|
n += c.length;
|
||||||
|
yield c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,519 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
import 'package:sync_relay_server/server.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
// The single-fetch source against the two-fetch one: a member passes on
|
||||||
|
// a page with separate video and audio, served by a fake CDN that can cut
|
||||||
|
// connections and refuse a busy client (412). Two ways of being the
|
||||||
|
// source, the same server, real mpv players (headless, the app's pins):
|
||||||
|
//
|
||||||
|
// today the source's player reads the CDN itself (EDL + headers); the
|
||||||
|
// relay source range-reads the CDN again per server pull (what
|
||||||
|
// a page with pieces tracks still does).
|
||||||
|
// single one shared reader per track (SharedSource) feeds the
|
||||||
|
// source's player (loopback EDL) and the server's pulls.
|
||||||
|
//
|
||||||
|
// Each run plays [play] s from the source's first frame, then both
|
||||||
|
// players seek to 45 s and back to 5 s (as a shared seek does) and the
|
||||||
|
// time until each plays there is taken.
|
||||||
|
//
|
||||||
|
// Opt-in (minutes, needs mpv and ffmpeg): WT_SINGLE_FETCH=1
|
||||||
|
// dart test test/single_fetch_e2e_test.dart -r expanded
|
||||||
|
// WT_SF_SCENARIOS=clean,cuts,lockout,slow and WT_SF_PLAY=<secs> narrow a run.
|
||||||
|
|
||||||
|
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
|
||||||
|
final _enabled = Platform.environment['WT_SINGLE_FETCH'] == '1';
|
||||||
|
|
||||||
|
Future<(String, String)> _media() async {
|
||||||
|
final v = '$_tmp/single_fetch_v.mp4', a = '$_tmp/single_fetch_a.m4a';
|
||||||
|
if (!await File(v).exists()) {
|
||||||
|
final r = await Process.run('ffmpeg', [
|
||||||
|
'-y', '-v', 'error', '-f', 'lavfi', '-i', 'testsrc2=size=1280x720:rate=30', '-t', '60', '-an',
|
||||||
|
'-c:v', 'libx264', '-preset', 'ultrafast', '-b:v', '4M', '-minrate', '4M', '-maxrate', '4M',
|
||||||
|
'-bufsize', '2M', '-g', '30', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', v,
|
||||||
|
]);
|
||||||
|
if (r.exitCode != 0) fail('ffmpeg video: ${r.stderr}');
|
||||||
|
}
|
||||||
|
if (!await File(a).exists()) {
|
||||||
|
final r = await Process.run('ffmpeg', [
|
||||||
|
'-y', '-v', 'error', '-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000', '-t', '60',
|
||||||
|
'-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', a,
|
||||||
|
]);
|
||||||
|
if (r.exitCode != 0) fail('ffmpeg audio: ${r.stderr}');
|
||||||
|
}
|
||||||
|
return (v, a);
|
||||||
|
}
|
||||||
|
|
||||||
|
class CdnRequest {
|
||||||
|
final double at;
|
||||||
|
final String path, who, range;
|
||||||
|
final int status;
|
||||||
|
int sent = 0;
|
||||||
|
bool cut = false;
|
||||||
|
CdnRequest(this.at, this.path, this.who, this.range, this.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A CDN in the Bilibili mould: Range GETs of two files, Referer
|
||||||
|
/// required (403 without), a per-connection rate, an optional cut of
|
||||||
|
/// every answer after a random 0..[cutMax] bytes, and an optional
|
||||||
|
/// lockout: more than [lockoutBurst] requests in 10 s and every request
|
||||||
|
/// gets 412 for [lockoutFor].
|
||||||
|
class FakeCdn {
|
||||||
|
final Map<String, List<int>> files;
|
||||||
|
final int bytesPerSec;
|
||||||
|
final Duration firstByte;
|
||||||
|
final int? cutMax;
|
||||||
|
final int? lockoutBurst;
|
||||||
|
final Duration lockoutFor;
|
||||||
|
final _rnd = Random(42);
|
||||||
|
final clock = Stopwatch()..start();
|
||||||
|
late HttpServer _http;
|
||||||
|
final requests = <CdnRequest>[];
|
||||||
|
final open = <String, int>{}, maxOpen = <String, int>{};
|
||||||
|
double _lockedUntil = -1;
|
||||||
|
int lockouts = 0;
|
||||||
|
|
||||||
|
FakeCdn(this.files,
|
||||||
|
{this.bytesPerSec = 3 << 20,
|
||||||
|
this.firstByte = const Duration(milliseconds: 40),
|
||||||
|
this.cutMax,
|
||||||
|
this.lockoutBurst,
|
||||||
|
this.lockoutFor = const Duration(seconds: 10)});
|
||||||
|
|
||||||
|
int get port => _http.port;
|
||||||
|
String url(String path) => 'http://127.0.0.1:$port/$path?sig=abc';
|
||||||
|
static const headers = {'Referer': 'https://www.example-video.site/', 'User-Agent': 'Mozilla/5.0 test'};
|
||||||
|
|
||||||
|
Future<void> start() async {
|
||||||
|
_http = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
|
_http.listen((req) => unawaited(_serve(req)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> close() => _http.close(force: true);
|
||||||
|
|
||||||
|
double get now => clock.elapsedMicroseconds / 1e6;
|
||||||
|
|
||||||
|
Future<void> _serve(HttpRequest req) async {
|
||||||
|
final path = req.uri.pathSegments.isEmpty ? '' : req.uri.pathSegments.first;
|
||||||
|
final who = req.headers.value('x-who') ?? 'player';
|
||||||
|
final range = req.headers.value(HttpHeaders.rangeHeader) ?? '';
|
||||||
|
final data = files[path];
|
||||||
|
final t = now;
|
||||||
|
final recent = requests.where((r) => r.at > t - 10).length;
|
||||||
|
int status;
|
||||||
|
if (data == null) {
|
||||||
|
status = 404;
|
||||||
|
} else if (req.headers.value('referer') == null) {
|
||||||
|
status = 403;
|
||||||
|
} else if (t < _lockedUntil) {
|
||||||
|
status = 412;
|
||||||
|
} else if (lockoutBurst != null && recent >= lockoutBurst!) {
|
||||||
|
_lockedUntil = t + lockoutFor.inMicroseconds / 1e6;
|
||||||
|
lockouts++;
|
||||||
|
status = 412;
|
||||||
|
} else {
|
||||||
|
status = 206;
|
||||||
|
}
|
||||||
|
final log = CdnRequest(t, path, who, range, status);
|
||||||
|
requests.add(log);
|
||||||
|
final res = req.response;
|
||||||
|
if (status != 206) {
|
||||||
|
res.statusCode = status;
|
||||||
|
await res.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final m = RegExp(r'bytes=(\d*)-(\d*)').firstMatch(range);
|
||||||
|
var start = 0, end = data!.length - 1;
|
||||||
|
if (m != null) {
|
||||||
|
if (m[1]!.isEmpty) {
|
||||||
|
start = max(0, data.length - int.parse(m[2]!));
|
||||||
|
} else {
|
||||||
|
start = int.parse(m[1]!);
|
||||||
|
if (m[2]!.isNotEmpty) end = min(end, int.parse(m[2]!));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (start >= data.length) {
|
||||||
|
res.statusCode = 416;
|
||||||
|
await res.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.statusCode = m == null ? 200 : 206;
|
||||||
|
res.headers
|
||||||
|
..set(HttpHeaders.contentTypeHeader, 'video/mp4')
|
||||||
|
..set(HttpHeaders.acceptRangesHeader, 'bytes')
|
||||||
|
..set(HttpHeaders.connectionHeader, 'close')
|
||||||
|
..contentLength = end - start + 1;
|
||||||
|
if (m != null) res.headers.set(HttpHeaders.contentRangeHeader, 'bytes $start-$end/${data.length}');
|
||||||
|
open[path] = (open[path] ?? 0) + 1;
|
||||||
|
maxOpen[path] = max(maxOpen[path] ?? 0, open[path]!);
|
||||||
|
final cutAt = cutMax == null ? null : _rnd.nextInt(cutMax!);
|
||||||
|
Socket? s;
|
||||||
|
try {
|
||||||
|
await Future<void>.delayed(firstByte);
|
||||||
|
s = await res.detachSocket(writeHeaders: true);
|
||||||
|
final sw = Stopwatch()..start();
|
||||||
|
var pos = start;
|
||||||
|
const chunk = 16 * 1024;
|
||||||
|
while (pos <= end) {
|
||||||
|
var n = min(chunk, end - pos + 1);
|
||||||
|
if (cutAt != null && log.sent + n > cutAt) {
|
||||||
|
n = cutAt - log.sent;
|
||||||
|
if (n > 0) s.add(data.sublist(pos, pos + n));
|
||||||
|
log.sent += max(0, n);
|
||||||
|
await s.flush();
|
||||||
|
log.cut = true;
|
||||||
|
s.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s.add(data.sublist(pos, pos + n));
|
||||||
|
await s.flush();
|
||||||
|
pos += n;
|
||||||
|
log.sent += n;
|
||||||
|
final ahead = log.sent / bytesPerSec - sw.elapsedMicroseconds / 1e6;
|
||||||
|
if (ahead > 0.002) await Future<void>.delayed(Duration(microseconds: (ahead * 1e6).round()));
|
||||||
|
}
|
||||||
|
await s.close();
|
||||||
|
} catch (_) {
|
||||||
|
// The client went away (a seek, a cancelled pull).
|
||||||
|
s?.destroy();
|
||||||
|
} finally {
|
||||||
|
open[path] = open[path]! - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A headless mpv with the app's pins, watched over its JSON IPC.
|
||||||
|
class Probe {
|
||||||
|
final String label;
|
||||||
|
final Stopwatch clock;
|
||||||
|
final double startedAt;
|
||||||
|
late Process _proc;
|
||||||
|
Socket? _ipc;
|
||||||
|
double? firstFrame; // seconds on [clock]
|
||||||
|
double pos = 0;
|
||||||
|
var stalls = 0;
|
||||||
|
double stalled = 0;
|
||||||
|
double? _stallSince;
|
||||||
|
final errors = <String>[];
|
||||||
|
Probe(this.label, this.clock) : startedAt = clock.elapsedMicroseconds / 1e6;
|
||||||
|
|
||||||
|
double get _now => clock.elapsedMicroseconds / 1e6;
|
||||||
|
|
||||||
|
static Future<Probe> start(String label, String url, Stopwatch clock, {Map<String, String> headers = const {}}) async {
|
||||||
|
final p = Probe(label, clock);
|
||||||
|
final sock = '$_tmp/sf-${label.hashCode}-${DateTime.now().microsecondsSinceEpoch}.sock';
|
||||||
|
p._proc = await Process.start('mpv', [
|
||||||
|
'--no-config', '--vo=null', '--ao=null', '--idle=no', '--keep-open=yes', '--input-ipc-server=$sock',
|
||||||
|
'--cache=yes', '--demuxer-max-bytes=268435456', '--demuxer-max-back-bytes=67108864',
|
||||||
|
'--demuxer-readahead-secs=150', '--cache-secs=150', '--ytdl=no', '--network-timeout=5',
|
||||||
|
'--stream-lavf-o=reconnect_streamed=1,reconnect_on_http_error=5xx,reconnect_delay_max=4,reconnect_max_retries=3',
|
||||||
|
'--msg-level=all=warn',
|
||||||
|
if (headers.isNotEmpty) '--http-header-fields=${headers.entries.map((e) => '${e.key}: ${e.value}').join(',')}',
|
||||||
|
url,
|
||||||
|
]);
|
||||||
|
p._proc.stdout.drain<void>();
|
||||||
|
p._proc.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen((l) {
|
||||||
|
if (p.errors.length < 40) p.errors.add(l);
|
||||||
|
});
|
||||||
|
final deadline = DateTime.now().add(const Duration(seconds: 5));
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
p._ipc = await Socket.connect(InternetAddress(sock, type: InternetAddressType.unix), 0);
|
||||||
|
break;
|
||||||
|
} catch (_) {
|
||||||
|
if (DateTime.now().isAfter(deadline)) rethrow;
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p._ipc!.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()).listen(p._on, onError: (_) {});
|
||||||
|
p._send(['observe_property', 1, 'time-pos']);
|
||||||
|
p._send(['observe_property', 2, 'paused-for-cache']);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _send(List<Object> cmd) => _ipc?.write('${jsonEncode({'command': cmd})}\n');
|
||||||
|
|
||||||
|
void _on(String line) {
|
||||||
|
final Map j;
|
||||||
|
try {
|
||||||
|
j = jsonDecode(line) as Map;
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (j['event']) {
|
||||||
|
case 'playback-restart':
|
||||||
|
firstFrame ??= _now;
|
||||||
|
case 'property-change':
|
||||||
|
if (j['name'] == 'time-pos' && j['data'] is num) pos = (j['data'] as num).toDouble();
|
||||||
|
if (j['name'] == 'paused-for-cache' && firstFrame != null) {
|
||||||
|
if (j['data'] == true && _stallSince == null) {
|
||||||
|
_stallSince = _now;
|
||||||
|
stalls++;
|
||||||
|
} else if (j['data'] == false && _stallSince != null) {
|
||||||
|
stalled += _now - _stallSince!;
|
||||||
|
_stallSince = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'end-file':
|
||||||
|
if (j['reason'] == 'error') errors.add('end-file error ${j['file_error'] ?? ''}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double get stalledNow => stalled + (_stallSince == null ? 0 : _now - _stallSince!);
|
||||||
|
|
||||||
|
/// Seeks to [to] s; the seconds until it plays there (null: not within
|
||||||
|
/// [limit]).
|
||||||
|
Future<double?> seek(double to, {Duration limit = const Duration(seconds: 20)}) async {
|
||||||
|
final t0 = _now;
|
||||||
|
_send(['seek', to, 'absolute']);
|
||||||
|
final end = DateTime.now().add(limit);
|
||||||
|
// Past the target and not waiting for the cache.
|
||||||
|
while (DateTime.now().isBefore(end)) {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
if (pos >= to + 0.3 && pos < to + 5 && _stallSince == null) return _now - t0;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> stop() async {
|
||||||
|
_send(['quit']);
|
||||||
|
await _proc.exitCode.timeout(const Duration(seconds: 3), onTimeout: () {
|
||||||
|
_proc.kill(ProcessSignal.sigkill);
|
||||||
|
return -1;
|
||||||
|
});
|
||||||
|
await _ipc?.close().catchError((_) {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Member {
|
||||||
|
final WebSocket ws;
|
||||||
|
final raw = <String>[];
|
||||||
|
late WelcomeMessage welcome;
|
||||||
|
_Member(this.ws);
|
||||||
|
|
||||||
|
static Future<_Member> join(int port, String name) async {
|
||||||
|
final ws = await WebSocket.connect('ws://127.0.0.1:$port/sync');
|
||||||
|
final m = _Member(ws);
|
||||||
|
final w = Completer<String>();
|
||||||
|
ws.listen((f) {
|
||||||
|
m.raw.add('$f');
|
||||||
|
if ('$f'.contains('"t":"welcome"') && !w.isCompleted) w.complete('$f');
|
||||||
|
}, onError: (_) {});
|
||||||
|
ws.add(jsonEncode(
|
||||||
|
HelloMessage(proto: RelayConstants.protoVersion, secret: 'pw', user: HelloUser(name: name)).toJson()));
|
||||||
|
m.welcome = WelcomeMessage.fromJson(
|
||||||
|
Map<String, Object?>.from(jsonDecode(await w.future.timeout(const Duration(seconds: 5))) as Map));
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object?>? get lastState {
|
||||||
|
for (final f in raw.reversed) {
|
||||||
|
final d = jsonDecode(f) as Map;
|
||||||
|
if (d['t'] == 'state') return Map<String, Object?>.from(d);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object?>? get relay => (lastState?['media'] as Map?)?['relay'] as Map<String, Object?>?;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Tracks implements RelayMedia {
|
||||||
|
final List<RelayTrack> tracks;
|
||||||
|
_Tracks(this.tracks);
|
||||||
|
@override
|
||||||
|
Future<List<RelayTrack>> open() async => tracks;
|
||||||
|
@override
|
||||||
|
Future<void> close() async {
|
||||||
|
for (final t in tracks) {
|
||||||
|
await t.bytes?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One run: [mode] `today` or `single` against [cdn] for [play] seconds
|
||||||
|
/// after the source's first frame.
|
||||||
|
Future<Map<String, Object?>> _run(String mode, FakeCdn cdn, int play) async {
|
||||||
|
final server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 't'));
|
||||||
|
await server.start();
|
||||||
|
final alice = await _Member.join(server.boundPort, 'alice');
|
||||||
|
final bob = await _Member.join(server.boundPort, 'bob');
|
||||||
|
alice.ws.add(jsonEncode({
|
||||||
|
't': 'swap',
|
||||||
|
'media': {
|
||||||
|
'name': 'page',
|
||||||
|
'duration': 60,
|
||||||
|
'relay': {
|
||||||
|
'tracks': [
|
||||||
|
{'kind': 'video'},
|
||||||
|
{'kind': 'audio'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
final until = DateTime.now().add(const Duration(seconds: 5));
|
||||||
|
while (alice.relay == null) {
|
||||||
|
if (DateTime.now().isAfter(until)) fail('no relay state');
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
}
|
||||||
|
final mediaId = alice.lastState!['mediaId'] as int;
|
||||||
|
final sourceUrl = Uri.parse('ws://127.0.0.1:${server.boundPort}/r/default/${alice.welcome.token}/source');
|
||||||
|
RelayTrack upstream(String kind, String path) => RelayTrack(
|
||||||
|
kind, HttpRelayBytes(Uri.parse(cdn.url(path)), headers: {...FakeCdn.headers, 'X-Who': 'relay'}));
|
||||||
|
final upstreamMedia = _Tracks([upstream('video', 'v'), upstream('audio', 'a')]);
|
||||||
|
|
||||||
|
final clock = Stopwatch()..start();
|
||||||
|
final reqBefore = cdn.requests.length;
|
||||||
|
RelaySource source;
|
||||||
|
SharedSource? shared;
|
||||||
|
Probe sourcePlayer;
|
||||||
|
if (mode == 'today') {
|
||||||
|
// As switchTo does: the relay source first (not awaited), then the
|
||||||
|
// player on the CDN's URLs with the page's headers.
|
||||||
|
source = RelaySource(sourceUrl, upstreamMedia, name: 'page', duration: 60);
|
||||||
|
unawaited(source.start());
|
||||||
|
sourcePlayer = await Probe.start('source', mpvEdl([cdn.url('v'), cdn.url('a')]), clock,
|
||||||
|
headers: {...FakeCdn.headers, 'X-Who': 'player'});
|
||||||
|
} else {
|
||||||
|
// As the app does: the player opens at once (the loopback endpoint
|
||||||
|
// waits for the sizes), the relay source starts meanwhile.
|
||||||
|
shared = SharedSource(upstreamMedia);
|
||||||
|
await shared.open();
|
||||||
|
source = RelaySource(sourceUrl, shared.upload, name: 'page', duration: 60);
|
||||||
|
unawaited(source.start());
|
||||||
|
sourcePlayer = await Probe.start('source', shared.playerUrl, clock);
|
||||||
|
}
|
||||||
|
// Viewers open once the relay is live (relayWaitsForSource).
|
||||||
|
while (bob.relay?['live'] != true) {
|
||||||
|
if (clock.elapsed > const Duration(seconds: 20)) fail('relay never live');
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||||
|
}
|
||||||
|
final liveAt = clock.elapsedMicroseconds / 1e6;
|
||||||
|
final base = 'http://127.0.0.1:${server.boundPort}/r/default/${bob.welcome.token}/$mediaId';
|
||||||
|
final viewer = await Probe.start('viewer', mpvEdl(['$base/0/page', '$base/1/page']), clock);
|
||||||
|
|
||||||
|
// Play: both from their first frame, no sync engine (stalls show as lag).
|
||||||
|
var sharedPeak = 0;
|
||||||
|
final peakTimer = Timer.periodic(const Duration(milliseconds: 100), (_) {
|
||||||
|
if (shared != null) sharedPeak = max(sharedPeak, shared.cachedBytes);
|
||||||
|
});
|
||||||
|
final end = DateTime.now().add(Duration(seconds: play + 15));
|
||||||
|
while (DateTime.now().isBefore(end)) {
|
||||||
|
if (sourcePlayer.firstFrame != null && clock.elapsedMicroseconds / 1e6 > sourcePlayer.firstFrame! + play) break;
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
final t = clock.elapsedMicroseconds / 1e6;
|
||||||
|
double lag(Probe p) => p.firstFrame == null ? double.nan : (t - p.firstFrame!) - p.pos;
|
||||||
|
final lags = (lag(sourcePlayer), lag(viewer)), stalls = (sourcePlayer.stalls, viewer.stalls);
|
||||||
|
final positions = (sourcePlayer.pos, viewer.pos);
|
||||||
|
// A shared seek far ahead, then back.
|
||||||
|
final ahead = await Future.wait([sourcePlayer.seek(45), viewer.seek(45)]);
|
||||||
|
final back = await Future.wait([sourcePlayer.seek(5), viewer.seek(5)]);
|
||||||
|
final reqs = cdn.requests.sublist(reqBefore);
|
||||||
|
int count(bool Function(CdnRequest r) f) => reqs.where(f).length;
|
||||||
|
// Peak requests in any 1 s window.
|
||||||
|
var peak = 0;
|
||||||
|
for (final r in reqs) {
|
||||||
|
peak = max(peak, reqs.where((q) => q.at >= r.at && q.at < r.at + 1).length);
|
||||||
|
}
|
||||||
|
final out = <String, Object?>{
|
||||||
|
'mode': mode,
|
||||||
|
'sourceFirstFrame': sourcePlayer.firstFrame,
|
||||||
|
'live': liveAt,
|
||||||
|
'viewerFirstFrame': viewer.firstFrame,
|
||||||
|
'sourcePos': positions.$1,
|
||||||
|
'viewerPos': positions.$2,
|
||||||
|
'sourceLag': lags.$1,
|
||||||
|
'viewerLag': lags.$2,
|
||||||
|
'sourceStalls': stalls.$1,
|
||||||
|
'viewerStalls': stalls.$2,
|
||||||
|
'seekAheadSource': ahead[0],
|
||||||
|
'seekAheadViewer': ahead[1],
|
||||||
|
'seekBackSource': back[0],
|
||||||
|
'seekBackViewer': back[1],
|
||||||
|
'cdnRequests': reqs.length,
|
||||||
|
'byPlayer': count((r) => r.who == 'player'),
|
||||||
|
'byRelay': count((r) => r.who == 'relay'),
|
||||||
|
'cdnMiB': reqs.fold<int>(0, (a, r) => a + r.sent) / (1 << 20),
|
||||||
|
'cuts': count((r) => r.cut),
|
||||||
|
'refused412': count((r) => r.status == 412),
|
||||||
|
'peakReqPerSec': peak,
|
||||||
|
'maxOpenV': cdn.maxOpen['v'],
|
||||||
|
'maxOpenA': cdn.maxOpen['a'],
|
||||||
|
'resumes': shared?.resumes,
|
||||||
|
'sharedPeakMiB': shared == null ? null : sharedPeak / (1 << 20),
|
||||||
|
'sourceErrors': sourcePlayer.errors.take(4).toList(),
|
||||||
|
'viewerErrors': viewer.errors.take(4).toList(),
|
||||||
|
};
|
||||||
|
peakTimer.cancel();
|
||||||
|
await sourcePlayer.stop();
|
||||||
|
await viewer.stop();
|
||||||
|
await source.close();
|
||||||
|
await shared?.close();
|
||||||
|
if (shared == null) await upstreamMedia.close();
|
||||||
|
await alice.ws.close();
|
||||||
|
await bob.ws.close();
|
||||||
|
await server.stop();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('single fetch vs today: one upstream reader per track, both players in step', () async {
|
||||||
|
final (vPath, aPath) = await _media();
|
||||||
|
final files = {'v': await File(vPath).readAsBytes(), 'a': await File(aPath).readAsBytes()};
|
||||||
|
final play = int.tryParse(Platform.environment['WT_SF_PLAY'] ?? '') ?? 20;
|
||||||
|
final wanted = (Platform.environment['WT_SF_SCENARIOS'] ?? 'clean,cuts,lockout').split(',');
|
||||||
|
final scenarios = <String, FakeCdn Function()>{
|
||||||
|
'clean': () => FakeCdn(files),
|
||||||
|
'cuts': () => FakeCdn(files, cutMax: 2 << 20),
|
||||||
|
'lockout': () => FakeCdn(files, cutMax: 2 << 20, lockoutBurst: 40),
|
||||||
|
// A per-connection rate about 1.5x the media's: today's two
|
||||||
|
// connections get twice what one shared reader gets.
|
||||||
|
'slow': () => FakeCdn(files, bytesPerSec: 768 << 10),
|
||||||
|
};
|
||||||
|
final results = <String, Map<String, Object?>>{};
|
||||||
|
for (final name in wanted) {
|
||||||
|
for (final mode in ['today', 'single']) {
|
||||||
|
final cdn = scenarios[name]!();
|
||||||
|
await cdn.start();
|
||||||
|
// Anything raised outside a handler (server.stop() under open /r
|
||||||
|
// answers among it) is counted: there must be none.
|
||||||
|
var teardown = 0;
|
||||||
|
final teardownErrors = <String>[];
|
||||||
|
final done = Completer<Map<String, Object?>>();
|
||||||
|
runZonedGuarded(() async => done.complete(await _run(mode, cdn, play)), (e, st) {
|
||||||
|
teardown++;
|
||||||
|
if (teardownErrors.length < 3) teardownErrors.add('$e');
|
||||||
|
});
|
||||||
|
final r = {...await done.future, 'teardownErrors': teardown, 'teardownSample': teardownErrors};
|
||||||
|
await cdn.close();
|
||||||
|
results['$name/$mode'] = r;
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('RESULT $name/$mode ${jsonEncode(r, toEncodable: (o) => '$o')}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (final e in results.entries.where((e) => e.key.endsWith('/single'))) {
|
||||||
|
final r = e.value;
|
||||||
|
expect(r['byPlayer'], 0, reason: '${e.key}: the source player never reads the CDN');
|
||||||
|
expect(r['sourceFirstFrame'], isNotNull, reason: e.key);
|
||||||
|
expect(r['viewerFirstFrame'], isNotNull, reason: e.key);
|
||||||
|
// One reader per track. The fake CDN counts a GET as open until its
|
||||||
|
// next write fails, so a cancelled one (the hand-over to the tail
|
||||||
|
// pin at the start) can overlap its successor and the next one.
|
||||||
|
expect((r['maxOpenV'] as int?) ?? 0, lessThanOrEqualTo(3), reason: e.key);
|
||||||
|
expect((r['maxOpenA'] as int?) ?? 0, lessThanOrEqualTo(3), reason: e.key);
|
||||||
|
expect(r['refused412'], 0, reason: '${e.key}: one reader never trips the lockout');
|
||||||
|
expect(r['seekAheadSource'], isNotNull, reason: '${e.key}: the source plays after a seek ahead');
|
||||||
|
expect(r['seekBackSource'], isNotNull, reason: '${e.key}: the source plays after a seek back');
|
||||||
|
expect(r['seekAheadViewer'], isNotNull, reason: e.key);
|
||||||
|
expect(r['seekBackViewer'], isNotNull, reason: e.key);
|
||||||
|
}
|
||||||
|
for (final e in results.entries) {
|
||||||
|
expect(e.value['teardownErrors'], 0, reason: '${e.key}: ${e.value['teardownSample']}');
|
||||||
|
}
|
||||||
|
}, skip: _enabled ? false : 'opt-in: WT_SINGLE_FETCH=1 (real mpv, minutes)', timeout: const Timeout(Duration(minutes: 15)));
|
||||||
|
}
|
||||||
Reference in new issue
Block a user