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:
1 parent
b2b77a6b3f
commit
b038706dff
19 files changed
+945
-286
No files matched your search
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,9 +119,11 @@ class LinkProbe {
|
|||||||
? null
|
? null
|
||||||
: info == null
|
: info == null
|
||||||
? notAVideoText(ytDlpError)
|
? notAVideoText(ytDlpError)
|
||||||
: info!.single == null
|
: info!.isLive
|
||||||
? noSingleFileText
|
? liveStreamText
|
||||||
: null;
|
: info!.tracks.isEmpty
|
||||||
|
? noRelayFormatText
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _probeLimit = 512 * 1024;
|
const _probeLimit = 512 * 1024;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
source: json['source'] is String ? json['source'] as String : '',
|
factory MediaRelay.fromJson(Map<String, Object?> json) {
|
||||||
fetch: json['fetch'] == true,
|
final t = json['tracks'];
|
||||||
live: json['live'] == true);
|
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,
|
||||||
|
tracks: ok ? List.unmodifiable(kinds) : const []);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class HelloMessage {
|
class HelloMessage {
|
||||||
|
|||||||
@@ -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) {
|
||||||
throw HttpException('upstream ignores Range', uri: url);
|
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);
|
||||||
|
}
|
||||||
|
// 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);
|
||||||
}
|
}
|
||||||
yield* res;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@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((_) {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 buffer = r.bufferAt(track);
|
||||||
final size = r.buffer.size;
|
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'));
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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'),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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});
|
||||||
|
}
|
||||||
@@ -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
@@ -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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user