Relay HLS/DASH-only pages as pieces tracks (protocol 11)
A track can be served as pieces: the server writes a VOD playlist whose entries are its own piece URLs (segments, init maps, AES-128 keys; byte ranges folded in; yt-dlp's DASH fragments become the same list). A piece buffer per track fills like the byte buffer. Picks a single file, then a split byte pair, then pieces. DRM, live, master playlists are refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
05f9e794a9
commit
fe5d6c3b3f
18 files changed
+1593
-207
No files matched your search
+64
-16
@@ -423,7 +423,8 @@ errors leave it out).
|
||||
|
||||
A member's media through the server's memory, for viewers who cannot
|
||||
reach it themselves (`packages/core/lib/src/relay.dart` for the wire and
|
||||
the source, `packages/server/lib/src/relay_buffer.dart`,
|
||||
the source, `relay_pieces.dart` for segmented streams,
|
||||
`packages/server/lib/src/relay_buffer.dart`, `piece_buffer.dart`,
|
||||
`relay_routes.dart`; wire in [PROTOCOL.md](PROTOCOL.md) "Relay").
|
||||
|
||||
What gets relayed (`SessionCoordinator.swapPicked`):
|
||||
@@ -450,6 +451,27 @@ 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.
|
||||
|
||||
A page with only segmented streams (HLS `m3u8_native`, DASH
|
||||
`http_dash_segments`, e.g. X's HLS, generic `.m3u8`/`.mpd`) is relayed as
|
||||
**pieces** tracks (`"as":"pieces"`, one muxed playlist or video + audio):
|
||||
the fetcher parses the upstream media playlist (`parseHlsPlaylist`), or
|
||||
yt-dlp's DASH `fragments` (`dashPieces`: a first fragment without a
|
||||
duration is the init section, so no MPD parser), into a
|
||||
`RelayPieceList`: each segment, `EXT-X-MAP` init section and AES-128 key
|
||||
is a piece, one upstream GET, with `EXT-X-BYTERANGE` folded into the
|
||||
piece. The server serves a VOD media playlist it writes itself from that
|
||||
list (`RelayPieceList.playlist`: durations, media sequence,
|
||||
discontinuities, MAP, AES-128 keys with their IVs; every URI its own
|
||||
`p/<i>`), so a source can never point viewers anywhere else. One URL per
|
||||
piece also avoids mpv's seek hang on byte-range fMP4 playlists (Apple's
|
||||
`bipbop` hung on the first seek read directly). Refused: DRM (SAMPLE-AES,
|
||||
`skd:`, any non-identity key format, `EXT-X-SESSION-KEY`, yt-dlp's
|
||||
`has_drm`), a live playlist (no `EXT-X-ENDLIST`; yt-dlp's `is_live`
|
||||
first), a master or I-frame playlist. A remux on the fetcher (ffmpeg
|
||||
`-c copy` into one pipe) and yt-dlp's own download + merge were rejected:
|
||||
the size is unknown until the end, a seek past the written part cannot be
|
||||
served, and it needs ffmpeg or disk.
|
||||
|
||||
How bytes move:
|
||||
- The source dials `/r/.../source` (outbound, so NAT/CGNAT are fine) and
|
||||
answers the server's pulls from the file or the URL (`RelaySource` over
|
||||
@@ -467,8 +489,8 @@ How bytes move:
|
||||
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
|
||||
- The server keeps one buffer per track of a relayed media (one for a
|
||||
file), a `RelayBuffer` for bytes: 1 MiB slices in
|
||||
memory, filled by one sequential pull at a time. Kept: the first and
|
||||
last 2 MiB (container indexes; mpv reads both on open and on seeks),
|
||||
64 MiB behind the slowest viewer and 256 MiB past the furthest, the
|
||||
@@ -480,9 +502,27 @@ How bytes move:
|
||||
each, which keeps both windows at the same playback distance (1080p
|
||||
video + aac: about 246 + 10 MiB ahead); each pins its own head and
|
||||
tail. A viewer is its token in every track's buffer.
|
||||
- Filling: a viewer blocked on a slice first (oldest wait first, handing
|
||||
the source over at slice boundaries), then the pinned slices, then
|
||||
read-ahead from the slowest viewer. Nothing is fetched while nobody
|
||||
- A pieces track gets a `PieceBuffer`: whole pieces by index, each one
|
||||
pull (a piece names its index on the channel), served whole with Range
|
||||
inside a piece. A viewer's position is its newest requested segment
|
||||
(opening the playlist puts a new viewer at the first one, so its first
|
||||
pieces are fetched before it asks). Filled: a blocked viewer's piece
|
||||
(oldest wait first; a pull nobody waits on gives way at once), the init
|
||||
and key pieces the viewers' segments use, then segments ahead of each
|
||||
viewer up to the ahead budget, slowest first. Init and key pieces are
|
||||
kept; segments are evicted by distance (behind costs 4x), sizes
|
||||
estimated from the bytes per second of the pieces in so far (before
|
||||
that 4 Mbit/s video, 160 kbit/s audio, which also splits the budget).
|
||||
A failed pull is retried after 1 s. Measured (mpv 0.41 through the
|
||||
dedicated server, loopback, ~30 MB/s upstream): Mux's TS HLS (1080p,
|
||||
one muxed playlist) opened in 0.8 s, seeks 0.07 to 1.1 s; Akamai's DASH
|
||||
(1080p + aac, two playlists, EDL) 1.3 s, seeks 0.03 to 1.4 s; an X
|
||||
video's HLS video + audio 0.3 to 0.75 s, seeks 0.03 to 0.4 s; A/V sync
|
||||
within 0.2 ms after every seek; a second viewer of the same pieces
|
||||
opened in 0.02 s from the buffer.
|
||||
- Filling a byte buffer: a viewer blocked on a slice first (oldest wait
|
||||
first, handing the source over at slice boundaries), then the pinned
|
||||
slices, then read-ahead from the slowest viewer. Nothing is fetched while nobody
|
||||
watches through the relay (a Jellyfin source with only account holders
|
||||
uploads nothing).
|
||||
- mpv leaves superseded requests open but unread after a seek: only a
|
||||
@@ -498,7 +538,8 @@ How bytes move:
|
||||
stop. A crash frees it with the process.
|
||||
- Source away: viewers keep playing from the buffer; a request waits up
|
||||
to 60 s for missing bytes. The source reconnects with backoff (same
|
||||
token after a resume); a different size drops that track's buffer.
|
||||
token after a resume); a different size (or piece count and roles)
|
||||
drops that track's buffer.
|
||||
`state.media.relay.live` follows the channel, and viewers show "waiting
|
||||
for <name> to pass the video on" while it is down.
|
||||
|
||||
@@ -581,7 +622,12 @@ all three link modes. Nothing is bundled or installed by hand.
|
||||
and the server buffer, and mpv decodes them all), 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.
|
||||
- Neither: segmented streams (`pickPieces`: `m3u8_native`/`m3u8` with a
|
||||
media playlist URL, or `http_dash_segments` with http(s) `fragments`;
|
||||
not `has_drm`): one muxed playlist (video and audio not known to be
|
||||
absent) unless a video-only + audio-only pair is taller, with the
|
||||
same height, codec and audio rules. Relayed as pieces (see "Relay").
|
||||
- A live stream, or nothing of the above: nothing to relay.
|
||||
- **Probe** (`probeLink`, `link_probe.dart`): a `Range: bytes=0-0` GET; a
|
||||
video/audio type, or a generic one on a media file name, is a file
|
||||
(`looksLikeMediaFile`); anything else goes to yt-dlp, and when yt-dlp
|
||||
@@ -595,14 +641,16 @@ all three link modes. Nothing is bundled or installed by hand.
|
||||
(`script-opts=ytdl_hook-ytdl_path=...`, `ytdl-format=bv*[height<=1080]+ba/b`,
|
||||
once per player), waiting for the download unless the URL names a media
|
||||
file. A hook error shows as "can't open this link: ...".
|
||||
- **Through me**: the putter's probe must give tracks, else the swap is
|
||||
refused ("Live streams can't be passed on", or no video file: only
|
||||
streaming playlists) with a hint to use "Everyone fetches it". The swap
|
||||
names the tracks; its relay source reads them with yt-dlp's headers
|
||||
(`PageRelayMedia`). On 403/410 (expired format URLs, 6 h on YouTube) from
|
||||
any track it resolves the page again once for all tracks, finds the
|
||||
same format ids with the same sizes (else "the video changed on the
|
||||
page") and goes on from where it stopped. Format URLs are bound to the
|
||||
- **Through me**: the putter's probe must give tracks, and a pieces
|
||||
track's playlist must be readable, else the swap is refused ("Live
|
||||
streams can't be passed on", DRM, or nothing a relay can read) with a
|
||||
hint to use "Everyone fetches it". The swap names the tracks; its relay
|
||||
source reads them with yt-dlp's headers (`PageRelayMedia`). On 403/410
|
||||
(expired format URLs, 6 h on YouTube; signed segment URLs) from any
|
||||
track it resolves the page again once for all tracks, finds the same
|
||||
format ids with the same sizes and piece counts and roles (playlists
|
||||
re-read and re-mapped by index; else "the video changed on the page")
|
||||
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
|
||||
(`LinkRelayMedia`) with its own yt-dlp (off with `--no-yt-dlp` /
|
||||
|
||||
+44
-18
@@ -235,17 +235,22 @@ flow control, cases) is in [ARCHITECTURE.md](ARCHITECTURE.md), "Relay".
|
||||
swap media.relay = {} the swapper's machine serves the bytes
|
||||
media.relay = {"tracks":[{"kind":"video"},{"kind":"audio"}]}
|
||||
the same, as two tracks played together
|
||||
media.relay = {"tracks":[{"kind":"video","as":"pieces"}]}
|
||||
the same, as one segmented playlist
|
||||
media.relay = {"fetch":true} the server reads media.url itself
|
||||
state media.relay = {"source":"m2","live":true} or {"fetch":true,"live":true}
|
||||
media.relay = {"fetch":true,"live":true,"tracks":[{"kind":"video"},{"kind":"audio"}]}
|
||||
```
|
||||
|
||||
Tracks: a relayed media is one file, or several byte tracks (a page's
|
||||
separate video and audio files) that viewers play as one. `tracks` lists
|
||||
them in order, 2 to 4 entries of `kind` `video` or `audio`; absent (or
|
||||
anything else) means one file. Every track is a Range-readable byte
|
||||
stream; other kinds of track (e.g. segmented playlists) would add a field
|
||||
to the entry.
|
||||
Tracks: a relayed media is one file, or tracks that viewers play as one
|
||||
(a page's separate video and audio). `tracks` lists them in order, up to
|
||||
4 entries of `kind` `video` or `audio`, each a Range-readable byte stream,
|
||||
or with `"as":"pieces"` a segmented stream (an HLS media playlist or DASH
|
||||
fragments) relayed piece by piece and served as a VOD media playlist. A
|
||||
piece is one segment, init section (`EXT-X-MAP`) or AES-128 key, fetched
|
||||
upstream whole with one GET. At least 2 entries, or 1 pieces entry (a
|
||||
muxed playlist, `kind` `video`); absent, an unknown `kind` or `as`, or
|
||||
anything else means one file.
|
||||
|
||||
- `swap`: `relay` with `jellyfin` keeps the ref (the swapper serves it to
|
||||
viewers without an account; `fetch` is ignored there). `relay` without
|
||||
@@ -269,19 +274,27 @@ to the entry.
|
||||
Clients build it after a swap like the `/m` one:
|
||||
`http://<host>/r/default/<token>/<mediaId>/<name>` (name with `/` and
|
||||
`\` made `_`, then URL-encoded; the server ignores it). With `tracks`,
|
||||
it is an mpv EDL of every track's URL
|
||||
`http://<host>/r/default/<token>/<mediaId>/<i>/<name>`, each framed by
|
||||
its byte length:
|
||||
each track's URL is `http://<host>/r/default/<token>/<mediaId>/<i>/<name>`
|
||||
(a pieces track: `<name>.m3u8`); one track is opened as is, several as
|
||||
an mpv EDL of them all, 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, 404 not the current relayed
|
||||
media (a player still on an older `mediaId` gets 404, never the new
|
||||
bytes) or no such track (the one-file URL of a media with `tracks`), 410
|
||||
unknown session, 503 + `retry-after` the source never connected or the
|
||||
server could not read the link (60 s). A request whose bytes do not
|
||||
arrive within 60 s ends early. Bytes count toward `bytesServed`.
|
||||
too); for a pieces track (any name), its media playlist
|
||||
(`application/vnd.apple.mpegurl`): `#EXT-X-PLAYLIST-TYPE:VOD`,
|
||||
`EXT-X-MEDIA-SEQUENCE`, `EXTINF` durations, `EXT-X-DISCONTINUITY`,
|
||||
`EXT-X-MAP` and `EXT-X-KEY:METHOD=AES-128` (with `IV` when given) as
|
||||
upstream, `EXT-X-ENDLIST`; every URI is `p/<j>`. `GET
|
||||
/r/<sid>/<token>/<mediaId>/<i>/p/<j>`: piece `j` of pieces track `i`,
|
||||
whole, once it is in (a Range inside the piece answers 206). Errors: 401
|
||||
bad token, 404 not the current relayed media (a player still on an
|
||||
older `mediaId` gets 404, never the new bytes), no such track (the
|
||||
one-file URL of a media with `tracks`) or piece (or `p/` of a byte
|
||||
track), 410 unknown session, 503 + `retry-after` the source never
|
||||
connected or the server could not read the link (60 s), or a piece did
|
||||
not arrive within 60 s. A byte request whose bytes do not arrive within
|
||||
60 s ends early. Bytes count toward `bytesServed`.
|
||||
- `GET /r/<sid>/<token>/source` (WebSocket): the source's relay channel,
|
||||
dialled by the source (works behind NAT). Only the current source's
|
||||
token is accepted; anyone else, or the source after the next swap, is
|
||||
@@ -293,18 +306,31 @@ source -> server {"t":"info","size":<bytes>} first frame, one file
|
||||
{"t":"info","tracks":[{"kind":"video","size":<bytes>},
|
||||
{"kind":"audio","size":<bytes>}]}
|
||||
first frame, tracks
|
||||
{"t":"info","tracks":[{"kind":"video","as":"pieces","seq":5,
|
||||
"pieces":[{"init":true},{"key":true},
|
||||
{"d":6.0,"map":0,"key":1,"iv":"0x..."},
|
||||
{"d":4.5,"map":0,"disc":true}]}]}
|
||||
a pieces track
|
||||
binary: [u32 big-endian pull id][bytes] in order per pull
|
||||
{"t":"end","id":1} {"t":"fail","id":1,"reason":"..."}
|
||||
server -> source {"t":"pull","id":1,"track":0,"start":0,"end":1048575}
|
||||
inclusive; no track: 0
|
||||
{"t":"pull","id":2,"track":1,"piece":17} piece 17, whole
|
||||
{"t":"ack","id":1,"n":65536} bytes arrived
|
||||
{"t":"cancel","id":1} stop that pull
|
||||
```
|
||||
|
||||
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.
|
||||
only for a media with `tracks`. A pieces track's `pieces` are its
|
||||
pieces in order: `{"init":true}` an init section, `{"key":true}` an
|
||||
AES-128 key, else a segment of `d` seconds whose `map` / `key` name the
|
||||
init and key pieces it uses, `iv` an explicit IV (`0x` + hex), `disc` a
|
||||
discontinuity before it; `seq` (default 0) the media sequence of the
|
||||
first segment. A piece pull's bytes end with `end` like a range pull.
|
||||
An `info` whose tracks differ from the swap's `tracks` (count, bytes or
|
||||
pieces), or a malformed piece list, closes the channel with 4001. A
|
||||
track `size` or piece count/roles different from the one before (a
|
||||
reconnect) drops that track's buffer.
|
||||
|
||||
## Join and reconnect
|
||||
|
||||
|
||||
+10
-14
@@ -19,22 +19,18 @@ off by deleting it in the same commit as the code. Wire changes also edit
|
||||
through media_kit; if not, resolve in the app and open the relay
|
||||
tracks' formats. YouTube: yt-dlp wants a JS runtime (deno) for full
|
||||
extraction. Formats that need yt-dlp's cookies.
|
||||
- Relay "pieces" tracks for pages with only HLS/DASH (`m3u8_native`,
|
||||
`http_dash_segments`): a track served as a synthesized VOD media
|
||||
playlist whose entries are pieces (one segment, init `EXT-X-MAP` or
|
||||
AES-128 key = one upstream GET, served whole, Range inside a piece
|
||||
honoured), with a piece buffer per track (pieces by index, filled
|
||||
like `RelayBuffer`: a blocked viewer, the init piece, then ahead of
|
||||
the slowest viewer; evicted by distance); `EXT-X-BYTERANGE` folded
|
||||
into pieces (it also avoids mpv's seek hang on byte-range fMP4
|
||||
playlists); yt-dlp's DASH `fragments` turned into the same playlist,
|
||||
so no MPD parser. Wire: a track entry gains `"as":"pieces"`, `info`
|
||||
lists piece durations, a pull names a piece. Refuse DRM
|
||||
(`skd:`/SAMPLE-AES/Widevine). Measured in a spike: piece relay of
|
||||
Mux/Apple/Akamai HLS/DASH opened in 1.5 to 2.2 s, seeks 0.02 to 2.4 s.
|
||||
- Live streams through the relay (today refused: the sync model needs a
|
||||
fixed duration and seeking).
|
||||
- `tool/relay_e2e.dart`: a two-track case over a shaped source link.
|
||||
- `tool/relay_e2e.dart`: a two-track case (bytes and pieces) over a
|
||||
shaped source link. Pieces on a slow source: a cold piece can take
|
||||
longer than media_kit's 5 s `network-timeout`; if it does, fetch the
|
||||
first pieces before setting `relay.live`.
|
||||
- Pieces, unverified so far: the 403/410 re-resolve against a real
|
||||
expiring HLS (signed segment URLs), HLS discontinuities / ad
|
||||
splicing, Windows' bundled ffmpeg with extensionless piece URLs
|
||||
(`p/<i>`; fine with ffmpeg 9 on Linux).
|
||||
- Thumbnails and scrubbing previews of a relayed media with tracks
|
||||
should open the video track's URL alone, not the EDL.
|
||||
|
||||
4. **AniList account**: sign in (OAuth via a localhost callback), "My list"
|
||||
filter on the Schedule; today's "Following" (AnimeFlow's monitored
|
||||
|
||||
Reference in new issue
Block a user