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

+1 -1
View File
@@ -42,4 +42,4 @@ pub use proton_sdk::ids::NodeUid;
pub use reader::{FileBlocks, LibraryOpener, RevisionBlocks};
pub use ring::{DEFAULT_RING_BYTES, RingStats};
pub use source::{RevisionOpener, StreamConfig, StreamSource};
pub use stream::{DEFAULT_READAHEAD_BLOCKS, StreamStats, VideoStream};
pub use stream::{COPY_BLOCKS_IN_FLIGHT, DEFAULT_READAHEAD_BLOCKS, StreamStats, VideoStream};
+1 -2
View File
@@ -13,7 +13,6 @@ use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use proton_drive_rs::ProtonDrivePublicLinkClient;
use proton_sdk::error::ProtonError;
use proton_sdk::ids::NodeUid;
@@ -159,7 +158,7 @@ impl LibraryOpener {
Self { library }
}
fn client(&self, share_id: &str) -> Result<&ProtonDrivePublicLinkClient> {
fn client(&self, share_id: &str) -> Result<&pstr_core::ShareClient> {
self.library
.client(share_id)
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))
+42
View File
@@ -63,6 +63,12 @@ const MAX_CONCURRENT_BLOCKS_PER_READ: usize = 4;
/// bandwidth on cancelled prefetches. Well past a 1080p bitrate either way.
pub const DEFAULT_READAHEAD_BLOCKS: usize = 12;
/// How many blocks of one offline copy are fetched at once, for
/// [`VideoStream::blocks_in_order`]. The same as one read is allowed, for the
/// same reason: enough to hide a round trip, few enough that a couple of
/// copies running together do not swamp a player.
pub const COPY_BLOCKS_IN_FLIGHT: usize = 4;
/// How far a read may land from where the previous one ended and still count as
/// a continuation rather than a seek.
///
@@ -299,6 +305,42 @@ impl VideoStream {
Ok(buf)
}
/// Every block from `first` on, fetched `in_flight` at a time and yielded
/// in file order.
///
/// What an offline copy reads through. File order is what a partial file's
/// "its length is a block boundary" resume rule needs, and a few blocks in
/// flight is what keeps a copy from running one round trip per block. Use
/// it on a stream from [`crate::StreamSource::open_for_copy`]: a playback
/// stream would add its own read-ahead on top.
pub fn blocks_in_order(
&self,
first: usize,
in_flight: usize,
) -> futures::stream::BoxStream<'static, Result<Vec<u8>>> {
let start: u64 = self.block_sizes()[..first.min(self.block_sizes().len())]
.iter()
.sum();
let ranges: Vec<(u64, u64)> = self
.block_sizes()
.iter()
.skip(first)
.scan(start, |offset, size| {
let range = (*offset, *size);
*offset += size;
Some(range)
})
.collect();
let stream = self.clone();
stream::iter(ranges)
.map(move |(offset, size)| {
let stream = stream.clone();
async move { stream.read_range(offset, size).await }
})
.buffered(in_flight.max(1))
.boxed()
}
pub fn stats(&self) -> StreamStats {
StreamStats {
ring: self.inner.ring.stats(),