This commit is contained in:
nvrl committed 2026-08-02 19:37:03 +02:00
commit a1b345a029
80 files changed
+28033

No files matched your search

+123
View File
@@ -0,0 +1,123 @@
# Architecture
## The problem
A Proton Drive public link is normally a download link. This app turns one into a
streamable library.
Nothing about the crypto prevents that: a file's content blocks are each a
self-contained PGP SEIPD packet decrypted under one shared session key, so there
is no sequential streaming session to resume and seeking costs exactly the blocks
the seek lands on. What was missing was plumbing — `RevisionReader`, the SDK's
random-access reader, was welded to the *authenticated* client. Undoing that weld
(`proton-sdk-rs` 0.3.3, `transport.rs`) is what made this app possible.
## Crates
```
pstr-app (bin: proton-stream) pstr-cli (bin: pstr)
│ │
├── pstr-player ── libmpv │
│ │ stream_cb + render API │
│ ▼ │
└── pstr-stream ───────────────────┤
│ │
└── pstr-core ─────────────┘
│
proton-drive-rs ──▶ proton-sdk
```
- **`pstr-core`** — the share list and its secrets, the catalog database, the
filename parser, and the crawl. Knows nothing about playback or pixels.
- **`pstr-stream`** — `RevisionReader` to a seekable byte stream: reader LRU,
in-memory block ring, forward read-ahead, optional on-disk block cache. Only
its `reader` module touches the SDK; everything else runs against a
`BlockSource` trait, which is what makes the caching and read-ahead testable
with no account and no network.
- **`pstr-player`** — libmpv. Fed through a custom `pstr://` protocol registered
with `mpv_stream_cb_add_ro`; rendered through `mpv_render_context_create` with
`MPV_RENDER_API_TYPE_OPENGL` into eframe's glow context. The URL is an opaque
token into a registry, never an identity: the caller opens the stream in async
code where a failure can be reported, then publishes it. mpv's open callback is
a hash lookup that cannot block and cannot leak a share secret into a log line.
Everything the callbacks decide lives in a safe, tested `StreamCursor`; the
`unsafe` is confined to one module of pointer plumbing. What is inside the file
— audio and subtitle tracks — is read from `track-list` one sub-property at a
time (`tracks.rs`) and selected through `aid`/`sid`; the *language* of a choice
is carried to the next file with `alang`/`slang`, which is what makes "Japanese
audio" hold for the rest of a season.
- **`pstr-app`** — the egui UI. Three threads' worth of work kept apart: the UI
thread only draws, an `Engine` runs everything with a network or a disk in it
on the tokio runtime and answers with events, and a player thread owns mpv for
the length of a file. Pages are pure functions of state that push `Action`s
rather than mutating, which is what allows a click to change the page it was
drawn on.
- **`pstr-cli`** — the headless front end, and the thing that tells you which
layer is broken.
## Data flow, playing an episode
```
click egui
│
▼
pstr://<share>/<link_id> mpv opens the URL, calls back on its demuxer thread
│
▼
VideoStream::read_at pstr-stream: ring? disk cache? else fetch
│
▼
RevisionReader::read_at SDK: the blocks overlapping the range, 10 at a time
│
▼
get_storage_blob block storage, pm-storage-token, no session credential
│
▼
ContentKey::decrypt_block one PGP packet per block, on the blocking pool
```
The demuxer thread is allowed to block, so the `stream_cb` callbacks bridge into
a dedicated tokio runtime with `Handle::block_on`.
Two properties of that middle layer are load-bearing enough to state here, both
established by measurement rather than reasoning (`docs/TESTING.md`):
- **A seek cancels outstanding read-ahead before it fetches anything.** Otherwise
the prefetches from the abandoned position hold the bandwidth the seek needs —
worth 5x on worst-case seek latency.
- **Read-ahead never runs deeper than half the ring.** A window larger than the
cache it lands in evicts its own blocks before the player reaches them.
- **How far to read ahead belongs to the consumer, not to the block layer.** mpv
buffers ahead of the picture itself, so under mpv the block layer reads ahead
*not at all*; a second speculative layer only takes bandwidth from the reads
mpv is blocked on. A reader with no buffer of its own still wants twelve.
Every block is fetched exactly once no matter how many readers want it: the
open, and each block fetch, go through a single-flight keyed on the block. The
demand read and the prefetch racing for the same block is the normal case, not
an edge case.
## Trust and secrets
- The share URL fragment **is** the decryption password, and a custom-password
link has a second secret. Both live in the OS credential store; `shares.json`
holds only the id, display name and token.
- Nothing this app holds can write to the share: a viewer link grants
`MemberRole::Viewer`, and the SDK's public-link write side is unported anyway.
- Signature verification is absent on the visitor path by design — an anonymous
visitor cannot read `core/v4/keys/all` to resolve a signer. Decryption still
proves the bytes were encrypted to the node key. Nodes come back with nothing
*claimed* rather than a verification that silently passed.
- Metadata enrichment is off by default because enabling it sends the user's
library titles to a third party.
## Persistence
| Store | Path | Rebuildable? |
|---|---|---|
| Share list | config dir, `shares.json` | **No** — atomic writes, never overwritten when unparseable |
| Share secrets | OS credential store | **No** |
| Catalog | data dir, `catalog.db` | Yes — recrawl |
| Watch state | same DB, own table | **No** — deliberately survives a recrawl |
| Block cache | cache dir, `blocks/` | Yes — must work correctly after deletion |
| Poster thumbnails | cache dir, `thumbs/` | Yes — refetched per file, one small decrypt each |
+282
View File
@@ -0,0 +1,282 @@
# Bugs
The issue ledger. Open items first, then a log of what was fixed and where.
Format: `Bn — one-line summary`, then symptom, cause, fix, and how it was
verified. Reference entries from PRs.
## Open
_None yet — the app is young enough that everything known is in
`docs/DEVELOPMENT.md` as unbuilt rather than broken._
## Fixed
### B13 — the player's controls ran off the bottom of the window
**Symptom.** On the player page the transport row — previous, ±seconds, play,
volume, the pickers — sat flush against the bottom edge of the window and was
clipped by it. The floating **Skip opening** button sat at the same height as
the seek bar rather than above the controls, four pixels out of line with them.
**Cause.** `CHROME_HEIGHT` was a constant 132 px. The controls are two rows of
themed buttons whose height follows the theme and the platform's text scaling,
so the constant was a guess — and a guess that is low does not clip in egui, it
draws past the rectangle and off the window. The skip button then used its own
22 px inset where the controls used 26.
**Fix.** `ui::player::chrome` returns the height it actually measured
(`ui.min_rect().height()` plus padding), the app carries it in `Overlay`, and the
next frame uses it for the scrim and for floating anything above the controls.
The content is anchored to the bottom of the window rather than laid out from
the top of the strip, so the last row ends a fixed distance above the edge
whatever it turns out to contain. One frame of lag, and only while resizing.
`floating` puts both the skip button and the up-next card at the controls' own
`CHROME_PAD_X`.
### B12 — the skip button believed a chapter's name
**Symptom.** Episode one of Oshi no Ko opens with a chapter called `Intro` that
is eleven minutes of the story. The player offered **Skip opening** over it.
**Cause.** `ChapterRole::of` read the first word of the name and nothing else,
mapping `intro` to `Opening` unconditionally. Nothing looked at how long the
chapter was or at what else the file contained.
**Fix.** Two passes — `Claim` from the name, `roles` from the name *and* the
file. A name that could go either way (`Intro`, `Credits`, `Cast`) is resolved
by length and position: an opening has to be theme-length, near the front, and
the only candidate in the file. Both ambiguities fail towards content. The same
pass is what `credits_start` reads, so the end-of-episode countdown does not
assume the last chapter is the ending either — see the Part C case in
`docs/DEVELOPMENT.md`.
**Verified.** `a_long_intro_is_the_episode_and_not_an_opening`,
`a_theme_length_intro_near_the_front_is_an_opening`,
`content_after_the_ending_keeps_the_ending_out_of_the_tail`.
### B11 — seasons past the first had no episode names, and were one query from having the wrong ones
**Symptom.** Oshi no Ko seasons two and three showed filenames where season one
showed episode names. Season one only "worked" because *its* filenames carry
them (`S01E01-Mother and Children.mkv`); `episode_metadata` was empty for the
whole library.
**Cause.** Two, stacked. A title is looked up once, and on AniList that match is
the *first season's* entry — a sequel is a separate id numbering from one — so
nothing was ever asked about seasons two and three. And `EpisodeGuide::get` fell
back from `(Some(2), 1)` to the provider's absolute `(None, 1)`, so the moment
season one's episodes were fetched, every later season would have been captioned
with season one's names. A wrong caption does not read as a bug; it reads as the
library being wrong.
**Fix.** `Provider::seasons_are_separate_entries` distinguishes the two
providers. For AniList, `MetadataService::season_episodes` searches each season
past the first by the name the provider uses (`<title> 2nd Season`, then
`<title> Season 2`) and tags the result with that season; TMDB already returns
seasons numbered and is left alone. The absolute fallback now stops at season
one.
**Verified.** `an_absolutely_numbered_answer_never_reaches_a_later_season`,
`a_season_is_searched_for_the_way_the_provider_names_it`. Against the live API,
`[Oshi no Ko] 2nd Season` is id 166531 with 13 episodes and `3rd Season` is
182587 with 11 — which is exactly what the share holds.
**Still missing for that show, and not a bug here.** AniList's episode titles
come from `streamingEpisodes`, and all three Oshi no Ko entries have none at
all. Those rows keep their filenames until the viewer switches to TMDB.
### B10 — every episode of season three was called "3rd Season"
**Symptom.** Under `Oshi no Ko`, all eleven rows of season three read
`3rd Season` instead of an episode name.
**Cause.** `naming::parse` knew `S03` and `Season 03` but not `3rd Season`, so
`[DB]Oshi no Ko 3rd Season_-_07_….mkv` parsed as a title of `Oshi no Ko 3rd
Season`. `catalog::build_rows` then took the difference between that and the
folder's `Oshi no Ko` to be the *episode's* own name — which is what it is for,
and here it was a season marker.
**Fix.** `find_worded_season` reads both orders (`3rd Season`, `Season 3`) and
ends the title there, so the season comes out of the filename as a season.
`build_rows` additionally asks `naming::is_season_marker` before keeping any
leftover as an episode name, and folds it into the season instead. The dash is
deliberately not a separator between `Season` and its number: `Show 2nd Season -
04` puts the episode there, and reading across it made episode four season four.
**Verified.** `underscore_separated_numbering_is_read`,
`a_worded_season_is_read_as_a_season`,
`an_ordinal_that_is_part_of_the_title_is_not_a_season`,
`a_season_stated_in_the_filename_does_not_become_the_episodes_name`.
### B9 — a series split into one poster per season on the shelf
**Symptom.** The library wall showed `Game of Thrones - Season 1` … `Season 8`
as eight separate titles, each with its own poster and its own resume point.
**Cause.** `naming::classify_folder` recognised a season folder only when the
whole name was one (`Season 01`, `S03`). A folder named `<Series> - Season 1`
therefore classified as a title, and the season number stayed in the title
string, so the grouping key differed per season.
**Fix.** `naming::trailing_season` splits a `… Season N` / `… SNN` tail off a
folder name, and `catalog::ancestry` takes the season from a folder that names
both. The keyword is required — `Evangelion 3` and `Alien 3` keep their number,
and `Blade Runner 2049` still reads as a year.
**Verified.** `a_folder_naming_series_and_season_yields_both`,
`a_title_ending_in_a_number_keeps_it`; on the real share, 30 titles became 23,
with Game of Thrones one poster of 73 episodes.
### B5 — a multi-byte character in a filename panicked the parser
**Symptom.** The crawl aborted on
`[Anime Time] Neon Genesis Evangelion - Death (True)²-007.mkv` with
`start byte index 34 is not a char boundary; it is inside '²'`.
**Cause.** Year detection scanned bytes and sliced on byte offsets, so a token
boundary could land inside a multi-byte character.
**Fix.** `bare_year` walks `char_indices`; `bracketed_year` verifies the four
bytes are ASCII digits *before* slicing.
**Verified.** `a_multi_byte_character_does_not_break_parsing`.
### B4 — files were catalogued with no title
**Symptom.** Every episode in the catalog had an empty title: a real library is
laid out `Sousou no Frieren/Season 01/S01E01.mkv`, and `S01E01.mkv` states no
series at all.
**Cause.** Rows were built from the filename alone.
**Fix.** `catalog::build_rows` walks the folder path: the folder names the
series, the filename names the numbering, grouping folders (`Movies`, `OVA`,
`Specials`) are stepped over, and a `Season NN` folder supplies a season the
filename omitted. Non-video files (`.nfo`, `.jpg`) are dropped.
**Verified.** Seven `build_rows` tests, and a real 354-node share that went from
0 usable titles to 22 correct ones.
### B3 — several naming conventions lost their episode numbers
**Symptom.** 35 of 310 files in a real share had no episode number.
**Cause.** Four gaps: a bare `E11.mkv`; a bare `11.mkv`; `_-_01_` underscore
separators; and — the subtle one — `Oshi no Ko S2 - 04`, where matching the
season *returned*, so the differently-stated episode was never looked for.
**Fix.** `find_episode_marker`, `find_bare_number`, underscore separators in
`find_dash_numbering`, and `find_numbering` continuing past a season-only match.
**Verified.** Down to 11 unnumbered files, all of which are films or OP/ED
extras that genuinely have no episode number.
### B1 — a public-link download silently truncated past 200 MiB
**Symptom.** `download_file` over a public link returned a file cut off at
exactly 50 blocks, with no error.
**Cause.** The visitor path issued a single un-paged
`GET …/revisions/{rid}` and used whatever `Blocks` came back. The endpoint pages
at 50.
**Fix.** `proton-sdk-rs` 0.3.3: the visitor path now uses the same paged
`list_blocks` as the authenticated one, and fetches blocks concurrently.
**Verified.** `a_visitor_can_seek_within_a_shared_multi_block_file` reads back a
9 MiB file whole and in ranges.
### B2 — a catalog crawl re-derived the same keys per child
**Symptom.** Listing a folder of several hundred episodes over a public link took
minutes.
**Cause.** `resolve_parent_key` walked and re-*unlocked* the whole ancestor chain
for every child. Each unlock is an S2K derivation — tens of milliseconds — so 500
siblings cost 500 redundant unlocks of the same parent key. `enumerate_nodes`
also never chunked its link ids, exceeding the server's 150-id batch limit.
**Fix.** `proton-sdk-rs` 0.3.3: an LRU node-key cache with an iterative
ancestor walk (up to the nearest cached key, then unlock back down caching each)
behind `SingleFlight`; `enumerate_nodes` chunked at 150 and fanned out.
### B6 — read-ahead starved the seeks it was supposed to smooth
**Symptom.** Dragging to a new position took ~2.9 s with the block layer's
read-ahead enabled, versus ~0.57 s with it turned off. Sustained throughput was
also *lower* with read-ahead than without: 5.0 MiB/s against 7.2.
**Cause.** Prefetches queued from the position the viewer had just left kept
running. The block they were fetching was exactly the bandwidth the seek needed,
and a 4 MiB block over this link takes most of a second, so the seek waited
behind up to six of them.
**Fix.** `pstr-stream::stream` tracks where the previous read ended and treats a
read landing more than one block away as a seek. A seek cancels every outstanding
prefetch *before* it fetches anything. Aborting mid-fetch throws those bytes
away, which is the intended trade — they were speculative, and the request slot
they held is what the seek is waiting for. Read-ahead resumes wherever the seek
settles.
**Verified.** Worst-case cold seek back to 715 ms with read-ahead on.
`a_seek_cancels_the_read_ahead_it_invalidates` and
`sequential_playback_never_cancels_its_own_read_ahead` pin both directions, and
`a_block_whose_prefetch_was_cancelled_can_still_be_read` pins that an aborted
prefetch releases its single-flight entry rather than leaving the block
permanently unfetchable.
### B7 — a read-ahead window deeper than the ring evicted its own blocks
**Symptom.** Raising read-ahead from 12 to 32 blocks *reduced* sustained
throughput, 8.7 MiB/s to 7.8, and the ring started reporting evictions.
**Cause.** The default 128 MiB ring holds 32 blocks of 4 MiB. A 32-block
read-ahead window is the entire ring, so blocks at the front of the window were
evicted before the player reached them and had to be fetched a second time.
**Fix.** `clamp_readahead` caps the window at half the ring's capacity in blocks
— half rather than all, because the ring also has to hold what the player has
just passed, so a scrub a few seconds backwards does not refetch. The default
depth is 12, chosen because it never evicted and wasted a third less bandwidth on
cancelled prefetches than 16 did, for indistinguishable throughput.
**Verified.** `read_ahead_is_capped_at_half_the_rings_capacity` and
`a_tiny_ring_still_reads_one_block_ahead`.
### B8 — the block layer's read-ahead made playback worse once mpv was in front of it
**Symptom.** With `pstr-stream`'s tuned default of 12 blocks, mpv took 5.5 s to
show a first frame and 3.6 s to resume after a mid-file seek. Turning the block
layer's read-ahead **off entirely** cut both to 2.5 s and 1.5 s, and fetched a
third as much data for the same playback.
**Cause.** [B6](#b6--read-ahead-starved-the-seeks-it-was-supposed-to-smooth) and
[B7](#b7--a-read-ahead-window-deeper-than-the-ring-evicted-its-own-blocks) were
measured against `pstr bench`, a reader with no read-ahead of its own — for
*that* reader, 12 blocks is right. mpv is not that reader. Its demuxer cache
already runs `demuxer-readahead-secs` ahead of the picture, sequentially and
eagerly, so a second speculative layer underneath adds no buffer at all. It only
competes for bandwidth with the reads mpv is blocked on right now, and it does so
worst exactly when the viewer is waiting: at startup and just after a seek. The
degradation is monotonic in depth.
| blocks ahead | first frame | seek resumed | blocks fetched |
|---:|---:|---:|---:|
| **0** | **2.5 s** | **1479 ms** | **12** |
| 6 | 3.7 s | 2225 ms | 21 |
| 12 | 5.5 s | 3581 ms | 31 |
| 24 | 7.7 s | 6122 ms | 42 |
**Fix.** `pstr_player::READAHEAD_BLOCKS` is 0, and it is what `pstr play` and the
app pass to `StreamConfig`. `pstr_stream::DEFAULT_READAHEAD_BLOCKS` stays 12 for
consumers that genuinely have no read-ahead — the benchmark, and anything that
reads a revision without a demuxer. mpv's buffer is also expressed in *seconds*
rather than bytes (`PlayerConfig::readahead_seconds`, 30 s), because a byte
budget buys two minutes of buffer on a 4 Mbit/s episode and eight seconds of it
on a 4K remux, which is backwards from what either wants; the byte figure is kept
only as a ceiling, sized under the ring so B7 cannot recur one layer up.
**Verified.** Sustained playback holds realtime at depth 0 (178 s of media in
180 s of wall clock, the deficit being the 2.4 s to first frame), with no ring
evictions, against the same 761 MiB episode over a real public link.
+193
View File
@@ -0,0 +1,193 @@
# 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.
+200
View File
@@ -0,0 +1,200 @@
# Testing
## Offline
Everything in `pstr-core` is offline and pure. That is deliberate: the filename
parser, the config writer and the catalog migrations are exactly the things whose
edge cases are painful to reproduce against a live share, so they are pinned by
table-driven tests instead.
```bash
cargo test --workspace --locked
cargo test -p pstr-core naming:: # just the parser
```
What is pinned today:
- **`naming`** — the fansub, scene and cross conventions; dashes inside titles;
version suffixes; a bracketed resolution that must not read as a year; a word
starting with `s` that must not start a season; dotted titles vs. extensions;
and that an unrecognisable name still yields a usable title rather than being
dropped.
- **`config`** — round trip, missing-is-`None`, **unparseable-is-an-error** (the
one that protects the share list from a read-modify-write wiping it), and no
temp file left behind.
- **`catalog`** — schema version, refuse-to-open on a newer schema, share replace
semantics (drops removed rows, leaves other shares alone), and that **a recrawl
preserves watch state**.
`pstr-stream` is offline too, and for the same reason: it runs against a
`BlockSource` fake, so the caching, deduplication and read-ahead behaviour that
is nearly impossible to provoke deliberately over a network is pinned exactly.
- **`block`** — byte range → block mapping, including non-uniform sizes, a range
ending exactly on a boundary (which must not pull the next block), and splicing
into the right part of the output buffer.
- **`ring`** — never exceeds its byte budget, evicts least-recently-*used*, a
containment check records no traffic and no recency, a block bigger than the
budget is not stored, reinserting does not double-count.
- **`disk`** — round trip, a block disagreeing with its sidecar is rejected *and
removed*, a block with no sidecar is a miss, eviction deletes the files rather
than merely forgetting them, a reopened cache adopts the previous run's bytes,
and cache paths carry no names.
- **`single_flight`** — N callers run the work once, a waiter sees the leader's
failure, a failed call leaves the key retryable, and a **cancelled** leader does
not wedge the key.
- **`stream`** — reads across block boundaries, a seek fetches only the block it
lands in, a demand read joins an in-flight prefetch instead of refetching, a
failing prefetch never fails the read, **a seek cancels the read-ahead it
invalidates** while sequential playback cancels none, and streaming a file far
larger than the ring stays inside the budget.
`pstr-player` is offline too, everywhere it can be. mpv itself needs a window and
a codec, but nothing mpv's stream callbacks *decide* lives in the callbacks —
they hand straight to `StreamCursor`, a plain safe type over a `BlockSource`
fake, which is what the tests drive.
- **`cursor`** — sequential reads walk the file and stop at EOF, a seek moves
where the next read starts, a read crossing a block boundary is allowed to come
up short but returns the right bytes, seeking exactly to EOF is allowed while
past it is an error, **mpv's post-open seekability probe to 0 succeeds** (fail
it and every seek bar is gone), and a cancelled stream fails its reads and
stays failed.
- **`registry`** — a published stream is reachable through its URL, dropping the
handle revokes it, and **a token is never reused** after the stream behind it
is gone, so a stale URL cannot start playing whatever took the number over.
Only well-formed `pstr://` URLs parse.
- **`player`** — every mpv end-file reason maps to something the UI can act on
(`Eof` means "next episode"; nothing else does), and an observed property
arriving in an unexpected format is *not* read as a position of zero.
## Against a real share
Needs a Proton Drive public link. Use a share you can afford to have indexed;
nothing here writes, but the crawl reads every node's metadata.
```bash
cargo run -p pstr-cli -- add --name anime 'https://drive.proton.me/urls/TOKEN#fragment'
cargo run -p pstr-cli -- add --name anime 'https://…' --custom-password # prompts
cargo run -p pstr-cli -- crawl
cargo run -p pstr-cli -- list
```
Check: node count matches the folder, titles and episode numbers look right, and
the crawl time is seconds rather than minutes (if it is minutes, the SDK's
node-key cache is not doing its job).
The reference run, against a real library: **354 nodes crawled, 310 playable, 22
titles, ~25s**. Eleven files end up with no episode number and that is correct —
they are films and OP/ED extras. Anything else unnumbered is a parser gap; add
the filename to `naming`'s tests before fixing it.
Useful for spotting gaps:
```bash
sqlite3 ~/.local/share/proton-stream/catalog.db \
"SELECT title, name FROM nodes WHERE is_folder=0 AND episode IS NULL;"
```
## Benchmarking playback, without a player
`pstr bench` reads a catalogued file the way a demuxer would — 64 KiB at a time —
and reports the three numbers that decide whether the app is usable: cold seek
latency, sustained throughput, and peak resident memory.
```bash
cargo run -p pstr-cli -- bench --file Frieren --verify
cargo run -p pstr-cli -- bench --file Frieren --readahead 0 # isolate read-ahead
cargo run -p pstr-cli -- bench --file Frieren --disk-cache # run twice
```
`--verify` cross-checks a mid-file read against the SDK's own `download_range`.
Run it whenever anything in the block-mapping or caching path changes; it is the
only check that catches bytes served from the wrong offset, which otherwise
surfaces as video that decodes into garbage rather than as an error.
Reference run, 761 MiB episode, 191 blocks, over a real public link:
| | cold seek (worst) | sustained | peak resident |
|---|---:|---:|---:|
| read-ahead off | 572 ms | 7.2 MiB/s | 60 MiB |
| read-ahead 6, no seek cancellation | **2895 ms** | 5.0 MiB/s | 128 MiB |
| read-ahead 12, seek cancellation | 715 ms | 8.7 MiB/s | 128 MiB |
| read-ahead 32 | 716 ms | 7.8 MiB/s | 128 MiB (evicting) |
| warm disk cache | **14 ms** | 1039 MiB/s | 76 MiB |
Two findings worth not rediscovering. Read-ahead left running from the position
the viewer just left holds exactly the bandwidth the seek needs — 5x on
worst-case latency. And a read-ahead window deeper than half the ring evicts its
own blocks before the player reaches them, so throughput *falls*. Both are fixed
in `pstr-stream::stream`; both are pinned by offline tests.
Expect run-to-run spread of 15–20% on the throughput number. Treat a change under
that as noise.
Note that `bench`'s tuned read-ahead default (12) is **not** the right one for
mpv, which brings its own — see `docs/BUGS.md` B8. A change that improves `bench`
has not thereby improved playback; measure both.
## Playing, with a player
```bash
cargo run --release -p pstr-cli -- play --file Frieren
cargo run --release -p pstr-cli -- play --file Frieren --start 900 # cold mid-file seek
cargo run --release -p pstr-cli -- play --file Frieren --disk-cache # run twice
cargo run --release -p pstr-cli -- play --file Frieren --headless --for-seconds 60
```
Use `--release`. A debug build is watchable but the numbers below are not
reproducible from one.
`--headless` decodes with no window and no sound, so the whole chain still runs
somewhere there is no display; `--for-seconds` ends an unattended run without a
window to close. Together they are the smoke test. Every run prints time to first
frame, seek-resume latency, and what the block layer actually fetched.
Reference run, same 761 MiB / 1560 s episode over a real public link, defaults:
| | cold | warm disk cache |
|---|---:|---:|
| time to first frame | 2.3 s | **0.3 s** |
| mid-file seek resumed in | 1.0–1.6 s | — |
| blocks fetched for 38 s of playback | 16 (61 MiB) | **0** |
| ring evictions | 0 | 0 |
Sustained playback holds exactly realtime: 178 s of media in 180 s of wall clock,
the 2 s deficit being the time to first frame, with no drift over the run. Check
that: a deficit that *grows* is the block layer failing to keep up, and it is the
one failure the smoke test can see but a short run cannot.
`Cannot load libcuda.so.1` on stderr is mpv's `hwdec=auto-safe` probe declining a
backend. Not an error.
## SDK-side
The streaming surface this app depends on is tested in `../proton-sdk-rs`:
```bash
cd ../proton-sdk-rs
PROTON_TOTP_SECRET=... cargo test -p proton-drive-rs --test live_sharing \
-- --ignored --nocapture --test-threads=1
```
`a_visitor_can_seek_within_a_shared_multi_block_file` is the load-bearing one —
it verifies that the `drive/unauth/` revision endpoint returns a usable `XAttr`,
without which block boundaries cannot be placed and seeking is impossible.
Live runs there must stay `--test-threads=1`: Proton throttles repeated logins
and each fresh one burns a TOTP window.
## Acceptance, once playback lands
1. Add the share; the catalog populates with correct titles and seasons.
2. Play a 1080p HEVC episode — first frame in a couple of seconds, not a full
download.
3. Drag the seek bar to ~75%; playback resumes there within a block fetch.
4. Close and reopen; the resume position is honoured.
5. Leave it paused past the anonymous session's lifetime, then seek — the
session renews rather than erroring.
6. Watch the same episode again; it comes from the disk block cache.
7. Repeat on Windows with the bundled `libmpv-2.dll`.