feat(core,stream,meta,app,android,cli): sign in to Proton and sync watch history

A Proton account is now optional alongside public links. Signing in (password,
then a second-factor code and a mailbox password where the account has them;
a CAPTCHA gate is solved in the Android app) stores one session blob in the OS
credential store under `proton-account`, writing back every refreshed token
since refresh tokens are single-use. With an account, folders of the viewer's
own Drive — My Files, other devices, shared with me — can be added as shares
through the same `ShareClient` surface as a public link.

Watch history syncs through a `proton-stream` device in that Drive: one
`watch-history/<installation>.json` per installation, so no two devices ever
write the same file. Each sync reads the others and merges per episode, newer
`updated_at` wins, with a deterministic tie-break. It runs at launch, after
sign-in, whenever the player closes and every five minutes. A session that can
no longer refresh signs the app out instead of failing on every launch.

The CLI gains `login`, `logout`, `account`, `browse` and `sync`.

Also in this change:
- Android offline downloads no longer look stuck (B64): two transfer at once,
  through a copy stream with no read-ahead, with a smoothed transfer rate.
- Choosing a metadata match by hand now enriches the title too, instead of
  keeping the old match's episodes and artwork.

The catalog schema is unchanged.
This commit is contained in:
nvrl committed 2026-10-06 13:06:54 +02:00
1 parent 6cdab2fde1
commit 0b2813cd42
37 files changed
+4111 -159

No files matched your search

+16
View File
@@ -421,6 +421,22 @@ impl MetadataService {
}
}
/// Pin `title` to the entry the viewer picked, and enrich it.
///
/// Both halves, always: the point of choosing an entry by hand is usually
/// that the episode names were wrong too, so a choice that stored only the
/// title record left the episodes, the backdrop and the franchise chain of
/// the *old* match — or none — until some later match run got round to it.
/// A failed enrichment still keeps the choice, as in [`Self::work`].
pub async fn choose(&self, title: &Title, found: TitleMetadata) -> Outcome {
let enrichment = self.enrich_or_log(title, &found).await;
Outcome {
title_key: title.key.clone(),
record: Some(self.chosen(title.key.clone(), found)),
enrichment,
}
}
async fn enrich_or_log(&self, title: &Title, found: &TitleMetadata) -> Option<Enrichment> {
self.enrich(title, found)
.await