Files
proton-stream/docs/DEVELOPMENT.md
T
2026-08-03 09:09:40 +02:00

14 KiB
Raw Blame History

Development

Where things stand

Layer State
SDK streaming surface (../proton-sdk-rs 0.3.3) done, live-verified — open_revision / download_range / thumbnails / refresh_session on the visitor path; node-key cache; chunked enumerate_nodes; paged download_file_to
pstr-core — shares, config, catalog, naming, library grouping done — 67 offline tests
pstr-cli — add / crawl / list done — verified against a real 354-node share: 310 playable files, 22 titles, correct seasons and episodes
pstr-stream — block cache, read-ahead done, live-verified — 68 offline tests; benchmarked against a real 761 MiB episode (see docs/TESTING.md)
pstr-player — libmpv stream_cb done, live-verified — 16 offline tests; plays a real 1080p HEVC episode over a public link, seeks, resumes from the disk cache. The render API (embedding) is not started
pstr-app — egui UI, poster grid done, offline-tested — library wall, title pages, transport, share management; mpv still opens its own window
Metadata providers (AniList / TMDB) not started — and this is what the wall is waiting on: see the note on thumbnails below
Packaging done, locally verified — scripts/build.sh (tarball, .deb, .rpm, .app, .dmg) and scripts/build.ps1 (portable .zip, WiX .msi), plus packaging/PKGBUILD. See packaging/README.md. The Windows and macOS halves are written but only the Linux ones have been run on this machine

Build order from here

  1. pstr-player, step two. mpv_render_context_create with MPV_RENDER_API_TYPE_OPENGL against eframe's glow context, rendered inside an egui::PaintCallback; overlay controls. If this proves troublesome, the standalone-window mode already built remains shippable. Nothing in pstr-player except PlayerConfig's window options is about where pixels land, so this does not reach the streaming path.
  2. Metadata providers, docs. Packaging is in place (packaging/README.md); what is left there is running scripts/build.ps1 and the macOS half on real hosts, and a release workflow that drives both.

Done: pstr-player step one — mpv_stream_cb_add_ro, mpv in its own window. pstr play --file <text> plays a catalogued episode over a public link.

Done: pstr-app step one — cargo run -p pstr-app opens a window with the library wall, a title page per series, a transport bar and share management. It plays through the same Player the CLI uses, so mpv still owns its own window; the app's transport drives it from a command channel. Two pieces of that are worth knowing before touching it:

  • Drawing never mutates. Pages are handed &-state and push Actions onto a list the app applies after the frame. That is what lets a card click change the page it was drawn on.
  • The UI thread never opens a share, reads the catalog or decodes a JPEG. All of it goes through pstr_app::engine::Engine, which spawns onto the tokio runtime and answers with an Event — plus a ctx.request_repaint(), without which the event sits in the channel until the mouse moves.

Done: player controls beyond play/pause/seek — volume with mute, an audio-track picker and a subtitle picker, in the transport bar (so on the player page and on the bar under the library alike), plus M and ↑/↓ on the player page. All four preferences persist to playback.json (pstr_core::prefs), and a track chosen by hand sets alang/slang for every file loaded afterwards — the point being that picking Japanese audio once picks it for the rest of the season. A dragged volume slider writes to disk on release, not per frame.

Done: the player page's overlay — a full-width seek bar with the file's chapters marked on it and the current chapter named beside the clock, a transport cluster (previous / −10s / play / +30s / next), and volume plus the audio, subtitle and chapter pickers to the right. Chapters come from mpv's chapter-list (pstr-player/src/chapters.rs); a chapter whose name reads as an opening, ending or preview also puts a skip button in the bottom-right corner, which is drawn whether or not the rest of the controls are up — an opening lasts ninety seconds and a button you have to wake with the mouse is one nobody uses. The strip under the library is the same controls at a lower density plus a Back to video button, which is what makes leaving the player page with Esc reversible.

Done: next / previous episode and autoplay. The neighbours come from Title::following/preceding in pstr-core — display order, so it walks off the end of a season into the next one — and autoplay fires only on a clean end of file, never on a failure. P/N on the player page, and the page stays put while the next file opens rather than dropping back to the library.

