Files
nvrl 8e42906a7f feat(core,app,android,cli,ios): sync the library and settings, verify a share before adding it
Sync carried only watch positions, so a second device started empty. The
per-installation history file now also carries per-key registers
(pstr-core::profile): every share with its link and custom password, the
playback, appearance and metadata settings, the TMDB key, hand-picked
matches and per-title track choices. Newer updated_at wins per key, a
removal travels as an empty register, and a first sync stamps at 0 so a
fresh install takes the library the other devices have instead of
overwriting it with defaults. Volume, mute and the device-only settings
(cache budget, Wi-Fi only, background audio, hardware decoding) stay out.
The secrets live only in the end-to-end encrypted Drive file, never in
shares.json. FORMAT_VERSION stays 1; an older build ignores the registers.

Adding a share stored it before opening the link, so a first failure left a
broken entry and every retry said "already configured". add_verified opens
the link first and stores nothing on failure, and only the new share is
crawled afterwards, so another broken share cannot fail the add.

Android drops picture-in-picture: the window sized the video wrongly and
background audio already covers leaving the app.

iOS sizes the Metal drawable on every layout, so the picture no longer
stretches after a rotation or a Split View resize. Settings show a save at
once rather than after the match run, and a failed save reverts and says
why. Both phone clients gain an activity banner naming what is running, and
the add-share, metadata and change-match forms show progress and errors in
place.
2026-10-09 03:15:39 +02:00

163 lines
9.5 KiB
Markdown

# 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.
- Signing in to a Proton account is optional (`pstr-core::account`). The
session — tokens, and per account key the passphrase that unlocks it,
derived at sign-in — is one blob in the same credential store, under
`proton-account`. The mailbox password itself is never stored. Only TOTP
second factors are spoken; an account whose only one is a security key is
refused with a reason before the code prompt. Refresh tokens are single-use, so
every refresh is written back. On the desktop the blob can outgrow one
credential entry — Windows caps one at 1280 UTF-16 units — so
`KeyringSecretStore` splits a long value across `key#0`, `key#1`, … under a
header in `key`. The account writes exactly one kind of thing: the
watch-history files on its own `proton-stream` device (`pstr-core::sync`),
including trashing the file of an installation quiet for 90 days once its
history is merged into this one's. Folders of the account added as shares
are only ever read.
- The same file carries the rest of what makes every device look alike
(`pstr-core::profile`): the shares, with each link and custom password so
another device can open them; the playback, appearance and metadata settings
except volume and mute; the TMDB key; hand-picked matches; per-show track
choices. Each is a register merged per key, newer wins, a removed share being
a register with no value. `sync-state.json` holds the registers as last
synced, and a sync stamps whatever differs from it, so nothing that writes a
setting has to know about sync. A first sync stamps what it finds at zero, so
a fresh install's defaults never outrank the library everyone else agreed on.
Automatic matches are not synced: every device with enrichment on finds them
again. The front end crawls what arrived (`uncrawled_shares` covers a sync
that ran in the background) and drops what was removed with its files.
- A CAPTCHA in front of a sign-in is shown in a webview, because the page
reports success by posting a message to its host. On the desktop that is a
child process of the app (`pstr-app::verify`, wry): WebKitGTK on Linux,
WebView2 on Windows. It may only navigate to `https://*.proton.me`.
- 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 and removing the share; with an account, mirrored to its Drive |
| Proton session | OS credential store, `proton-account` | No — sign in again |
| Sync identity | config dir, `sync.json` | A new id if missing — the old history file is still read |
| Sync state | config dir, `sync-state.json` | Yes — a missing one is a first sync, which takes everything other devices have |
| Watch history and profile in Drive | device `proton-stream`, `watch-history/<id>.json` | One file per installation; merged per episode and per register, newer wins. Holds share links and passwords, end-to-end encrypted by Drive. A file unwritten for 90 days is retired by the next installation to sync, after merging it; a live installation whose file was retired writes it again |
| Block cache | cache dir, `blocks/` | Yes — must work correctly after deletion |
| Poster thumbnails | cache dir, `thumbs/` | Yes — refetched per file, one small decrypt each; stored scaled to 640 px |
| Provider artwork | cache dir, `posters/` | Yes — refetched by URL; stored scaled to 640 px |
| Desktop-only settings | config dir, `desktop.json` | Defaults if missing — hardware decoding, cache budget; never overwritten when unparseable |
| Window size | config dir, `window.json` | Defaults if missing or implausible |
| Single-instance pipe key (Windows) | config dir, `instance.key` | Yes — regenerated if missing |
| Notification app id (Windows) | `HKCU\Software\Classes\AppUserModelId\io.narl.proton-stream`, icon in cache dir | Yes — written again the first time a session notifies |