Skip to content
media · rust

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.

reelApp
Rust + AxumStack
MP4 + HLSDelivery
activeStatus

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.

  • 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.

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.

resolving-metadataconnectingdownloadingmovingready, 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).

  • 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 + derived phase + 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.

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 stable index, relative name, size and detected content_type, plus the archive name, total bytes and the expires_at stamp 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 a Content-Disposition: attachment header 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.

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.

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.

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.

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.