Done: what a chapter is, and the "up next" countdown. Chapter names are read in two passes (pstr-player/src/chapters.rs). The first reads the name alone — OP, ED, Next Episode, and the Japanese forms — and yields a claim; the second (roles) settles the claims that a name cannot settle, using the rest of the file. Intro is the opening only when the file has no chapter that says so outright, the chapter is the length of a theme song and it is near the front: episode one of Oshi no Ko opens with eleven minutes called Intro that are the story. Credits / Cast are the ending at about that length or in the last quarter. Both ambiguities fail towards content, because not offering a skip costs a button and skipping the story costs the story.

credits_start then takes the run of chapters that ends the file — walking back from the last one and stopping at the first that is content. That is the part worth stating: the ending is not the tail if something follows it. Fullmetal Alchemist Brotherhood episode 46 has a Part C after the ED and the preview after that, so only the preview is skippable there, while the ordinary … ED · Preview gives up both. Reaching that run starts a ten-second Up next card (App::tick_up_next, ui::player::up_next_card) with Play now and Watch till the end; the latter holds for the rest of the file. It needs chapters, autoplay on, a next episode, and playback actually running — a paused episode is one somebody walked away from.

Done: per-episode metadata. Provider::episodes fetches an episode list for a title that already matched (AniList streamingEpisodes, which is titles and thumbnails only and no synopsis; TMDB /tv/{id} plus every season appended onto one more request), stored in episode_metadata — schema v4. The reconciling between a release's numbering and a provider's lives in one tested place, EpisodeGuide::get: the season and number the filename states, then the provider's absolute numbering, then season one for a file that states no season at all. That last fallback is what makes #057 line up with TMDB filing a 64-episode anime under one season. Episode names show on the title page's rows, in the player's title strip and in the "next:" line autoplay prints.

A season is a separate lookup on AniList. Provider::seasons_are_separate_entries is what splits the two providers: TMDB files every season under one show and returns them numbered, while AniList makes each sequel its own entry numbering from one ([Oshi no Ko] 2nd Season, id 166531, is not [Oshi no Ko], id 150672). So for AniList each season past the first is searched for by name — <title> 2nd Season, then <title> Season 2 — and its episodes are stored tagged with that season. The absolute fallback in EpisodeGuide::get stops at season one for the same reason: without that it answered S02E01 with season one's episode one, which is not a missing caption but a wrong one.

