Files
proton-stream/crates/pstr-core/src/error.rs
T
nvrl 0b2813cd42 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.
2026-10-06 13:06:54 +02:00

74 lines
2.5 KiB
Rust

//! One error type for everything this crate does.
/// What can go wrong opening a share, crawling it, or persisting what was found.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// The Drive SDK failed — a network error, an API refusal, or a decrypt.
#[error(transparent)]
Drive(#[from] proton_sdk::error::ProtonError),
/// The catalog database failed.
#[error("catalog database: {0}")]
Db(#[from] rusqlite::Error),
/// The OS credential store failed.
///
/// Boxed because `keyring::Error` is large and this variant is rare; an
/// un-boxed one would inflate every `Result` in the crate.
#[cfg(not(target_os = "android"))]
#[error("credential store: {0}")]
Keyring(#[from] Box<keyring::Error>),
/// A config file could not be read, written or parsed.
#[error("config: {0}")]
Config(String),
/// Filesystem failure outside of config handling.
#[error("io: {0}")]
Io(#[from] std::io::Error),
/// The caller asked for something that does not exist or does not apply.
#[error("{0}")]
NotFound(String),
/// Proton gated a sign-in behind a CAPTCHA. The front end presents
/// [`HumanVerification::verification_url`](proton_sdk::api::HumanVerification::verification_url)
/// and signs in again with the token it yields.
#[error("Proton wants to confirm you are human before signing in")]
HumanVerification(Box<proton_sdk::api::HumanVerification>),
/// No account is signed in, or its session can no longer be refreshed and
/// the viewer has to sign in again.
#[error("{0}")]
SignedOut(String),
/// Signing in failed for a reason the viewer can act on, such as a wrong
/// mailbox password.
#[error("{0}")]
SignIn(String),
}
impl Error {
/// Whether this is the account's session having ended: the refresh token
/// was revoked or expired, or the token lacks a scope only a password
/// sign-in grants. Nothing but signing in again fixes it.
pub fn is_signed_out(&self) -> bool {
match self {
Self::SignedOut(_) => true,
Self::Drive(proton_sdk::error::ProtonError::Api(api)) => {
api.is_invalid_refresh_token() || api.is_insufficient_scope()
}
_ => false,
}
}
}
#[cfg(not(target_os = "android"))]
impl From<keyring::Error> for Error {
fn from(error: keyring::Error) -> Self {
Self::Keyring(Box::new(error))
}
}
pub type Result<T> = std::result::Result<T, Error>;