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

194 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `Action`s 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.
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.