Done: matching a title by hand. MATCH_FLOOR is set where a wrong poster costs more than none, and the price of that is a few titles it refuses to decide about — Fate/stay night [Heaven's Feel] is the standing example, three films in one folder against a provider that files each of them as its own entry, so there is no single answer for the scorer to find. Change match on the title page opens ui::matcher: the provider's own answers for whatever is typed, unscored and in its own order (MetadataService::search), and a click pins one. Nothing is ranked there on purpose — the floor exists to stop the scorer guessing, and a person reading eight entries with their posters is not guessing.

A chosen row is stored manual (schema v5), which means two things: MetadataRecord::is_fresh never expires it, and run_match skips it even when forced — "match again" must not undo the one title someone fixed by hand. The way back is Clear, which drops the row entirely (Catalog::forget_metadata) rather than storing a miss, so the next run treats the title as one it has never asked about. Switching provider still clears everything, hand-picked rows included: a remote_id means nothing to the other provider.

Worth knowing before chasing a missing name: AniList's episode titles come from streamingEpisodes, which is what streaming sites published, and for some shows it is simply empty — all three Oshi no Ko entries have none, while Fullmetal Alchemist Brotherhood has 64. There is nothing to fix on our side for those; TMDB is the provider that has them.

Note the glyph trap: egui's bundled fonts cover the transport icons (⏮ ⏭ ⏸ ▶ 🔊 🔇 ⛶) and not ← 💬 ▣, which draw as an empty box. Anything new on a button wants checking on screen before it ships.

Not done: sidecar subtitle files. A .srt/.ass sitting beside the video in the share is invisible to all of this — the crawl stores video files only, so there is nothing to offer in the menu. The player side is nearly free once the catalog has them (sub-add pstr://<token> through the same registry, since mpv reads an external subtitle through the same protocol); the work is in pstr-core: crawl them, and pair them to a video by name.

Design decisions already taken

  • Fat client, no server. Each viewer's app talks to Proton directly.
  • Public links only, but several, merged into one catalog. No account login.
  • Embedded libmpv, not a bundled decoder stack. HEVC 10-bit, HDR and ASS subtitles with hardware decode, for free.
  • No transcoding. mpv demuxes and decodes natively; there is no remux or re-encode step and no ffmpeg dependency of our own.
  • stream_cb, not a loopback HTTP server. No port to open, no local attack surface, no plaintext leaving the process.
  • Metadata enrichment is opt-in and off by default, with the privacy cost stated in the UI: enabling it sends your library's titles to a third party. With it off, posters fall back to Proton thumbnails.
  • WASM is out. Not a small port and not worth reopening: the SDK pulls tokio with rt-multi-thread + fs, has four spawn_blocking sites, resolves two getrandom versions with no wasm backend, and drags aws-lc-rs/ring through rustls. Separately a browser cannot reach the Proton API at all (CORS) without a proxy server, which the fat-client decision rules out.

Traps

  • Block sizes are not uniform. Always RevisionReader::block_sizes(); never a hardcoded 4 MiB. A padded vector shifts every later block's start and serves full-length reads of the wrong bytes, silently.
  • Cache validity is the revision id, not (mtime, size). It advances if and only if a new revision was sealed.
  • Read-ahead must yield to seeks, and must not outrun the ring. Both were measured, both cost more than they bought when got wrong: prefetches left running from the position the viewer left took worst-case seek latency from 572 ms to 2895 ms, and a window deeper than half the ring evicts its own blocks before the player reaches them. pstr-stream::stream cancels on seek and clamps the depth; the numbers are in its module docs.
  • Read-ahead depth is a property of the consumer, not of the block layer. mpv brings its own, so under mpv the block layer's read-ahead should be 0: at depth 12 the first frame took 5.5 s instead of 2.5 s and a seek 3.6 s instead of 1.5 s. pstr_player::READAHEAD_BLOCKS is what a player-driven caller passes; pstr_stream::DEFAULT_READAHEAD_BLOCKS is for readers that have none of their own. Do not "unify" them. See docs/BUGS.md B8.
  • mpv's buffer is in seconds, not bytes. A byte budget means two minutes of buffer on a 4 Mbit/s episode and eight seconds on a 4K remux. The byte figure survives only as a ceiling, and it must stay under the ring or B7 recurs a layer up.
  • The anonymous session expires mid-film. The SDK renews it in place and readers survive; the app should still be able to reopen and resume at the same byte offset as a belt-and-braces fallback.
  • A share filled by the FUSE client has no thumbnails at all. Proton renders a thumbnail at upload time and the SDK does not generate one, so every download_thumbnail on this library answers Ok(None) and every tile falls back to initials. Nothing is broken — it is why metadata providers matter more than they looked like they did, and why an empty thumbs/ cache directory is not evidence the thumbnail path is wrong.
  • Negative caching matters. Without it, every grid render pays a round-trip per unmatched title. proton-drive-linux's photo grid learned this the hard way. ThumbnailCache remembers both the textures it has and the files that turned out to have no thumbnail.
  • A dragged seek bar must seek on release, not per frame. Every seek cancels outstanding read-ahead and refetches; a bar that seeks while dragging spends the whole drag throwing away blocks it just paid for. ui::transport acts on clicked() || drag_stopped().
  • egui 0.35 moved the panels. eframe::App is fn ui(&mut self, ui, frame), not update(ctx, frame); panels are egui::Panel::top(id).show(ui, …) rather than TopBottomPanel::top(id).show(ctx, …), and Context::style_mut is now all_styles_mut. Older egui snippets will not compile as written.