Protocol 11: split-track relay (YouTube video+audio), chunked upstream reads

Relayed media can carry 2-4 byte tracks (video, audio): yt-dlp picks a
split pair when no single-file format exists (h264 first), the source
channel serves each track, the server keeps a buffer per track (budget
split by size, 8 MiB floor) and viewers open an mpv edl:// of the track
URLs. Expired URLs re-resolve once for all tracks. Upstream GETs are cut
into <= http_chunk_size (10 MiB) ranges: YouTube throttles larger ones.
Live streams and DRM are refused; HLS/DASH pieces are a follow-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-09 19:59:41 +03:00
1 parent b2b77a6b3f
commit b038706dff
19 files changed
+939 -280

No files matched your search

+3 -3
View File
@@ -304,9 +304,9 @@ Web pages work in all three: the app and the dedicated server each
download [yt-dlp](https://github.com/yt-dlp/yt-dlp) by themselves (checked download [yt-dlp](https://github.com/yt-dlp/yt-dlp) by themselves (checked
against the release's checksums, updated once a day; Settings → Player against the release's checksums, updated once a day; Settings → Player
shows its version and has "Update now"), so there is nothing to install. shows its version and has "Update now"), so there is nothing to install.
The last two need a page with a single-file video (X has one; YouTube only YouTube works in all three too. The last two never show the link to
has separate video and audio streams, so use "Everyone fetches it") and anyone; they can't pass on live streams or pages that only offer streaming
never show the link to anyone. Your upload has to beat the video's playlists (use "Everyone fetches it" for those). Your upload has to beat the video's
bitrate: a 25-minute 2 GB episode is about 11 Mbit/s. bitrate: a 25-minute 2 GB episode is about 11 Mbit/s.
## Video quality and shaders ## Video quality and shaders
@@ -525,21 +525,23 @@ class SessionCoordinator {
} }
} }
String _relayMediaUrlFor(String name, int mediaId) => /// This viewer's `/r/` URL; an mpv EDL of every track when there are
'http://$_wsHost:$_wsPort/r/default/${sync.token}/$mediaId/${relayUrlName(name)}'; /// several (a page's separate video and audio).
String _relayMediaUrlFor(String name, int mediaId, [int tracks = 1]) =>
relayViewerUrl('http://$_wsHost:$_wsPort/r/default/${sync.token}/$mediaId', name, tracks: tracks);
/// Serves [bytes] 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. /// The server only pulls while someone watches through it.
void _startRelaySource(int mediaId, RelayBytes bytes, {bool announce = false}) { void _startRelaySource(int mediaId, RelayMedia media, {bool announce = false}) {
// Back after a reconnect on the same media: it is still serving (and // Back after a reconnect on the same media: it is still serving (and
// redials by itself). // redials by itself).
if (_relaySource != null && _relaySourceMediaId == mediaId) { if (_relaySource != null && _relaySourceMediaId == mediaId) {
unawaited(bytes.close()); unawaited(media.close());
return; return;
} }
_stopRelaySource(); _stopRelaySource();
final src = RelaySource( final src = RelaySource(
Uri.parse('ws://$_wsHost:$_wsPort/r/default/${sync.token}/source'), bytes); Uri.parse('ws://$_wsHost:$_wsPort/r/default/${sync.token}/source'), media);
_relaySource = src; _relaySource = src;
_relaySourceMediaId = mediaId; _relaySourceMediaId = mediaId;
relaying.value = true; relaying.value = true;
@@ -646,11 +648,11 @@ class SessionCoordinator {
memberRelayUrl: relay != null && !mine ? _relayMediaUrlFor(name, r.mediaId) : null); memberRelayUrl: relay != null && !mine ? _relayMediaUrlFor(name, r.mediaId) : null);
} else if (origin is _FileOrigin) { } else if (origin is _FileOrigin) {
await _openMedia(origin.path); await _openMedia(origin.path);
_startRelaySource(r.mediaId, FileRelayBytes(File(origin.path)), announce: true); _startRelaySource(r.mediaId, RelayMedia.single(FileRelayBytes(File(origin.path))), announce: true);
} else if (origin is _LinkOrigin) { } else if (origin is _LinkOrigin) {
await _openMedia(origin.url); await _openMedia(origin.url);
_startRelaySource( _startRelaySource(
r.mediaId, LinkRelayBytes(Uri.parse(origin.url), ytDlp: origin.ytDlp, probe: origin.probe), r.mediaId, LinkRelayMedia(Uri.parse(origin.url), ytDlp: origin.ytDlp, probe: origin.probe),
announce: true); announce: true);
} else if (relay != null && !mine) { } else if (relay != null && !mine) {
if (relay.fetch && !relay.live) { if (relay.fetch && !relay.live) {
@@ -660,7 +662,7 @@ class SessionCoordinator {
_awaitLiveFor = r.mediaId; _awaitLiveFor = r.mediaId;
health.value = const HealthStatus(HealthSeverity.info, 'the server is getting the video…'); health.value = const HealthStatus(HealthSeverity.info, 'the server is getting the video…');
} else { } else {
await _openMedia(_relayMediaUrlFor(name, r.mediaId)); await _openMedia(_relayMediaUrlFor(name, r.mediaId, relay.tracks.length));
} }
} else { } else {
var url = stateMediaUrl(r); var url = stateMediaUrl(r);
@@ -679,7 +681,8 @@ class SessionCoordinator {
_clearPendingSeek(); _clearPendingSeek();
} else if (_awaitLiveFor == r.mediaId && (stateMediaRelay(r)?.live ?? false)) { } else if (_awaitLiveFor == r.mediaId && (stateMediaRelay(r)?.live ?? false)) {
_awaitLiveFor = -1; _awaitLiveFor = -1;
await _openMedia(_relayMediaUrlFor((r.media?['name'] as String?) ?? '', r.mediaId)); await _openMedia(
_relayMediaUrlFor((r.media?['name'] as String?) ?? '', r.mediaId, stateMediaRelay(r)!.tracks.length));
} }
_jellyfinTransport(r); _jellyfinTransport(r);
_relayStatus(r); _relayStatus(r);
@@ -1152,29 +1155,36 @@ class SessionCoordinator {
return null; return null;
} }
if (relay == LinkRelay.me) { if (relay == LinkRelay.me) {
// This computer reads it: a file, or a page's single-file format. // This computer reads it: a file, or a page's formats (one file, or
// separate video and audio tracks).
if (probe == null) return 'cannot pass this link on: $probeError'; if (probe == null) return 'cannot pass this link on: $probeError';
final problem = probe.relayProblem; final problem = probe.relayProblem;
if (problem != null) return problem; if (problem != null) return problem;
final y = await ytDlp.ytDlp; final y = await ytDlp.ytDlp;
final check = LinkRelayBytes(uri, ytDlp: y, probe: probe); final check = LinkRelayMedia(uri, ytDlp: y, probe: probe);
final int size; var size = 0;
final List<String> kinds;
try { try {
size = await check.size().timeout(const Duration(seconds: 15)); final tracks = await check.open().timeout(const Duration(seconds: 15));
for (final t in tracks) {
size += await t.bytes.size().timeout(const Duration(seconds: 15));
}
kinds = [for (final t in tracks) t.kind];
} catch (e) { } catch (e) {
return 'cannot pass this link on: $e'; return 'cannot pass this link on: $e';
} finally { } finally {
await check.close(); await check.close();
} }
_pendingOrigin = _LinkOrigin(url, probe, y); _pendingOrigin = _LinkOrigin(url, probe, y);
_swap(HelloMedia(name: name, size: size, duration: 0, relay: const MediaRelay())); _swap(HelloMedia(
name: name, size: size, duration: 0, relay: MediaRelay(tracks: kinds.length > 1 ? kinds : const [])));
return null; return null;
} }
// The server reads it with its own yt-dlp; only a page this app knows // The server reads it with its own yt-dlp; only a page this app knows
// has no single file is refused here (the server may reach what this // it cannot relay (live, no video file) is refused here (the server
// computer cannot). // may reach what this computer cannot).
if (probe != null && probe.isPage && probe.info != null && probe.info!.single == null) { if (probe != null && probe.isPage && probe.info != null && probe.relayProblem != null) {
return noSingleFileText; return probe.relayProblem;
} }
_swap(HelloMedia( _swap(HelloMedia(
name: name, size: max(0, probe?.size ?? 0), duration: 0, url: url, relay: const MediaRelay(fetch: true))); name: name, size: max(0, probe?.size ?? 0), duration: 0, url: url, relay: const MediaRelay(fetch: true)));
@@ -1377,10 +1387,10 @@ class SessionCoordinator {
// its own sign-in, else the pasted link (played directly here too). // its own sign-in, else the pasted link (played directly here too).
if (origin != null) { if (origin != null) {
if (plan is OwnPlay) { if (plan is OwnPlay) {
_startRelaySource(r.mediaId, HttpRelayBytes(Uri.parse(plan.url), headers: plan.headers)); _startRelaySource(r.mediaId, RelayMedia.single(HttpRelayBytes(Uri.parse(plan.url), headers: plan.headers)));
} else if (origin.link != null) { } else if (origin.link != null) {
plan = RelayPlay(origin.link!); plan = RelayPlay(origin.link!);
_startRelaySource(r.mediaId, HttpRelayBytes(Uri.parse(origin.link!))); _startRelaySource(r.mediaId, RelayMedia.single(HttpRelayBytes(Uri.parse(origin.link!))));
} }
} }
jellyfinPlan.value = plan; jellyfinPlan.value = plan;
+2 -2
View File
@@ -123,7 +123,7 @@ void main() {
expect(bob.relaying.peek(), isTrue); expect(bob.relaying.peek(), isTrue);
await playTogether([alice, bob]); await playTogether([alice, bob]);
final relay = server.hub.relay!; final relay = server.hub.relay!;
expect(relay.buffer.bytesToViewers, greaterThan(0), reason: 'alice played through the relay'); expect(relay.buffers.single.bytesToViewers, greaterThan(0), reason: 'alice played through the relay');
expect(relay.sourceToken, bob.sync.token); expect(relay.sourceToken, bob.sync.token);
// The next swap retires bob's source and the buffer. // The next swap retires bob's source and the buffer.
@@ -144,7 +144,7 @@ void main() {
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source == bob.sync.memberId, 'me state'); await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source == bob.sync.memberId, 'me state');
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!.buffer.bytesToViewers, greaterThan(0)); expect(server.hub.relay!.buffers.single.bytesToViewers, greaterThan(0));
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);
+61 -24
View File
@@ -395,24 +395,49 @@ What gets relayed (`SessionCoordinator.swapPicked`):
| Link, "Through me" | relayed from that member's computer; for links only they can open | | Link, "Through me" | relayed from that member's computer; for links only they can open |
| Jellyfin item (browser pick or pasted link) | own account first, then the server's login (`/j`), then relayed from the member who put it up (with their sign-in, or the pasted link's key, which stays on their machine) | | Jellyfin item (browser pick or pasted link) | own account first, then the server's login (`/j`), then relayed from the member who put it up (with their sign-in, or the pasted link's key, which stays on their machine) |
Relayed links are range-read as one file: a media file as is, a web page Relayed links are range-read: a media file as is, a web page as yt-dlp's
as yt-dlp's single-file format, resolved where the bytes are fetched (see formats, resolved where the bytes are fetched (see "yt-dlp"); the swap
"yt-dlp"); the swap carries only the page URL and the mode. carries only the page URL and the mode. A page's formats are one file, or
a video-only and an audio-only file (YouTube has only those): a media of
two **tracks**, each relayed as its own byte stream with its own buffer
and URL, played together by viewers as an mpv EDL
(`edl://!no_clip;%n%<video>;!new_stream;!no_clip;%n%<audio>`,
`relayViewerUrl` / `mpvEdl`). media_kit opens an `edl://` URL unchanged
(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"
path (sync, swap, reopen) as is. The source member plays the page
itself (yt-dlp hook) like any other link.
How bytes move: How bytes move:
- The source dials `/r/.../source` (outbound, so NAT/CGNAT are fine) and - The source dials `/r/.../source` (outbound, so NAT/CGNAT are fine) and
answers the server's pulls from the file or the URL (`RelaySource`, answers the server's pulls from the file or the URL (`RelaySource` over
`FileRelayBytes`, `HttpRelayBytes`, `LinkRelayBytes`; a Jellyfin source sends its own a `RelayMedia`: its `RelayTrack`s, each a `RelayBytes`:
`Authorization` header from this machine). Viewers only ever talk HTTP `FileRelayBytes`, `HttpRelayBytes`, `LinkRelayMedia` / `PageRelayMedia`
to the server, so the source never sees mpv's request pattern. for links; a Jellyfin source sends its own `Authorization` header from
- The server keeps one `RelayBuffer` per relayed media: 1 MiB slices in 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
channel and its flow control (they compete for the same uplink).
- Upstream GETs (`HttpRelayBytes.read`) are split into back-to-back
ranges of at most yt-dlp's `http_chunk_size` for the format, else
10 MiB (`relayUpstreamChunkBytes`); a buffer pull stays one read.
YouTube serves a range up to 10 MiB at full speed but throttles a longer
or open-ended one to about twice the bitrate (measured 0.8 MB/s against
8.6 to 12.5 MB/s); chunked, a 1080p relay seeks in under 0.8 s (4 to
6.6 s unchunked) and even beats mpv reading YouTube directly, since the
buffer reads ahead at full speed.
- The server keeps one `RelayBuffer` per track of a relayed media (one
for a file): 1 MiB slices in
memory, filled by one sequential pull at a time. Kept: the first and memory, filled by one sequential pull at a time. Kept: the first and
last 2 MiB (container indexes; mpv reads both on open and on seeks), last 2 MiB (container indexes; mpv reads both on open and on seeks),
64 MiB behind the slowest viewer and 256 MiB past the furthest, the 64 MiB behind the slowest viewer and 256 MiB past the furthest, the
same as each viewer's mpv buffer (`PlaybackController`), so a skip same as each viewer's mpv buffer (`PlaybackController`), so a skip
inside the viewers' buffer window is served from memory. Evicted by inside the viewers' buffer window is served from memory. Evicted by
distance from the viewers, never LRU. With 2+ viewers the source sends distance from the viewers, never LRU. With 2+ viewers the source sends
each byte about once. each byte about once. Several tracks split the ahead and behind budgets
by size (one duration, so by bitrate; `relayBudgets`), at least 8 MiB
each, which keeps both windows at the same playback distance (1080p
video + aac: about 246 + 10 MiB ahead); each pins its own head and
tail. A viewer is its token in every track's buffer.
- Filling: a viewer blocked on a slice first (oldest wait first, handing - Filling: a viewer blocked on a slice first (oldest wait first, handing
the source over at slice boundaries), then the pinned slices, then the source over at slice boundaries), then the pinned slices, then
read-ahead from the slowest viewer. Nothing is fetched while nobody read-ahead from the slowest viewer. Nothing is fetched while nobody
@@ -431,7 +456,7 @@ How bytes move:
stop. A crash frees it with the process. stop. A crash frees it with the process.
- Source away: viewers keep playing from the buffer; a request waits up - Source away: viewers keep playing from the buffer; a request waits up
to 60 s for missing bytes. The source reconnects with backoff (same to 60 s for missing bytes. The source reconnects with backoff (same
token after a resume); a different file size drops the buffer. token after a resume); a different size drops that track's buffer.
`state.media.relay.live` follows the channel, and viewers show "waiting `state.media.relay.live` follows the channel, and viewers show "waiting
for <name> to pass the video on" while it is down. for <name> to pass the video on" while it is down.
@@ -481,10 +506,17 @@ all three link modes. Nothing is bundled or installed by hand.
`~/.cache/watch-together/yt-dlp`. `~/.cache/watch-together/yt-dlp`.
- **Resolve** (`YtDlp.resolve`): `yt-dlp -J --no-playlist --no-warnings - **Resolve** (`YtDlp.resolve`): `yt-dlp -J --no-playlist --no-warnings
--ignore-config`, 25 s timeout: title, uploader, duration, thumbnail, --ignore-config`, 25 s timeout: title, uploader, duration, thumbnail,
and the best single-file format (`pickSingleFile`: video and audio not live or not, and what a relay reads (`pickTracks`), each format with
`none`, plain http(s), a video container, tallest up to 1080p) with its its URL, `http_headers` and `http_chunk_size`. Only plain http(s)
URL and `http_headers`; none when the page has only split or HLS formats without DRM count:
streams (YouTube today). - the best single file (`pickSingleFile`: video and audio not `none`, a
video container, tallest up to 1080p, else the shortest above), unless
- a video-only + audio-only pair is taller (`pickSplit`): video tallest
up to 1080p (else the shortest above), avc1 before vp9 before av01
(hardware decoding), then bitrate; audio the original language, not a
`-drc` variant unless alone, m4a/aac before opus, then bitrate.
YouTube has only these (299 + 140 for a 1080p60 video).
- Neither (only HLS/DASH playlists) or a live stream: nothing to relay.
- **Probe** (`probeLink`, `link_probe.dart`): a `Range: bytes=0-0` GET; a - **Probe** (`probeLink`, `link_probe.dart`): a `Range: bytes=0-0` GET; a
video/audio type, or a generic one on a media file name, is a file video/audio type, or a generic one on a media file name, is a file
(`looksLikeMediaFile`); anything else goes to yt-dlp, and when yt-dlp (`looksLikeMediaFile`); anything else goes to yt-dlp, and when yt-dlp
@@ -498,17 +530,22 @@ all three link modes. Nothing is bundled or installed by hand.
(`script-opts=ytdl_hook-ytdl_path=...`, `ytdl-format=bv*[height<=1080]+ba/b`, (`script-opts=ytdl_hook-ytdl_path=...`, `ytdl-format=bv*[height<=1080]+ba/b`,
once per player), waiting for the download unless the URL names a media once per player), waiting for the download unless the URL names a media
file. A hook error shows as "can't open this link: ...". file. A hook error shows as "can't open this link: ...".
- **Through me**: the putter's probe must give a single-file format, else - **Through me**: the putter's probe must give tracks, else the swap is
the swap is refused with a hint to use "Everyone fetches it". Its relay refused ("Live streams can't be passed on", or no video file: only
source reads that format with yt-dlp's headers (`PageRelayBytes`); on streaming playlists) with a hint to use "Everyone fetches it". The swap
403/410 (expired format URL) it resolves the page again once and goes names the tracks; its relay source reads them with yt-dlp's headers
on from where it stopped. (`PageRelayMedia`). On 403/410 (expired format URLs, 6 h on YouTube) from
any track it resolves the page again once for all tracks, finds the
same format ids with the same sizes (else "the video changed on the
page") and goes on from where it stopped. Format URLs are bound to the
resolving machine's address, so they never leave it.
- **Server fetches it**: the server probes the link itself - **Server fetches it**: the server probes the link itself
(`LinkRelayBytes`) with its own yt-dlp (off with `--no-yt-dlp` / (`LinkRelayMedia`) with its own yt-dlp (off with `--no-yt-dlp` /
`ytDlp: false`) and relays the single-file format the same way. The `ytDlp: false`) and relays its tracks the same way, setting
putter only refuses a page its own yt-dlp says has no single file (the `relay.tracks` with `live`. The putter only refuses a page its own
server may reach what the putter cannot); when the server cannot read yt-dlp says cannot be relayed (the server may reach what the putter
it, the putter gets an `err` with the reason. Viewers open a fetched cannot); when the server cannot read it, the putter gets an `err` with
the reason. Viewers open a fetched
link's `/r/` URL only once `relay.live` is set ("the server is getting link's `/r/` URL only once `relay.live` is set ("the server is getting
the video…" until then): resolving takes seconds and media_kit's mpv the video…" until then): resolving takes seconds and media_kit's mpv
gives up on a silent request after 5 s (`network-timeout`). gives up on a silent request after 5 s (`network-timeout`).
+43 -13
View File
@@ -1,4 +1,4 @@
# Protocol (proto = 10) # Protocol (proto = 11)
Wire format only. Behavior (gate, clock, timeouts) is in Wire format only. Behavior (gate, clock, timeouts) is in
[ARCHITECTURE.md](ARCHITECTURE.md). Source of truth: [ARCHITECTURE.md](ARCHITECTURE.md). Source of truth:
@@ -30,7 +30,7 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
## Client to server ## Client to server
``` ```
{"t":"hello","proto":10,"secret":"","user":{"name":"","avatar":""}, {"t":"hello","proto":11,"secret":"","user":{"name":"","avatar":""},
"media":{"name":"","size":0,"duration":0,"url":null},"hostKey":"...", "media":{"name":"","size":0,"duration":0,"url":null},"hostKey":"...",
"resume":"<token>"} "resume":"<token>"}
{"t":"ready","ready":true,"source":"manual"} source: manual | auto {"t":"ready","ready":true,"source":"manual"} source: manual | auto
@@ -218,10 +218,20 @@ flow control, cases) is in [ARCHITECTURE.md](ARCHITECTURE.md), "Relay".
``` ```
swap media.relay = {} the swapper's machine serves the bytes swap media.relay = {} the swapper's machine serves the bytes
media.relay = {"tracks":[{"kind":"video"},{"kind":"audio"}]}
the same, as two tracks played together
media.relay = {"fetch":true} the server reads media.url itself media.relay = {"fetch":true} the server reads media.url itself
state media.relay = {"source":"m2","live":true} or {"fetch":true,"live":true} state media.relay = {"source":"m2","live":true} or {"fetch":true,"live":true}
media.relay = {"fetch":true,"live":true,"tracks":[{"kind":"video"},{"kind":"audio"}]}
``` ```
Tracks: a relayed media is one file, or several byte tracks (a page's
separate video and audio files) that viewers play as one. `tracks` lists
them in order, 2 to 4 entries of `kind` `video` or `audio`; absent (or
anything else) means one file. Every track is a Range-readable byte
stream; other kinds of track (e.g. segmented playlists) would add a field
to the entry.
- `swap`: `relay` with `jellyfin` keeps the ref (the swapper serves it to - `swap`: `relay` with `jellyfin` keeps the ref (the swapper serves it to
viewers without an account; `fetch` is ignored there). `relay` without viewers without an account; `fetch` is ignored there). `relay` without
`jellyfin`: no URL is stored or broadcast; `fetch` needs an http(s) `jellyfin`: no URL is stored or broadcast; `fetch` needs an http(s)
@@ -231,19 +241,32 @@ state media.relay = {"source":"m2","live":true} or {"fetch":true,"live":true}
for anyone). A web page `url` is read through the server's yt-dlp; when for anyone). A web page `url` is read through the server's yt-dlp; when
the server cannot read the link, the swapper later gets `The server the server cannot read the link, the swapper later gets `The server
can't fetch this link: <reason>`. Any later swap ends the relay. can't fetch this link: <reason>`. Any later swap ends the relay.
`tracks` on a member's swap names the tracks its source will serve; the
server ignores it with `fetch` and with `jellyfin`.
- `state.media.relay.source`: member id of the source (absent with - `state.media.relay.source`: member id of the source (absent with
`fetch`). `live` (omitted when false): bytes can flow (the source's `fetch`). `live` (omitted when false): bytes can flow (the source's
channel is connected, or the server could read the link). channel is connected, or the server could read the link). `tracks`: as
the swap named them, or, with `fetch`, set once the server has read the
link (together with `live`).
- `welcome.mediaUrl` for relayed media without `jellyfin`: this member's - `welcome.mediaUrl` for relayed media without `jellyfin`: this member's
`/r/` URL; absent for the source itself (it plays its own copy). Clients build it after a swap like the `/m` one: `/r/` URL; absent for the source itself (it plays its own copy) and for
a fetched link the server has not read yet (clients open it on `live`).
Clients build it after a swap like the `/m` one:
`http://<host>/r/default/<token>/<mediaId>/<name>` (name with `/` and `http://<host>/r/default/<token>/<mediaId>/<name>` (name with `/` and
`\` made `_`, then URL-encoded; the server ignores it). `\` made `_`, then URL-encoded; the server ignores it). With `tracks`,
- `GET /r/<sid>/<token>/<mediaId>/<name>`: the media, Range table as for it is an mpv EDL of every track's URL
`/m`. Errors: 401 bad token, 403 kicked, 404 not the current relayed `http://<host>/r/default/<token>/<mediaId>/<i>/<name>`, each framed by
its byte length:
`edl://!no_clip;%<len>%<track 0 url>;!new_stream;!no_clip;%<len>%<track 1 url>`.
- `GET /r/<sid>/<token>/<mediaId>/<name>`: the media (one file), Range
table as for `/m`. `GET /r/<sid>/<token>/<mediaId>/<i>/<name>`: track
`i` (from 0) of a media with `tracks` (track 0 of a one-file media
too). Errors: 401 bad token, 403 kicked, 404 not the current relayed
media (a player still on an older `mediaId` gets 404, never the new media (a player still on an older `mediaId` gets 404, never the new
bytes), 410 unknown session, 503 + `retry-after` the source never bytes) or no such track (the one-file URL of a media with `tracks`), 410
connected (60 s). A request whose bytes do not arrive within 60 s ends unknown session, 503 + `retry-after` the source never connected or the
early. Bytes count toward `bytesServed`. server could not read the link (60 s). A request whose bytes do not
arrive within 60 s ends early. Bytes count toward `bytesServed`.
- `GET /r/<sid>/<token>/source` (WebSocket): the source's relay channel, - `GET /r/<sid>/<token>/source` (WebSocket): the source's relay channel,
dialled by the source (works behind NAT). Only the current source's dialled by the source (works behind NAT). Only the current source's
token is accepted; anyone else, or the source after the next swap, is token is accepted; anyone else, or the source after the next swap, is
@@ -251,15 +274,22 @@ state media.relay = {"source":"m2","live":true} or {"fetch":true,"live":true}
Frames: Frames:
``` ```
source -> server {"t":"info","size":<bytes>} first frame source -> server {"t":"info","size":<bytes>} first frame, one file
{"t":"info","tracks":[{"kind":"video","size":<bytes>},
{"kind":"audio","size":<bytes>}]}
first frame, tracks
binary: [u32 big-endian pull id][bytes] in order per pull binary: [u32 big-endian pull id][bytes] in order per pull
{"t":"end","id":1} {"t":"fail","id":1,"reason":"..."} {"t":"end","id":1} {"t":"fail","id":1,"reason":"..."}
server -> source {"t":"pull","id":1,"start":0,"end":1048575} inclusive server -> source {"t":"pull","id":1,"track":0,"start":0,"end":1048575}
inclusive; no track: 0
{"t":"ack","id":1,"n":65536} bytes arrived {"t":"ack","id":1,"n":65536} bytes arrived
{"t":"cancel","id":1} stop that pull {"t":"cancel","id":1} stop that pull
``` ```
A `size` different from the one before (a reconnect) drops the buffer. Pull ids are unique across the tracks of a connection; `track` is sent
only for a media with `tracks`. An `info` whose track count differs
from the swap's `tracks` closes the channel with 4001. A track `size`
different from the one before (a reconnect) drops that track's buffer.
## Join and reconnect ## Join and reconnect
+20 -4
View File
@@ -15,10 +15,26 @@ off by deleting it in the same commit as the code. Wire changes also edit
countdown). Later: tracking for file series. countdown). Later: tracking for file series.
3. **yt-dlp follow-ups** (see ARCHITECTURE "yt-dlp"): check that the 3. **yt-dlp follow-ups** (see ARCHITECTURE "yt-dlp"): check that the
bundled Windows libmpv has the ytdl hook (Lua); if not, resolve in the bundled Windows libmpv has the ytdl hook (Lua) and plays `edl://` URLs
app and open the single-file format. YouTube: yt-dlp wants a JS runtime through media_kit; if not, resolve in the app and open the relay
(deno) for full extraction, and it only has split streams, so relaying tracks' formats. YouTube: yt-dlp wants a JS runtime (deno) for full
it would need muxing. Formats that need yt-dlp's cookies. extraction. Formats that need yt-dlp's cookies.
- Relay "pieces" tracks for pages with only HLS/DASH (`m3u8_native`,
`http_dash_segments`): a track served as a synthesized VOD media
playlist whose entries are pieces (one segment, init `EXT-X-MAP` or
AES-128 key = one upstream GET, served whole, Range inside a piece
honoured), with a piece buffer per track (pieces by index, filled
like `RelayBuffer`: a blocked viewer, the init piece, then ahead of
the slowest viewer; evicted by distance); `EXT-X-BYTERANGE` folded
into pieces (it also avoids mpv's seek hang on byte-range fMP4
playlists); yt-dlp's DASH `fragments` turned into the same playlist,
so no MPD parser. Wire: a track entry gains `"as":"pieces"`, `info`
lists piece durations, a pull names a piece. Refuse DRM
(`skd:`/SAMPLE-AES/Widevine). Measured in a spike: piece relay of
Mux/Apple/Akamai HLS/DASH opened in 1.5 to 2.2 s, seeks 0.02 to 2.4 s.
- Live streams through the relay (today refused: the sync model needs a
fixed duration and seeking).
- `tool/relay_e2e.dart`: a two-track case over a shaped source link.
4. **AniList account**: sign in (OAuth via a localhost callback), "My list" 4. **AniList account**: sign in (OAuth via a localhost callback), "My list"
filter on the Schedule; today's "Following" (AnimeFlow's monitored filter on the Schedule; today's "Following" (AnimeFlow's monitored
+8 -1
View File
@@ -1,5 +1,5 @@
abstract class RelayConstants { abstract class RelayConstants {
static const int protoVersion = 10; static const int protoVersion = 11;
// Relay (`/r/...`): a member's bytes through the server's buffer. // Relay (`/r/...`): a member's bytes through the server's buffer.
static const int relaySliceBytes = 1 << 20; static const int relaySliceBytes = 1 << 20;
@@ -12,6 +12,13 @@ abstract class RelayConstants {
/// Kept regardless of position: containers keep their index at the start /// Kept regardless of position: containers keep their index at the start
/// or the end, and players read both on open and on seeks. /// or the end, and players read both on open and on seeks.
static const int relayPinBytes = 2 << 20; static const int relayPinBytes = 2 << 20;
/// A relayed media of several tracks splits the buffer by bitrate; no
/// track gets less than this ahead or behind.
static const int relayTrackFloorBytes = 8 << 20;
/// Upstream GETs are split into ranges of at most this (yt-dlp's
/// `http_chunk_size` when it gives one): YouTube throttles longer ones to
/// about twice the bitrate.
static const int relayUpstreamChunkBytes = 10 << 20;
static const int relayMinWindowBytes = 256 * 1024; static const int relayMinWindowBytes = 256 * 1024;
static const int relayMaxWindowBytes = 8 << 20; static const int relayMaxWindowBytes = 8 << 20;
/// A viewer request waits this long for its bytes (source away) before /// A viewer request waits this long for its bytes (source away) before
+97 -55
View File
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'constants.dart';
import 'relay.dart'; import 'relay.dart';
import 'yt_dlp.dart'; import 'yt_dlp.dart';
@@ -70,9 +71,12 @@ String _unescape(String s) => s
String notAVideoText(String? ytDlpError) => String notAVideoText(String? ytDlpError) =>
ytDlpError == null ? 'Not a video' : "Not a video (and yt-dlp couldn't read it: $ytDlpError)"; ytDlpError == null ? 'Not a video' : "Not a video (and yt-dlp couldn't read it: $ytDlpError)";
/// The text for a page with only split or HLS streams. /// The text for a page with no video file a relay can read.
const noSingleFileText = 'This page has no single-file video to pass on (only separate video and audio ' const noRelayFormatText = 'This page has no video file to pass on (only streaming playlists). '
"streams). Choose 'Everyone fetches it' instead."; "Choose 'Everyone fetches it' instead.";
/// The text for a live stream.
const liveStreamText = "Live streams can't be passed on. Choose 'Everyone fetches it' instead.";
class LinkProbeException implements Exception { class LinkProbeException implements Exception {
final String message; final String message;
@@ -115,8 +119,10 @@ class LinkProbe {
? null ? null
: info == null : info == null
? notAVideoText(ytDlpError) ? notAVideoText(ytDlpError)
: info!.single == null : info!.isLive
? noSingleFileText ? liveStreamText
: info!.tracks.isEmpty
? noRelayFormatText
: null; : null;
} }
@@ -184,72 +190,94 @@ Future<String> _readText(HttpClientResponse res) async {
return utf8.decode(out.takeBytes(), allowMalformed: true); return utf8.decode(out.takeBytes(), allowMalformed: true);
} }
/// A page's single-file format (see [YtDlpInfo.single]), range-read with /// A page's relay tracks (see [YtDlpInfo.tracks]: one file, or video +
/// yt-dlp's headers. Format URLs expire: on 403/410 the page is resolved /// audio), range-read with yt-dlp's headers in GETs of the format's
/// again once and the read goes on from where it stopped. /// `http_chunk_size`. Format URLs expire: on 403/410 from any track the
class PageRelayBytes implements RelayBytes { /// page is resolved again once for all tracks (same formats, same sizes)
/// and the read goes on from where it stopped.
class PageRelayMedia implements RelayMedia {
final Uri page; final Uri page;
final YtDlp ytDlp; final YtDlp ytDlp;
YtDlpFormat? _format; List<YtDlpTrack>? _picked;
HttpRelayBytes? _inner; List<HttpRelayBytes?> _inner = const [];
int? _size; List<RelayTrack>? _tracks;
final _sizes = <int, int>{};
var _gen = 0; var _gen = 0;
Future<void>? _refreshing; Future<void>? _refreshing;
PageRelayBytes(this.page, this.ytDlp, {YtDlpFormat? format}) : _format = format; PageRelayMedia(this.page, this.ytDlp, {List<YtDlpTrack>? tracks}) : _picked = tracks;
Future<YtDlpFormat> _resolve() async { Future<YtDlpInfo> _info() async {
final f = (await ytDlp.resolve(page.toString())).single; final info = await ytDlp.resolve(page.toString());
if (f == null) throw const LinkProbeException(noSingleFileText); if (info.isLive) throw const LinkProbeException(liveStreamText);
return f; return info;
} }
Future<HttpRelayBytes> _open() async => @override
_inner ??= () { Future<List<RelayTrack>> open() async {
final f = _format!; if (_tracks != null) return _tracks!;
return HttpRelayBytes(f.url, headers: f.headers); final picked = _picked ??= (await _info()).tracks;
if (picked.isEmpty) throw const LinkProbeException(noRelayFormatText);
_inner = List.filled(picked.length, null, growable: false);
return _tracks = [for (var i = 0; i < picked.length; i++) RelayTrack(picked[i].kind, _PageTrack(this, i))];
}
HttpRelayBytes _open(int i) => _inner[i] ??= () {
final f = _picked![i].format;
return HttpRelayBytes(f.url,
headers: f.headers, chunkSize: f.chunkSize ?? RelayConstants.relayUpstreamChunkBytes);
}(); }();
static bool _expired(Object e) => e is UpstreamHttpException && (e.status == 403 || e.status == 410); static bool _expired(Object e) => e is UpstreamHttpException && (e.status == 403 || e.status == 410);
/// Resolves the page again: the same format ids with the same sizes,
/// else the video changed.
Future<void> _refresh(int gen) { Future<void> _refresh(int gen) {
if (gen != _gen) return Future.value(); if (gen != _gen) return Future.value();
return _refreshing ??= () async { return _refreshing ??= () async {
try { try {
final f = await _resolve(); final info = await _info();
final next = <YtDlpTrack>[];
for (final t in _picked!) {
final f = info.formats[t.format.id];
if (f == null) throw const LinkProbeException('the video changed on the page');
next.add((kind: t.kind, format: f));
}
final old = _inner; final old = _inner;
_format = f; _picked = next;
_inner = null; _inner = List.filled(next.length, null, growable: false);
for (final e in _sizes.entries) {
if (await _open(e.key).size() != e.value) throw const LinkProbeException('the video changed on the page');
}
_gen++; _gen++;
await old?.close(); for (final b in old) {
await b?.close();
}
} finally { } finally {
_refreshing = null; _refreshing = null;
} }
}(); }();
} }
@override Future<int> _size(int i) async {
Future<int> size() async { final known = _sizes[i];
if (_size != null) return _size!; if (known != null) return known;
_format ??= await _resolve();
final gen = _gen; final gen = _gen;
try { try {
return _size = await (await _open()).size(); return _sizes[i] = await _open(i).size();
} catch (e) { } catch (e) {
if (!_expired(e)) rethrow; if (!_expired(e)) rethrow;
await _refresh(gen); await _refresh(gen);
return _size = await (await _open()).size(); return _sizes[i] = await _open(i).size();
} }
} }
@override Stream<List<int>> _read(int i, int start, int end) async* {
Stream<List<int>> read(int start, int end) async* {
var pos = start; var pos = start;
var retried = false; var retried = false;
while (true) { while (true) {
final gen = _gen; final gen = _gen;
final inner = await _open();
try { try {
await for (final chunk in inner.read(pos, end)) { await for (final chunk in _open(i).read(pos, end)) {
pos += chunk.length; pos += chunk.length;
yield chunk; yield chunk;
} }
@@ -258,45 +286,59 @@ class PageRelayBytes implements RelayBytes {
if (retried || !_expired(e)) rethrow; if (retried || !_expired(e)) rethrow;
retried = true; retried = true;
await _refresh(gen); await _refresh(gen);
final n = await (await _open()).size();
if (_size != null && n != _size) throw const LinkProbeException('the video changed on the page');
} }
} }
} }
@override @override
Future<void> close() async => _inner?.close(); Future<void> close() async {
for (final b in _inner) {
await b?.close();
}
}
} }
/// A link of either kind: probed on first [size] (or given [probe]), then class _PageTrack implements RelayBytes {
/// read as a file ([HttpRelayBytes]) or a page ([PageRelayBytes]). Throws final PageRelayMedia media;
/// [LinkProbeException] with the reason when it cannot be relayed. final int index;
class LinkRelayBytes implements RelayBytes { _PageTrack(this.media, this.index);
@override
Future<int> size() => media._size(index);
@override
Stream<List<int>> read(int start, int end) => media._read(index, start, end);
/// The media closes its tracks.
@override
Future<void> close() async {}
}
/// A link of either kind, probed on first [open] (or given [probe]): a
/// file as one track ([HttpRelayBytes]), a page as its picked formats
/// ([PageRelayMedia]). Throws [LinkProbeException] with the reason when it
/// cannot be relayed.
class LinkRelayMedia implements RelayMedia {
final Uri url; final Uri url;
final YtDlp? ytDlp; final YtDlp? ytDlp;
LinkProbe? _probe; LinkProbe? _probe;
RelayBytes? _inner; RelayMedia? _inner;
LinkRelayBytes(this.url, {this.ytDlp, LinkProbe? probe}) : _probe = probe; LinkRelayMedia(this.url, {this.ytDlp, LinkProbe? probe}) : _probe = probe;
/// The probe, after [size]. /// The probe, after [open].
LinkProbe? get probe => _probe; LinkProbe? get probe => _probe;
@override @override
Future<int> size() async { Future<List<RelayTrack>> open() async {
if (_inner == null) { if (_inner == null) {
final probe = _probe ??= await probeLink(url, ytDlp: ytDlp); final probe = _probe ??= await probeLink(url, ytDlp: ytDlp);
final problem = probe.relayProblem; final problem = probe.relayProblem;
if (problem != null) throw LinkProbeException(problem); if (problem != null) throw LinkProbeException(problem);
_inner = probe.isPage ? PageRelayBytes(url, ytDlp!, format: probe.info!.single) : HttpRelayBytes(url); _inner = probe.isPage
? PageRelayMedia(url, ytDlp!, tracks: probe.info!.tracks)
: RelayMedia.single(HttpRelayBytes(url));
} }
return _inner!.size(); return _inner!.open();
}
@override
Stream<List<int>> read(int start, int end) {
final inner = _inner;
if (inner == null) return Stream.error(StateError('size() first'));
return inner.read(start, end);
} }
@override @override
+22 -4
View File
@@ -149,24 +149,42 @@ class HelloMedia {
/// or, with [fetch], the server reads the swap's link itself. A swap sends /// or, with [fetch], the server reads the swap's link itself. A swap sends
/// `{}` or `{"fetch":true}`; the server fills in [source] (the swapper's /// `{}` or `{"fetch":true}`; the server fills in [source] (the swapper's
/// member id, empty with [fetch]) and whether bytes can flow ([live]). /// member id, empty with [fetch]) and whether bytes can flow ([live]).
/// [tracks]: the kinds (`video`, `audio`) of a media relayed as several
/// tracks played together; empty for one file. A member swap names them,
/// the server fills them in for [fetch] once it has read the link.
class MediaRelay { class MediaRelay {
final String source; final String source;
final bool fetch; final bool fetch;
final bool live; final bool live;
const MediaRelay({this.source = '', this.fetch = false, this.live = false}); final List<String> tracks;
const MediaRelay({this.source = '', this.fetch = false, this.live = false, this.tracks = const []});
MediaRelay copyWith({bool? live}) => MediaRelay(source: source, fetch: fetch, live: live ?? this.live); static const trackKinds = {'video', 'audio'};
static const maxTracks = 4;
MediaRelay copyWith({bool? live, List<String>? tracks}) =>
MediaRelay(source: source, fetch: fetch, live: live ?? this.live, tracks: tracks ?? this.tracks);
Map<String, Object?> toJson() => { Map<String, Object?> toJson() => {
if (source.isNotEmpty) 'source': source, if (source.isNotEmpty) 'source': source,
if (fetch) 'fetch': true, if (fetch) 'fetch': true,
if (live) 'live': true, if (live) 'live': true,
if (tracks.length > 1) 'tracks': [for (final k in tracks) {'kind': k}],
}; };
factory MediaRelay.fromJson(Map<String, Object?> json) => MediaRelay( /// Unknown kinds, a single entry or too many make it one file.
factory MediaRelay.fromJson(Map<String, Object?> json) {
final t = json['tracks'];
final kinds = t is List
? [for (final e in t) e is Map && e['kind'] is String ? e['kind'] as String : '']
: <String>[];
final ok = kinds.length > 1 && kinds.length <= maxTracks && kinds.every(trackKinds.contains);
return MediaRelay(
source: json['source'] is String ? json['source'] as String : '', source: json['source'] is String ? json['source'] as String : '',
fetch: json['fetch'] == true, fetch: json['fetch'] == true,
live: json['live'] == true); live: json['live'] == true,
tracks: ok ? List.unmodifiable(kinds) : const []);
}
} }
class HelloMessage { class HelloMessage {
+116 -24
View File
@@ -11,19 +11,22 @@ import 'logging.dart';
// The relay channel: a member's machine (the source) serves the current // The relay channel: a member's machine (the source) serves the current
// media's bytes to the server, which buffers them for every viewer (`/r/...`). // media's bytes to the server, which buffers them for every viewer (`/r/...`).
// The source dials out (works behind NAT/CGNAT) on its own WebSocket, so // The source dials out (works behind NAT/CGNAT) on its own WebSocket, so
// video bytes never queue behind `/sync` messages. // video bytes never queue behind `/sync` messages. A media is one track (a
// file) or several (a page's separate video and audio).
// //
// source -> server {"t":"info","size":N} first frame // source -> server {"t":"info","size":N} first frame, one track
// {"t":"info","tracks":[{"kind":"video","size":N},...]}
// binary [u32 BE pull id][bytes] in order per pull // binary [u32 BE pull id][bytes] in order per pull
// {"t":"end","id":1} pull complete // {"t":"end","id":1} pull complete
// {"t":"fail","id":1,"reason":"..."} cannot read // {"t":"fail","id":1,"reason":"..."} cannot read
// server -> source {"t":"pull","id":1,"start":0,"end":N} bytes start..end // server -> source {"t":"pull","id":1,"track":0,"start":0,"end":N}
// bytes start..end
// {"t":"ack","id":1,"n":65536} n bytes arrived // {"t":"ack","id":1,"n":65536} n bytes arrived
// {"t":"cancel","id":1} stop this pull // {"t":"cancel","id":1} stop this pull
// //
// Flow control is on the source: one adaptive in-flight budget per // Pull ids are unique across tracks. Flow control is on the source: one
// connection (see [RelayWindow]); a cancelled pull's unacked bytes are // adaptive in-flight budget per connection (see [RelayWindow]), shared by
// written off. // the tracks; a cancelled pull's unacked bytes are written off.
final _log = createLogger('relay'); final _log = createLogger('relay');
@@ -32,6 +35,21 @@ final _log = createLogger('relay');
/// slash still splits the path). The server ignores the segment. /// slash still splits the path). The server ignores the segment.
String relayUrlName(String name) => Uri.encodeComponent(name.replaceAll(RegExp(r'[/\\]'), '_')); String relayUrlName(String name) => Uri.encodeComponent(name.replaceAll(RegExp(r'[/\\]'), '_'));
/// What a viewer opens for relayed media at [base]
/// (`http://<host>/r/<sid>/<token>/<mediaId>`): `<base>/<name>` for one
/// track, else an mpv EDL playing `<base>/<i>/<name>` of every track
/// together (see [mpvEdl]).
String relayViewerUrl(String base, String name, {int tracks = 1}) {
final n = relayUrlName(name);
return tracks <= 1 ? '$base/$n' : mpvEdl([for (var i = 0; i < tracks; i++) '$base/$i/$n']);
}
/// An mpv `edl://` URL playing [urls] as the streams of one file (a video
/// track and its audio track), each `%<byte length>%`-framed so `;` and
/// `,` inside a URL are safe.
String mpvEdl(List<String> urls) =>
'edl://${urls.map((u) => '!no_clip;%${utf8.encode(u).length}%$u').join(';!new_stream;')}';
/// Binary relay frame: 4-byte big-endian pull id, then payload. /// Binary relay frame: 4-byte big-endian pull id, then payload.
Uint8List encodeRelayFrame(int id, List<int> payload) { Uint8List encodeRelayFrame(int id, List<int> payload) {
final out = Uint8List(4 + payload.length); final out = Uint8List(4 + payload.length);
@@ -78,13 +96,17 @@ class UpstreamHttpException extends HttpException {
} }
/// An HTTP(S) resource with Range support, e.g. a link or a Jellyfin /// An HTTP(S) resource with Range support, e.g. a link or a Jellyfin
/// stream; [headers] (auth) never leave this machine. /// stream; [headers] (auth) never leave this machine. A read is one GET
/// per [chunkSize] bytes, back to back (see
/// [RelayConstants.relayUpstreamChunkBytes]).
class HttpRelayBytes implements RelayBytes { class HttpRelayBytes implements RelayBytes {
final Uri url; final Uri url;
final Map<String, String> headers; final Map<String, String> headers;
final int chunkSize;
final HttpClient _client; final HttpClient _client;
int? _size; int? _size;
HttpRelayBytes(this.url, {this.headers = const {}, HttpClient? client}) HttpRelayBytes(this.url,
{this.headers = const {}, this.chunkSize = RelayConstants.relayUpstreamChunkBytes, HttpClient? client})
: _client = client ?? HttpClient(); : _client = client ?? HttpClient();
Future<HttpClientResponse> _get(String range) async { Future<HttpClientResponse> _get(String range) async {
@@ -118,11 +140,33 @@ class HttpRelayBytes implements RelayBytes {
@override @override
Stream<List<int>> read(int start, int end) async* { Stream<List<int>> read(int start, int end) async* {
final res = await _get('bytes=$start-$end'); var pos = start;
if (res.statusCode == 200 && start > 0) { while (pos <= end) {
final last = chunkSize > 0 ? min(end, pos + chunkSize - 1) : end;
final res = await _get('bytes=$pos-$last');
if (res.statusCode == 200) {
if (pos > 0) {
await res.drain<void>().catchError((_) {});
throw HttpException('upstream ignores Range', uri: url); throw HttpException('upstream ignores Range', uri: url);
} }
yield* res; // The whole file from 0: take what was asked in this one answer.
await for (final c in res) {
final take = min(c.length, end - pos + 1);
yield take == c.length ? c : c.sublist(0, take);
pos += take;
if (pos > end) return;
}
if (pos <= end) throw HttpException('upstream ended early', uri: url);
return;
}
await for (final c in res) {
final take = min(c.length, last - pos + 1);
if (take <= 0) break;
yield take == c.length ? c : c.sublist(0, take);
pos += take;
}
if (pos <= last) throw HttpException('upstream ended early', uri: url);
}
} }
@override @override
@@ -200,6 +244,38 @@ class RelayWindow {
} }
} }
/// One byte stream of a relayed media: [kind] is `video` or `audio` when
/// a media has several, empty for a single file.
class RelayTrack {
final String kind;
final RelayBytes bytes;
const RelayTrack(this.kind, this.bytes);
}
/// What a relay serves: one track, or several played together (a page's
/// separate video and audio).
abstract interface class RelayMedia {
/// The tracks, in order; a link is resolved on the first call. Throws
/// with the reason when it cannot be relayed.
Future<List<RelayTrack>> open();
Future<void> close();
/// One file.
factory RelayMedia.single(RelayBytes bytes) = _SingleRelayMedia;
}
class _SingleRelayMedia implements RelayMedia {
final RelayBytes bytes;
_SingleRelayMedia(this.bytes);
@override
Future<List<RelayTrack>> open() async => [RelayTrack('', bytes)];
@override
Future<void> close() => bytes.close();
}
class _Pull { class _Pull {
final int id; final int id;
var sent = 0, acked = 0; var sent = 0, acked = 0;
@@ -213,14 +289,14 @@ class _Pull {
/// the current media": the source stops instead of reconnecting. /// the current media": the source stops instead of reconnecting.
const relayCloseNotSource = 4001; const relayCloseNotSource = 4001;
/// Serves [bytes] on the relay channel at [url] (`ws://host/r/<sid>/<token>/source`) /// Serves [media] on the relay channel at [url] (`ws://host/r/<sid>/<token>/source`)
/// until [close]. Reconnects with backoff when the channel drops; stops for /// until [close]. Reconnects with backoff when the channel drops; stops for
/// good when the server closes it with [relayCloseNotSource]. /// good when the server closes it with [relayCloseNotSource].
class RelaySource { class RelaySource {
final Uri url; final Uri url;
final RelayBytes bytes; final RelayMedia media;
final Duration Function(int attempt) backoff; final Duration Function(int attempt) backoff;
RelaySource(this.url, this.bytes, {Duration Function(int attempt)? backoff}) RelaySource(this.url, this.media, {Duration Function(int attempt)? backoff})
: backoff = backoff ?? ((a) => Duration(milliseconds: min(8000, 500 * (1 << min(a, 4))))); : backoff = backoff ?? ((a) => Duration(milliseconds: min(8000, 500 * (1 << min(a, 4)))));
final window = RelayWindow(); final window = RelayWindow();
@@ -235,12 +311,26 @@ class RelaySource {
/// Bytes sent to the server, for stats. /// Bytes sent to the server, for stats.
int bytesSent = 0; int bytesSent = 0;
var _tracks = const <RelayTrack>[];
/// Opens the media (a page is resolved) and starts serving; throws
/// when it cannot be read.
Future<void> start() async { Future<void> start() async {
final size = await bytes.size(); final tracks = await media.open();
unawaited(_loop(size)); final sizes = [for (final t in tracks) await t.bytes.size()];
_tracks = tracks;
unawaited(_loop({
't': 'info',
if (tracks.length == 1)
'size': sizes.single
else
'tracks': [
for (var i = 0; i < tracks.length; i++) {'kind': tracks[i].kind, 'size': sizes[i]},
],
}));
} }
Future<void> _loop(int size) async { Future<void> _loop(Map<String, Object?> info) async {
var attempt = 0; var attempt = 0;
while (!_closed) { while (!_closed) {
try { try {
@@ -251,7 +341,7 @@ class RelaySource {
} }
_ws = ws; _ws = ws;
attempt = 0; attempt = 0;
ws.add(jsonEncode({'t': 'info', 'size': size})); ws.add(jsonEncode(info));
await for (final m in ws) { await for (final m in ws) {
if (m is String) _onText(ws, m); if (m is String) _onText(ws, m);
} }
@@ -268,7 +358,7 @@ class RelaySource {
await Future<void>.delayed(backoff(attempt++)); await Future<void>.delayed(backoff(attempt++));
} }
_abandonAll(); _abandonAll();
await bytes.close(); await media.close();
if (!_done.isCompleted) _done.complete(); if (!_done.isCompleted) _done.complete();
} }
@@ -283,11 +373,12 @@ class RelaySource {
if (id is! int) return; if (id is! int) return;
switch (j['t']) { switch (j['t']) {
case 'pull': case 'pull':
final start = j['start'], end = j['end']; final start = j['start'], end = j['end'], track = j['track'] ?? 0;
if (start is! int || end is! int || start < 0 || end < start) return; if (start is! int || end is! int || start < 0 || end < start) return;
if (track is! int || track < 0 || track >= _tracks.length) return;
final p = _Pull(id); final p = _Pull(id);
_pulls[id] = p; _pulls[id] = p;
unawaited(_serve(ws, p, start, end)); unawaited(_serve(ws, p, _tracks[track].bytes, start, end));
case 'ack': case 'ack':
final p = _pulls[id]; final p = _pulls[id];
final n = j['n']; final n = j['n'];
@@ -307,7 +398,8 @@ class RelaySource {
p.cancelled = true; p.cancelled = true;
window.writeOff(p.sent - p.acked); window.writeOff(p.sent - p.acked);
p.acked = p.sent; p.acked = p.sent;
unawaited(p.it?.cancel()); // A read cut off mid-request (the client closing) errors here: moot.
unawaited(p.it?.cancel().catchError((_) {}));
} }
void _abandonAll() { void _abandonAll() {
@@ -317,7 +409,7 @@ class RelaySource {
_pulls.clear(); _pulls.clear();
} }
Future<void> _serve(WebSocket ws, _Pull p, int start, int end) async { Future<void> _serve(WebSocket ws, _Pull p, RelayBytes bytes, int start, int end) async {
final it = p.it = StreamIterator(bytes.read(start, end)); final it = p.it = StreamIterator(bytes.read(start, end));
final pending = BytesBuilder(copy: false); final pending = BytesBuilder(copy: false);
const frame = RelayConstants.relayFrameBytes; const frame = RelayConstants.relayFrameBytes;
@@ -352,7 +444,7 @@ class RelaySource {
} finally { } finally {
p.finished = true; p.finished = true;
if (p.settled) _pulls.remove(p.id); if (p.settled) _pulls.remove(p.id);
await it.cancel(); await it.cancel().catchError((_) {});
} }
} }
+140 -40
View File
@@ -31,9 +31,32 @@ class YtDlpFormat {
final Map<String, String> headers; final Map<String, String> headers;
final int? height; final int? height;
final String ext; final String ext;
const YtDlpFormat({required this.id, required this.url, this.headers = const {}, this.height, this.ext = ''}); /// yt-dlp's `downloader_options.http_chunk_size`: the longest GET the
/// site serves at full speed.
final int? chunkSize;
const YtDlpFormat(
{required this.id, required this.url, this.headers = const {}, this.height, this.ext = '', this.chunkSize});
static YtDlpFormat _of(Map f) {
final headers = f['http_headers'];
final opts = f['downloader_options'];
final chunk = opts is Map ? opts['http_chunk_size'] : null;
final h = f['height'] is num ? (f['height'] as num).toInt() : 0;
return YtDlpFormat(
id: '${f['format_id'] ?? ''}',
url: Uri.parse(f['url'] as String),
headers: headers is Map ? {for (final e in headers.entries) '${e.key}': '${e.value}'} : const {},
height: h == 0 ? null : h,
ext: '${f['ext']}',
chunkSize: chunk is num && chunk > 0 ? chunk.toInt() : null,
);
}
} }
/// A format a relay plays as one of a media's tracks ([kind] `video` or
/// `audio`; empty for a single file holding both).
typedef YtDlpTrack = ({String kind, YtDlpFormat format});
/// What `yt-dlp -J` says about a page. /// What `yt-dlp -J` says about a page.
class YtDlpInfo { class YtDlpInfo {
final String title; final String title;
@@ -43,10 +66,15 @@ class YtDlpInfo {
/// Seconds; null when unknown (a live stream). /// Seconds; null when unknown (a live stream).
final double? duration; final double? duration;
final String? thumbnail; final String? thumbnail;
/// The best format holding video and audio in one http(s) file (what a /// A live stream (never relayed).
/// relay can range-read); null when the page only has split or HLS final bool isLive;
/// streams. /// What a relay range-reads (see [pickTracks]): one file, or a video and
final YtDlpFormat? single; /// an audio file; empty when the page has neither (only streaming
/// playlists, or live).
final List<YtDlpTrack> tracks;
/// Every plain http(s) format by id, to find [tracks] again after a
/// re-resolve.
final Map<String, YtDlpFormat> formats;
const YtDlpInfo({ const YtDlpInfo({
required this.title, required this.title,
this.uploader = '', this.uploader = '',
@@ -54,7 +82,9 @@ class YtDlpInfo {
this.webpageUrl = '', this.webpageUrl = '',
this.duration, this.duration,
this.thumbnail, this.thumbnail,
this.single, this.isLive = false,
this.tracks = const [],
this.formats = const {},
}); });
/// Parses `yt-dlp -J` output; a playlist answer uses its first entry. /// Parses `yt-dlp -J` output; a playlist answer uses its first entry.
@@ -67,6 +97,7 @@ class YtDlpInfo {
} }
String str(String k) => j[k] is String ? (j[k] as String).trim() : ''; String str(String k) => j[k] is String ? (j[k] as String).trim() : '';
final formats = j['formats'] is List ? (j['formats'] as List) : [if (j['url'] is String) j]; final formats = j['formats'] is List ? (j['formats'] as List) : [if (j['url'] is String) j];
final isLive = j['is_live'] == true || j['live_status'] == 'is_live' || j['live_status'] == 'is_upcoming';
return YtDlpInfo( return YtDlpInfo(
title: str('title').isNotEmpty ? str('title') : str('fulltitle'), title: str('title').isNotEmpty ? str('title') : str('fulltitle'),
uploader: str('uploader').isNotEmpty ? str('uploader') : str('channel'), uploader: str('uploader').isNotEmpty ? str('uploader') : str('channel'),
@@ -74,53 +105,122 @@ class YtDlpInfo {
webpageUrl: str('webpage_url'), webpageUrl: str('webpage_url'),
duration: j['duration'] is num ? (j['duration'] as num).toDouble() : null, duration: j['duration'] is num ? (j['duration'] as num).toDouble() : null,
thumbnail: str('thumbnail').isEmpty ? null : str('thumbnail'), thumbnail: str('thumbnail').isEmpty ? null : str('thumbnail'),
single: pickSingleFile(formats), isLive: isLive,
tracks: isLive ? const [] : pickTracks(formats),
formats: {
for (final f in formats)
if (_httpUrl(f) && f is Map) '${f['format_id'] ?? ''}': YtDlpFormat._of(f),
},
); );
} }
} }
const _singleFileExts = {'mp4', 'webm', 'mov', 'm4v', 'mkv'}; const _singleFileExts = {'mp4', 'webm', 'mov', 'm4v', 'mkv'};
const _audioExts = {'m4a', 'mp4', 'webm', 'opus', 'ogg', 'mp3'};
/// A plain http(s) format with a URL, not DRM-protected.
bool _httpUrl(Object? f) {
if (f is! Map || f['has_drm'] == true) return false;
final url = f['url'];
final protocol = f['protocol'];
if (url is! String || (protocol != 'http' && protocol != 'https')) return false;
final uri = Uri.tryParse(url);
return uri != null && (uri.isScheme('http') || uri.isScheme('https'));
}
int _height(Map f) => f['height'] is num ? (f['height'] as num).toInt() : 0;
double _tbr(Map f) =>
f['tbr'] is num ? (f['tbr'] as num).toDouble() : (f['abr'] is num ? (f['abr'] as num).toDouble() : 0);
/// Height order: up to [maxHeight] the tallest first, then the shortest
/// above it.
int _byHeight(int ha, int hb, int maxHeight) {
final ina = ha <= maxHeight, inb = hb <= maxHeight;
if (ina != inb) return ina ? -1 : 1;
return ha == hb ? 0 : (ina ? hb.compareTo(ha) : ha.compareTo(hb));
}
/// The best single-file format of [formats] (yt-dlp format maps): video /// The best single-file format of [formats] (yt-dlp format maps): video
/// and audio both present (or not known to be absent), plain http(s), a /// and audio both present (or not known to be absent), plain http(s), a
/// video container; the tallest up to [maxHeight], else the shortest above /// video container; the tallest up to [maxHeight], else the shortest above
/// it; mp4/webm and higher bitrate break ties. /// it; mp4/webm and higher bitrate break ties.
YtDlpFormat? pickSingleFile(List<Object?> formats, {int maxHeight = 1080}) { YtDlpFormat? pickSingleFile(List<Object?> formats, {int maxHeight = 1080}) {
final ok = <(Map, int)>[]; final ok = [
for (final f in formats) { for (final f in formats)
if (f is! Map) continue; if (_httpUrl(f) && (f as Map)['vcodec'] != 'none' && f['acodec'] != 'none' && _singleFileExts.contains(f['ext']))
final url = f['url']; f,
final protocol = f['protocol']; ];
if (url is! String || (protocol != 'http' && protocol != 'https')) continue;
if (f['vcodec'] == 'none' || f['acodec'] == 'none') continue;
if (!_singleFileExts.contains(f['ext'])) continue;
final uri = Uri.tryParse(url);
if (uri == null || !(uri.isScheme('http') || uri.isScheme('https'))) continue;
ok.add((f, f['height'] is num ? (f['height'] as num).toInt() : 0));
}
if (ok.isEmpty) return null; if (ok.isEmpty) return null;
double tbr(Map f) => f['tbr'] is num ? (f['tbr'] as num).toDouble() : 0; bool mp4(Map f) => f['ext'] == 'mp4' || f['ext'] == 'webm';
int rank((Map, int) a, (Map, int) b) { ok.sort((a, b) {
final (fa, ha) = a; final h = _byHeight(_height(a), _height(b), maxHeight);
final (fb, hb) = b; if (h != 0) return h;
final ina = ha <= maxHeight, inb = hb <= maxHeight; if (mp4(a) != mp4(b)) return mp4(a) ? -1 : 1;
if (ina != inb) return ina ? -1 : 1; return _tbr(b).compareTo(_tbr(a));
if (ha != hb) return ina ? hb.compareTo(ha) : ha.compareTo(hb); });
final ma = fa['ext'] == 'mp4' || fa['ext'] == 'webm', mb = fb['ext'] == 'mp4' || fb['ext'] == 'webm'; return YtDlpFormat._of(ok.first);
if (ma != mb) return ma ? -1 : 1; }
return tbr(fb).compareTo(tbr(fa));
}
ok.sort(rank); int _videoCodecRank(Map f) {
final (f, h) = ok.first; final c = '${f['vcodec']}'.toLowerCase();
final headers = f['http_headers']; if (c.startsWith('avc') || c.startsWith('h264')) return 0;
return YtDlpFormat( if (c.startsWith('vp9') || c.startsWith('vp09')) return 1;
id: '${f['format_id'] ?? ''}', if (c.startsWith('av01')) return 2;
url: Uri.parse(f['url'] as String), return 3;
headers: headers is Map ? {for (final e in headers.entries) '${e.key}': '${e.value}'} : const {}, }
height: h == 0 ? null : h,
ext: '${f['ext']}', /// The best video-only + audio-only pair of plain http(s) files (YouTube's
); /// DASH formats), or null. Video: the tallest up to [maxHeight] (else the
/// shortest above), avc1 before vp9 before av01 (hardware decoding), then
/// bitrate. Audio: the original language, not a `-drc` variant, m4a/aac
/// before others (opus), then bitrate.
(YtDlpFormat, YtDlpFormat)? pickSplit(List<Object?> formats, {int maxHeight = 1080}) {
final video = [
for (final f in formats)
if (_httpUrl(f) && (f as Map)['acodec'] == 'none' && f['vcodec'] is String && f['vcodec'] != 'none' &&
_singleFileExts.contains(f['ext']))
f,
];
final audio = [
for (final f in formats)
if (_httpUrl(f) && (f as Map)['vcodec'] == 'none' && f['acodec'] is String && f['acodec'] != 'none' &&
_audioExts.contains(f['ext']))
f,
];
if (video.isEmpty || audio.isEmpty) return null;
video.sort((a, b) {
final h = _byHeight(_height(a), _height(b), maxHeight);
if (h != 0) return h;
final c = _videoCodecRank(a).compareTo(_videoCodecRank(b));
return c != 0 ? c : _tbr(b).compareTo(_tbr(a));
});
bool drc(Map f) => '${f['format_id']}'.contains('drc');
int lang(Map f) => f['language_preference'] is num ? (f['language_preference'] as num).toInt() : 0;
bool aac(Map f) => f['ext'] == 'm4a' || '${f['acodec']}'.startsWith('mp4a');
audio.sort((a, b) {
if (drc(a) != drc(b)) return drc(a) ? 1 : -1;
final l = lang(b).compareTo(lang(a));
if (l != 0) return l;
if (aac(a) != aac(b)) return aac(a) ? -1 : 1;
return _tbr(b).compareTo(_tbr(a));
});
return (YtDlpFormat._of(video.first), YtDlpFormat._of(audio.first));
}
/// What a relay reads of [formats]: the single file ([pickSingleFile])
/// unless a video + audio pair ([pickSplit]) is taller (both by the
/// [maxHeight] rule); empty when there is neither.
List<YtDlpTrack> pickTracks(List<Object?> formats, {int maxHeight = 1080}) {
final single = pickSingleFile(formats, maxHeight: maxHeight);
final split = pickSplit(formats, maxHeight: maxHeight);
if (split != null &&
(single == null ||
(single.height != null &&
split.$1.height != null &&
_byHeight(split.$1.height!, single.height!, maxHeight) < 0))) {
return [(kind: 'video', format: split.$1), (kind: 'audio', format: split.$2)];
}
return single == null ? const [] : [(kind: '', format: single)];
} }
/// The official standalone build for this platform, or null if there is /// The official standalone build for this platform, or null if there is
+77 -6
View File
@@ -1,19 +1,21 @@
// yt-dlp output parsing, the single-file pick and the link classifier. // yt-dlp output parsing, the relay track pick, chunked upstream reads and
// the link classifier.
// The live test (real download from GitHub, a real tweet) runs only with // The live test (real download from GitHub, a real tweet) runs only with
// YTDLP_LIVE=1. // YTDLP_LIVE=1.
library; library;
import 'dart:ffi' show Abi; import 'dart:ffi' show Abi;
import 'dart:io'; import 'dart:io';
import 'dart:math';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
Map<String, Object?> _f(String id, String protocol, Object? vcodec, Object? acodec, int? height, Map<String, Object?> _f(String id, String protocol, Object? vcodec, Object? acodec, int? height,
{String ext = 'mp4', num tbr = 0}) => {String ext = 'mp4', num tbr = 0, Map<String, Object?> extra = const {}}) =>
{ {
'format_id': id, 'protocol': protocol, 'vcodec': vcodec, 'acodec': acodec, 'height': height, // 'format_id': id, 'protocol': protocol, 'vcodec': vcodec, 'acodec': acodec, 'height': height, //
'ext': ext, 'tbr': tbr, 'url': 'https://cdn.example/$id', 'http_headers': {'User-Agent': 'UA'}, 'ext': ext, 'tbr': tbr, 'url': 'https://cdn.example/$id', 'http_headers': {'User-Agent': 'UA'}, ...extra,
}; };
void main() { void main() {
@@ -68,11 +70,79 @@ void main() {
}, },
], ],
}); });
expect((info.title, info.uploader, info.duration, info.thumbnail, info.extractor, info.single?.id), expect((info.title, info.uploader, info.duration, info.thumbnail, info.extractor, info.tracks.single.format.id),
('a girl. - https://t.co/x', 'a girl.', 6.3, 'https://pbs/x.jpg', 'Twitter', 'http-2176')); ('a girl. - https://t.co/x', 'a girl.', 6.3, 'https://pbs/x.jpg', 'Twitter', 'http-2176'));
}); });
}); });
group('relay tracks', () {
// YouTube now: only video-only and audio-only https files (+ HLS).
final youtube = [
_f('139', 'https', 'none', 'mp4a.40.5', null, ext: 'm4a', tbr: 49),
_f('140', 'https', 'none', 'mp4a.40.2', null, ext: 'm4a', tbr: 129),
_f('140-drc', 'https', 'none', 'mp4a.40.2', null, ext: 'm4a', tbr: 129),
_f('251', 'https', 'none', 'opus', null, ext: 'webm', tbr: 135),
_f('248', 'https', 'vp9', 'none', 1080, ext: 'webm', tbr: 2600),
_f('137', 'https', 'avc1.640028', 'none', 1080, tbr: 4400),
_f('299', 'https', 'avc1.64002a', 'none', 1080, tbr: 6000,
extra: {'downloader_options': {'http_chunk_size': 10485760}}),
_f('401', 'https', 'av01.0.12M.08', 'none', 2160, tbr: 18000),
_f('96', 'm3u8_native', 'avc1', 'mp4a', 1080),
];
test('YouTube: avc1 video up to 1080p (highest bitrate) + m4a audio, with the chunk size', () {
final t = pickTracks(youtube);
expect([for (final x in t) (x.kind, x.format.id)], [('video', '299'), ('audio', '140')]);
expect(t.first.format.chunkSize, 10485760);
expect(YtDlpInfo.parse({'title': 'x', 'formats': youtube}).formats['137']?.height, 1080);
});
test('a single file wins unless the pair is taller; nothing relayable: empty; live: none', () {
final single = _f('22', 'https', 'avc1', 'mp4a', 720);
expect(pickTracks([single, ...youtube]).map((x) => x.kind), ['video', 'audio']);
expect(pickTracks([_f('18', 'https', 'avc1', 'mp4a', 1080), ...youtube]).single.format.id, '18');
expect(pickTracks([_f('96', 'm3u8_native', 'avc1', 'mp4a', 1080)]), isEmpty);
final live = YtDlpInfo.parse({'title': 'x', 'is_live': true, 'formats': youtube});
expect(live.isLive, isTrue);
expect(live.tracks, isEmpty);
expect(LinkProbe.page(Uri.parse('https://x'), info: live).relayProblem, liveStreamText);
});
test('vp9 when there is no avc1; the drc variant only when alone', () {
final t = pickTracks([youtube[2], youtube[4]]);
expect([for (final x in t) x.format.id], ['248', '140-drc']);
});
});
test('viewer URL: one track plain, several an mpv EDL with %length% framing', () {
expect(relayViewerUrl('http://h:1/r/default/t/3', 'a/b c'), 'http://h:1/r/default/t/3/a_b%20c');
const v = 'http://h:1/r/default/t/3/0/x', a = 'http://h:1/r/default/t/3/1/x';
expect(relayViewerUrl('http://h:1/r/default/t/3', 'x', tracks: 2),
'edl://!no_clip;%${v.length}%$v;!new_stream;!no_clip;%${a.length}%$a');
expect(mpvEdl(['http://x/a;b,c']), 'edl://!no_clip;%14%http://x/a;b,c');
});
test('upstream reads go in GETs of at most the chunk size, back to back', () async {
final data = List.generate(2500, (i) => i % 251);
final seen = <String>[];
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
addTearDown(() => server.close(force: true));
server.listen((req) async {
final m = RegExp(r'bytes=(\d+)-(\d+)').firstMatch(req.headers.value('range')!)!;
final s = int.parse(m.group(1)!), e = min(int.parse(m.group(2)!), data.length - 1);
seen.add('$s-$e');
req.response
..statusCode = 206
..headers.set('content-range', 'bytes $s-$e/${data.length}')
..add(data.sublist(s, e + 1));
await req.response.close();
});
final b = HttpRelayBytes(Uri.parse('http://127.0.0.1:${server.port}/f.mp4'), chunkSize: 1000);
addTearDown(b.close);
expect([for (final c in await b.read(100, 2400).toList()) ...c], data.sublist(100, 2401));
expect(seen, ['100-1099', '1100-2099', '2100-2400']);
});
test('link classifier: media types and media file names are files; pages, HLS, errors are not', () { test('link classifier: media types and media file names are files; pages, HLS, errors are not', () {
bool file(int status, String type, String url) => bool file(int status, String type, String url) =>
looksLikeMediaFile(status: status, contentType: type, url: Uri.parse(url)); looksLikeMediaFile(status: status, contentType: type, url: Uri.parse(url));
@@ -120,13 +190,14 @@ void main() {
expect(probe.isPage, isTrue); expect(probe.isPage, isTrue);
expect(probe.title, startsWith('a girl.')); expect(probe.title, startsWith('a girl.'));
expect(probe.relayProblem, isNull); expect(probe.relayProblem, isNull);
final bytes = LinkRelayBytes(url, ytDlp: y, probe: probe); final media = LinkRelayMedia(url, ytDlp: y, probe: probe);
final bytes = (await media.open()).single.bytes;
final size = await bytes.size(); final size = await bytes.size();
var n = 0; var n = 0;
await for (final c in bytes.read(0, size - 1)) { await for (final c in bytes.read(0, size - 1)) {
n += c.length; n += c.length;
} }
await bytes.close(); await media.close();
expect(n, size); expect(n, size);
}, timeout: const Timeout(Duration(minutes: 3)), skip: Platform.environment['YTDLP_LIVE'] == '1' ? false : 'YTDLP_LIVE not set'); }, timeout: const Timeout(Duration(minutes: 3)), skip: Platform.environment['YTDLP_LIVE'] == '1' ? false : 'YTDLP_LIVE not set');
} }
+24 -10
View File
@@ -31,7 +31,7 @@ class LocalRelayFeed extends RelayFeed {
_subs[id] = bytes.read(start, end).listen( _subs[id] = bytes.read(start, end).listen(
(c) => buffer.feedBytes(id, c is Uint8List ? c : Uint8List.fromList(c)), (c) => buffer.feedBytes(id, c is Uint8List ? c : Uint8List.fromList(c)),
onError: (Object e) { onError: (Object e) {
_subs.remove(id)?.cancel(); unawaited(_subs.remove(id)?.cancel().catchError((_) {}));
buffer.feedFail(id, '$e'); buffer.feedFail(id, '$e');
}, },
onDone: () { onDone: () {
@@ -42,26 +42,29 @@ class LocalRelayFeed extends RelayFeed {
); );
} }
/// A read cut off mid-request errors on cancel: moot.
@override @override
void cancel(int id) => _subs.remove(id)?.cancel(); void cancel(int id) => unawaited(_subs.remove(id)?.cancel().catchError((_) {}));
Future<void> close() async { Future<void> close() async {
for (final s in _subs.values) { for (final s in _subs.values) {
await s.cancel(); await s.cancel().catchError((_) {});
} }
_subs.clear(); _subs.clear();
await bytes.close(); await bytes.close();
} }
} }
/// A relay channel (`/r/.../source`) as a feed. /// A relay channel (`/r/.../source`) as a feed: [track] of the source's
/// media (null: a single file, the pull names no track).
class ChannelRelayFeed extends RelayFeed { class ChannelRelayFeed extends RelayFeed {
final void Function(String text) send; final void Function(String text) send;
ChannelRelayFeed(this.send); final int? track;
ChannelRelayFeed(this.send, {this.track});
@override @override
void pull(int id, int start, int end) => void pull(int id, int start, int end) =>
send(jsonEncode({'t': 'pull', 'id': id, 'start': start, 'end': end})); send(jsonEncode({'t': 'pull', 'id': id, if (track != null) 'track': track, 'start': start, 'end': end}));
@override @override
void cancel(int id) => send(jsonEncode({'t': 'cancel', 'id': id})); void cancel(int id) => send(jsonEncode({'t': 'cancel', 'id': id}));
@@ -100,14 +103,24 @@ class _Viewer {
/// for anything; older ones still get bytes once they are buffered. /// for anything; older ones still get bytes once they are buffered.
/// Everything lives in memory: dropping the buffer is the cleanup. /// Everything lives in memory: dropping the buffer is the cleanup.
class RelayBuffer { class RelayBuffer {
final int ahead, back, pin; /// Set by the relay when a media's tracks share the budget.
int ahead, back;
final int pin;
final Duration waitLimit; final Duration waitLimit;
/// Pull ids; the tracks of one media share one sequence (one channel).
final int Function() _newId;
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),
}); int Function()? nextId,
}) : _newId = nextId ?? _counter();
static int Function() _counter() {
var n = 0;
return () => ++n;
}
static const slice = RelayConstants.relaySliceBytes; static const slice = RelayConstants.relaySliceBytes;
@@ -118,7 +131,7 @@ class RelayBuffer {
final _blocked = <int, int>{}; // slice -> wait start (us) final _blocked = <int, int>{}; // slice -> wait start (us)
final _viewers = <String, _Viewer>{}; final _viewers = <String, _Viewer>{};
_Pull? _pull; _Pull? _pull;
var _nextId = 1, _reqSeq = 0; var _reqSeq = 0;
var _disposed = false; var _disposed = false;
Completer<void> _kick = Completer(); Completer<void> _kick = Completer();
final Completer<void> _ready = Completer(); final Completer<void> _ready = Completer();
@@ -217,6 +230,7 @@ class RelayBuffer {
} }
void feedFail(int id, String reason) { void feedFail(int id, String reason) {
if (_pull?.id != id) return;
_log.warning('relay pull $id failed: $reason'); _log.warning('relay pull $id failed: $reason');
feedEnd(id); feedEnd(id);
} }
@@ -323,7 +337,7 @@ class RelayBuffer {
while (e + 1 < _nSlices && !_slices.containsKey(e + 1) && _wanted(e + 1)) { while (e + 1 < _nSlices && !_slices.containsKey(e + 1) && _wanted(e + 1)) {
e++; e++;
} }
final pl = _Pull(_nextId++, s * slice, _sliceEnd(e)); final pl = _Pull(_newId(), s * slice, _sliceEnd(e));
_pull = pl; _pull = pl;
feed.pull(pl.id, pl.start, pl.end); feed.pull(pl.id, pl.start, pl.end);
} }
+163 -47
View File
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:math';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:relic/relic.dart'; import 'package:relic/relic.dart';
@@ -12,43 +13,83 @@ import 'session.dart';
final _log = createLogger('relay'); final _log = createLogger('relay');
/// The relay of the current media: its buffer and where the bytes come /// Each track's (ahead, back) bytes when a media's [sizes] share one
/// from. One per relayed swap; [dispose] on the next swap or server stop /// buffer budget: by size (the tracks have one duration, so by bitrate),
/// (the buffer is memory only, so that is the whole cleanup). /// never under [RelayConstants.relayTrackFloorBytes]. One track keeps the
/// whole budget.
List<(int, int)> relayBudgets(List<int> sizes,
{int ahead = RelayConstants.relayAheadBytes, int back = RelayConstants.relayBackBytes}) {
if (sizes.length <= 1) return [for (final _ in sizes) (ahead, back)];
final total = sizes.fold(0, (a, n) => a + n);
int share(int all, int n) => max(RelayConstants.relayTrackFloorBytes, (all * (n / total)).round());
return [for (final n in sizes) (share(ahead, n), share(back, n))];
}
/// The relay of the current media: one buffer per track and where the
/// bytes come from. One per relayed swap; [dispose] on the next swap or
/// server stop (the buffers are memory only, so that is the whole cleanup).
class ActiveRelay { class ActiveRelay {
final int mediaId; final int mediaId;
final String name; final String name;
/// Token of the member whose machine serves the bytes; null when the /// Token of the member whose machine serves the bytes; null when the
/// server fetches the link itself ([local]). /// server fetches the link itself.
final String? sourceToken; final String? sourceToken;
final RelayBuffer buffer;
final LocalRelayFeed? local;
final void Function() onLiveChanged; final void Function() onLiveChanged;
/// One per track, in track order; empty until the server has read a
/// fetched link.
List<RelayBuffer> buffers = const [];
/// `media.relay.tracks`: the kinds when there are several tracks.
List<String> kinds = const [];
final _known = Completer<void>();
var _ids = 0;
RelayMedia? _media;
var _local = const <LocalRelayFeed>[];
RelicWebSocket? _channel; RelicWebSocket? _channel;
ChannelRelayFeed? _channelFeed; var _channelFeeds = const <ChannelRelayFeed>[];
ActiveRelay._(this.mediaId, this.name, this.sourceToken, this.buffer, this.local, this.onLiveChanged); ActiveRelay._(this.mediaId, this.name, this.sourceToken, this.onLiveChanged);
/// Bytes from member [sourceToken] over its relay channel. int _nextId() => ++_ids;
void _setTracks(List<String> kinds) {
this.kinds = kinds.length > 1 ? List.unmodifiable(kinds) : const [];
buffers = [for (var i = 0; i < max(1, kinds.length); i++) RelayBuffer(nextId: _nextId)];
if (!_known.isCompleted) _known.complete();
}
void _budget(List<int> sizes) {
final b = relayBudgets(sizes);
for (var i = 0; i < buffers.length; i++) {
buffers[i]
..ahead = b[i].$1
..back = b[i].$2;
}
}
/// Bytes from member [sourceToken] over its relay channel, as the
/// [tracks] its swap named (empty: one file).
factory ActiveRelay.fromMember(int mediaId, String name, String sourceToken, factory ActiveRelay.fromMember(int mediaId, String name, String sourceToken,
{RelayBuffer? buffer, required void Function() onLiveChanged}) => {List<String> tracks = const [], required void Function() onLiveChanged}) =>
ActiveRelay._(mediaId, name, sourceToken, buffer ?? RelayBuffer(), null, onLiveChanged); ActiveRelay._(mediaId, name, sourceToken, onLiveChanged).._setTracks(tracks);
/// The server reads [url] itself: a media file as is, a web page /// The server reads [url] itself: a media file as is, a web page
/// through [ytDlp]. [onFail] gets the reason when it cannot. /// through [ytDlp] (one file, or video + audio tracks). [onFail] gets
/// the reason when it cannot.
static ActiveRelay fetch(int mediaId, String name, Uri url, static ActiveRelay fetch(int mediaId, String name, Uri url,
{RelayBuffer? buffer, {YtDlp? ytDlp, required void Function() onLiveChanged, void Function(String reason)? onFail}) {
YtDlp? ytDlp, final r = ActiveRelay._(mediaId, name, null, onLiveChanged);
required void Function() onLiveChanged, final media = r._media = LinkRelayMedia(url, ytDlp: ytDlp);
void Function(String reason)? onFail}) {
final b = buffer ?? RelayBuffer();
final local = LocalRelayFeed(LinkRelayBytes(url, ytDlp: ytDlp), b);
final r = ActiveRelay._(mediaId, name, null, b, local, onLiveChanged);
unawaited(() async { unawaited(() async {
try { try {
final size = await local.bytes.size(); final tracks = await media.open();
final sizes = [for (final t in tracks) await t.bytes.size()];
if (r._disposed) return; if (r._disposed) return;
b.attach(local, size); r._setTracks([for (final t in tracks) t.kind]);
r._budget(sizes);
r._local = [for (var i = 0; i < tracks.length; i++) LocalRelayFeed(tracks[i].bytes, r.buffers[i])];
for (var i = 0; i < tracks.length; i++) {
r.buffers[i].attach(r._local[i], sizes[i]);
}
onLiveChanged(); onLiveChanged();
} catch (e) { } catch (e) {
_log.warning('relay fetch: cannot read the link: $e'); _log.warning('relay fetch: cannot read the link: $e');
@@ -59,13 +100,40 @@ class ActiveRelay {
} }
var _disposed = false; var _disposed = false;
bool get live => buffer.live; bool get live => buffers.isNotEmpty && buffers.every((b) => b.live);
/// The tracks are known (a fetched link has been read).
bool get known => _known.isCompleted && !_disposed;
/// Waits until the tracks are known, up to [RelayConstants.relayWaitSecs].
Future<bool> whenKnown() async {
if (!_known.isCompleted) {
await _known.future.timeout(const Duration(seconds: RelayConstants.relayWaitSecs), onTimeout: () {});
}
return known;
}
/// The buffer of [track]; null (the single-file URL) only for a media of
/// one track. Null when there is no such track.
RelayBuffer? bufferAt(int? track) {
if (track == null) return buffers.length == 1 ? buffers.single : null;
return track >= 0 && track < buffers.length ? buffers[track] : null;
}
void dispose() { void dispose() {
if (_disposed) return; if (_disposed) return;
_disposed = true; _disposed = true;
buffer.dispose(); for (final b in buffers) {
unawaited(local?.close()); b.dispose();
}
if (!_known.isCompleted) _known.complete();
final local = _local, media = _media;
unawaited(() async {
for (final f in local) {
await f.close();
}
await media?.close();
}());
final ch = _channel; final ch = _channel;
_channel = null; _channel = null;
if (ch != null) unawaited(_closeNotSource(ch)); if (ch != null) unawaited(_closeNotSource(ch));
@@ -78,25 +146,48 @@ class ActiveRelay {
if (ch != null) unawaited(_closeNotSource(ch)); if (ch != null) unawaited(_closeNotSource(ch));
} }
void _attachChannel(RelicWebSocket ws, int size) { void _attachChannel(RelicWebSocket ws, List<int> sizes) {
final old = _channel; final old = _channel;
if (old != null && !identical(old, ws)) unawaited(old.tryClose()); if (old != null && !identical(old, ws)) unawaited(old.tryClose());
_channel = ws; _channel = ws;
final feed = _channelFeed = ChannelRelayFeed((t) => ws.trySendText(t)); final multi = buffers.length > 1;
buffer.attach(feed, size); final feeds = _channelFeeds = [
for (var i = 0; i < buffers.length; i++) ChannelRelayFeed((t) => ws.trySendText(t), track: multi ? i : null),
];
_budget(sizes);
for (var i = 0; i < buffers.length; i++) {
buffers[i].attach(feeds[i], sizes[i]);
}
onLiveChanged(); onLiveChanged();
} }
void _detachChannel(RelicWebSocket ws) { void _detachChannel(RelicWebSocket ws) {
if (!identical(_channel, ws)) return; if (!identical(_channel, ws)) return;
_channel = null; _channel = null;
final feed = _channelFeed; final feeds = _channelFeeds;
_channelFeed = null; _channelFeeds = const [];
if (feed != null) buffer.detach(feed); for (var i = 0; i < feeds.length && i < buffers.length; i++) {
buffers[i].detach(feeds[i]);
}
onLiveChanged(); onLiveChanged();
} }
} }
/// The track sizes of a channel's `info` frame (`size`, or `tracks[].size`),
/// or null when malformed.
List<int>? _infoSizes(Map j) {
final size = j['size'], tracks = j['tracks'];
if (size is int) return size > 0 ? [size] : null;
if (tracks is! List || tracks.isEmpty) return null;
final out = <int>[];
for (final t in tracks) {
final n = t is Map ? t['size'] : null;
if (n is! int || n <= 0) return null;
out.add(n);
}
return out;
}
Future<void> _closeNotSource(RelicWebSocket ws) async { Future<void> _closeNotSource(RelicWebSocket ws) async {
try { try {
await ws.close(relayCloseNotSource, 'not the source'); await ws.close(relayCloseNotSource, 'not the source');
@@ -107,7 +198,8 @@ Future<void> _closeNotSource(RelicWebSocket ws) async {
/// `GET /r/<sid>/<token>/source` (WebSocket): the source member's relay /// `GET /r/<sid>/<token>/source` (WebSocket): the source member's relay
/// channel. `GET /r/<sid>/<token>/<mediaId>/<name>`: a viewer reads the /// channel. `GET /r/<sid>/<token>/<mediaId>/<name>`: a viewer reads the
/// relayed media, Range as for `/m`. [relay] gives the current relay. /// relayed file, Range as for `/m`; `.../<mediaId>/<track>/<name>` one
/// track of a media relayed as several. [relay] gives the current relay.
void mountRelayRoutes( void mountRelayRoutes(
RelicApp app, RelicApp app,
SessionMap sessions, SessionMap sessions,
@@ -144,21 +236,37 @@ void mountRelayRoutes(
final id = j['id']; final id = j['id'];
switch (j['t']) { switch (j['t']) {
case 'info': case 'info':
final size = j['size']; if (attached) continue;
if (size is int && size > 0 && !attached) { final sizes = _infoSizes(j);
attached = true; if (sizes == null || sizes.length != r.buffers.length) {
_log.info('relay channel up (${r.name}, $size bytes)'); // Not the media this swap named: the source stops.
r._attachChannel(ws, size); _log.warning('relay channel: info does not match the media (${r.name})');
await _closeNotSource(ws);
return;
} }
attached = true;
_log.info('relay channel up (${r.name}, ${sizes.join(' + ')} bytes)');
r._attachChannel(ws, sizes);
case 'end': case 'end':
if (id is int) r.buffer.feedEnd(id); if (id is int) {
for (final b in r.buffers) {
b.feedEnd(id);
}
}
case 'fail': case 'fail':
if (id is int) r.buffer.feedFail(id, '${j['reason']}'); if (id is int) {
for (final b in r.buffers) {
b.feedFail(id, '${j['reason']}');
}
}
} }
} else if (e is BinaryDataReceived && attached) { } else if (e is BinaryDataReceived && attached) {
try { try {
// Pull ids are unique across tracks: only the owner takes it.
final (id, payload) = decodeRelayFrame(e.data); final (id, payload) = decodeRelayFrame(e.data);
r.buffer.feedBytes(id, payload); for (final b in r.buffers) {
b.feedBytes(id, payload);
}
} catch (_) {} } catch (_) {}
} }
} }
@@ -171,11 +279,12 @@ void mountRelayRoutes(
}); });
}); });
app.get('/r/:sid/:token/:mediaId/:name', (final req) async { Future<Response> viewer(Request req, {required bool tracked}) async {
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
final sid = req.pathParameters.raw[#sid] ?? ''; final sid = req.pathParameters.raw[#sid] ?? '';
final token = req.pathParameters.raw[#token] ?? ''; final token = req.pathParameters.raw[#token] ?? '';
final mediaId = int.tryParse(req.pathParameters.raw[#mediaId] ?? ''); final mediaId = int.tryParse(req.pathParameters.raw[#mediaId] ?? '');
final track = tracked ? int.tryParse(req.pathParameters.raw[#seg] ?? '') ?? -1 : null;
final session = sessions.get(sid); final session = sessions.get(sid);
if (session == null) return _plain(410); if (session == null) return _plain(410);
switch (session.gate(token)) { switch (session.gate(token)) {
@@ -189,12 +298,14 @@ void mountRelayRoutes(
final r = relay(); final r = relay();
// A player still on an older media must not get the new one's bytes. // A player still on an older media must not get the new one's bytes.
if (r == null || r.mediaId != mediaId) return _plain(404); if (r == null || r.mediaId != mediaId) return _plain(404);
if (!await r.buffer.ready()) { final notConnected = Response(503,
return Response(503,
body: Body.fromString('relay source not connected'), body: Body.fromString('relay source not connected'),
headers: Headers.build((h) => h['retry-after'] = ['2'])); headers: Headers.build((h) => h['retry-after'] = ['2']));
} if (!await r.whenKnown()) return notConnected;
final size = r.buffer.size; final buffer = r.bufferAt(track);
if (buffer == null) return _plain(404);
if (!await buffer.ready()) return notConnected;
final size = buffer.size;
final typed = req.headers.range; final typed = req.headers.range;
String? rangeHeader; String? rangeHeader;
if (typed != null && typed.ranges.isNotEmpty) { if (typed != null && typed.ranges.isNotEmpty) {
@@ -212,14 +323,14 @@ void mountRelayRoutes(
token, token,
MediaLog( MediaLog(
method: req.method.name, method: req.method.name,
path: '/r/$sid/$shortToken/$mediaId/${r.name}', path: '/r/$sid/$shortToken/$mediaId/${track == null ? '' : '$track/'}${r.name}',
rangeHeader: rangeHeader, rangeHeader: rangeHeader,
status: res.status, status: res.status,
bytesWritten: end - res.start + 1, bytesWritten: end - res.start + 1,
durationMs: stopwatch.elapsedMilliseconds, durationMs: stopwatch.elapsedMilliseconds,
)); ));
final Stream<Uint8List> body = final Stream<Uint8List> body =
r.buffer.read(token, res.start, end, onSent: (n) => session.addBytes(token, n)); buffer.read(token, res.start, end, onSent: (n) => session.addBytes(token, n));
MimeType mime; MimeType mime;
try { try {
mime = MimeType.parse(res.headers['content-type'] ?? ''); mime = MimeType.parse(res.headers['content-type'] ?? '');
@@ -229,7 +340,12 @@ void mountRelayRoutes(
return Response(res.status, return Response(res.status,
body: Body.fromDataStream(body, mimeType: mime, contentLength: end - res.start + 1), body: Body.fromDataStream(body, mimeType: mime, contentLength: end - res.start + 1),
headers: headers); headers: headers);
}); }
// `:seg` is the name of a single file, the track of several (the router
// wants one parameter name per level).
app.get('/r/:sid/:token/:mediaId/:seg', (final req) => viewer(req, tracked: false));
app.get('/r/:sid/:token/:mediaId/:seg/:name', (final req) => viewer(req, tracked: true));
} }
Response _plain(int status) => Response(status, body: Body.fromString('status $status')); Response _plain(int status) => Response(status, body: Body.fromString('status $status'));
+22 -13
View File
@@ -46,16 +46,20 @@ class ServerHub {
relay = next; relay = next;
} }
/// `media.relay` for a relayed swap by [token]: who serves the bytes. /// `media.relay` for a relayed swap by [token]: who serves the bytes
MediaRelay relayFor(String token, MediaRelay requested) => MediaRelay( /// and, from a member, its tracks (the server finds a fetched link's).
source: requested.fetch ? '' : (engine.members[token]?.id ?? ''), fetch: requested.fetch); MediaRelay relayFor(String token, MediaRelay requested) => requested.fetch
? const MediaRelay(fetch: true)
: MediaRelay(source: engine.members[token]?.id ?? '', tracks: requested.tracks);
/// The relay's channel came up or went down: `media.relay.live` follows. /// The relay's channel came up or went down, or a fetched link was read:
/// `media.relay.live` and `tracks` follow.
void relayLiveChanged(ActiveRelay r) { void relayLiveChanged(ActiveRelay r) {
final m = engine.media; final m = engine.media;
if (!identical(relay, r) || m?.relay == null) return; if (!identical(relay, r) || m?.relay == null) return;
if (m!.relay!.live == r.live) return; final was = m!.relay!;
engine.media = m.copyWith(relay: m.relay!.copyWith(live: r.live)); if (was.live == r.live && was.tracks.join(',') == r.kinds.join(',')) return;
engine.media = m.copyWith(relay: was.copyWith(live: r.live, tracks: r.kinds));
changed(); changed();
} }
@@ -243,7 +247,7 @@ void mountSyncRoute(
String Function(String sid, String token, SessionMedia? media, String host) String Function(String sid, String token, SessionMedia? media, String host)
urlBuilder, { urlBuilder, {
String Function(String sid, String token, String itemId, String host)? relayUrlBuilder, String Function(String sid, String token, String itemId, String host)? relayUrlBuilder,
String Function(String sid, String token, int mediaId, String name, String host)? relayMediaUrlBuilder, String Function(String sid, String token, int mediaId, String name, int tracks, String host)? relayMediaUrlBuilder,
}) { }) {
app.get('/sync', (final req) { app.get('/sync', (final req) {
// Host the client dialled (Host header), so file URLs work for remote joiners. // Host the client dialled (Host header), so file URLs work for remote joiners.
@@ -310,7 +314,7 @@ void mountSyncRoute(
size: parsed.size, size: parsed.size,
duration: parsed.duration, duration: parsed.duration,
jellyfin: ref, jellyfin: ref,
relay: relayReq == null ? null : hub.relayFor(token, relayReq)); relay: relayReq == null ? null : hub.relayFor(token, const MediaRelay()));
if (media == null || !hub.engine.applySwap(token, media)) { if (media == null || !hub.engine.applySwap(token, media)) {
wsErr(ws, 'bad swap media'); wsErr(ws, 'bad swap media');
return; return;
@@ -321,7 +325,7 @@ void mountSyncRoute(
duration: media.duration, duration: media.duration,
jellyfin: ref, jellyfin: ref,
mediaId: hub.engine.mediaId); mediaId: hub.engine.mediaId);
hub.setRelay(relayReq == null ? null : _memberRelay(hub, token, media.name)); hub.setRelay(relayReq == null ? null : _memberRelay(hub, token, media.name, const []));
hub.changed(); hub.changed();
return; return;
} }
@@ -361,7 +365,7 @@ void mountSyncRoute(
ytDlp: hub.ytDlp, ytDlp: hub.ytDlp,
onLiveChanged: () => hub.relayLiveChanged(active), onLiveChanged: () => hub.relayLiveChanged(active),
onFail: (why) => wsErr(ws, "The server can't fetch this link: $why")) onFail: (why) => wsErr(ws, "The server can't fetch this link: $why"))
: _memberRelay(hub, token, name); : _memberRelay(hub, token, name, media.relay!.tracks);
hub.setRelay(active); hub.setRelay(active);
hub.changed(); hub.changed();
return; return;
@@ -556,13 +560,17 @@ void mountSyncRoute(
// Jellyfin media resolves per member (own account first); only // Jellyfin media resolves per member (own account first); only
// relayed files and links start from the /r/ URL, and never for // relayed files and links start from the /r/ URL, and never for
// the source itself (it plays its own copy). // the source itself (it plays its own copy).
// A fetched link not read yet has no URL: the state's `live` opens it.
mediaUrl: relayed != null && mediaUrl: relayed != null &&
media != null && media != null &&
media.jellyfin == null && media.jellyfin == null &&
relayed.sourceToken != activeToken && relayed.sourceToken != activeToken &&
relayed.mediaId == media.mediaId && relayed.mediaId == media.mediaId &&
relayMediaUrlBuilder != null relayMediaUrlBuilder != null
? relayMediaUrlBuilder('default', activeToken, relayed.mediaId, relayed.name, dialledHost) ? (relayed.known
? relayMediaUrlBuilder(
'default', activeToken, relayed.mediaId, relayed.name, relayed.buffers.length, dialledHost)
: null)
: media?.file != null : media?.file != null
? urlBuilder('default', activeToken, media, dialledHost) ? urlBuilder('default', activeToken, media, dialledHost)
: media?.url, : media?.url,
@@ -587,8 +595,9 @@ void mountSyncRoute(
}); });
} }
ActiveRelay _memberRelay(ServerHub hub, String token, String name) { ActiveRelay _memberRelay(ServerHub hub, String token, String name, List<String> tracks) {
late final ActiveRelay r; late final ActiveRelay r;
r = ActiveRelay.fromMember(hub.engine.mediaId, name, token, onLiveChanged: () => hub.relayLiveChanged(r)); r = ActiveRelay.fromMember(hub.engine.mediaId, name, token,
tracks: tracks, onLiveChanged: () => hub.relayLiveChanged(r));
return r; return r;
} }
+5 -4
View File
@@ -48,11 +48,12 @@ class SyncServer {
/// Upstream connections of the Jellyfin relay; created on first use. /// Upstream connections of the Jellyfin relay; created on first use.
HttpClient? _relayClient; HttpClient? _relayClient;
SyncServer(this.config) { /// [ytDlp] replaces the server's own (tests).
SyncServer(this.config, {YtDlp? ytDlp}) {
hub = ServerHub(sessions, config.secret, hub = ServerHub(sessions, config.secret,
hostKey: config.hostKey, hostKey: config.hostKey,
jellyfin: config.jellyfin, jellyfin: config.jellyfin,
ytDlp: YtDlp(serverYtDlpDir(config.ytDlpDir), enabled: config.ytDlp)); ytDlp: ytDlp ?? YtDlp(serverYtDlpDir(config.ytDlpDir), enabled: config.ytDlp));
} }
bool get isRunning => _server != null; bool get isRunning => _server != null;
@@ -103,8 +104,8 @@ class SyncServer {
return 'http://${authority(dialledHost)}/m/$sid/$token/$name'; return 'http://${authority(dialledHost)}/m/$sid/$token/$name';
}, relayUrlBuilder: (sid, token, itemId, dialledHost) => }, relayUrlBuilder: (sid, token, itemId, dialledHost) =>
'http://${authority(dialledHost)}/j/$sid/$token/${Uri.encodeComponent(itemId)}', 'http://${authority(dialledHost)}/j/$sid/$token/${Uri.encodeComponent(itemId)}',
relayMediaUrlBuilder: (sid, token, mediaId, name, dialledHost) => relayMediaUrlBuilder: (sid, token, mediaId, name, tracks, dialledHost) =>
'http://${authority(dialledHost)}/r/$sid/$token/$mediaId/${relayUrlName(name)}'); relayViewerUrl('http://${authority(dialledHost)}/r/$sid/$token/$mediaId', name, tracks: tracks));
_app.fallback = (final req) => Response.notFound( _app.fallback = (final req) => Response.notFound(
body: Body.fromString('not found'), body: Body.fromString('not found'),
); );
+98 -4
View File
@@ -92,10 +92,10 @@ void main() {
return m; return m;
} }
Future<RelaySource> startSource(_Member m, {RelayBytes? from}) async { Future<RelaySource> startSource(_Member m, {RelayMedia? from}) async {
final src = RelaySource( final src = RelaySource(
Uri.parse('ws://127.0.0.1:${server.boundPort}/r/default/${m.welcome.token}/source'), Uri.parse('ws://127.0.0.1:${server.boundPort}/r/default/${m.welcome.token}/source'),
from ?? FileRelayBytes(file), from ?? RelayMedia.single(FileRelayBytes(file)),
backoff: (_) => const Duration(milliseconds: 100)); backoff: (_) => const Duration(milliseconds: 100));
await src.start(); await src.start();
cleanup.add(src.close); cleanup.add(src.close);
@@ -207,7 +207,7 @@ void main() {
final src = await startSource(alice); final src = await startSource(alice);
final oldId = mediaIdOf(alice); final oldId = mediaIdOf(alice);
expect((await get(viewerUrl(bob, oldId), range: 'bytes=0-9')).$1, 206); expect((await get(viewerUrl(bob, oldId), range: 'bytes=0-9')).$1, 206);
final buf = server.hub.relay!.buffer; final buf = server.hub.relay!.buffers.single;
alice.send({ alice.send({
't': 'swap', 't': 'swap',
'media': {'name': 'x', 'size': 0, 'duration': 0, 'url': 'http://example.invalid/x.mkv'}, 'media': {'name': 'x', 'size': 0, 'duration': 0, 'url': 'http://example.invalid/x.mkv'},
@@ -222,7 +222,7 @@ void main() {
test('server stop drops the relay', () async { test('server stop drops the relay', () async {
final alice = await join('alice'); final alice = await join('alice');
await swapRelay(alice); await swapRelay(alice);
final buf = server.hub.relay!.buffer; final buf = server.hub.relay!.buffers.single;
await server.stop(); await server.stop();
expect(server.hub.relay, isNull); expect(server.hub.relay, isNull);
expect(buf.cachedBytes, 0); expect(buf.cachedBytes, 0);
@@ -250,6 +250,39 @@ void main() {
expect(body, bytes.sublist(RelayBuffer.slice * 4)); expect(body, bytes.sublist(RelayBuffer.slice * 4));
}); });
test('two tracks: each byte-exact at its own URL, welcome is an EDL of both', () async {
final alice = await join('alice');
final bob = await join('bob');
final audio = Uint8List.fromList(List.generate(RelayBuffer.slice + 1234, (i) => i * 7 % 256));
final audioFile = File('${tmp.path}/a.m4a')..writeAsBytesSync(audio);
await swapRelay(alice, relay: {
'tracks': [
{'kind': 'video'},
{'kind': 'audio'},
],
});
expect((alice.media!['relay'] as Map)['tracks'], [
{'kind': 'video'},
{'kind': 'audio'},
]);
await startSource(alice,
from: _Tracks([RelayTrack('video', FileRelayBytes(file)), RelayTrack('audio', FileRelayBytes(audioFile))]));
final id = mediaIdOf(alice);
final base = 'http://127.0.0.1:${server.boundPort}/r/default/${bob.welcome.token}/$id';
final name = Uri.encodeComponent('ep 01.mkv');
final (vs, _, v) = await get('$base/0/$name', range: 'bytes=1000-');
final (aStatus, _, a) = await get('$base/1/$name');
expect((vs, aStatus), (206, 200));
expect(v, bytes.sublist(1000));
expect(a, audio);
// The single-file URL and a track past the end are not this media.
expect((await get('$base/$name')).$1, 404);
expect((await get('$base/2/$name')).$1, 404);
final carol = await join('carol');
final carolBase = base.replaceFirst(bob.welcome.token, carol.welcome.token);
expect(carol.welcome.mediaUrl, mpvEdl(['$carolBase/0/$name', '$carolBase/1/$name']));
});
test('Jellyfin swap can carry a relay', () async { test('Jellyfin swap can carry a relay', () async {
final alice = await join('alice'); final alice = await join('alice');
final before = alice.raw.length; final before = alice.raw.length;
@@ -271,9 +304,19 @@ void main() {
group('server fetches the link', () { group('server fetches the link', () {
late HttpServer origin; late HttpServer origin;
final ranges = <(String, int)>[];
setUp(() async { setUp(() async {
origin = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); origin = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
ranges.clear();
origin.listen((req) async { origin.listen((req) async {
if (req.uri.path == '/watch') {
req.response.headers.contentType = ContentType.html;
req.response.write('<title>page</title>');
await req.response.close();
return;
}
final m = RegExp(r'bytes=(\d+)-(\d+)').firstMatch(req.headers.value('range') ?? '');
if (m != null) ranges.add((req.uri.path, int.parse(m.group(2)!) - int.parse(m.group(1)!) + 1));
final r = resolveMediaRequest( final r = resolveMediaRequest(
rangeHeader: req.headers.value('range'), size: bytes.length, filename: 'x.mkv'); rangeHeader: req.headers.value('range'), size: bytes.length, filename: 'x.mkv');
req.response.statusCode = r.status; req.response.statusCode = r.status;
@@ -300,6 +343,39 @@ void main() {
expect(body, bytes.sublist(1000)); expect(body, bytes.sublist(1000));
}); });
test('a page with separate video and audio: two tracks, read in chunks', () async {
final alice = await join('alice');
final y = _FakeYtDlp(tmp.path, [
(
kind: 'video',
format: YtDlpFormat(id: 'v', url: Uri.parse('http://127.0.0.1:${origin.port}/v.mp4'), chunkSize: 300000),
),
(kind: 'audio', format: YtDlpFormat(id: 'a', url: Uri.parse('http://127.0.0.1:${origin.port}/a.m4a'))),
]);
await server.stop();
server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 't'), ytDlp: y);
await server.start();
final m = await _Member.join(server.boundPort, 'alice');
cleanup.add(() => m.ws.close());
await alice.ws.close();
await swapRelay(m, relay: {'fetch': true}, url: 'http://127.0.0.1:${origin.port}/watch?v=1');
await _until(() => (m.media?['relay'] as Map?)?['live'] == true, 'live');
expect((m.media!['relay'] as Map)['tracks'], [
{'kind': 'video'},
{'kind': 'audio'},
]);
final bob = await _Member.join(server.boundPort, 'bob');
cleanup.add(() => bob.ws.close());
final url = bob.welcome.mediaUrl!;
expect(url, startsWith('edl://!no_clip;%'));
final parts = RegExp(r'%\d+%([^;]+)').allMatches(url).map((x) => x.group(1)!).toList();
expect(parts, hasLength(2));
expect((await get(parts[0])).$3, bytes);
expect((await get(parts[1], range: 'bytes=5-')).$3, bytes.sublist(5));
// The 300 000-byte chunk size: no upstream GET for the video asked for more.
expect(ranges.where((r) => r.$1 == '/v.mp4').every((r) => r.$2 <= 300000), isTrue);
});
test('an open server (no password) refuses to fetch', () async { test('an open server (no password) refuses to fetch', () async {
await server.stop(); await server.stop();
server = SyncServer(const AppConfig(port: 0, secret: '', name: 'open')); server = SyncServer(const AppConfig(port: 0, secret: '', name: 'open'));
@@ -384,3 +460,21 @@ class _CountingBytes implements RelayBytes {
@override @override
Future<void> close() => inner.close(); Future<void> close() => inner.close();
} }
class _Tracks implements RelayMedia {
final List<RelayTrack> tracks;
_Tracks(this.tracks);
@override
Future<List<RelayTrack>> open() async => tracks;
@override
Future<void> close() async {}
}
/// yt-dlp that "resolves" any page to [tracks].
class _FakeYtDlp extends YtDlp {
final List<YtDlpTrack> tracks;
_FakeYtDlp(super.dir, this.tracks);
@override
Future<YtDlpInfo> resolve(String url, {Duration timeout = const Duration(seconds: 25)}) async =>
YtDlpInfo(title: 'page', tracks: tracks, formats: {for (final t in tracks) t.format.id: t.format});
}
+2 -2
View File
@@ -80,8 +80,8 @@ void main() {
}); });
group('core welcome + proto', () { group('core welcome + proto', () {
test('proto 10: avatars by hash, profile intent', () { test('proto 11: avatars by hash, profile intent', () {
expect(RelayConstants.protoVersion, 10); expect(RelayConstants.protoVersion, 11);
const v = MemberView(id: 'm1', name: 'a', ready: false, inSet: false, buffered: 0, stale: false); const v = MemberView(id: 'm1', name: 'a', ready: false, inSet: false, buffered: 0, stale: false);
expect(v.toJson().containsKey('avatarHash'), isFalse); expect(v.toJson().containsKey('avatarHash'), isFalse);
expect(v.toJson().containsKey('avatar'), isFalse); expect(v.toJson().containsKey('avatar'), isFalse);
+5 -3
View File
@@ -50,7 +50,8 @@ Future<void> main(List<String> argv) async {
.toJson())); .toJson()));
await Future<void>.delayed(const Duration(milliseconds: 300)); await Future<void>.delayed(const Duration(milliseconds: 300));
final via = a['via'] as String? ?? '$port'; final via = a['via'] as String? ?? '$port';
var source = RelaySource(Uri.parse('ws://127.0.0.1:$via/r/default/${srcW.token}/source'), FileRelayBytes(file)); var source = RelaySource(
Uri.parse('ws://127.0.0.1:$via/r/default/${srcW.token}/source'), RelayMedia.single(FileRelayBytes(file)));
await source.start(); await source.start();
final urls = <String>[]; final urls = <String>[];
for (var i = 0; i < int.parse(a['viewers'] as String); i++) { for (var i = 0; i < int.parse(a['viewers'] as String); i++) {
@@ -58,14 +59,15 @@ Future<void> main(List<String> argv) async {
} }
stdout.writeln('URL ${urls.join(',')}'); stdout.writeln('URL ${urls.join(',')}');
Timer.periodic(const Duration(seconds: 5), (_) { Timer.periodic(const Duration(seconds: 5), (_) {
final b = server.hub.relay?.buffer; final b = server.hub.relay?.buffers.firstOrNull;
stdout.writeln('STATS sourceSent=${source.bytesSent} toViewers=${b?.bytesToViewers} ' stdout.writeln('STATS sourceSent=${source.bytesSent} toViewers=${b?.bytesToViewers} '
'cached=${b?.cachedBytes} window=${source.window.window}'); 'cached=${b?.cachedBytes} window=${source.window.window}');
}); });
// SIGUSR1: drop the source; SIGUSR2: bring it back (outage tests). // SIGUSR1: drop the source; SIGUSR2: bring it back (outage tests).
ProcessSignal.sigusr1.watch().listen((_) => source.close()); ProcessSignal.sigusr1.watch().listen((_) => source.close());
ProcessSignal.sigusr2.watch().listen((_) async { ProcessSignal.sigusr2.watch().listen((_) async {
source = RelaySource(Uri.parse('ws://127.0.0.1:$via/r/default/${srcW.token}/source'), FileRelayBytes(file)); source = RelaySource(
Uri.parse('ws://127.0.0.1:$via/r/default/${srcW.token}/source'), RelayMedia.single(FileRelayBytes(file)));
await source.start(); await source.start();
}); });
} }