Ephemeral media fetch & streamReel
A Rust/Axum service that fetches legal torrents behind a WireGuard VPN, holds the content as an ephemeral hot-cache reaped on idle, and progressively streams it to the browser as raw MP4 or live HLS.
What it gives you
Features
VPN-isolated fetch
librqbit embedded in the binary leeches and seeds legal torrents entirely behind a Gluetun/WireGuard sidecar with a killswitch — no traffic leaves the tunnel.
Ephemeral hot-cache
Content is a cache, not a library — a TTL reaper deletes entries after they go idle, freeing disk and stopping seeding through one unified delete path.
Progressive delivery
ffprobe picks the cheapest correct route per file — raw byte-range MP4 while still downloading, remux-HLS, or transcode-HLS — so anything plays in the browser.
Same-origin player
An astro-kbve hls.js island streams through the axum-kbve proxy at /api/v1/reel, staff-gated, with the service token injected server-side.
Overview
What reel does
Reel is an internal media service. It embeds librqbit to fetch legal torrents
behind a WireGuard VPN, keeps the content as an ephemeral hot-cache (reaped after an
idle TTL, not a permanent library), and progressively delivers it to the browser.
Pipeline
Section titled “Pipeline”- Fetch — librqbit leeches and seeds behind a Gluetun killswitch; a reaper deletes idle entries.
- Transcode — ffprobe routes each file to remux (faststart MP4) or re-encode.
- Stream — live HLS while still downloading (popcorn); byte-range progressive MP4 for already-playable files; HLS for containers/codecs the browser can’t play raw.
- Player — an astro-kbve hls.js island at
/media/reel/, proxied same-origin through axum-kbve at/api/v1/reel, staff-gated.
Live (popcorn) streaming
Section titled “Live (popcorn) streaming”Hitting Play on a still-downloading torrent no longer waits for the fetch to
finish. GET /manifest.m3u8 on a Leeching entry starts a live HLS job that
reads directly from librqbit’s sequential-priority stream and pipes it through
ffmpeg (pipe:0 → -f hls, event playlist), so the front of the file is
watchable within seconds while the tail keeps downloading. h264 video is
stream-copied (realtime); anything else is re-encoded libx264 -preset veryfast;
audio is always downmixed to stereo aac so it plays with sound. The player
already polls 202 → 200 and switches to hls.js unchanged. Gated by
REEL_LIVE_HLS (default on); when off, leeching falls back to raw progressive.
Playback quality tracks download rate — a starved swarm buffers.
To keep playback smooth, the live job holds back going Live until
REEL_LIVE_PREBUFFER_SEGMENTS (default 3, ≈12s at 4s segments) are ready, so a
brief download dip right after Play doesn’t immediately re-buffer, and the player
(hls.js) is tuned to hold a large forward buffer (up to ~10 min) of the
downloaded-ahead lead. Because the copy path muxes as fast as pieces arrive, the
segment head stays ahead of the playhead whenever download rate beats the video
bitrate — so after the initial prime, buffering only happens on a genuinely slow
swarm.
Subtitles. When the primary file carries a text subtitle track (SubRip/ASS/
mov_text — image subs like PGS/VobSub are skipped), the live job emits a WebVTT
subtitle rendition alongside the video: ffmpeg produces a master playlist with an
EXT-X-MEDIA subtitle group (-c:s webvtt, -var_stream_map), the player marks
it DEFAULT=YES, and hls.js auto-enables it. The manifest served at
…/manifest.m3u8 rewrites child-playlist and segment references under hls/ so
they resolve against the segment route, while the child playlists are served raw.
Reusing the live stream. If a live (popcorn) HLS job was watched to
completion during the download, ffmpeg has already produced a full, playable HLS
of the whole file — so on completion reel adopts that HLS as the deliverable
and skips the VOD re-encode entirely (a huge win for non-h264 sources that
would otherwise take hours). The completion watcher waits for the live job to
finalize (#EXT-X-ENDLIST), relocates the directory, marks the entry Ready with
its HLS, and drops the source video (keeping subtitles/poster). If no live job
ran, or it didn’t finish cleanly, the normal transcode runs as before.
Once a download completes, its transcoded .reel.mp4 no longer carries the
subtitle streams, so the embedded text subs extracted to sidecar .srt files are
served separately: GET /torrents/{id}/subtitles lists them and
GET /torrents/{id}/subtitles/{n} returns each converted to WebVTT on the fly.
The player attaches them as <track> elements (token on the query string, since
<track> can’t send headers), so finished reels play with selectable subtitles.
Operations
Observability & diagnostics
Every torrent moves through an explicit phase so an operator can tell exactly where it is — and, when it stops, why.
Phases
Section titled “Phases”resolving-metadata → connecting → downloading → moving → ready, with
transcoding / streaming-hls once seeding, and failed on any terminal error.
The phase is derived from persisted state plus the live librqbit snapshot, so a
torrent stuck at connecting (metadata resolved, zero bytes) is distinct from one
still resolving-metadata (no peers responding at all).
Status endpoints
Section titled “Status endpoints”GET /status— one call for the whole service:vpn_ok, tracker count, counts by state (leeching/seeding/failed), and every torrent as a merged view (persisted metadata + derivedphase+ live peers/speed/progress).GET /torrents/{id}/status— the same merged view for a single torrent.GET /stats— raw librqbit live snapshot (peers seen/live/connecting, mbps).GET /torrents·GET /torrents/{id}— persisted metadata only.
Downloading files
Section titled “Downloading files”Streaming is not the only reason to fetch something — a FLAC rip, a WAV stem pack or a set of stills are meant to be saved, not played in a browser tab. Once a torrent finishes and moves to the library, its contents are downloadable:
GET /torrents/{id}/files— the listing: every file with a stableindex, relativename,sizeand detectedcontent_type, plus the archive name, total bytes and theexpires_atstamp so the UI can show the reap countdown.GET /torrents/{id}/files/{index}— one file, served with range support (so a cancelled download resumes) and aContent-Disposition: attachmentheader carrying both an ASCII fallback and the RFC 6266 UTF-8 name.GET /torrents/{id}/archive.zip— the whole torrent as one zip, written straight to the response by a blocking task feeding a duplex pipe. Nothing is staged on disk and entries are Stored, never deflated — FLAC, mp4 and jpeg are already compressed, so deflate would cost CPU and save nothing.
Downloads address files by listing index, never by path, so no request can
walk out of the torrent directory. Generated artifacts (hls/, the transcoder’s
*.reel.mp4) and dotfiles are excluded from the listing. While a torrent is
still leeching the bytes on disk are sparse, so the routes answer 425 Too
Early rather than handing back a silently truncated file; the UI polls through
that state. Every listing and every download touches the entry, so the 6h
idle TTL restarts on each interaction and a long transfer cannot be reaped out
from under itself. Set REEL_DOWNLOAD_ENABLED=false to turn the routes off.
Fetch billing
Section titled “Fetch billing”Pulling a torrent costs real bandwidth, so every fetch is charged to the caller’s wallet — 1 credit per MiB, rounded up, once. At the $1 = 1,000,000 credit peg that is about $1 per TiB. A 400 MB album costs 400 credits; a 1080p film around 8,000. Staff are billed like everyone else.
Charged when the size is known, not when you click. POST /torrents cannot
price anything — a magnet arrives with no size, and reel writes size: 0 until
metadata resolves. The billing sweep waits for the torrent’s real byte count
(live total_bytes while leeching, the moved size once seeding) and only then
debits. An account that can’t cover it has the torrent dropped before the bulk
of the transfer, instead of the cluster eating the bandwidth for free.
Everything after the fetch is free. Streams, HLS, per-file downloads, the whole-torrent zip, re-downloads — none of them bill. You paid to bring the bytes in; serving them back off local disk is nearly free.
The cache bonus. Re-adding something reel still holds hits the
already_active check, which returns the existing entry without creating a new
one — same fetch epoch, same idempotency key, so the wallet no-ops and you are
not charged twice. Once the entry is reaped, the next add stamps a fresh
added_at, produces a different key, and bills again. The rule reduces to: you
pay each time reel actually has to pull the bytes.
The key is uuidv5("reel_fetch:{account}:{infohash}:{added_at}"). The account
is in it because wallet.ledger.idempotency_key is globally unique — an
account-less key would collide across users and trip the replay-fingerprint
check rather than billing the second account. added_at is persisted in
state.json and carried across the completion rebuild, so a reel restart never
re-bills a fetch that was already paid for.
The billing routes are gateway-only. /billing/queue and the settle/refund
acks are a private channel between reel and axum-kbve — reaching them from the
edge would let any signed-in user mark their own fetch paid for zero credits. The
proxy refuses to forward any path containing a billing segment (404, so the
edge does not even confirm the route exists), and a POST to any spelling of the
add route that misses the billed handler (torrents/, torrents//) is refused
the same way. Media tokens are playback credentials that ride in cookies and
query strings, so they are read-only: adding, deleting, transcoding and
touching all require a real dashboard session.
Who moves the credits. Reel never touches the wallet. It records the payer
and exposes GET /billing/queue (what is owed) plus
POST /torrents/{id}/billing/settle and /billing/refunded (what was paid).
axum-kbve owns wallet access: it stamps the caller’s account onto the add
request, then sweeps the queue every REEL_BILLING_INTERVAL_SECS (default 20)
debiting via wallet.service_debit. A fetch that fails after being charged is
refunded automatically; a reaped fetch is not — expiry is the deal, the bytes
were delivered. Charges use source_kind = purchase and refunds refund; there
is deliberately no reel-specific enum variant, since adding one means an enum
ALTER in its own migration.
Error surfacing
Section titled “Error surfacing”Failures no longer collapse to an opaque status code. A failed torrent returns
its stored reason as a JSON body on /stream and /manifest.m3u8
({"state":"failed","error":"…"}), and the reason names the phase it died in —
could not resolve torrent metadata within Ns — no peers responded,
no data received for Ns — no seeders, move failed, ffprobe failed. HLS
failures now capture the ffmpeg stderr tail instead of just the exit code.
Structured log events
Section titled “Structured log events”All lifecycle transitions emit a stable event= field (JSON on stdout, tailed by
Vector into ClickHouse): torrent_added, torrent_completed, torrent_failed
(with phase + reason), probe_decided, transcode_started,
transcode_ready, transcode_failed, hls_started, hls_ready, hls_failed,
stream_served, download_served, archive_served, fetch_billed,
fetch_refunded, vpn_leak, vpn_restored, reaped. A per-torrent leech
heartbeat logs progress + live peer count each stall-check tick. The default log
filter is info (reel=debug) so the happy path is visible without setting
RUST_LOG.
VPN port forwarding (inbound peers)
Section titled “VPN port forwarding (inbound peers)”Public UDP trackers reject the shared VPN exit IP, and without an open inbound
port the swarm is outbound-connect only — fine for well-seeded TCP-tracked
torrents, thin for everything else. When the gluetun sidecar’s VPN provider
supports NAT-PMP port forwarding, VPN_PORT_FORWARDING=on makes gluetun request
a dynamic port and write it to /tmp/gluetun/forwarded_port (shared with the reel
container via an emptyDir). reel reads that file at startup (REEL_BT_PORT_FILE)
and pins librqbit’s peer listener to it, so the port announced to trackers matches
the forwarded port — real inbound peers, and a stabler swarm.
The provider + PF flags live in a reel-scoped secret (reel-gluetun) sealed
into the single kasm VPN vault alongside vpn-wireguard, then pulled into the
reel namespace by ESO — the same path every reel VPN secret takes, so there’s one
source of truth and no SealedSecret-vs-ESO drift. It’s layered last in the
gluetun envFrom so it overrides the shared config for reel only; kasm/firecracker
are untouched, and the provider name stays encrypted (out of the repo). Because the
shared secret is a custom-provider WireGuard config that pins one server endpoint,
and a real provider selects its own server for NAT-PMP, reel-gluetun blanks the
inherited WIREGUARD_ENDPOINT_IP / WIREGUARD_ENDPOINT_PORT / WIREGUARD_PUBLIC_KEY
(gluetun refuses to start with a pinned endpoint under provider server-selection)
while keeping the client WIREGUARD_ADDRESSES + WIREGUARD_PRIVATE_KEY. If the
forwarded-port file never appears, reel falls back to a random port (no inbound
peers) rather than failing to start.
GET /status reports whether it’s actually working: bt_listen_port (the port
librqbit announces), forwarded_port (what gluetun currently forwards), and
inbound_ready (true only when the two match). The staff console shows this as
● inbound :PORT vs ○ outbound-only.
The forwarded port is dynamic — NAT-PMP settles on a port a few seconds after
the tunnel comes up (the first value written can differ from the final one). So reel
doesn’t trust a single early read: at startup it waits for the value to hold steady
(REEL_BT_PORT_STABLE_SECS) before binding librqbit’s listener, and a watcher
(REEL_BT_PORT_WATCH_SECS) compares the live forwarded_port against the bound
bt_listen_port — on a durable mismatch it logs vpn_forwarded_port_changed and
exits for a clean restart so restart-resume rebinds with no torrent loss.
That rebind only makes sense when the port is otherwise stable. On the current
provider exit it isn’t — gluetun’s NAT-PMP renewal returns a new external port
roughly every 90s (requested as N but received M), so the forwarded port rotates
continuously and can never match a pinned socket. Chasing it would restart-loop, so
REEL_BT_PORT_WATCH_SECS is set very high to disable the rebind: reel binds its
startup port once and stays put. The service runs outbound-only (fine for
well-seeded torrents; peer stability comes from the VPN watchdog + MTU + encode-cap
fixes, not inbound PF). If a future exit holds the port, lowering the interval
re-enables live rebind.
Port forwarding uses NAT-PMP against the WireGuard gateway (10.2.0.1), so
FIREWALL_OUTBOUND_SUBNETS must not cover the tunnel gateway — a broad
10.0.0.0/8 swallows it and gluetun routes the NAT-PMP request outside the tunnel
(getting external IPv4 address … i/o timeout, no forwarded port). It’s scoped to
the cluster pod + service CIDRs (10.244.0.0/16, 10.96.0.0/12) instead, which
excludes 10.2.0.0/16 while still letting reel answer in-cluster health probes.
Request-path media scans (pick_primary_file) run on a blocking thread pool so a
large library tree can’t stall the async runtime, and the hls.js player carries
its scoped token in the Authorization header rather than the URL query — the
token never lands in a manifest/segment request URL or a Referer.
Transcode jobs report live progress with an ETA: ffprobe reads the source
duration, ffmpeg streams -progress, and reel derives {pct, speed, eta_secs}
held in memory (never persisted). GET /status surfaces it as
transcode_progress. The staff console renders animated determinate bars for
both download (percent, throughput, peers, ETA) and transcode (percent,
encode speed ×, ETA), plus an indeterminate shimmer while resolving metadata or
preparing HLS — all theme-aware and reduced-motion friendly.
Routing keys on the video codec, not the whole codec pair. Re-encoding h264 with
libx264 is the slow part (minutes-to-hours on a single core), and it’s almost never
needed: the browser plays h264 directly. So any file whose video is already h264
copies the video stream untouched (-c:v copy) and only touches the audio —
-c:a copy when it’s already aac, -c:a aac when it isn’t (ac3/eac3/dts/mp3/opus).
That makes “hit Play on a finished h264” resolve in seconds regardless of its audio
or container, instead of kicking off a full re-encode. A true video re-encode
(-c:v libx264) is reserved for genuinely incompatible video — hevc/av1/mpeg2 —
where there’s no way around it. h264+aac in an mp4 still streams raw with no ffmpeg
at all.
Container edge cases are handled explicitly so odd files don’t wedge ffmpeg. Every
ffmpeg invocation maps exactly the first real video stream and (optionally) the first
audio stream — -map 0:V:0 -map 0:a:0? — which drops subtitle, data, and attachment
streams. Without that, an MKV or AVI carrying subtitle tracks makes a -c copy
remux fail outright (subrip/ass can’t be muxed into MPEG-TS or MP4). The optional
audio map (?) means a video with no audio track transcodes cleanly instead of
erroring. ffprobe also ignores attached-picture streams (embedded cover art,
reported as an mjpeg/png “video”) when detecting the codec, so an h264 file with a
poster frame routes as h264 rather than being mistaken for an image and sent to a
full re-encode.
The cache is swept at two levels so nothing is left to sit on disk. The state-aware
reaper removes tracked entries once they pass their idle TTL (deleting both the state
record and the files). On top of that, a filesystem sweeper runs every reap cycle and
deletes anything in the active or library directories that is not referenced by
any state entry and is older than the TTL by modification time — orphans left behind
by a failed delete, an interrupted move, or lost state. Actively tracked downloads
(and the librqbit .session directory) are always skipped, so an in-progress or
currently-streaming item is never swept mid-use. File deletions that fail are now
logged rather than silently swallowed, so a leaked file is visible and gets cleaned
on the next sweep instead of accumulating forever.
Adding a torrent is idempotent. A torrent is keyed by its info_hash, so submitting
the same magnet or .torrent while it is already leeching or seeding returns the
existing id instead of registering it again. Without this guard, a repeated submit
(a double-click, or a client that retries) spawned a second completion watcher for
the same info_hash; the duplicate watcher tracked an empty handle, reported zero
progress and zero peers, and — once its stall timer elapsed — marked the torrent
failed and deleted it from the session with its files, killing the healthy
download the first watcher was still driving. That presented as the swarm
“resetting to zero” mid-download and as finished files disappearing. The dedupe
check removes the duplicate watcher entirely; a genuinely failed torrent can still
be re-added to retry.
A download that goes quiet is treated as a connection problem before it is treated as a missing file. Because there is no forwarded port behind the VPN, the swarm is outbound-only: nothing ever dials in, so once the peers Reel dialed out to go silent — a stale NAT mapping, a choke, a tunnel blip — nothing replaces them until the tracker is announced again, which can be half an hour away. The old watcher only looked at whether the byte count moved, waited five minutes, then declared that no seeders had the content and deleted the partial download. That verdict was wrong whenever the tracker showed a healthy seed count: the content was there, the connections were not. Reel now reads the peer counts it was already collecting. With peers still connected the stall budget stretches to fifteen minutes, since a connected-but-silent peer is choked rather than gone. When the budget does elapse, Reel pauses and unpauses the torrent — tearing down every stale socket and forcing a fresh announce — and retries that re-dial three times before giving up. A download that does finally fail keeps its partial data and reports what it actually saw (live, seen, and connecting peer counts) instead of a guess, so re-adding the magnet resumes where it stopped rather than starting over. The idle-TTL reaper still clears the leftovers.
Playback is meant to be effortless: download a file and it becomes watchable on its own, with no manual transcode step and without keeping two copies. When a download finishes, an auto-transcode worker wakes and processes it. If the file is already directly playable in the browser — h264 video, aac audio, in an mp4 container — it is left untouched and served as-is. Otherwise it is transcoded once to a browser-ready mp4 and the original source is deleted, so a 4 GB MKV does not sit on disk next to a 4 GB mp4. The manifest and the raw stream both resolve to the finished transcode when one is ready, so a completed item plays directly instead of being routed back through the original (which, for a container like MKV, the browser cannot decode). The result is a single playable file per download and a Play button that just works.
Each download lives in its own entry directory, and preparing it avoids storing the
same video twice without throwing away the useful extras. Audio is normalized for the
browser: it is stream-copied only when it is already stereo aac, and otherwise
re-encoded to stereo aac — multichannel (5.1) audio is downmixed, which is why a
transcoded file plays with sound instead of silent video. Subtitles the source
carried embedded are extracted to sidecar .srt files before the redundant source
container is removed, so they survive. Poster artwork and existing sidecars are kept.
The only thing deleted during preparation is the large duplicate source video; when
the user deletes the entry, the whole directory — video, subtitles, artwork — goes
with it. The idle-TTL reaper and filesystem sweeper still apply on top.