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
+938 -279

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
against the release's checksums, updated once a day; Settings → Player
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
has separate video and audio streams, so use "Everyone fetches it") and
never show the link to anyone. Your upload has to beat the video's
YouTube works in all three too. The last two never show the link to
anyone; they can't pass on live streams or pages that only offer streaming
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.
## Video quality and shaders
@@ -525,21 +525,23 @@ class SessionCoordinator {
}
}
String _relayMediaUrlFor(String name, int mediaId) =>
'http://$_wsHost:$_wsPort/r/default/${sync.token}/$mediaId/${relayUrlName(name)}';
/// This viewer's `/r/` URL; an mpv EDL of every track when there are
/// 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.
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
// redials by itself).
if (_relaySource != null && _relaySourceMediaId == mediaId) {
unawaited(bytes.close());
unawaited(media.close());
return;
}
_stopRelaySource();
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;
_relaySourceMediaId = mediaId;
relaying.value = true;
@@ -646,11 +648,11 @@ class SessionCoordinator {
memberRelayUrl: relay != null && !mine ? _relayMediaUrlFor(name, r.mediaId) : null);
} else if (origin is _FileOrigin) {
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) {
await _openMedia(origin.url);
_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);
} else if (relay != null && !mine) {
if (relay.fetch && !relay.live) {
@@ -660,7 +662,7 @@ class SessionCoordinator {
_awaitLiveFor = r.mediaId;
health.value = const HealthStatus(HealthSeverity.info, 'the server is getting the video…');
} else {
await _openMedia(_relayMediaUrlFor(name, r.mediaId));
await _openMedia(_relayMediaUrlFor(name, r.mediaId, relay.tracks.length));
}
} else {
var url = stateMediaUrl(r);
@@ -679,7 +681,8 @@ class SessionCoordinator {
_clearPendingSeek();
} else if (_awaitLiveFor == r.mediaId && (stateMediaRelay(r)?.live ?? false)) {
_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);
_relayStatus(r);
@@ -1152,29 +1155,36 @@ class SessionCoordinator {
return null;
}
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';
final problem = probe.relayProblem;
if (problem != null) return problem;
final y = await ytDlp.ytDlp;
final check = LinkRelayBytes(uri, ytDlp: y, probe: probe);
final int size;
final check = LinkRelayMedia(uri, ytDlp: y, probe: probe);
var size = 0;
final List<String> kinds;
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) {
return 'cannot pass this link on: $e';
} finally {
await check.close();
}
_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;
}
// 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
// computer cannot).
if (probe != null && probe.isPage && probe.info != null && probe.info!.single == null) {
return noSingleFileText;
// it cannot relay (live, no video file) is refused here (the server
// may reach what this computer cannot).
if (probe != null && probe.isPage && probe.info != null && probe.relayProblem != null) {
return probe.relayProblem;
}
_swap(HelloMedia(
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).
if (origin != null) {
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) {
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;
+2 -2
View File
@@ -123,7 +123,7 @@ void main() {
expect(bob.relaying.peek(), isTrue);
await playTogether([alice, bob]);
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);
// 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');
expect(alice.serverState.peek()!.toJson().toString(), isNot(contains('SECRET')));
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;
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 |
| 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
as yt-dlp's single-file format, resolved where the bytes are fetched (see
"yt-dlp"); the swap carries only the page URL and the mode.
Relayed links are range-read: a media file as is, a web page as yt-dlp's
formats, resolved where the bytes are fetched (see "yt-dlp"); the swap
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:
- The source dials `/r/.../source` (outbound, so NAT/CGNAT are fine) and
answers the server's pulls from the file or the URL (`RelaySource`,
`FileRelayBytes`, `HttpRelayBytes`, `LinkRelayBytes`; a Jellyfin source sends its own
`Authorization` header from this machine). Viewers only ever talk HTTP
to the server, so the source never sees mpv's request pattern.
- The server keeps one `RelayBuffer` per relayed media: 1 MiB slices in
answers the server's pulls from the file or the URL (`RelaySource` over
a `RelayMedia`: its `RelayTrack`s, each a `RelayBytes`:
`FileRelayBytes`, `HttpRelayBytes`, `LinkRelayMedia` / `PageRelayMedia`
for links; a Jellyfin source sends its own `Authorization` header from
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
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
same as each viewer's mpv buffer (`PlaybackController`), so a skip
inside the viewers' buffer window is served from memory. Evicted by
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
the source over at slice boundaries), then the pinned slices, then
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.
- Source away: viewers keep playing from the buffer; a request waits up
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
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`.
- **Resolve** (`YtDlp.resolve`): `yt-dlp -J --no-playlist --no-warnings
--ignore-config`, 25 s timeout: title, uploader, duration, thumbnail,
and the best single-file format (`pickSingleFile`: video and audio not
`none`, plain http(s), a video container, tallest up to 1080p) with its
URL and `http_headers`; none when the page has only split or HLS
streams (YouTube today).
live or not, and what a relay reads (`pickTracks`), each format with
its URL, `http_headers` and `http_chunk_size`. Only plain http(s)
formats without DRM count:
- 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
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
@@ -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`,
once per player), waiting for the download unless the URL names a media
file. A hook error shows as "can't open this link: ...".
- **Through me**: the putter's probe must give a single-file format, else
the swap is refused with a hint to use "Everyone fetches it". Its relay
source reads that format with yt-dlp's headers (`PageRelayBytes`); on
403/410 (expired format URL) it resolves the page again once and goes
on from where it stopped.
- **Through me**: the putter's probe must give tracks, else the swap is
refused ("Live streams can't be passed on", or no video file: only
streaming playlists) with a hint to use "Everyone fetches it". The swap
names the tracks; its relay source reads them with yt-dlp's headers
(`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
(`LinkRelayBytes`) with its own yt-dlp (off with `--no-yt-dlp` /
`ytDlp: false`) and relays the single-file format the same way. The
putter only refuses a page its own yt-dlp says has no single file (the
server may reach what the putter cannot); when the server cannot read
it, the putter gets an `err` with the reason. Viewers open a fetched
(`LinkRelayMedia`) with its own yt-dlp (off with `--no-yt-dlp` /
`ytDlp: false`) and relays its tracks the same way, setting
`relay.tracks` with `live`. The putter only refuses a page its own
yt-dlp says cannot be relayed (the server may reach what the putter
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
the video…" until then): resolving takes seconds and media_kit's mpv
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
[ARCHITECTURE.md](ARCHITECTURE.md). Source of truth:
@@ -30,7 +30,7 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
## 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":"...",
"resume":"<token>"}
{"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
media.relay = {"tracks":[{"kind":"video"},{"kind":"audio"}]}
the same, as two tracks played together
media.relay = {"fetch":true} the server reads media.url itself
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
viewers without an account; `fetch` is ignored there). `relay` without
`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
the server cannot read the link, the swapper later gets `The server
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
`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
`/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
`\` made `_`, then URL-encoded; the server ignores it).
- `GET /r/<sid>/<token>/<mediaId>/<name>`: the media, Range table as for
`/m`. Errors: 401 bad token, 403 kicked, 404 not the current relayed
`\` made `_`, then URL-encoded; the server ignores it). With `tracks`,
it is an mpv EDL of every track's URL
`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
bytes), 410 unknown session, 503 + `retry-after` the source never
connected (60 s). A request whose bytes do not arrive within 60 s ends
early. Bytes count toward `bytesServed`.
bytes) or no such track (the one-file URL of a media with `tracks`), 410
unknown session, 503 + `retry-after` the source never connected or the
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,
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
@@ -251,15 +274,22 @@ state media.relay = {"source":"m2","live":true} or {"fetch":true,"live":true}
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
{"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":"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
+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.
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
app and open the single-file format. YouTube: yt-dlp wants a JS runtime
(deno) for full extraction, and it only has split streams, so relaying
it would need muxing. Formats that need yt-dlp's cookies.
bundled Windows libmpv has the ytdl hook (Lua) and plays `edl://` URLs
through media_kit; if not, resolve in the app and open the relay
tracks' formats. YouTube: yt-dlp wants a JS runtime (deno) for full
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"
filter on the Schedule; today's "Following" (AnimeFlow's monitored
+8 -1
View File
@@ -1,5 +1,5 @@
abstract class RelayConstants {
static const int protoVersion = 10;
static const int protoVersion = 11;
// Relay (`/r/...`): a member's bytes through the server's buffer.
static const int relaySliceBytes = 1 << 20;
@@ -12,6 +12,13 @@ abstract class RelayConstants {
/// Kept regardless of position: containers keep their index at the start
/// or the end, and players read both on open and on seeks.
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 relayMaxWindowBytes = 8 << 20;
/// 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:typed_data';
import 'constants.dart';
import 'relay.dart';
import 'yt_dlp.dart';
@@ -70,9 +71,12 @@ String _unescape(String s) => s
String notAVideoText(String? ytDlpError) =>
ytDlpError == null ? 'Not a video' : "Not a video (and yt-dlp couldn't read it: $ytDlpError)";
/// The text for a page with only split or HLS streams.
const noSingleFileText = 'This page has no single-file video to pass on (only separate video and audio '
"streams). Choose 'Everyone fetches it' instead.";
/// The text for a page with no video file a relay can read.
const noRelayFormatText = 'This page has no video file to pass on (only streaming playlists). '
"Choose 'Everyone fetches it' instead.";
/// The text for a live stream.
const liveStreamText = "Live streams can't be passed on. Choose 'Everyone fetches it' instead.";
class LinkProbeException implements Exception {
final String message;
@@ -115,8 +119,10 @@ class LinkProbe {
? null
: info == null
? notAVideoText(ytDlpError)
: info!.single == null
? noSingleFileText
: info!.isLive
? liveStreamText
: info!.tracks.isEmpty
? noRelayFormatText
: null;
}
@@ -184,72 +190,94 @@ Future<String> _readText(HttpClientResponse res) async {
return utf8.decode(out.takeBytes(), allowMalformed: true);
}
/// A page's single-file format (see [YtDlpInfo.single]), range-read with
/// yt-dlp's headers. Format URLs expire: on 403/410 the page is resolved
/// again once and the read goes on from where it stopped.
class PageRelayBytes implements RelayBytes {
/// A page's relay tracks (see [YtDlpInfo.tracks]: one file, or video +
/// audio), range-read with yt-dlp's headers in GETs of the format's
/// `http_chunk_size`. Format URLs expire: on 403/410 from any track the
/// page is resolved again once for all tracks (same formats, same sizes)
/// and the read goes on from where it stopped.
class PageRelayMedia implements RelayMedia {
final Uri page;
final YtDlp ytDlp;
YtDlpFormat? _format;
HttpRelayBytes? _inner;
int? _size;
List<YtDlpTrack>? _picked;
List<HttpRelayBytes?> _inner = const [];
List<RelayTrack>? _tracks;
final _sizes = <int, int>{};
var _gen = 0;
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 {
final f = (await ytDlp.resolve(page.toString())).single;
if (f == null) throw const LinkProbeException(noSingleFileText);
return f;
Future<YtDlpInfo> _info() async {
final info = await ytDlp.resolve(page.toString());
if (info.isLive) throw const LinkProbeException(liveStreamText);
return info;
}
Future<HttpRelayBytes> _open() async =>
_inner ??= () {
final f = _format!;
return HttpRelayBytes(f.url, headers: f.headers);
@override
Future<List<RelayTrack>> open() async {
if (_tracks != null) return _tracks!;
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);
/// Resolves the page again: the same format ids with the same sizes,
/// else the video changed.
Future<void> _refresh(int gen) {
if (gen != _gen) return Future.value();
return _refreshing ??= () async {
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;
_format = f;
_inner = null;
_picked = next;
_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++;
await old?.close();
for (final b in old) {
await b?.close();
}
} finally {
_refreshing = null;
}
}();
}
@override
Future<int> size() async {
if (_size != null) return _size!;
_format ??= await _resolve();
Future<int> _size(int i) async {
final known = _sizes[i];
if (known != null) return known;
final gen = _gen;
try {
return _size = await (await _open()).size();
return _sizes[i] = await _open(i).size();
} catch (e) {
if (!_expired(e)) rethrow;
await _refresh(gen);
return _size = await (await _open()).size();
return _sizes[i] = await _open(i).size();
}
}
@override
Stream<List<int>> read(int start, int end) async* {
Stream<List<int>> _read(int i, int start, int end) async* {
var pos = start;
var retried = false;
while (true) {
final gen = _gen;
final inner = await _open();
try {
await for (final chunk in inner.read(pos, end)) {
await for (final chunk in _open(i).read(pos, end)) {
pos += chunk.length;
yield chunk;
}
@@ -258,45 +286,59 @@ class PageRelayBytes implements RelayBytes {
if (retried || !_expired(e)) rethrow;
retried = true;
await _refresh(gen);
final n = await (await _open()).size();
if (_size != null && n != _size) throw const LinkProbeException('the video changed on the page');
}
}
}
@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
/// read as a file ([HttpRelayBytes]) or a page ([PageRelayBytes]). Throws
/// [LinkProbeException] with the reason when it cannot be relayed.
class LinkRelayBytes implements RelayBytes {
class _PageTrack implements RelayBytes {
final PageRelayMedia media;
final int index;
_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 YtDlp? ytDlp;
LinkProbe? _probe;
RelayBytes? _inner;
LinkRelayBytes(this.url, {this.ytDlp, LinkProbe? probe}) : _probe = probe;
RelayMedia? _inner;
LinkRelayMedia(this.url, {this.ytDlp, LinkProbe? probe}) : _probe = probe;
/// The probe, after [size].
/// The probe, after [open].
LinkProbe? get probe => _probe;
@override
Future<int> size() async {
Future<List<RelayTrack>> open() async {
if (_inner == null) {
final probe = _probe ??= await probeLink(url, ytDlp: ytDlp);
final problem = probe.relayProblem;
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();
}
@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);
return _inner!.open();
}
@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 `{"fetch":true}`; the server fills in [source] (the swapper's
/// member id, empty with [fetch]) and whether bytes can flow ([live]).
/// [tracks]: the kinds (`video`, `audio`) of a media relayed as several
/// tracks played together; empty for one file. A member swap names them,
/// the server fills them in for [fetch] once it has read the link.
class MediaRelay {
final String source;
final bool fetch;
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() => {
if (source.isNotEmpty) 'source': source,
if (fetch) 'fetch': true,
if (live) 'live': true,
if (tracks.length > 1) 'tracks': [for (final k in tracks) {'kind': k}],
};
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 : '',
fetch: json['fetch'] == true,
live: json['live'] == true);
live: json['live'] == true,
tracks: ok ? List.unmodifiable(kinds) : const []);
}
}
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
// media's bytes to the server, which buffers them for every viewer (`/r/...`).
// The source dials out (works behind NAT/CGNAT) on its own WebSocket, so
// video bytes never queue behind `/sync` messages.
// 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
// {"t":"end","id":1} pull complete
// {"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":"cancel","id":1} stop this pull
//
// Flow control is on the source: one adaptive in-flight budget per
// connection (see [RelayWindow]); a cancelled pull's unacked bytes are
// written off.
// Pull ids are unique across tracks. Flow control is on the source: one
// adaptive in-flight budget per connection (see [RelayWindow]), shared by
// the tracks; a cancelled pull's unacked bytes are written off.
final _log = createLogger('relay');
@@ -32,6 +35,21 @@ final _log = createLogger('relay');
/// slash still splits the path). The server ignores the segment.
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.
Uint8List encodeRelayFrame(int id, List<int> payload) {
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
/// 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 {
final Uri url;
final Map<String, String> headers;
final int chunkSize;
final HttpClient _client;
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();
Future<HttpClientResponse> _get(String range) async {
@@ -118,11 +140,33 @@ class HttpRelayBytes implements RelayBytes {
@override
Stream<List<int>> read(int start, int end) async* {
final res = await _get('bytes=$start-$end');
if (res.statusCode == 200 && start > 0) {
var pos = start;
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);
}
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
@@ -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 {
final int id;
var sent = 0, acked = 0;
@@ -213,14 +289,14 @@ class _Pull {
/// the current media": the source stops instead of reconnecting.
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
/// good when the server closes it with [relayCloseNotSource].
class RelaySource {
final Uri url;
final RelayBytes bytes;
final RelayMedia media;
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)))));
final window = RelayWindow();
@@ -235,12 +311,26 @@ class RelaySource {
/// Bytes sent to the server, for stats.
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 {
final size = await bytes.size();
unawaited(_loop(size));
final tracks = await media.open();
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;
while (!_closed) {
try {
@@ -251,7 +341,7 @@ class RelaySource {
}
_ws = ws;
attempt = 0;
ws.add(jsonEncode({'t': 'info', 'size': size}));
ws.add(jsonEncode(info));
await for (final m in ws) {
if (m is String) _onText(ws, m);
}
@@ -268,7 +358,7 @@ class RelaySource {
await Future<void>.delayed(backoff(attempt++));
}
_abandonAll();
await bytes.close();
await media.close();
if (!_done.isCompleted) _done.complete();
}
@@ -283,11 +373,12 @@ class RelaySource {
if (id is! int) return;
switch (j['t']) {
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 (track is! int || track < 0 || track >= _tracks.length) return;
final p = _Pull(id);
_pulls[id] = p;
unawaited(_serve(ws, p, start, end));
unawaited(_serve(ws, p, _tracks[track].bytes, start, end));
case 'ack':
final p = _pulls[id];
final n = j['n'];
@@ -307,7 +398,8 @@ class RelaySource {
p.cancelled = true;
window.writeOff(p.sent - p.acked);
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() {
@@ -317,7 +409,7 @@ class RelaySource {
_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 pending = BytesBuilder(copy: false);
const frame = RelayConstants.relayFrameBytes;
@@ -352,7 +444,7 @@ class RelaySource {
} finally {
p.finished = true;
if (p.settled) _pulls.remove(p.id);
await it.cancel();
await it.cancel().catchError((_) {});
}
}
+139 -39
View File
@@ -31,8 +31,31 @@ class YtDlpFormat {
final Map<String, String> headers;
final int? height;
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.
class YtDlpInfo {
@@ -43,10 +66,15 @@ class YtDlpInfo {
/// Seconds; null when unknown (a live stream).
final double? duration;
final String? thumbnail;
/// The best format holding video and audio in one http(s) file (what a
/// relay can range-read); null when the page only has split or HLS
/// streams.
final YtDlpFormat? single;
/// A live stream (never relayed).
final bool isLive;
/// What a relay range-reads (see [pickTracks]): one file, or a video and
/// an audio file; empty when the page has neither (only streaming
/// playlists, or live).
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({
required this.title,
this.uploader = '',
@@ -54,7 +82,9 @@ class YtDlpInfo {
this.webpageUrl = '',
this.duration,
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.
@@ -67,6 +97,7 @@ class YtDlpInfo {
}
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 isLive = j['is_live'] == true || j['live_status'] == 'is_live' || j['live_status'] == 'is_upcoming';
return YtDlpInfo(
title: str('title').isNotEmpty ? str('title') : str('fulltitle'),
uploader: str('uploader').isNotEmpty ? str('uploader') : str('channel'),
@@ -74,53 +105,122 @@ class YtDlpInfo {
webpageUrl: str('webpage_url'),
duration: j['duration'] is num ? (j['duration'] as num).toDouble() : null,
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 _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
/// 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
/// it; mp4/webm and higher bitrate break ties.
YtDlpFormat? pickSingleFile(List<Object?> formats, {int maxHeight = 1080}) {
final ok = <(Map, int)>[];
for (final f in formats) {
if (f is! Map) continue;
final url = f['url'];
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));
}
final ok = [
for (final f in formats)
if (_httpUrl(f) && (f as Map)['vcodec'] != 'none' && f['acodec'] != 'none' && _singleFileExts.contains(f['ext']))
f,
];
if (ok.isEmpty) return null;
double tbr(Map f) => f['tbr'] is num ? (f['tbr'] as num).toDouble() : 0;
int rank((Map, int) a, (Map, int) b) {
final (fa, ha) = a;
final (fb, hb) = b;
final ina = ha <= maxHeight, inb = hb <= maxHeight;
if (ina != inb) return ina ? -1 : 1;
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';
if (ma != mb) return ma ? -1 : 1;
return tbr(fb).compareTo(tbr(fa));
bool mp4(Map f) => f['ext'] == 'mp4' || f['ext'] == 'webm';
ok.sort((a, b) {
final h = _byHeight(_height(a), _height(b), maxHeight);
if (h != 0) return h;
if (mp4(a) != mp4(b)) return mp4(a) ? -1 : 1;
return _tbr(b).compareTo(_tbr(a));
});
return YtDlpFormat._of(ok.first);
}
ok.sort(rank);
final (f, h) = ok.first;
final headers = f['http_headers'];
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']}',
);
int _videoCodecRank(Map f) {
final c = '${f['vcodec']}'.toLowerCase();
if (c.startsWith('avc') || c.startsWith('h264')) return 0;
if (c.startsWith('vp9') || c.startsWith('vp09')) return 1;
if (c.startsWith('av01')) return 2;
return 3;
}
/// 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
+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
// YTDLP_LIVE=1.
library;
import 'dart:ffi' show Abi;
import 'dart:io';
import 'dart:math';
import 'package:sync_relay_core/core.dart';
import 'package:test/test.dart';
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, //
'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() {
@@ -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'));
});
});
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', () {
bool file(int status, String type, String url) =>
looksLikeMediaFile(status: status, contentType: type, url: Uri.parse(url));
@@ -120,13 +190,14 @@ void main() {
expect(probe.isPage, isTrue);
expect(probe.title, startsWith('a girl.'));
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();
var n = 0;
await for (final c in bytes.read(0, size - 1)) {
n += c.length;
}
await bytes.close();
await media.close();
expect(n, size);
}, 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(
(c) => buffer.feedBytes(id, c is Uint8List ? c : Uint8List.fromList(c)),
onError: (Object e) {
_subs.remove(id)?.cancel();
unawaited(_subs.remove(id)?.cancel().catchError((_) {}));
buffer.feedFail(id, '$e');
},
onDone: () {
@@ -42,26 +42,29 @@ class LocalRelayFeed extends RelayFeed {
);
}
/// A read cut off mid-request errors on cancel: moot.
@override
void cancel(int id) => _subs.remove(id)?.cancel();
void cancel(int id) => unawaited(_subs.remove(id)?.cancel().catchError((_) {}));
Future<void> close() async {
for (final s in _subs.values) {
await s.cancel();
await s.cancel().catchError((_) {});
}
_subs.clear();
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 {
final void Function(String text) send;
ChannelRelayFeed(this.send);
final int? track;
ChannelRelayFeed(this.send, {this.track});
@override
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
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.
/// Everything lives in memory: dropping the buffer is the cleanup.
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;
/// Pull ids; the tracks of one media share one sequence (one channel).
final int Function() _newId;
RelayBuffer({
this.ahead = RelayConstants.relayAheadBytes,
this.back = RelayConstants.relayBackBytes,
this.pin = RelayConstants.relayPinBytes,
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;
@@ -118,7 +131,7 @@ class RelayBuffer {
final _blocked = <int, int>{}; // slice -> wait start (us)
final _viewers = <String, _Viewer>{};
_Pull? _pull;
var _nextId = 1, _reqSeq = 0;
var _reqSeq = 0;
var _disposed = false;
Completer<void> _kick = Completer();
final Completer<void> _ready = Completer();
@@ -217,6 +230,7 @@ class RelayBuffer {
}
void feedFail(int id, String reason) {
if (_pull?.id != id) return;
_log.warning('relay pull $id failed: $reason');
feedEnd(id);
}
@@ -323,7 +337,7 @@ class RelayBuffer {
while (e + 1 < _nSlices && !_slices.containsKey(e + 1) && _wanted(e + 1)) {
e++;
}
final pl = _Pull(_nextId++, s * slice, _sliceEnd(e));
final pl = _Pull(_newId(), s * slice, _sliceEnd(e));
_pull = pl;
feed.pull(pl.id, pl.start, pl.end);
}
+163 -47
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:relic/relic.dart';
@@ -12,43 +13,83 @@ import 'session.dart';
final _log = createLogger('relay');
/// The relay of the current media: its buffer and where the bytes come
/// from. One per relayed swap; [dispose] on the next swap or server stop
/// (the buffer is memory only, so that is the whole cleanup).
/// Each track's (ahead, back) bytes when a media's [sizes] share one
/// buffer budget: by size (the tracks have one duration, so by bitrate),
/// 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 {
final int mediaId;
final String name;
/// 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 RelayBuffer buffer;
final LocalRelayFeed? local;
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;
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,
{RelayBuffer? buffer, required void Function() onLiveChanged}) =>
ActiveRelay._(mediaId, name, sourceToken, buffer ?? RelayBuffer(), null, onLiveChanged);
{List<String> tracks = const [], required void Function() onLiveChanged}) =>
ActiveRelay._(mediaId, name, sourceToken, onLiveChanged).._setTracks(tracks);
/// 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,
{RelayBuffer? buffer,
YtDlp? ytDlp,
required void Function() onLiveChanged,
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);
{YtDlp? ytDlp, required void Function() onLiveChanged, void Function(String reason)? onFail}) {
final r = ActiveRelay._(mediaId, name, null, onLiveChanged);
final media = r._media = LinkRelayMedia(url, ytDlp: ytDlp);
unawaited(() async {
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;
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();
} catch (e) {
_log.warning('relay fetch: cannot read the link: $e');
@@ -59,13 +100,40 @@ class ActiveRelay {
}
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() {
if (_disposed) return;
_disposed = true;
buffer.dispose();
unawaited(local?.close());
for (final b in buffers) {
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;
_channel = null;
if (ch != null) unawaited(_closeNotSource(ch));
@@ -78,25 +146,48 @@ class ActiveRelay {
if (ch != null) unawaited(_closeNotSource(ch));
}
void _attachChannel(RelicWebSocket ws, int size) {
void _attachChannel(RelicWebSocket ws, List<int> sizes) {
final old = _channel;
if (old != null && !identical(old, ws)) unawaited(old.tryClose());
_channel = ws;
final feed = _channelFeed = ChannelRelayFeed((t) => ws.trySendText(t));
buffer.attach(feed, size);
final multi = buffers.length > 1;
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();
}
void _detachChannel(RelicWebSocket ws) {
if (!identical(_channel, ws)) return;
_channel = null;
final feed = _channelFeed;
_channelFeed = null;
if (feed != null) buffer.detach(feed);
final feeds = _channelFeeds;
_channelFeeds = const [];
for (var i = 0; i < feeds.length && i < buffers.length; i++) {
buffers[i].detach(feeds[i]);
}
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 {
try {
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
/// 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(
RelicApp app,
SessionMap sessions,
@@ -144,21 +236,37 @@ void mountRelayRoutes(
final id = j['id'];
switch (j['t']) {
case 'info':
final size = j['size'];
if (size is int && size > 0 && !attached) {
attached = true;
_log.info('relay channel up (${r.name}, $size bytes)');
r._attachChannel(ws, size);
if (attached) continue;
final sizes = _infoSizes(j);
if (sizes == null || sizes.length != r.buffers.length) {
// Not the media this swap named: the source stops.
_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':
if (id is int) r.buffer.feedEnd(id);
if (id is int) {
for (final b in r.buffers) {
b.feedEnd(id);
}
}
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) {
try {
// Pull ids are unique across tracks: only the owner takes it.
final (id, payload) = decodeRelayFrame(e.data);
r.buffer.feedBytes(id, payload);
for (final b in r.buffers) {
b.feedBytes(id, payload);
}
} 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 sid = req.pathParameters.raw[#sid] ?? '';
final token = req.pathParameters.raw[#token] ?? '';
final mediaId = int.tryParse(req.pathParameters.raw[#mediaId] ?? '');
final track = tracked ? int.tryParse(req.pathParameters.raw[#seg] ?? '') ?? -1 : null;
final session = sessions.get(sid);
if (session == null) return _plain(410);
switch (session.gate(token)) {
@@ -189,12 +298,14 @@ void mountRelayRoutes(
final r = relay();
// 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 (!await r.buffer.ready()) {
return Response(503,
final notConnected = Response(503,
body: Body.fromString('relay source not connected'),
headers: Headers.build((h) => h['retry-after'] = ['2']));
}
final size = r.buffer.size;
if (!await r.whenKnown()) return notConnected;
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;
String? rangeHeader;
if (typed != null && typed.ranges.isNotEmpty) {
@@ -212,14 +323,14 @@ void mountRelayRoutes(
token,
MediaLog(
method: req.method.name,
path: '/r/$sid/$shortToken/$mediaId/${r.name}',
path: '/r/$sid/$shortToken/$mediaId/${track == null ? '' : '$track/'}${r.name}',
rangeHeader: rangeHeader,
status: res.status,
bytesWritten: end - res.start + 1,
durationMs: stopwatch.elapsedMilliseconds,
));
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;
try {
mime = MimeType.parse(res.headers['content-type'] ?? '');
@@ -229,7 +340,12 @@ void mountRelayRoutes(
return Response(res.status,
body: Body.fromDataStream(body, mimeType: mime, contentLength: end - res.start + 1),
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'));
+22 -13
View File
@@ -46,16 +46,20 @@ class ServerHub {
relay = next;
}
/// `media.relay` for a relayed swap by [token]: who serves the bytes.
MediaRelay relayFor(String token, MediaRelay requested) => MediaRelay(
source: requested.fetch ? '' : (engine.members[token]?.id ?? ''), fetch: requested.fetch);
/// `media.relay` for a relayed swap by [token]: who serves the bytes
/// and, from a member, its tracks (the server finds a fetched link's).
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) {
final m = engine.media;
if (!identical(relay, r) || m?.relay == null) return;
if (m!.relay!.live == r.live) return;
engine.media = m.copyWith(relay: m.relay!.copyWith(live: r.live));
final was = m!.relay!;
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();
}
@@ -243,7 +247,7 @@ void mountSyncRoute(
String Function(String sid, String token, SessionMedia? media, String host)
urlBuilder, {
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) {
// Host the client dialled (Host header), so file URLs work for remote joiners.
@@ -310,7 +314,7 @@ void mountSyncRoute(
size: parsed.size,
duration: parsed.duration,
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)) {
wsErr(ws, 'bad swap media');
return;
@@ -321,7 +325,7 @@ void mountSyncRoute(
duration: media.duration,
jellyfin: ref,
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();
return;
}
@@ -361,7 +365,7 @@ void mountSyncRoute(
ytDlp: hub.ytDlp,
onLiveChanged: () => hub.relayLiveChanged(active),
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.changed();
return;
@@ -556,13 +560,17 @@ void mountSyncRoute(
// Jellyfin media resolves per member (own account first); only
// relayed files and links start from the /r/ URL, and never for
// 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 &&
media != null &&
media.jellyfin == null &&
relayed.sourceToken != activeToken &&
relayed.mediaId == media.mediaId &&
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
? urlBuilder('default', activeToken, media, dialledHost)
: 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;
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;
}
+5 -4
View File
@@ -48,11 +48,12 @@ class SyncServer {
/// Upstream connections of the Jellyfin relay; created on first use.
HttpClient? _relayClient;
SyncServer(this.config) {
/// [ytDlp] replaces the server's own (tests).
SyncServer(this.config, {YtDlp? ytDlp}) {
hub = ServerHub(sessions, config.secret,
hostKey: config.hostKey,
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;
@@ -103,8 +104,8 @@ class SyncServer {
return 'http://${authority(dialledHost)}/m/$sid/$token/$name';
}, relayUrlBuilder: (sid, token, itemId, dialledHost) =>
'http://${authority(dialledHost)}/j/$sid/$token/${Uri.encodeComponent(itemId)}',
relayMediaUrlBuilder: (sid, token, mediaId, name, dialledHost) =>
'http://${authority(dialledHost)}/r/$sid/$token/$mediaId/${relayUrlName(name)}');
relayMediaUrlBuilder: (sid, token, mediaId, name, tracks, dialledHost) =>
relayViewerUrl('http://${authority(dialledHost)}/r/$sid/$token/$mediaId', name, tracks: tracks));
_app.fallback = (final req) => Response.notFound(
body: Body.fromString('not found'),
);
+98 -4
View File
@@ -92,10 +92,10 @@ void main() {
return m;
}
Future<RelaySource> startSource(_Member m, {RelayBytes? from}) async {
Future<RelaySource> startSource(_Member m, {RelayMedia? from}) async {
final src = RelaySource(
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));
await src.start();
cleanup.add(src.close);
@@ -207,7 +207,7 @@ void main() {
final src = await startSource(alice);
final oldId = mediaIdOf(alice);
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({
't': 'swap',
'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 {
final alice = await join('alice');
await swapRelay(alice);
final buf = server.hub.relay!.buffer;
final buf = server.hub.relay!.buffers.single;
await server.stop();
expect(server.hub.relay, isNull);
expect(buf.cachedBytes, 0);
@@ -250,6 +250,39 @@ void main() {
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 {
final alice = await join('alice');
final before = alice.raw.length;
@@ -271,9 +304,19 @@ void main() {
group('server fetches the link', () {
late HttpServer origin;
final ranges = <(String, int)>[];
setUp(() async {
origin = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
ranges.clear();
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(
rangeHeader: req.headers.value('range'), size: bytes.length, filename: 'x.mkv');
req.response.statusCode = r.status;
@@ -300,6 +343,39 @@ void main() {
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 {
await server.stop();
server = SyncServer(const AppConfig(port: 0, secret: '', name: 'open'));
@@ -384,3 +460,21 @@ class _CountingBytes implements RelayBytes {
@override
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', () {
test('proto 10: avatars by hash, profile intent', () {
expect(RelayConstants.protoVersion, 10);
test('proto 11: avatars by hash, profile intent', () {
expect(RelayConstants.protoVersion, 11);
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('avatar'), isFalse);
+5 -3
View File
@@ -50,7 +50,8 @@ Future<void> main(List<String> argv) async {
.toJson()));
await Future<void>.delayed(const Duration(milliseconds: 300));
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();
final urls = <String>[];
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(',')}');
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} '
'cached=${b?.cachedBytes} window=${source.window.window}');
});
// SIGUSR1: drop the source; SIGUSR2: bring it back (outage tests).
ProcessSignal.sigusr1.watch().listen((_) => source.close());
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();
});
}