Compare commits

26 Commits
Author SHA1 Message Date
nvrl ce02bbc35a fix(vers) updated cargo + pkgbuild version 2026-10-12 19:10:58 +02:00
nvrl 0b9a9dab50 fix(ios,android): offer the right episode as up next while the next one opens
After "next episode" during the credits, the previous file keeps
playing until the new stream is open. Its chapters and position were
read against the already-advanced index, so the up-next card offered
the episode after the next one for those seconds. The card, the skip
offer and auto-skip now only act on readings of the open episode.
2026-10-12 18:36:33 +02:00
nvrl 73973841c2 feat(core,app,android,ios,cli): look up new titles after every crawl, show presses and work on iOS
Metadata was only fetched after adding a share (mobile) or from the
Settings buttons (desktop), so a show or episode that turned up in an
existing share stayed bare. Every crawl is now followed by a match run
when enrichment is on; `plan` makes no requests for a library that is
already matched. A stored episode guide is fetched again when a file
newer than it has no named entry, and daily for two weeks while an
aired episode is still unnamed.

Removing a share now carries its watch state to any other share holding
the same file, `pstr remove` drops the share's catalog rows, and the app
forgets catalog rows of shares no longer configured — an older `pstr
remove` left them, and every episode showed twice.

iOS, for a slow iPad:
- swipe back on a settings page uses its own edge strip; on the whole
  page it also pressed the row under the finger and opened the metadata
  sheet
- "Change match" from the tile menu opens once the menu has dismissed,
  not after a guessed 350 ms that a slow device missed
- the activity banner tracks each running line by itself (also Android),
  so overlapping work no longer leaves it spinning forever
- buttons, tiles and rows draw their press; marking watched shows at
  once; remove/repair share, sign out and deletions show the banner;
  the player says "Opening…" while the stream opens
- posters are read into a reserved buffer instead of a byte at a time
2026-10-12 18:36:33 +02:00
nvrl 2f3be1d193 chore(version): bump to 1.4.0
Minor. Sync now carries the whole library, not only watch positions:
shares with their links and passwords, playback, appearance and metadata
settings, the TMDB key, hand-picked matches and per-title tracks, so every
signed-in device shows the same library. Devices share it once all of them
run 1.4.0; an older build keeps syncing watch positions and ignores the
rest.

Android drops picture-in-picture. iOS fixes the stretched player after a
rotation or resize, the share that failed to add on the first try, and
settings that saved late or silently. Both phone clients now show what is
running and why a form was refused.

No catalog schema change since 1.3.0.
2026-10-09 03:15:55 +02:00
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
nvrl dc70cafbb7 chore(version): bump to 1.3.0
Minor. One new client: an iOS 17+ app, a SwiftUI port of the Android one
over the same Rust engine, published as an unsigned .ipa for SideStore and
AltStore. docs/IOS.md lists where iOS cannot follow Android: no
picture-in-picture, links come in through the protonstream:// scheme or
Paste link rather than the share sheet, and downloads run while the app
does.

No change to the Rust crates, the catalog schema or the desktop and Android
clients since 1.2.0.
2026-10-07 01:55:22 +02:00
nvrl a28135cd51 ci(release): publish the unsigned iOS .ipa
The release calls ios.yml after the version check, as it does android.yml,
and attaches proton-stream-<version>-ios.ipa next to the other artifacts and
in SHA256SUMS.
2026-10-07 01:55:22 +02:00
nvrl 4b9c01d52d fix(ios): clear the compiler warnings from the first CI build
previewPalette does not throw, and mpv's wid now gets the layer's address
as an explicit int64 instead of a pointer to a Swift reference.
2026-10-07 01:27:10 +02:00
nvrl 3356602951 fix(ios): give the test target the generated header path
`@testable import ProtonStream` imports the app's bridging header, which
includes PstrBridgeFFI.h from ios/build/generated.
2026-10-07 00:54:45 +02:00
nvrl 537217e70a fix(ios): make scripts/build-ios.sh executable
The iOS workflow runs it directly, and it was committed as 100644.
2026-10-07 00:34:48 +02:00
nvrl ed05a1e723 feat(ios): port the Android client to SwiftUI
The iOS client is now a screen-for-screen port of the Android app over the
same pstr-android UniFFI surface, with no Rust changes: Library, title page,
History, Shares (public links and Drive folders), Downloads and Settings,
with the same strings, undo snackbars and two-pane layout on wide screens.

- Theme from the engine's palette (Material role names, Inter, the same
  type ramp and corners), repainted live from the appearance settings.
- Account sign-in with two-factor code, mailbox password and the Proton
  CAPTCHA in a non-persistent WKWebView, the Drive folder browser, and
  watch-history sync every five minutes, after sign-in, on player close and
  when the app leaves the screen.
- Player over libmpv: resume, progress saved every 5 s, autoplay with the
  up-next card, skip and auto-skip from the chapter plan, track, speed,
  volume and episode pickers, landscape lock, mini transport, and
  lock-screen controls with interruption and route-change handling.
- Offline downloads with the Android record format, free-space floor,
  retries, transfer rate and the Wi-Fi-only policy; interrupted transfers
  resume at next launch.
- Config, data and download state are excluded from backup, as Android's
  allowBackup=false: a restore would bring shares back without their
  Keychain secrets and give two devices one sync identity.

Picture-in-picture, the share sheet and background downloads are not
possible the same way on iOS; docs/IOS.md lists why and what replaces them.

Adds XCTest ports of the Android unit tests, a `test` command to
scripts/build-ios.sh, the app icon and its render script, and an iOS
workflow on macos-15 that runs the tests and builds the unsigned .ipa.
2026-10-07 00:32:03 +02:00
nvrl da16ab0575 fix(core,app,android): keep the password out of storage and refuse key-only 2FA
Moves to proton-sdk / proton-drive-rs 0.7.3.

The stored Proton session now holds, per account key, the passphrase derived
from the mailbox password at sign-in, and resumes through
`ProtonDriveClient::with_key_passphrases`. The mailbox password — on a
single-password account, the login password — and the key salts are no longer
stored. A session in the earlier shape, which only development builds of
1.2.0 wrote, reads as signed out: signing in again replaces it.

An account whose only second factor is a security key is refused at sign-in
with a reason, rather than asked for a code that can only fail as a wrong one.
The code prompts now say plainly that security keys are not supported.
2026-10-06 15:01:37 +02:00
nvrl d9ca8f8d60 fix(core,app,android,cli,packaging): harden account sign-in and watch-history sync
- Desktop shows Proton's CAPTCHA instead of failing the sign-in: a wry webview
  in a child process of the app (`pstr-app::verify`), confined to
  https://*.proton.me, hands the token back and the sign-in is retried. Linux
  packages and CI gain WebKitGTK 4.1; the Windows zip and MSI ship
  WebView2Loader.dll, without which a -gnu build does not start.
- The desktop keyring splits a secret too long for one entry across several,
  so a Proton session with many key salts survives Windows' 1280-unit cap.
  Short secrets stay one entry, so every existing secret still reads.
- `WatchSync` runs against a small `HistoryDrive` trait and is tested end to
  end against an in-memory Drive: two devices, newer wins, no re-upload of an
  unchanged history, a missing file written again, an unreadable own file
  never overwritten, a newer-format file left alone.
- An installation's file unwritten for 90 days is retired by the next device
  to sync, after its entries are merged and carried in that device's file.
- Removing a share keeps its watch state: sync would bring it back from the
  other devices anyway, and re-adding the share picks up where it left off.
- Watch history syncs once more on desktop exit (bounded to 3 s) and, on
  Android, through a WorkManager job enqueued when the app leaves the screen.
- The second-factor prompts say security keys are not supported.
2026-10-06 14:30:15 +02:00
nvrl 26cccd5c63 chore(version): bump to 1.2.0
Minor. Two new features, both optional and both behind signing in to a Proton
account:

- Sign in with a Proton account (password, two-factor code, separate mailbox
  password) on desktop, Android and the CLI, and add folders of your own Drive
  — My Files, other devices, shared with me — as library shares next to public
  links. The session lives in the OS credential store, never in shares.json.
- Watch history follows you across devices. Each installation keeps its own
  file on a `proton-stream` device in your Drive and merges everyone else's
  per episode, newest position wins. It syncs at launch, after sign-in, when
  the player closes and every five minutes.

Also: Android offline downloads no longer look stuck (B64), and picking a
metadata match by hand refreshes the title's episodes and artwork.

The catalog schema is unchanged from 1.1.3. A new `sync.json` beside
`shares.json` holds this installation's sync id.
2026-10-06 13:19:52 +02:00
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
nvrl 6cdab2fde1 chore(version): bump to 1.1.3
Patch. Fixes two Windows installer defects: an MSI upgrade no longer leaves
the app without libmpv until a second install (B62), and a taskbar pin keeps
its icon, which the exe now carries itself (B63). The catalog schema and
on-disk data are unchanged from 1.1.2.
2026-10-06 03:05:20 +02:00
nvrl 6239780978 fix(app,packaging): keep libmpv and the taskbar icon across Windows upgrades
An MSI upgrade left the app without libmpv-2.dll until it was installed a
second time (B62). Costing runs before RemoveExistingProducts, saw the old
DLL at the same version and skipped the copy, and removing the old product
then deleted it. Both installer definitions now set REINSTALLMODE=amus;
every file is private to the install directory.

A taskbar pin lost its icon after a while (B63). The exe carried no icon
resource, and the Start Menu shortcut's icon lived in the per-ProductCode
installer cache that each upgrade deletes. build.rs now compiles the .ico
into proton-stream.exe, and the shortcut has no Icon of its own (and in the
WiX build is no longer advertised), so both show the exe's icon.
2026-10-06 03:04:07 +02:00
nvrl 67cf3044f1 fix(android): keep the accent button's width and corners
Moving the gradient onto a row inside the button lost both: the button's
own row hands its children no minimum size, so a fillMaxWidth button
painted the ramp only behind its label, and with square corners.

The gradient goes back on the button, and the 48dp touch-target padding
is taken by a box around it instead, with the button inside told not to
add its own. The primary button now matches the other buttons' 40dp
container at every width. The screenshots are re-recorded for it.
2026-10-04 17:14:11 +02:00
nvrl 66be35c3d4 chore(version): bump to 1.1.2
Patch. The only change since 1.1.1 is on Android: the primary button on the
library hero no longer draws taller than the button beside it. The catalog
schema and on-disk data are unchanged from 1.1.1.
2026-10-04 16:55:38 +02:00
nvrl 046cfe62b8 fix(android): paint the accent button's gradient inside its container
The gradient sat on the outer modifier, which Material pads out to the
48dp minimum touch target, so the primary button drew 8dp taller than
the 40dp tonal button beside it on the library hero.
2026-10-04 16:45:25 +02:00
nvrl e3dcbec1f2 chore(version): bump to 1.1.1
Patch, not minor. Nothing a viewer uses changes: the Android build moves to
NDK r30 and mpv-android 687c7fd, which brings newer harfbuzz, fribidi,
unibreak, fontconfig, libxml2 and curl into the bundled libmpv. The catalog
schema and on-disk data are unchanged from 1.1.0.
2026-10-04 02:54:21 +02:00
nvrl 99abc8f205 build(android): move to NDK r30 and mpv-android 687c7fd
Upstream mpv-android moved its build to NDK r30 (30.0.16248370), and each
mpv-android revision pins its own NDK, so the two move together. The new
revision also updates harfbuzz to 14.5.0, unibreak to 8.0, fribidi to
1.0.17, libxml2 to 2.15.4, fontconfig to 2.18.3 and curl to 8.22.0, and
builds libass with assembly on x86_64 (nasm, already installed in CI).

r30 brings an LLVM update with codegen and LTO fixes and sysroots up to
API 37. It is also the NDK current SDK managers install, so a fresh setup
no longer needs r29 installed beside it.

The script change invalidates the cached libmpv tree, so CI rebuilds it
once. The staged libraries are the same set as before, all 16 KiB aligned.
2026-10-04 02:42:53 +02:00
nvrl 3638e0e5df feat(core,app,android): sort, fold and shelve the Android library
Bring the phase 2 library to Android. Search, sorting and franchise folding
move from the desktop app into pstr_core::browse, so both clients find,
fold and order the same titles the same way.

The bridge gains AndroidEngine::arrangement, which returns the grid's tiles
and up to three shelves as keys into the library's titles. Each TitleRecord
now carries its display name, wide art, format, season, studios, directors,
tags, airing state and franchise. The library bar has a sort menu with the
franchise toggle, folded tiles show a +N badge, and shelves sit above the
grid. The title page shows the same facts, tags and franchise row as the
desktop. Settings -> Appearance gains Title names.

The default title name is now English, which is what Android always showed.
A folded franchise now sorts by its lead's displayed name rather than by its
alphabetically first member.
2026-10-04 02:04:15 +02:00
nvrl aef182e7e8 chore(version): bump to 1.1.0
Minor, not patch. This release adds features and changes on-disk data:

- ani.zip episode names and fanart.
- Franchise grouping, shelves, poster grid and new sorts.
- Two catalog migrations (v9, v10). Catalogs are forward-only, so 1.0.x
  refuses to open a catalog 1.1.0 has upgraded.

The first match run after upgrading fetches every title's details and
episode list again. It refetches by id and does not search, so
hand-picked matches are kept.
2026-10-04 01:27:40 +02:00
nvrl 6853725ca0 feat(core,meta,app,android,cli): sort, group and shelve the library
The AniList query now keeps the start date, season, banner, popularity,
status, next airing episode, tags, main studios, director and story
relations, as TitleDetails in one JSON column (schema v10). Matches
stored without them are fetched again by id, never searched, so
hand-picked matches stay; refreshing the same entry keeps its episodes
and fanart. Enrichment records the entries it walked. node_first_seen
remembers when each file appeared, across recrawls.

pstr_core::franchise groups titles that share a provider id. The grid
folds each franchise into its lead with a +N badge, and the title page
lists the whole franchise in release order. pstr_core::shelves picks
rows by airing status, director, studio and genre. A row needs three
franchises, so one franchise's titles never make a row on their own.

Desktop:
- Covers are the default grid (Settings > Appearance > Library tiles).
- The hero and title band use fanart or banner decoded at 1920 px.
- New sorts: recently added, newest, highest rated, most popular.
- Titles can show the share's, English or romaji name; search matches
  all of them.

Android carries TitleDetails through a hand-picked match; its screens
are unchanged.
2026-10-04 01:26:21 +02:00
nvrl 4c80076762 fix(core,meta,app,android,cli): number episodes the way releases do
AniList's per-season lookup searched for "<title> 2nd Season" and the
scorer picked the base entry again, so Attack on Titan seasons 2-4 and
Jigokuraku season 2 were captioned with season one's names. Episode
names came from streamingEpisodes, which is partial and numbered however
a streaming site counted. Bleach S17 and Mushishi S02/S03 had none.

AniList now walks SEQUEL relations from the match and takes each
entry's episodes from ani.zip, which carries TVDB's season, number and
absolute number. An episode row keeps all three plus its place in the
AniList entry. EpisodeGuide::get matches whichever numbering a file
uses and never answers from another season.

Episodes and fanart are stored as one enrichment. The episode list is
replaced even when empty, and the enrichment is recorded as asked, so a
film is not re-asked every run. Storing a different match drops the old
entry's episode rows. AniList's 4.75:1 bannerImage is no longer stored
as the 16:9 backdrop; ani.zip's 1920x1080 fanart is used instead.

Schema v9 adds the columns and deletes the AniList episode rows and
banners. Matches are kept. Desktop, Android and the CLI now share
service::plan and MetadataService::work. The settings text names
ani.zip. Audit and roadmap in docs/METADATA.md; bug entry B61.
2026-10-04 00:47:41 +02:00
138 changed files with 21495 additions and 1642 deletions

No files matched your search

+2 -2
View File
@@ -39,8 +39,8 @@ env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
ANDROID_API: '36' ANDROID_API: '36'
ANDROID_BUILD_TOOLS: '37.0.0' ANDROID_BUILD_TOOLS: '37.0.0'
ANDROID_NDK: '29.0.14206865' ANDROID_NDK: '30.0.16248370'
ANDROID_NDK_HOME: /usr/local/lib/android/sdk/ndk/29.0.14206865 ANDROID_NDK_HOME: /usr/local/lib/android/sdk/ndk/30.0.16248370
jobs: jobs:
build: build:
+3 -3
View File
@@ -23,11 +23,11 @@ jobs:
- uses: Swatinem/rust-cache@v2.9.1 - uses: Swatinem/rust-cache@v2.9.1
# libmpv for pstr-player. # libmpv for pstr-player, WebKitGTK for the CAPTCHA window.
- name: Install libmpv - name: Install libmpv and WebKitGTK
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y libmpv-dev sudo apt-get install -y libmpv-dev libwebkit2gtk-4.1-dev
- run: cargo fmt --all -- --check - run: cargo fmt --all -- --check
+71
View File
@@ -0,0 +1,71 @@
name: iOS
on:
push:
branches: [main]
workflow_call:
pull_request:
paths:
- 'ios/**'
- 'android/app/src/main/res/font/**'
- 'android/app/src/main/assets/licenses/**'
- 'crates/pstr-android/**'
- 'crates/pstr-core/**'
- 'crates/pstr-meta/**'
- 'crates/pstr-stream/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'scripts/build-ios.sh'
- '.github/workflows/ios.yml'
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
jobs:
build:
name: Unit tests and unsigned .ipa
runs-on: macos-15
steps:
- uses: actions/checkout@v7
- id: toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: '1.96'
targets: aarch64-apple-ios,aarch64-apple-ios-sim
- name: Restore the Rust target tree
uses: actions/cache@v6
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: rust-target-ios-${{ steps.toolchain.outputs.cachekey }}-${{ hashFiles('Cargo.lock') }}
restore-keys: rust-target-ios-${{ steps.toolchain.outputs.cachekey }}-
- name: Install XcodeGen
run: brew install xcodegen
- name: Bindings
run: scripts/build-ios.sh bindings
- name: Unit tests on the simulator
run: scripts/build-ios.sh test
# SideStore and AltStore re-sign whatever they install with the viewer's
# own Apple ID, so the release artifact is unsigned.
- name: Unsigned .ipa
run: |
scripts/build-ios.sh rust
scripts/build-ios.sh ipa
- uses: actions/upload-artifact@v7
with:
name: ios
path: dist/*.ipa
if-no-files-found: error
+13 -2
View File
@@ -76,7 +76,7 @@ jobs:
- name: Install build dependencies - name: Install build dependencies
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y libmpv-dev rpm sudo apt-get install -y libmpv-dev libwebkit2gtk-4.1-dev rpm
- name: Gate - name: Gate
run: | run: |
@@ -150,10 +150,17 @@ jobs:
signed_release: true signed_release: true
secrets: inherit secrets: inherit
# The unsigned .ipa: SideStore and AltStore re-sign it with the viewer's own
# Apple ID when they install it.
ios:
name: iOS
needs: verify
uses: ./.github/workflows/ios.yml
publish: publish:
name: Publish name: Publish
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
needs: [linux, windows, android] needs: [linux, windows, android, ios]
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions: permissions:
contents: write contents: write
@@ -172,6 +179,10 @@ jobs:
with: with:
name: android name: android
path: artifacts path: artifacts
- uses: actions/download-artifact@v8
with:
name: ios
path: artifacts
- name: Checksums - name: Checksums
run: | run: |
Generated
+1275 -48
View File
File diff suppressed because it is too large. Load diff
+13 -3
View File
@@ -11,7 +11,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "1.0.1" version = "1.4.1"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
repository = "https://github.com/narrrl/proton-stream" repository = "https://github.com/narrrl/proton-stream"
@@ -28,8 +28,8 @@ authors = ["Nils Pukropp <contact@narl.io>"]
opt-level = 2 opt-level = 2
[workspace.dependencies] [workspace.dependencies]
proton-sdk = { version = "0.7.2" } proton-sdk = { version = "0.7.3" }
proton-drive-rs = { version = "0.7.2" } proton-drive-rs = { version = "0.7.3" }
# Internal crates. # Internal crates.
pstr-core = { path = "crates/pstr-core" } pstr-core = { path = "crates/pstr-core" }
@@ -54,6 +54,8 @@ directories = "5"
# Local dates for the history page. Already in the tree through the SDK. # Local dates for the history page. Already in the tree through the SDK.
chrono = { version = "0.4", default-features = false, features = ["clock"] } chrono = { version = "0.4", default-features = false, features = ["clock"] }
sha2 = "0.10" sha2 = "0.10"
# The id a watch-history file is named by. Already in the tree through the SDK.
getrandom = "0.3"
# `bundled` so a Windows build needs no system SQLite. # `bundled` so a Windows build needs no system SQLite.
rusqlite = { version = "0.32", features = ["bundled"] } rusqlite = { version = "0.32", features = ["bundled"] }
# The share URL fragment and the custom password are both secrets; they go in # The share URL fragment and the custom password are both secrets; they go in
@@ -103,6 +105,14 @@ dbus = "0.9"
windows-sys = "0.61" windows-sys = "0.61"
# Toasts on Windows. Linux notifications go over the `dbus` above. # Toasts on Windows. Linux notifications go over the `dbus` above.
tauri-winrt-notification = "0.8" tauri-winrt-notification = "0.8"
# The exe's own icon resource, which is what Explorer and a taskbar pin draw.
embed-resource = "3"
# Proton's CAPTCHA page, which reports success by posting a message to its
# host, so it needs a webview this app controls. Run in a child process of its
# own (`pstr-app::verify`): tao and eframe's winit cannot share an event loop.
# WebKitGTK on Linux, WebView2 on Windows, WKWebView on macOS.
wry = { version = "0.57", default-features = false, features = ["os-webview"] }
tao = { version = "0.37", default-features = false, features = ["rwh_06"] }
rpassword = "7" rpassword = "7"
libloading = "0.8" libloading = "0.8"
lru = "0.18" lru = "0.18"
+16 -6
View File
@@ -33,6 +33,10 @@ streaming implementation through UniFFI. It features a modern Material 3 interfa
an immersive fullscreen player with auto-hiding controls, and offline downloads. an immersive fullscreen player with auto-hiding controls, and offline downloads.
Its toolchain, emulator, signing, and release instructions are in [`docs/ANDROID.md`](docs/ANDROID.md). Its toolchain, emulator, signing, and release instructions are in [`docs/ANDROID.md`](docs/ANDROID.md).
An iOS 17+ client under `ios/` is a SwiftUI port of the Android one over the same
Rust engine, built as an unsigned `.ipa` for SideStore or AltStore; see
[`docs/IOS.md`](docs/IOS.md).
```bash ```bash
cargo run --release -p pstr-app # the app cargo run --release -p pstr-app # the app
proton-stream 'https://drive.proton.me/urls/TOKEN#fragment' # open the Add form with a link proton-stream 'https://drive.proton.me/urls/TOKEN#fragment' # open the Add form with a link
@@ -72,8 +76,8 @@ against the SDK's own range download.
| Transcoding | None. mpv demuxes and decodes natively. | | Transcoding | None. mpv demuxes and decodes natively. |
| UI | egui/eframe on glow; mpv renders into the same GL context. | | UI | egui/eframe on glow; mpv renders into the same GL context. |
| Themes | The shipped near-black palette, Catppuccin Latte, Frappé, Macchiato and Mocha, and a Persona 5 one — black, white and one loud red, which is what the Red accent is there for — each with a choice of nine accents, drawn as a gradient unless you turn that off. Picked on the Settings page, applied without a restart. | | Themes | The shipped near-black palette, Catppuccin Latte, Frappé, Macchiato and Mocha, and a Persona 5 one — black, white and one loud red, which is what the Red accent is there for — each with a choice of nine accents, drawn as a gradient unless you turn that off. Picked on the Settings page, applied without a restart. |
| Metadata | Filename parsing, with optional AniList/TMDB enrichment — **off by default**, because enabling it sends your library's titles to a third party. It brings posters, synopses and per-episode titles; a title the scorer refuses to guess about can be pinned by hand, and a pinned match is never overwritten. | | Metadata | Filename parsing, with optional AniList/TMDB enrichment — **off by default**, because enabling it sends your library's titles to a third party. It brings posters, synopses, fanart and per-episode titles and synopses (for AniList, from ani.zip, which is sent the matched ids); a title the scorer refuses to guess about can be pinned by hand, and a pinned match is never overwritten. The library sorts by name, watch, add or air date, rating and popularity, folds a franchise into one tile, and shelves titles by director, studio and genre. See [docs/METADATA.md](docs/METADATA.md). |
| Platforms | Linux and Windows; macOS packaging is written but has not been run on a Mac. A native Android 12+ client is available featuring a Material 3 UI and immersive fullscreen playback. | | Platforms | Linux and Windows; macOS packaging is written but has not been run on a Mac. A native Android 12+ client is available featuring a Material 3 UI and immersive fullscreen playback, and an iOS 17+ port of it for sideloading. |
## Building ## Building
@@ -83,7 +87,9 @@ Rust 2024, MSRV 1.96.
cargo build --release cargo build --release
``` ```
`pstr-player` needs libmpv development headers (`mpv` ≥ 2.0). On Windows those `pstr-player` needs libmpv development headers (`mpv` ≥ 2.0), and on Linux
`pstr-app` needs WebKitGTK 4.1's, for the window Proton's CAPTCHA is shown in.
On Windows the libmpv ones
come from the separate **mpv-dev** archive rather than mpv's player build, and come from the separate **mpv-dev** archive rather than mpv's player build, and
`libmpv-2.dll` ships next to the executable. The Proton SDK crates come from `libmpv-2.dll` ships next to the executable. The Proton SDK crates come from
crates.io; nothing here builds against a sibling checkout. crates.io; nothing here builds against a sibling checkout.
@@ -97,6 +103,9 @@ Android builds use JDK 17, Android SDK 36/NDK 28, Gradle 8.13, `cargo-ndk`, and
UniFFI. Run `bash scripts/build-android.sh debug` after installing that toolchain; UniFFI. Run `bash scripts/build-android.sh debug` after installing that toolchain;
see [`docs/ANDROID.md`](docs/ANDROID.md) for exact setup and release signing. see [`docs/ANDROID.md`](docs/ANDROID.md) for exact setup and release signing.
iOS builds need macOS with Xcode, XcodeGen and the `aarch64-apple-ios` Rust
target: `scripts/build-ios.sh all` writes `dist/proton-stream-<version>-ios.ipa`.
## Privacy ## Privacy
- The share URL fragment **is** the decryption password. It and any custom - The share URL fragment **is** the decryption password. It and any custom
@@ -119,9 +128,10 @@ see [`docs/ANDROID.md`](docs/ANDROID.md) for exact setup and release signing.
## License ## License
The shared Rust crates and desktop application are MIT licensed. The Android The shared Rust crates and desktop application are MIT licensed. The Android and
application and its combined native distribution are GPL-3.0-or-later; see iOS applications and their combined native distributions are GPL-3.0-or-later; see
[`android/LICENSE.md`](android/LICENSE.md) and the bundled-component notices. [`android/LICENSE.md`](android/LICENSE.md), [`ios/LICENSE.md`](ios/LICENSE.md) and
the bundled-component notices.
The desktop application embeds the Inter typeface, under the SIL Open Font The desktop application embeds the Inter typeface, under the SIL Open Font
License 1.1 ([`crates/pstr-app/assets/fonts/Inter-OFL.txt`](crates/pstr-app/assets/fonts/Inter-OFL.txt)), License 1.1 ([`crates/pstr-app/assets/fonts/Inter-OFL.txt`](crates/pstr-app/assets/fonts/Inter-OFL.txt)),
and the Phosphor icon font, under MIT. and the Phosphor icon font, under MIT.
+1 -1
View File
@@ -7,7 +7,7 @@ distributions.
| Component | License | | Component | License |
|---|---| |---|---|
| mpv/libmpv | GPL-2.0-or-later by default; verify the pinned build configuration | | mpv/libmpv | GPL-2.0-or-later by default; verify the pinned build configuration |
| LLVM libc++ shared runtime (Android NDK r29) | Apache-2.0 WITH LLVM-exception | | LLVM libc++ shared runtime (Android NDK r30) | Apache-2.0 WITH LLVM-exception |
| AndroidX and Jetpack Compose | Apache-2.0 | | AndroidX and Jetpack Compose | Apache-2.0 |
| Kotlin | Apache-2.0 | | Kotlin | Apache-2.0 |
| JNA | Apache-2.0 or LGPL-2.1-or-later | | JNA | Apache-2.0 or LGPL-2.1-or-later |
+1 -1
View File
@@ -47,7 +47,7 @@ android {
namespace = "io.narl.protonstream" namespace = "io.narl.protonstream"
compileSdk = 36 compileSdk = 36
buildToolsVersion = "37.0.0" buildToolsVersion = "37.0.0"
ndkVersion = "29.0.14206865" ndkVersion = "30.0.16248370"
defaultConfig { defaultConfig {
applicationId = "io.narl.protonstream" applicationId = "io.narl.protonstream"
-1
View File
@@ -35,7 +35,6 @@
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|uiMode|density|fontScale|locale|layoutDirection|keyboardHidden" android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|uiMode|density|fontScale|locale|layoutDirection|keyboardHidden"
android:exported="true" android:exported="true"
android:launchMode="singleTask" android:launchMode="singleTask"
android:supportsPictureInPicture="true"
android:resizeableActivity="true"> android:resizeableActivity="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@@ -6,7 +6,7 @@ complete GNU GPL version 3 text is packaged beside this file as `GPL-3.0.txt`.
| Component | License | | Component | License |
|---|---| |---|---|
| mpv/libmpv | GPL-2.0-or-later by default; verify the pinned build configuration | | mpv/libmpv | GPL-2.0-or-later by default; verify the pinned build configuration |
| LLVM libc++ shared runtime (Android NDK r29) | Apache-2.0 WITH LLVM-exception | | LLVM libc++ shared runtime (Android NDK r30) | Apache-2.0 WITH LLVM-exception |
| AndroidX and Jetpack Compose | Apache-2.0 | | AndroidX and Jetpack Compose | Apache-2.0 |
| Kotlin | Apache-2.0 | | Kotlin | Apache-2.0 |
| JNA | Apache-2.0 or LGPL-2.1-or-later | | JNA | Apache-2.0 or LGPL-2.1-or-later |
@@ -1,53 +1,37 @@
package io.narl.protonstream package io.narl.protonstream
import android.Manifest import android.Manifest
import android.app.PendingIntent
import android.app.PictureInPictureParams
import android.app.RemoteAction
import android.content.BroadcastReceiver
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection import android.content.ServiceConnection
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Rect
import android.graphics.drawable.Icon
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import android.util.Rational
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshotFlow
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toDrawable import androidx.core.graphics.drawable.toDrawable
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import io.narl.protonstream.native.NativeRuntime import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.playback.MpvPlaybackState
import io.narl.protonstream.playback.NativeMpvHost import io.narl.protonstream.playback.NativeMpvHost
import io.narl.protonstream.playback.PlaybackService import io.narl.protonstream.playback.PlaybackService
import io.narl.protonstream.settings.SettingsStore import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.sync.WatchSyncWorker
import io.narl.protonstream.ui.ProtonStreamApp import io.narl.protonstream.ui.ProtonStreamApp
import io.narl.protonstream.ui.theme.AppearanceState import io.narl.protonstream.ui.theme.AppearanceState
import io.narl.protonstream.ui.theme.ProtonStreamTheme import io.narl.protonstream.ui.theme.ProtonStreamTheme
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import uniffi.pstr_android.PaletteRecord import uniffi.pstr_android.PaletteRecord
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val playerHost = mutableStateOf<NativeMpvHost?>(null) private val playerHost = mutableStateOf<NativeMpvHost?>(null)
private val inPictureInPicture = mutableStateOf(false)
/** A share link handed over by a browser or the share sheet, until the Add form takes it. */ /** A share link handed over by a browser or the share sheet, until the Add form takes it. */
private val incomingShareLink = mutableStateOf<String?>(null) private val incomingShareLink = mutableStateOf<String?>(null)
@@ -68,30 +52,10 @@ class MainActivity : ComponentActivity() {
} }
} }
/**
* The transport behind the buttons Android draws over the PiP window.
*
* A broadcast rather than a service intent: PiP is one of the states in
* which a background service start is refused, and the receiver is
* registered for as long as the activity that owns the window exists.
*/
private val pictureInPictureControls = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val host = playerHost.value ?: return
when (intent?.getStringExtra(EXTRA_CONTROL)) {
CONTROL_PLAY -> host.setPaused(false)
CONTROL_PAUSE -> host.setPaused(true)
CONTROL_NEXT -> host.onSkipNext?.invoke()
CONTROL_PREVIOUS -> host.onSkipPrevious?.invoke()
}
}
}
private val notificationPermission = registerForActivityResult( private val notificationPermission = registerForActivityResult(
ActivityResultContracts.RequestPermission(), ActivityResultContracts.RequestPermission(),
) { /* Downloads remain usable; Android suppresses their notifications if denied. */ } ) { /* Downloads remain usable; Android suppresses their notifications if denied. */ }
@OptIn(ExperimentalCoroutinesApi::class)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Before anything draws. The palette decides the window's own // Before anything draws. The palette decides the window's own
@@ -115,34 +79,16 @@ class MainActivity : ComponentActivity() {
playbackConnection, playbackConnection,
Context.BIND_AUTO_CREATE, Context.BIND_AUTO_CREATE,
) )
ContextCompat.registerReceiver(
this,
pictureInPictureControls,
IntentFilter(ACTION_PIP_CONTROL),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
if ( if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) { ) {
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
} }
// Auto-enter has to be armed *before* the gesture that would use it:
// building the params inside onUserLeaveHint is too late for the swipe
// home that gesture navigation sends, which never calls it. Re-register
// whenever the picture's shape or the transport's does.
lifecycleScope.launch {
snapshotFlow { playerHost.value }
.flatMapLatest { host -> host?.state ?: flowOf(null) }
.map { state -> PictureInPictureShape.of(state) }
.distinctUntilChanged()
.collect { shape -> runCatching { setPictureInPictureParams(shape.params()) } }
}
setContent { setContent {
ProtonStreamTheme { ProtonStreamTheme {
ProtonStreamApp( ProtonStreamApp(
playerHost.value, playerHost.value,
inPictureInPicture.value,
shareLink = incomingShareLink.value, shareLink = incomingShareLink.value,
onShareLinkTaken = { incomingShareLink.value = null }, onShareLinkTaken = { incomingShareLink.value = null },
) )
@@ -177,27 +123,7 @@ class MainActivity : ComponentActivity() {
intent.shareLink()?.let { incomingShareLink.value = it } intent.shareLink()?.let { incomingShareLink.value = it }
} }
override fun onUserLeaveHint() {
super.onUserLeaveHint()
// The backstop for launchers that still send this instead of arming
// auto-enter. Entering twice is not an error; not entering at all is.
if (playerHost.value?.hasActivePlayback == true && !isInPictureInPictureMode) {
runCatching {
enterPictureInPictureMode(PictureInPictureShape.of(playerHost.value?.state?.value).params())
}
}
}
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
// The window is now a thumbnail with its own controls. Anything the
// player would draw over it — the transport, the title bar, the skip
// offer — is unreadable at that size and covers the picture.
inPictureInPicture.value = isInPictureInPictureMode
}
override fun onDestroy() { override fun onDestroy() {
runCatching { unregisterReceiver(pictureInPictureControls) }
if (bound) { if (bound) {
unbindService(playbackConnection) unbindService(playbackConnection)
bound = false bound = false
@@ -207,89 +133,14 @@ class MainActivity : ComponentActivity() {
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
// Where the viewer stopped is what their other devices want next, and
// a process in the background may not live to the next timed sync.
if (!isChangingConfigurations) WatchSyncWorker.enqueue(this)
if ( if (
!isChangingConfigurations && !isChangingConfigurations &&
!isInPictureInPictureMode &&
!SettingsStore(this).backgroundAudio !SettingsStore(this).backgroundAudio
) { ) {
playerHost.value?.stop() playerHost.value?.stop()
} }
} }
/** Everything the PiP window's shape depends on, and nothing that polls. */
private data class PictureInPictureShape(
val active: Boolean,
val paused: Boolean,
val width: Int,
val height: Int,
) {
companion object {
fun of(state: MpvPlaybackState?): PictureInPictureShape = PictureInPictureShape(
active = state != null && state.duration > 0.0 && !state.ended,
paused = state?.paused != false,
width = state?.videoWidth?.toInt() ?: 0,
height = state?.videoHeight?.toInt() ?: 0,
)
}
}
private fun PictureInPictureShape.params(): PictureInPictureParams =
PictureInPictureParams.Builder()
.setAspectRatio(aspectRatio(width, height))
.setAutoEnterEnabled(active)
.setSeamlessResizeEnabled(false)
// The player owns the whole window, so the picture's rectangle is
// the window's. Without it the transition into PiP cross-fades from
// a screenshot instead of shrinking the video that is already there.
.setSourceRectHint(windowBounds())
.setActions(
listOf(
remoteAction(android.R.drawable.ic_media_previous, "Previous episode", CONTROL_PREVIOUS, 11),
if (paused) remoteAction(android.R.drawable.ic_media_play, "Play", CONTROL_PLAY, 12)
else remoteAction(android.R.drawable.ic_media_pause, "Pause", CONTROL_PAUSE, 13),
remoteAction(android.R.drawable.ic_media_next, "Next episode", CONTROL_NEXT, 14),
),
)
.build()
private fun windowBounds(): Rect = window.decorView.let { Rect(0, 0, it.width, it.height) }
private fun remoteAction(icon: Int, label: String, control: String, requestCode: Int): RemoteAction =
RemoteAction(
Icon.createWithResource(this, icon),
label,
label,
PendingIntent.getBroadcast(
this,
requestCode,
Intent(ACTION_PIP_CONTROL).setPackage(packageName).putExtra(EXTRA_CONTROL, control),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
),
)
private companion object {
const val ACTION_PIP_CONTROL = "io.narl.protonstream.PIP_CONTROL"
const val EXTRA_CONTROL = "control"
const val CONTROL_PLAY = "play"
const val CONTROL_PAUSE = "pause"
const val CONTROL_NEXT = "next"
const val CONTROL_PREVIOUS = "previous"
/**
* Android refuses a window narrower than 1:2.39 or taller than 2.39:1,
* and refuses the request outright rather than clamping it — so a
* 2.40:1 scope release would take PiP away entirely.
*/
fun aspectRatio(width: Int, height: Int): Rational {
if (width <= 0 || height <= 0) return Rational(16, 9)
val ratio = width.toDouble() / height
return when {
ratio > MAX_RATIO -> Rational(239, 100)
ratio < 1 / MAX_RATIO -> Rational(100, 239)
else -> Rational(width, height)
}
}
const val MAX_RATIO = 2.39
}
} }
@@ -13,6 +13,8 @@ data class RetainedDownload(
val total: Long = 0, val total: Long = 0,
val status: String = STATUS_QUEUED, val status: String = STATUS_QUEUED,
val error: String? = null, val error: String? = null,
/** Recent transfer rate while running; 0 when unknown or not running. */
val bytesPerSecond: Long = 0,
) { ) {
val key: String get() = "$shareId\u001f$linkId" val key: String get() = "$shareId\u001f$linkId"
@@ -99,6 +101,7 @@ class DownloadStateStore(context: Context) {
.put("total", record.total) .put("total", record.total)
.put("status", record.status) .put("status", record.status)
.put("error", record.error) .put("error", record.error)
.put("rate", record.bytesPerSecond)
.toString() .toString()
private fun decode(encoded: String): RetainedDownload { private fun decode(encoded: String): RetainedDownload {
@@ -112,6 +115,7 @@ class DownloadStateStore(context: Context) {
total = json.optLong("total"), total = json.optLong("total"),
status = json.optString("status", RetainedDownload.STATUS_QUEUED), status = json.optString("status", RetainedDownload.STATUS_QUEUED),
error = json.optString("error").takeIf(String::isNotBlank), error = json.optString("error").takeIf(String::isNotBlank),
bytesPerSecond = json.optLong("rate"),
) )
} }
@@ -25,20 +25,24 @@ class OfflineDownloadWorker(
) : CoroutineWorker(appContext, parameters) { ) : CoroutineWorker(appContext, parameters) {
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
createNotificationChannel() createNotificationChannel()
setForeground(createForegroundInfo(0)) setForeground(createForegroundInfo(null))
val shareId = inputData.getString(KEY_SHARE_ID) ?: return Result.failure(error("missing share")) val shareId = inputData.getString(KEY_SHARE_ID) ?: return Result.failure(error("missing share"))
val volumeId = inputData.getString(KEY_VOLUME_ID) ?: return Result.failure(error("missing volume")) val volumeId = inputData.getString(KEY_VOLUME_ID) ?: return Result.failure(error("missing volume"))
val linkId = inputData.getString(KEY_LINK_ID) ?: return Result.failure(error("missing link")) val linkId = inputData.getString(KEY_LINK_ID) ?: return Result.failure(error("missing link"))
val label = inputData.getString(KEY_LABEL) ?: linkId val label = inputData.getString(KEY_LABEL) ?: linkId
val store = DownloadStateStore(applicationContext) val store = DownloadStateStore(applicationContext)
// Queued, not running: the engine lets two downloads transfer at once
// and holds the rest back, and only the first progress report means
// this one has a slot.
var retained = store.get(shareId, linkId) var retained = store.get(shareId, linkId)
?: RetainedDownload(shareId, volumeId, linkId, label) ?: RetainedDownload(shareId, volumeId, linkId, label)
retained = retained.copy(status = RetainedDownload.STATUS_RUNNING, error = null) retained = retained.copy(status = RetainedDownload.STATUS_QUEUED, error = null, bytesPerSecond = 0)
store.put(retained) store.put(retained)
val observer = object : DownloadObserver { val observer = object : DownloadObserver {
private var reportedAt = 0L private var reportedAt = 0L
private var reportedPercent = -1 private var reportedPercent = -1
private val rate = TransferRate()
/** /**
* Called once per 4 MiB block — a dozen times a second on a fast * Called once per 4 MiB block — a dozen times a second on a fast
@@ -51,6 +55,7 @@ class OfflineDownloadWorker(
val percent = if (total == 0UL) 0 else ((downloaded * 100UL) / total).toInt() val percent = if (total == 0UL) 0 else ((downloaded * 100UL) / total).toInt()
val now = SystemClock.elapsedRealtime() val now = SystemClock.elapsedRealtime()
val complete = total > 0UL && downloaded >= total val complete = total > 0UL && downloaded >= total
val bytesPerSecond = rate.sample(downloaded.toLong(), now)
if (!complete && percent == reportedPercent && now - reportedAt < PROGRESS_INTERVAL_MS) { if (!complete && percent == reportedPercent && now - reportedAt < PROGRESS_INTERVAL_MS) {
return return
} }
@@ -66,6 +71,7 @@ class OfflineDownloadWorker(
current.copy( current.copy(
downloaded = downloaded.toLong(), downloaded = downloaded.toLong(),
total = total.toLong(), total = total.toLong(),
bytesPerSecond = bytesPerSecond,
status = when (current.status) { status = when (current.status) {
RetainedDownload.STATUS_PAUSED, RetainedDownload.STATUS_PAUSED,
RetainedDownload.STATUS_CANCELLED, RetainedDownload.STATUS_CANCELLED,
@@ -114,11 +120,14 @@ class OfflineDownloadWorker(
) )
} }
private fun createForegroundInfo(progress: Int): ForegroundInfo { /** [progress] is a percentage, or null while waiting for a download slot. */
private fun createForegroundInfo(progress: Int?): ForegroundInfo {
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground) .setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
.setContentTitle("Making episode available offline") .setContentTitle(
.setProgress(100, progress, progress == 0) if (progress == null) "Waiting to download" else "Making episode available offline",
)
.setProgress(100, progress ?: 0, progress == null || progress == 0)
.setOngoing(true) .setOngoing(true)
.build() .build()
return ForegroundInfo( return ForegroundInfo(
@@ -146,5 +155,35 @@ class OfflineDownloadWorker(
} }
} }
/**
* Bytes per second over roughly the last few seconds.
*
* Progress arrives once per 4 MiB block, so on a slow link the bar moves in
* steps seconds apart; a rate beside it is what tells a slow download from a
* stuck one. Smoothed, because one block that took a little longer than the
* last would otherwise halve the figure for a moment.
*/
internal class TransferRate {
private var lastBytes = -1L
private var lastAt = 0L
private var smoothed = 0.0
/** Record [bytes] transferred at [now] (milliseconds) and return the rate. */
fun sample(bytes: Long, now: Long): Long {
if (lastBytes < 0 || bytes < lastBytes) {
lastBytes = bytes
lastAt = now
return smoothed.toLong()
}
val elapsed = now - lastAt
if (elapsed <= 0) return smoothed.toLong()
val instant = (bytes - lastBytes) * 1000.0 / elapsed
smoothed = if (smoothed > 0.0) 0.7 * smoothed + 0.3 * instant else instant
lastBytes = bytes
lastAt = now
return smoothed.toLong()
}
}
internal fun stableNotificationId(workId: UUID): Int = internal fun stableNotificationId(workId: UUID): Int =
(workId.hashCode() and Int.MAX_VALUE).coerceAtLeast(1) (workId.hashCode() and Int.MAX_VALUE).coerceAtLeast(1)
@@ -85,9 +85,9 @@ data class MpvPlaybackState(
/** /**
* What is on screen, for the surfaces that are not the player. * What is on screen, for the surfaces that are not the player.
* *
* The media session, its notification and Picture-in-Picture all have to name * The media session and its notification have to name the episode, and
* the episode, and none of them can reach the composition that knows it. The * neither can reach the composition that knows it. The player publishes this
* player publishes this once per episode and they read it. * once per episode and they read it.
*/ */
data class NowPlaying( data class NowPlaying(
val media: String, val media: String,
@@ -134,8 +134,8 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
/** /**
* Where a transport outside the player sends previous/next. * Where a transport outside the player sends previous/next.
* *
* The player owns which episode is open, so the notification, the lock * The player owns which episode is open, so the notification and the lock
* screen and Picture-in-Picture cannot walk the playlist themselves. Both * screen cannot walk the playlist themselves. Both
* run on the main thread, which is where the player's state lives. * run on the main thread, which is where the player's state lives.
*/ */
var onSkipPrevious: (() -> Unit)? = null var onSkipPrevious: (() -> Unit)? = null
@@ -35,7 +35,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
/** Owns libmpv so audio survives Activity recreation, rotation and PiP. */ /** Owns libmpv so audio survives Activity recreation and rotation. */
class PlaybackService : Service() { class PlaybackService : Service() {
inner class PlaybackBinder : Binder() { inner class PlaybackBinder : Binder() {
val host: NativeMpvHost? get() = this@PlaybackService.host val host: NativeMpvHost? get() = this@PlaybackService.host
@@ -131,18 +131,14 @@ private val VOLUME_SLIDER_MIN_WIDTH = 600.dp
/** /**
* The player, as its own screen on the activity's own window. * The player, as its own screen on the activity's own window.
* *
* Not a dialog: a dialog window has its own insets, cannot be told to draw into * Not a dialog: a dialog window has its own insets and cannot be told to draw
* the display cutout, and hands Picture-in-Picture the wrong window. Owning the * into the display cutout. Owning the activity window is what makes
* activity window is what makes "fullscreen" mean the whole panel, notch * "fullscreen" mean the whole panel, notch included.
* included.
* *
* [episodes] is the title's episodes in display order and [index] the one that is * [episodes] is the title's episodes in display order and [index] the one that is
* open, which is what previous/next and autoplay walk. The index is hoisted so * open, which is what previous/next and autoplay walk. The index is hoisted so
* that it survives process recreation with the rest of the navigation state — * that it survives process recreation with the rest of the navigation state —
* this screen is destroyed and rebuilt by a dark-mode toggle like any other. * this screen is destroyed and rebuilt by a dark-mode toggle like any other.
*
* In [inPictureInPicture] the window is a thumbnail with the system's own
* controls over it, so this screen draws the picture and nothing else.
*/ */
@Composable @Composable
fun PlayerScreen( fun PlayerScreen(
@@ -150,7 +146,6 @@ fun PlayerScreen(
episodes: List<EpisodeRecord>, episodes: List<EpisodeRecord>,
index: Int, index: Int,
host: LibmpvHost?, host: LibmpvHost?,
inPictureInPicture: Boolean,
minimized: Boolean, minimized: Boolean,
onIndexChange: (Int) -> Unit, onIndexChange: (Int) -> Unit,
onSaveProgress: (EpisodeRecord, Double, Double, Boolean) -> Unit, onSaveProgress: (EpisodeRecord, Double, Double, Boolean) -> Unit,
@@ -294,15 +289,14 @@ fun PlayerScreen(
onIndexChange(target) onIndexChange(target)
} }
// What the media notification, the lock screen and Picture-in-Picture show. // What the media notification and the lock screen show. Neither can reach
// None of them can reach this composition, and all three have to name the // this composition, and both have to name the episode rather than the app.
// episode rather than the app.
LaunchedEffect(key, index, episodes.size, title.key) { LaunchedEffect(key, index, episodes.size, title.key) {
nativeHost.publish( nativeHost.publish(
NowPlaying( NowPlaying(
media = key, media = key,
title = episode.label, title = episode.label,
show = title.canonicalName ?: title.name, show = title.displayName,
detail = episode.detail, detail = episode.detail,
artworkUrl = title.posterUrl ?: title.backdropUrl, artworkUrl = title.posterUrl ?: title.backdropUrl,
artworkFile = episode.thumbnailSource, artworkFile = episode.thumbnailSource,
@@ -339,7 +333,12 @@ fun PlayerScreen(
val problem by nativeHost.problem.collectAsState() val problem by nativeHost.problem.collectAsState()
LaunchedEffect(problem) { problem?.let { playbackError = it } } LaunchedEffect(problem) { problem?.let { playbackError = it } }
val offer = remember(chapters, key, (state.position * 4).roundToInt()) { // Whether mpv's readings are this episode's. Not while the next one opens:
// the last one's credits play on until it is loaded, and read against the
// new index they offered the episode after the next as up next.
val playingThis = state.media == key
val offer = remember(chapters, key, playingThis, (state.position * 4).roundToInt()) {
if (!playingThis) return@remember null
runCatching { skipOffer(chapters, state.position) }.getOrNull() runCatching { skipOffer(chapters, state.position) }.getOrNull()
} }
// Auto-skip is offered once per chapter: seeking back into an opening on // Auto-skip is offered once per chapter: seeking back into an opening on
@@ -359,12 +358,6 @@ fun PlayerScreen(
showControls = false showControls = false
} }
} }
// Everything this screen draws over the picture is sized for a phone panel
// and unreadable in a thumbnail, and Android supplies its own transport
// there. Leaving PiP must find the controls up rather than mid-timeout.
val overlays = !inPictureInPicture
LaunchedEffect(inPictureInPicture) { if (!inPictureInPicture) showControls = true }
if (minimized) return if (minimized) return
Box( Box(
@@ -374,7 +367,6 @@ fun PlayerScreen(
.clickable( .clickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null, indication = null,
enabled = overlays,
onClick = { showControls = !showControls }, onClick = { showControls = !showControls },
), ),
) { ) {
@@ -393,7 +385,7 @@ fun PlayerScreen(
) )
AnimatedVisibility( AnimatedVisibility(
visible = overlays && showControls, visible = showControls,
modifier = Modifier.align(Alignment.TopStart), modifier = Modifier.align(Alignment.TopStart),
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut(), exit = fadeOut(),
@@ -425,7 +417,7 @@ fun PlayerScreen(
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
) )
Text( Text(
listOfNotNull(title.canonicalName ?: title.name, episode.detail.takeIf(String::isNotBlank)) listOfNotNull(title.displayName, episode.detail.takeIf(String::isNotBlank))
.joinToString(" • "), .joinToString(" • "),
color = Color.White.copy(alpha = 0.7f), color = Color.White.copy(alpha = 0.7f),
maxLines = 1, maxLines = 1,
@@ -450,7 +442,7 @@ fun PlayerScreen(
} }
AnimatedVisibility( AnimatedVisibility(
visible = overlays && showControls, visible = showControls,
modifier = Modifier.align(Alignment.BottomCenter), modifier = Modifier.align(Alignment.BottomCenter),
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut(), exit = fadeOut(),
@@ -476,7 +468,7 @@ fun PlayerScreen(
// Drawn whether or not the rest of the controls are up: an opening lasts // Drawn whether or not the rest of the controls are up: an opening lasts
// ninety seconds, and a button you have to wake with a tap is one nobody // ninety seconds, and a button you have to wake with a tap is one nobody
// reaches in time. // reaches in time.
offer?.takeIf { overlays && !autoSkip }?.let { skip -> offer?.takeIf { !autoSkip }?.let { skip ->
TonalButton( TonalButton(
onClick = { nativeHost.seek(skip.target) }, onClick = { nativeHost.seek(skip.target) },
modifier = Modifier modifier = Modifier
@@ -488,7 +480,7 @@ fun PlayerScreen(
UpNextCard( UpNextCard(
next = next, next = next,
visible = overlays && autoplay && !state.paused && visible = autoplay && !state.paused && playingThis &&
chapters.creditsStart?.let { state.position >= it } == true, chapters.creditsStart?.let { state.position >= it } == true,
onPlayNow = { advance() }, onPlayNow = { advance() },
modifier = Modifier modifier = Modifier
@@ -0,0 +1,56 @@
package io.narl.protonstream.sync
import android.content.Context
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import io.narl.protonstream.native.NativeRuntime
import uniffi.pstr_android.AccountState
import java.util.concurrent.TimeUnit
/**
* One watch-history sync after the app leaves the screen.
*
* The model's timer only runs while the app is alive, and Android is free to
* kill a process in the background without notice — so the position the
* viewer stopped at would wait for the next launch to reach their other
* devices. Handed to WorkManager instead, it goes as soon as there is a
* network, even if the process is gone by then.
*/
class WatchSyncWorker(
appContext: Context,
parameters: WorkerParameters,
) : CoroutineWorker(appContext, parameters) {
override suspend fun doWork(): Result {
val engine = NativeRuntime.engine()
return runCatching { engine.syncWatchHistory() }.fold(
onSuccess = { Result.success() },
onFailure = {
// Signed out by the engine — the session ended — is not
// something a retry fixes; a dropped connection is.
val signedOut = runCatching { engine.accountState() }.getOrNull() == AccountState.SignedOut
if (signedOut || runAttemptCount >= MAX_ATTEMPTS) Result.failure() else Result.retry()
},
)
}
companion object {
private const val WORK_NAME = "watch-history-sync"
private const val MAX_ATTEMPTS = 3
/** Replacing: one pending sync covers every position saved before it runs. */
fun enqueue(context: Context) {
val request = OneTimeWorkRequestBuilder<WatchSyncWorker>()
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context.applicationContext)
.enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, request)
}
}
}
@@ -0,0 +1,507 @@
package io.narl.protonstream.ui
import android.annotation.SuppressLint
import android.os.Handler
import android.os.Looper
import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Computer
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.FolderShared
import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material.icons.filled.Logout
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.SecureFlagPolicy
import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.TonalButton
import uniffi.pstr_android.AccountState
import uniffi.pstr_android.DriveEntryRecord
import uniffi.pstr_android.DrivePlaceRecord
import uniffi.pstr_android.PlaceType
import uniffi.pstr_android.verificationToken
/** What the account card and its dialogs can ask the model for. */
internal data class AccountActions(
val signIn: (String, String, String?) -> Unit,
val submitSecondFactor: (String) -> Unit,
val submitMailboxPassword: (String) -> Unit,
val cancelSignIn: () -> Unit,
val dismissVerification: () -> Unit,
val signOut: () -> Unit,
val syncNow: () -> Unit,
val addFolder: (String, String, String) -> Unit,
val onError: (Throwable) -> Unit,
)
/**
* The Proton account, at the top of the shares page: why one would sign in,
* or who is signed in and how watch-history sync is doing.
*/
@Composable
internal fun AccountCard(
account: AccountUiState,
actions: AccountActions,
onSignIn: () -> Unit,
onBrowse: () -> Unit,
) {
val state = account.state ?: return
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
if (state is AccountState.SignedIn) {
var menu by remember { mutableStateOf(false) }
ListItem(
headlineContent = { Text(state.username, fontWeight = FontWeight.SemiBold) },
supportingContent = { Text(syncLine(account, System.currentTimeMillis())) },
leadingContent = {
Icon(Icons.Default.AccountCircle, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
},
trailingContent = {
Box {
IconButton(onClick = { menu = true }) {
Icon(Icons.Default.MoreVert, contentDescription = "Account actions")
}
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
DropdownMenuItem(
text = { Text("Sync now") },
leadingIcon = { Icon(Icons.Default.Sync, contentDescription = null) },
onClick = { menu = false; actions.syncNow() },
)
DropdownMenuItem(
text = { Text("Sign out") },
leadingIcon = { Icon(Icons.Default.Logout, contentDescription = null) },
onClick = { menu = false; actions.signOut() },
)
}
}
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
TonalButton(
onClick = onBrowse,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
) {
Icon(Icons.Default.Folder, contentDescription = null)
Text("Add from your Drive", Modifier.padding(start = 8.dp))
}
} else {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Proton account", style = MaterialTheme.typography.titleMedium)
Text(
"Optional. Signed in, your shares, settings and watch history are kept in your own " +
"Drive, so every device shows the same library and resumes where another left off, " +
"and folders of your Drive can join the library.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TonalButton(onClick = onSignIn) { Text("Sign in") }
}
}
}
}
/** "Synced 3 min ago · 2 from other devices", or why it did not. */
internal fun syncLine(account: AccountUiState, now: Long): String {
account.syncError?.let { return "Did not sync: $it" }
val at = account.syncedAt ?: return "Library, settings and watch history sync through your Drive"
val minutes = ((now - at).coerceAtLeast(0) / 60_000).toInt()
val ago = when {
minutes < 1 -> "just now"
minutes < 60 -> "$minutes min ago"
else -> "${minutes / 60} h ago"
}
val applied = if (account.applied > 0) " · ${account.applied} from other devices" else ""
return "Synced $ago$applied"
}
/**
* Signing in, one step at a time: address and password, then a second-factor
* code or the mailbox password if the account has them, with Proton's CAPTCHA
* over the top when it asks for one.
*/
@Composable
internal fun SignInDialog(account: AccountUiState, actions: AccountActions, onDismiss: () -> Unit) {
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var code by remember { mutableStateOf("") }
var mailbox by remember { mutableStateOf("") }
val state = account.state
LaunchedEffect(state) {
if (state is AccountState.SignedIn) onDismiss()
}
val dismiss = {
if (state == AccountState.SecondFactor || state == AccountState.MailboxPassword) actions.cancelSignIn()
onDismiss()
}
AlertDialog(
onDismissRequest = dismiss,
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
title = { Text("Sign in to Proton") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
when (state) {
AccountState.SecondFactor -> {
OutlinedTextField(
code,
{ code = it },
label = { Text("Code from your authenticator app") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
)
Text(SECURITY_KEY_NOTE, style = MaterialTheme.typography.bodySmall)
}
AccountState.MailboxPassword -> {
Text(
"This account has a separate mailbox password. It unlocks your files.",
style = MaterialTheme.typography.bodySmall,
)
PasswordField(mailbox, { mailbox = it }, "Mailbox password")
}
else -> {
OutlinedTextField(
username,
{ username = it },
label = { Text("Address") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
)
PasswordField(password, { password = it }, "Password")
}
}
account.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
if (account.busy) CircularProgressIndicator(Modifier.padding(top = 4.dp))
}
},
confirmButton = {
when (state) {
AccountState.SecondFactor -> AccentButton(
onClick = { actions.submitSecondFactor(code.trim()) },
enabled = code.isNotBlank() && !account.busy,
) { Text("Continue") }
AccountState.MailboxPassword -> AccentButton(
onClick = { actions.submitMailboxPassword(mailbox) },
enabled = mailbox.isNotEmpty() && !account.busy,
) { Text("Unlock") }
else -> AccentButton(
onClick = { actions.signIn(username.trim(), password, null) },
enabled = username.isNotBlank() && password.isNotEmpty() && !account.busy,
) { Text("Sign in") }
}
},
dismissButton = { TonalButton(onClick = dismiss) { Text("Cancel") } },
)
account.verificationUrl?.let { url ->
VerificationDialog(
url = url,
onToken = { token -> actions.signIn(username.trim(), password, token) },
onDismiss = actions.dismissVerification,
)
}
}
@Composable
private fun PasswordField(value: String, onChange: (String) -> Unit, label: String) = OutlinedTextField(
value,
onChange,
label = { Text(label) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
/**
* Proton's hosted CAPTCHA, which it puts in front of a sign-in it does not
* recognise — a new network, a VPN exit.
*
* In a WebView rather than the browser because the page reports success by
* posting a message to its host, not by redirecting: a browser has nowhere to
* post it back to. The page targets `window.parent`, which for a top-level
* page is the page itself, so a listener on its own window hears it.
*/
@SuppressLint("SetJavaScriptEnabled") // The verification page is a script; it does not run without.
@Composable
private fun VerificationDialog(url: String, onToken: (String) -> Unit, onDismiss: () -> Unit) {
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
Card(Modifier.fillMaxWidth().padding(16.dp).height(560.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"Confirm you are human",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f).padding(start = 16.dp),
)
IconButton(onClick = onDismiss) { Icon(Icons.Default.Close, contentDescription = "Cancel") }
}
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
val main = Handler(Looper.getMainLooper())
var answered = false
WebView(context).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
addJavascriptInterface(
object {
@JavascriptInterface
fun post(message: String) {
// Every message the page posts lands here,
// most of them its own chatter.
val token = verificationToken(message) ?: return
main.post {
if (!answered) {
answered = true
onToken(token)
}
}
}
},
"pstrVerification",
)
webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView, url: String?, favicon: android.graphics.Bitmap?) {
view.evaluateJavascript(VERIFICATION_BRIDGE, null)
}
override fun onPageFinished(view: WebView, url: String?) {
view.evaluateJavascript(VERIFICATION_BRIDGE, null)
}
// The bridge above is reachable by whatever the
// frame shows, so the frame stays on Proton.
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest) =
!isProtonPage(request.url.scheme, request.url.host)
}
loadUrl(url)
}
},
)
}
}
}
/**
* Security keys need WebAuthn, which the sign-in here does not speak. An account
* with nothing else is refused before this form (`AccountStore::sign_in`).
*/
private const val SECURITY_KEY_NOTE =
"Security keys are not supported here — use the code from your authenticator app."
/** Installed once per page, at start and again at finish in case the first ran too early. */
private const val VERIFICATION_BRIDGE = """
if (!window.__pstrVerification) {
window.__pstrVerification = true;
window.addEventListener('message', function (event) {
try {
pstrVerification.post(typeof event.data === 'string' ? event.data : JSON.stringify(event.data));
} catch (e) {}
});
}
"""
/** Whether a page is Proton's own, over HTTPS. */
internal fun isProtonPage(scheme: String?, host: String?): Boolean =
scheme == "https" && host != null && (host == "proton.me" || host.endsWith(".proton.me"))
/**
* Walk the account's Drive and add a folder of it to the library.
*
* Full screen, because a Drive is deep and a dialog's height is a few rows of
* it. Listings are asked for here rather than through the model: they are
* this dialog's alone, and gone when it closes.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun DriveBrowserDialog(
actions: AccountActions,
onDismiss: () -> Unit,
) {
val trail = remember { mutableStateListOf<Pair<String, DrivePlaceRecord>>() }
var places by remember { mutableStateOf<List<DrivePlaceRecord>?>(null) }
var entries by remember { mutableStateOf<List<DriveEntryRecord>?>(null) }
var error by remember { mutableStateOf<String?>(null) }
val current = trail.lastOrNull()?.second
LaunchedEffect(current) {
error = null
if (current == null) {
if (places == null) {
runCatching { NativeRuntime.engine().drivePlaces() }
.onSuccess { places = it }
.onFailure { error = it.message }
}
} else {
entries = null
runCatching { NativeRuntime.engine().driveFolder(current.volumeId, current.linkId) }
.onSuccess { entries = it }
.onFailure { error = it.message }
}
}
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
BackHandler(enabled = trail.isNotEmpty()) { trail.removeAt(trail.lastIndex) }
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
trail.lastOrNull()?.first ?: "Your Drive",
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon = {
IconButton(onClick = { if (trail.isEmpty()) onDismiss() else trail.removeAt(trail.lastIndex) }) {
Icon(
if (trail.isEmpty()) Icons.Default.Close else Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = if (trail.isEmpty()) "Close" else "Up a folder",
)
}
},
)
},
bottomBar = {
// A folder already in the library is refused by the engine,
// with a message saying so.
val folder = current ?: return@Scaffold
Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.End) {
AccentButton(
onClick = {
actions.addFolder(trail.last().first, folder.volumeId, folder.linkId)
onDismiss()
},
) { Text("Add this folder") }
}
},
) { padding ->
Box(Modifier.fillMaxSize().padding(padding)) {
val loading = error == null && (if (current == null) places == null else entries == null)
when {
error != null -> EmptyState("Could not list this", error.orEmpty())
loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
current == null -> LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
items(places.orEmpty(), key = { "${it.volumeId}/${it.linkId}" }) { place ->
DriveRow(
icon = when (place.kind) {
PlaceType.MY_FILES -> Icons.Default.Storage
PlaceType.DEVICE -> Icons.Default.Computer
PlaceType.SHARED_WITH_ME -> Icons.Default.FolderShared
},
name = place.name,
detail = null,
onClick = { trail.add(place.name to place) },
)
}
}
entries.orEmpty().isEmpty() -> EmptyState("Empty folder", "Nothing to add from here.")
else -> LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
items(entries.orEmpty(), key = { "${it.volumeId}/${it.linkId}" }) { entry ->
DriveRow(
icon = when {
entry.isFolder -> Icons.Default.Folder
entry.isVideo -> Icons.Default.Movie
else -> Icons.Default.InsertDriveFile
},
name = entry.name,
detail = entry.size?.let(::formatBytes),
onClick = if (entry.isFolder) {
{
trail.add(
entry.name to DrivePlaceRecord(
PlaceType.MY_FILES,
entry.name,
entry.volumeId,
entry.linkId,
),
)
}
} else {
null
},
)
}
}
}
}
}
}
}
@Composable
private fun DriveRow(
icon: androidx.compose.ui.graphics.vector.ImageVector,
name: String,
detail: String?,
onClick: (() -> Unit)?,
) {
ListItem(
headlineContent = { Text(name, maxLines = 2, overflow = TextOverflow.Ellipsis) },
supportingContent = detail?.let { { Text(it) } },
leadingContent = {
Icon(
icon,
contentDescription = null,
tint = if (onClick != null) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier,
)
}
@@ -25,6 +25,9 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import io.narl.protonstream.native.NativeRuntime import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.ui.theme.AppearanceState
import uniffi.pstr_android.ArrangementRecord
import uniffi.pstr_android.LibrarySort
import uniffi.pstr_android.ShareRecord import uniffi.pstr_android.ShareRecord
import uniffi.pstr_android.TitleRecord import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.OfflineRecord import uniffi.pstr_android.OfflineRecord
@@ -34,12 +37,25 @@ import uniffi.pstr_android.MetadataSettingsRecord
import uniffi.pstr_android.StorageUsageRecord import uniffi.pstr_android.StorageUsageRecord
import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.WatchStateRecord import uniffi.pstr_android.WatchStateRecord
import uniffi.pstr_android.AccountState
import uniffi.pstr_android.SignInOutcome
import uniffi.pstr_android.SyncRecord
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
data class AppUiState( data class AppUiState(
val loading: Boolean = true, val loading: Boolean = true,
val refreshing: Boolean = false, val refreshing: Boolean = false,
val query: String = "", val query: String = "",
val titles: List<TitleRecord> = emptyList(), val titles: List<TitleRecord> = emptyList(),
/**
* The grid: [titles] ordered, each franchise folded into one tile when
* [grouped], and the shelves above it. Null until the bridge has answered,
* when the grid falls back to [titles] as they are.
*/
val arrangement: ArrangementRecord? = null,
val sort: LibrarySort = LibrarySort.NAME,
val grouped: Boolean = true,
val shares: List<ShareRecord> = emptyList(), val shares: List<ShareRecord> = emptyList(),
val offline: List<OfflineRecord> = emptyList(), val offline: List<OfflineRecord> = emptyList(),
val metadataSettings: MetadataSettingsRecord = MetadataSettingsRecord( val metadataSettings: MetadataSettingsRecord = MetadataSettingsRecord(
@@ -50,8 +66,33 @@ data class AppUiState(
), ),
val storage: StorageUsageRecord = StorageUsageRecord(0uL, 0uL, 0uL, 0uL), val storage: StorageUsageRecord = StorageUsageRecord(0uL, 0uL, 0uL, 0uL),
val message: String? = null, val message: String? = null,
/**
* What is running in the background right now, said in a banner for as
* long as it runs: an add, a crawl, a match, a sync. Without it a
* minute-long crawl looked like a tap that did nothing.
*/
val activity: String? = null,
/** What Undo on [message] puts back, when the message offers one. */ /** What Undo on [message] puts back, when the message offers one. */
val undo: List<WatchSnapshot>? = null, val undo: List<WatchSnapshot>? = null,
val account: AccountUiState = AccountUiState(),
)
/** The Proton account: where a sign-in stands, and how sync is doing. */
data class AccountUiState(
/** Null until the bridge has said. */
val state: AccountState? = null,
/** A sign-in step is out and not answered yet. */
val busy: Boolean = false,
/** Why the last sign-in step was refused. */
val error: String? = null,
/** The CAPTCHA page Proton wants solved before it accepts the sign-in. */
val verificationUrl: String? = null,
/** When watch history last synced, in epoch milliseconds. */
val syncedAt: Long? = null,
/** Positions the last sync took from other devices. */
val applied: Int = 0,
/** Why the last sync failed, until one succeeds. */
val syncError: String? = null,
) )
/** /**
@@ -66,6 +107,15 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
private val mutableState = MutableStateFlow(AppUiState()) private val mutableState = MutableStateFlow(AppUiState())
private val searchQuery = MutableStateFlow("") private val searchQuery = MutableStateFlow("")
val state: StateFlow<AppUiState> = mutableState.asStateFlow() val state: StateFlow<AppUiState> = mutableState.asStateFlow()
/** Launch and coming back both ask; one crawl answers. */
private var catchingUp = false
/**
* Every banner line whose work is still running, oldest first. Touched on
* the main thread only, as everything in [viewModelScope] is.
*/
private val activities = mutableListOf<Pair<Long, String>>()
private var nextActivity = 0L
init { init {
if (!NativeRuntime.tlsReady) { if (!NativeRuntime.tlsReady) {
@@ -74,6 +124,22 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
} }
} }
reload() reload()
catchUp()
viewModelScope.launch {
val account = runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().accountState() } }
.getOrDefault(AccountState.SignedOut)
updateAccount { it.copy(state = account) }
// While the app is alive, on top of after sign-in and whenever the
// player closes. Cheap when nothing changed: one folder listing,
// and no upload.
while (isActive) {
syncWatchHistory()
// A sync WatchSyncWorker ran while the app was away may have
// brought shares this one has not crawled.
catchUp()
delay(SYNC_INTERVAL_MS)
}
}
viewModelScope.launch { viewModelScope.launch {
searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) } searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) }
} }
@@ -86,6 +152,12 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
} }
} }
/** Reorder the grid, or fold or unfold its franchises. */
fun arrange(sort: LibrarySort, grouped: Boolean) {
mutableState.update { it.copy(sort = sort, grouped = grouped) }
viewModelScope.launch { reloadLibrary(mutableState.value.query) }
}
fun search(query: String) { fun search(query: String) {
mutableState.update { it.copy(query = query) } mutableState.update { it.copy(query = query) }
searchQuery.value = query searchQuery.value = query
@@ -93,14 +165,51 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
fun refresh() { fun refresh() {
viewModelScope.launch { viewModelScope.launch {
mutableState.update { it.copy(refreshing = true, message = null) } busy("Refreshing the library…") {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(null) } } mutableState.update { it.copy(message = null) }
.onFailure { error -> mutableState.update { it.copy(message = error.message) } } runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(null) } }
mutableState.update { it.copy(refreshing = false) } .onFailure { error -> mutableState.update { it.copy(message = error.message) } }
reload() }
reloadNow()
matchAfterCrawl()
} }
} }
/**
* Look up whatever a crawl brought that has no answer yet: a new show, or
* a new episode its stored guide cannot name. When everything already has
* one the bridge asks nothing, so this is cheap after every crawl.
*/
private suspend fun matchAfterCrawl() {
val settings = mutableState.value.metadataSettings
if (settings.enabled && settings.ready) match(force = false, quiet = true)
}
/**
* Say [what] in the activity banner while [work] runs.
*
* The banner shows the newest line still running. Work overlaps — a sync
* starts during a crawl and ends after it — so each line is removed by
* itself rather than by putting back whatever was showing before, which
* once brought back a finished crawl's line and spun it forever.
*/
private suspend fun busy(what: String, work: suspend () -> Unit) {
val token = nextActivity++
activities += token to what
showActivity()
try {
work()
} finally {
activities.removeAll { it.first == token }
showActivity()
}
}
private fun showActivity() {
val newest = activities.lastOrNull()?.second
mutableState.update { it.copy(activity = newest, refreshing = newest != null) }
}
/** /**
* Recrawl one share rather than the whole library. * Recrawl one share rather than the whole library.
* *
@@ -111,11 +220,14 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
*/ */
fun refreshShare(id: String) { fun refreshShare(id: String) {
viewModelScope.launch { viewModelScope.launch {
mutableState.update { it.copy(refreshing = true, message = null) } val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the share"
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } } busy("Reading $name…") {
.onFailure { error -> mutableState.update { it.copy(message = error.message) } } mutableState.update { it.copy(message = null) }
mutableState.update { it.copy(refreshing = false) } runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } }
reload() .onFailure { error -> mutableState.update { it.copy(message = error.message) } }
}
reloadNow()
matchAfterCrawl()
} }
} }
@@ -128,26 +240,90 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
* changed. * changed.
*/ */
fun matchTitles(force: Boolean) { fun matchTitles(force: Boolean) {
viewModelScope.launch { viewModelScope.launch { match(force) }
mutableState.update { it.copy(refreshing = true, message = null) } }
/**
* With [quiet], a run nobody asked for says nothing when it found nothing
* to do.
*/
private suspend fun match(force: Boolean, quiet: Boolean = false) {
busy("Looking up titles…") {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().matchTitles(force) } } runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().matchTitles(force) } }
.onSuccess { summary -> mutableState.update { it.copy(message = describe(summary)) } } .onSuccess { summary ->
if (!quiet || !summary.isEmpty()) {
mutableState.update { it.copy(message = describe(summary)) }
}
}
.onFailure { error -> mutableState.update { it.copy(message = error.message) } } .onFailure { error -> mutableState.update { it.copy(message = error.message) } }
mutableState.update { it.copy(refreshing = false) } }
reload() reloadNow()
}
private fun MatchSummary.isEmpty() = matched == 0u && unmatched == 0u && failed == 0u && episodes == 0u
/**
* Add a link, and return why it was refused, if it was.
*
* The link is opened before anything is stored, so the form can stay up
* with the reason under it and a retry is a retry — not "already in the
* library" for a share that never opened. Only the new share is crawled:
* another share that fails to open must not make this one look broken.
*/
suspend fun addShare(name: String, url: String, password: String?): String? {
val share = runCatching {
withContext(Dispatchers.IO) {
NativeRuntime.engine().addShare(name, url, password?.takeIf(String::isNotBlank))
}
}.getOrElse { return it.message ?: "That link could not be added" }
reload()
viewModelScope.launch { crawlAdded(listOf(share.id), announce = true) }
return null
}
/**
* Crawl shares that are new here — added on this device or by sync — then
* match them when enrichment is on.
*/
private suspend fun crawlAdded(ids: List<String>, announce: Boolean) {
if (ids.isEmpty()) return
val before = mutableState.value.titles.map { it.key }.toSet()
val failed = mutableListOf<String>()
for (id in ids) {
val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the new share"
busy("Reading $name…") {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } }
.onFailure { failed += "$name: ${it.message}" }
}
}
reloadNow()
if (failed.isNotEmpty()) {
mutableState.update { it.copy(message = failed.joinToString("\n")) }
return
}
matchAfterCrawl()
if (announce) {
val added = (mutableState.value.titles.map { it.key }.toSet() - before).size
mutableState.update { it.copy(message = if (added == 1) "1 title added" else "$added titles added") }
} }
} }
fun addShare(name: String, url: String, password: String?) { /**
* Crawl shares the catalog has nothing of: brought by a sync that ran in
* [io.narl.protonstream.sync.WatchSyncWorker], or an add whose crawl never
* finished.
*/
fun catchUp() {
if (catchingUp) return
catchingUp = true
viewModelScope.launch { viewModelScope.launch {
runCatching { try {
withContext(Dispatchers.IO) { val ids = runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().uncrawledShares() } }
val engine = NativeRuntime.engine() .getOrDefault(emptyList())
engine.addShare(name, url, password?.takeIf(String::isNotBlank)) crawlAdded(ids, announce = false)
engine.crawl(null) } finally {
} catchingUp = false
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } } }
reload()
} }
} }
@@ -169,7 +345,8 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
engine.crawl(id) engine.crawl(id)
} }
}.onFailure(::reportError) }.onFailure(::reportError)
reload() reloadNow()
matchAfterCrawl()
} }
} }
@@ -196,6 +373,171 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
fun dismissMessage() = mutableState.update { it.copy(message = null, undo = null) } fun dismissMessage() = mutableState.update { it.copy(message = null, undo = null) }
private fun updateAccount(change: (AccountUiState) -> AccountUiState) =
mutableState.update { it.copy(account = change(it.account)) }
/**
* Start signing in, or start again with [verificationToken] once the
* CAPTCHA from a first try is solved.
*/
fun signIn(username: String, password: String, verificationToken: String? = null) =
signInStep { engine -> engine.signIn(username, password, verificationToken) }
fun submitSecondFactor(code: String) = signInStep { engine -> engine.submitSecondFactor(code) }
fun submitMailboxPassword(password: String) =
signInStep(keepStepOnFailure = true) { engine -> engine.submitMailboxPassword(password) }
fun cancelSignIn() {
updateAccount { AccountUiState(state = AccountState.SignedOut) }
viewModelScope.launch { runCatching { NativeRuntime.engine().cancelSignIn() } }
}
/** The CAPTCHA page closed without a token. */
fun dismissVerification() = updateAccount { it.copy(verificationUrl = null, busy = false) }
private fun signInStep(
keepStepOnFailure: Boolean = false,
step: suspend (uniffi.pstr_android.AndroidEngine) -> SignInOutcome,
) {
updateAccount { it.copy(busy = true, error = null, verificationUrl = null) }
viewModelScope.launch {
runCatching { withContext(Dispatchers.IO) { step(NativeRuntime.engine()) } }
.onSuccess { outcome ->
when (outcome) {
is SignInOutcome.Done -> {
updateAccount {
AccountUiState(state = AccountState.SignedIn(outcome.username))
}
mutableState.update { it.copy(message = "Signed in as ${outcome.username}") }
syncWatchHistory()
reload()
}
SignInOutcome.SecondFactor ->
updateAccount { it.copy(state = AccountState.SecondFactor, busy = false) }
SignInOutcome.MailboxPassword ->
updateAccount { it.copy(state = AccountState.MailboxPassword, busy = false) }
is SignInOutcome.HumanVerification ->
updateAccount { it.copy(verificationUrl = outcome.url) }
}
}
.onFailure { error ->
updateAccount {
it.copy(
busy = false,
error = error.message ?: "Sign-in failed",
// A refused code spends the half-made session.
state = if (keepStepOnFailure) it.state else AccountState.SignedOut,
)
}
}
}
}
fun signOut() {
viewModelScope.launch {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().signOut() } }
.onFailure(::reportError)
updateAccount { AccountUiState(state = AccountState.SignedOut) }
reload()
}
}
/** Sync now, from a tap: says so when it is done. */
fun syncNow() {
viewModelScope.launch {
if (syncWatchHistory()) {
val applied = mutableState.value.account.applied
mutableState.update {
it.copy(
message = if (applied > 0) {
"Watch history synced, $applied from other devices"
} else {
"Watch history is up to date"
},
)
}
}
}
}
/**
* Pull other devices' positions in and push this one's out. Quiet: it runs
* on a timer, and a failure is shown on the account card rather than as a
* snackbar every five minutes of an offline evening. Returns whether it
* synced.
*/
private suspend fun syncWatchHistory(): Boolean {
if (mutableState.value.account.state !is AccountState.SignedIn) return false
return runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().syncWatchHistory() } }
.fold(
onSuccess = { report ->
if (report == null) return false
updateAccount {
it.copy(
syncedAt = System.currentTimeMillis(),
applied = report.applied.toInt(),
syncError = null,
)
}
takeSynced(report)
true
},
onFailure = { error ->
val state = runCatching { NativeRuntime.engine().accountState() }.getOrNull()
updateAccount { it.copy(state = state ?: it.state, syncError = error.message) }
// Signed out by the engine: the session ended, and that is
// worth interrupting for, unlike a dropped connection.
if (state == AccountState.SignedOut) reportError(error)
false
},
)
}
/**
* What another device changed: its shares crawled or dropped here, its
* settings repainted.
*/
private suspend fun takeSynced(report: SyncRecord) {
if (report.sharesRemoved.isNotEmpty()) {
withContext(Dispatchers.IO) {
val downloads = DownloadStateStore(appContext)
report.sharesRemoved.forEach { id ->
workManager.cancelAllWorkByTag(DownloadCoordinator.shareTag(id)).result.get()
downloads.removeShare(id)
}
}
}
if (report.settingsChanged) AppearanceState.reload()
if (report.applied > 0u || report.titles > 0u || report.settingsChanged || report.sharesRemoved.isNotEmpty()) {
reloadNow()
}
if (report.sharesAdded.isNotEmpty()) {
crawlAdded(report.sharesAdded, announce = false)
val count = report.sharesAdded.size
mutableState.update {
it.copy(message = if (count == 1) "A share arrived from another device" else "$count shares arrived from other devices")
}
}
}
/** After the player closes: where it stopped is what another device wants next. */
fun playerClosed() {
reload()
viewModelScope.launch { syncWatchHistory() }
}
fun addAccountFolder(name: String, volumeId: String, linkId: String) {
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) { NativeRuntime.engine().addAccountFolder(name, volumeId, linkId) }
}.onSuccess { share ->
reload()
crawlAdded(listOf(share.id), announce = true)
}.onFailure(::reportError)
}
}
/** /**
* Every episode of a title watched or unwatched, with an Undo: a whole * Every episode of a title watched or unwatched, with an Undo: a whole
* title's positions are what this throws away, and a mis-tap on a tile's * title's positions are what this throws away, and a mis-tap on a tile's
@@ -205,7 +547,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
val episodes = title.seasons.flatMap { it.episodes } val episodes = title.seasons.flatMap { it.episodes }
changeWatch( changeWatch(
episodes, episodes,
"${title.canonicalName ?: title.name} marked ${if (watched) "watched" else "unwatched"}", "${title.displayName} marked ${if (watched) "watched" else "unwatched"}",
) { engine, episode, before -> ) { engine, episode, before ->
val duration = before?.durationSecs val duration = before?.durationSecs
engine.saveWatchState( engine.saveWatchState(
@@ -225,7 +567,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
fun forgetPosition(title: TitleRecord, episode: EpisodeRecord) { fun forgetPosition(title: TitleRecord, episode: EpisodeRecord) {
changeWatch( changeWatch(
listOf(episode), listOf(episode),
"${title.canonicalName ?: title.name} removed from Continue watching", "${title.displayName} removed from Continue watching",
) { engine, target, before -> ) { engine, target, before ->
engine.saveWatchState(target.shareId, target.linkId, 0.0, before?.durationSecs, false) engine.saveWatchState(target.shareId, target.linkId, 0.0, before?.durationSecs, false)
} }
@@ -238,7 +580,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
fun removeFromHistory(title: TitleRecord, episode: EpisodeRecord) { fun removeFromHistory(title: TitleRecord, episode: EpisodeRecord) {
changeWatch( changeWatch(
listOf(episode), listOf(episode),
"${episode.label} of ${title.canonicalName ?: title.name} removed from history", "${episode.label} of ${title.displayName} removed from history",
) { engine, target, before -> ) { engine, target, before ->
engine.saveWatchState(target.shareId, target.linkId, 0.0, before?.durationSecs, false) engine.saveWatchState(target.shareId, target.linkId, 0.0, before?.durationSecs, false)
} }
@@ -294,22 +636,27 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
mutableState.update { it.copy(message = error.message ?: "Unexpected error") } mutableState.update { it.copy(message = error.message ?: "Unexpected error") }
} }
fun saveMetadataSettings(enabled: Boolean, provider: MetadataProvider, language: String, apiKey: String) { /**
viewModelScope.launch { * Store the enrichment settings, and return why not, if they were not.
runCatching { *
withContext(Dispatchers.IO) { * The page shows the new settings as soon as they are stored; the match
val engine = NativeRuntime.engine() * run that follows can take minutes and says so in the activity banner.
if (provider == MetadataProvider.TMDB && apiKey.isNotBlank()) { */
engine.setMetadataApiKey(provider, apiKey) suspend fun saveMetadataSettings(enabled: Boolean, provider: MetadataProvider, language: String, apiKey: String): String? {
} val stored = runCatching {
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true)) withContext(Dispatchers.IO) {
if (enabled) engine.matchTitles(false) else null val engine = NativeRuntime.engine()
if (provider == MetadataProvider.TMDB && apiKey.isNotBlank()) {
engine.setMetadataApiKey(provider, apiKey)
} }
}.onSuccess { summary -> engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
summary?.let { mutableState.update { state -> state.copy(message = describe(it)) } } engine.metadataSettings()
}.onFailure(::reportError) }
reload() }.getOrElse { return it.message ?: "The settings could not be saved" }
} mutableState.update { it.copy(metadataSettings = stored) }
reloadNow()
if (enabled) viewModelScope.launch { match(force = false) }
return null
} }
fun reloadAfterMetadataChange() = reload() fun reloadAfterMetadataChange() = reload()
@@ -436,43 +783,60 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
} }
private fun reload() { private fun reload() {
viewModelScope.launch { viewModelScope.launch { reloadNow() }
runCatching { }
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine() /** [reload], for a caller that goes on to read what it loaded. */
// Here rather than inside `library()`, which is read-only private suspend fun reloadNow() {
// and cached: a full reload is the one place that has runCatching {
// already paid for a walk of the offline files. withContext(Dispatchers.IO) {
engine.pruneOfflineFiles() val engine = NativeRuntime.engine()
Reloaded( // Here rather than inside `library()`, which is read-only
engine.shares(), // and cached: a full reload is the one place that has
engine.library(mutableState.value.query.takeIf(String::isNotBlank)), // already paid for a walk of the offline files.
engine.offlineFiles(), engine.pruneOfflineFiles()
engine.metadataSettings(), val current = mutableState.value
engine.storageUsage(), val query = current.query.takeIf(String::isNotBlank)
) Reloaded(
} engine.shares(),
}.onSuccess { result -> engine.library(query),
mutableState.update { it.copy( engine.arrangement(query, current.sort, current.grouped),
loading = false, engine.offlineFiles(),
shares = result.shares, engine.metadataSettings(),
titles = result.titles, engine.storageUsage(),
offline = result.offline, )
metadataSettings = result.metadataSettings, }
storage = result.storage, }.onSuccess { result ->
) } mutableState.update { it.copy(
} loading = false,
.onFailure { error -> shares = result.shares,
mutableState.update { it.copy(loading = false, message = error.message) } titles = result.titles,
} arrangement = result.arrangement,
} offline = result.offline,
metadataSettings = result.metadataSettings,
storage = result.storage,
) }
}
.onFailure { error ->
mutableState.update { it.copy(loading = false, message = error.message) }
}
} }
private suspend fun reloadLibrary(query: String) { private suspend fun reloadLibrary(query: String) {
val current = mutableState.value
runCatching { runCatching {
withContext(Dispatchers.IO) { NativeRuntime.engine().library(query.takeIf(String::isNotBlank)) } withContext(Dispatchers.IO) {
}.onSuccess { titles -> mutableState.update { it.copy(titles = titles) } } val engine = NativeRuntime.engine()
.onFailure { error -> mutableState.update { it.copy(message = error.message) } } val search = query.takeIf(String::isNotBlank)
engine.library(search) to engine.arrangement(search, current.sort, current.grouped)
}
}.onSuccess { (titles, arrangement) ->
mutableState.update { it.copy(titles = titles, arrangement = arrangement) }
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
}
private companion object {
const val SYNC_INTERVAL_MS = 5 * 60 * 1000L
} }
class Factory(private val context: Context, private val workManager: WorkManager) : ViewModelProvider.Factory { class Factory(private val context: Context, private val workManager: WorkManager) : ViewModelProvider.Factory {
@@ -484,6 +848,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
private data class Reloaded( private data class Reloaded(
val shares: List<ShareRecord>, val shares: List<ShareRecord>,
val titles: List<TitleRecord>, val titles: List<TitleRecord>,
val arrangement: ArrangementRecord,
val offline: List<OfflineRecord>, val offline: List<OfflineRecord>,
val metadataSettings: MetadataSettingsRecord, val metadataSettings: MetadataSettingsRecord,
val storage: StorageUsageRecord, val storage: StorageUsageRecord,
@@ -19,6 +19,7 @@ import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import io.narl.protonstream.download.RetainedDownload
import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.EpisodeRecord
/** /**
@@ -120,6 +121,20 @@ internal fun formatBytes(bytes: ULong): String {
} }
} }
/**
* One line under a download that has not finished: "Running · 120.0 MiB of
* 1.2 GiB · 2.4 MiB/s", or "Queued" while it waits for a slot.
*/
internal fun downloadStatusLine(download: RetainedDownload): String = listOfNotNull(
download.status.replaceFirstChar { it.uppercase() },
download.total.takeIf { it > 0L && download.downloaded > 0L }?.let {
"${formatBytes(download.downloaded.toULong())} of ${formatBytes(it.toULong())}"
},
download.bytesPerSecond
.takeIf { it > 0L && download.status == RetainedDownload.STATUS_RUNNING }
?.let { "${formatBytes(it.toULong())}/s" },
).joinToString(" · ")
/** /**
* What an episode row is headed with: "1. Mother and Children". * What an episode row is headed with: "1. Mother and Children".
* *
@@ -74,7 +74,7 @@ internal fun DownloadsScreen(
state.offline state.offline
.groupBy { shows["${it.shareId}/${it.linkId}"] } .groupBy { shows["${it.shareId}/${it.linkId}"] }
.map { (title, files) -> .map { (title, files) ->
OfflineGroup(title?.let { it.canonicalName ?: it.name } ?: "Not in the library", title, files) OfflineGroup(title?.displayName ?: "Not in the library", title, files)
} }
.sortedBy { it.name } .sortedBy { it.name }
} }
@@ -204,14 +204,7 @@ private fun PartialRow(
headlineContent = { Text(download.label, maxLines = 2, overflow = TextOverflow.Ellipsis) }, headlineContent = { Text(download.label, maxLines = 2, overflow = TextOverflow.Ellipsis) },
supportingContent = { supportingContent = {
Column { Column {
Text( Text(downloadStatusLine(download))
listOfNotNull(
download.status.replaceFirstChar { it.uppercase() },
download.total.takeIf { it > 0L }?.let {
"${formatBytes(download.downloaded.toULong())} of ${formatBytes(it.toULong())}"
},
).joinToString(" · "),
)
if (download.total > 0L) { if (download.total > 0L) {
AccentProgress( AccentProgress(
progress = { (download.downloaded.toFloat() / download.total).coerceIn(0f, 1f) }, progress = { (download.downloaded.toFloat() / download.total).coerceIn(0f, 1f) },
@@ -145,7 +145,7 @@ private fun HistoryRow(entry: Played, onPlay: () -> Unit, onTitle: () -> Unit, o
} }
Column(Modifier.weight(1f).clickable(onClick = onTitle).padding(horizontal = 12.dp, vertical = 4.dp)) { Column(Modifier.weight(1f).clickable(onClick = onTitle).padding(horizontal = 12.dp, vertical = 4.dp)) {
Text( Text(
entry.title.canonicalName ?: entry.title.name, entry.title.displayName,
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@@ -76,7 +76,13 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.narl.protonstream.ui.theme.AccentProgress import io.narl.protonstream.ui.theme.AccentProgress
import androidx.compose.material.icons.automirrored.filled.Sort
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.LibrarySort
import uniffi.pstr_android.SeasonRecord import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.TitleRecord import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType import uniffi.pstr_android.TitleType
@@ -92,6 +98,12 @@ private const val CONTINUE_WATCHING_MAX = 12
/** One row of the continue-watching shelf: which episode, and where it sits. */ /** One row of the continue-watching shelf: which episode, and where it sits. */
private data class Resumable(val title: TitleRecord, val episode: EpisodeRecord, val index: Int) private data class Resumable(val title: TitleRecord, val episode: EpisodeRecord, val index: Int)
/** One tile of the grid: the title drawn, and how many more of its franchise it stands for. */
private data class Tile(val title: TitleRecord, val folded: Int)
/** A row above the grid: titles that share a director, a studio or a genre. */
private data class Shelf(val label: String, val titles: List<TitleRecord>)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
internal fun LibraryScreen( internal fun LibraryScreen(
@@ -105,6 +117,7 @@ internal fun LibraryScreen(
padding: PaddingValues, padding: PaddingValues,
onMatchChanged: () -> Unit = {}, onMatchChanged: () -> Unit = {},
onError: (Throwable) -> Unit = {}, onError: (Throwable) -> Unit = {},
onArrange: (LibrarySort, Boolean) -> Unit = { _, _ -> },
) { ) {
val context = LocalContext.current val context = LocalContext.current
// What a long press opened a menu for: a title, or a Continue watching card. // What a long press opened a menu for: a title, or a Continue watching card.
@@ -144,6 +157,7 @@ internal fun LibraryScreen(
IconButton(onClick = { searching = true }) { IconButton(onClick = { searching = true }) {
Icon(Icons.Default.Search, contentDescription = "Search library") Icon(Icons.Default.Search, contentDescription = "Search library")
} }
ArrangeMenu(state.sort, state.grouped, onArrange)
// Pull to refresh is the gesture; the icon stays for a // Pull to refresh is the gesture; the icon stays for a
// viewer who does not know it. // viewer who does not know it.
IconButton(onClick = onRefresh, enabled = !state.refreshing) { IconButton(onClick = onRefresh, enabled = !state.refreshing) {
@@ -183,7 +197,7 @@ internal fun LibraryScreen(
menuFor?.let { title -> menuFor?.let { title ->
val playlist = title.seasons.flatMap(SeasonRecord::episodes) val playlist = title.seasons.flatMap(SeasonRecord::episodes)
val allWatched = title.episodeCount > 0uL && title.watchedCount == title.episodeCount val allWatched = title.episodeCount > 0uL && title.watchedCount == title.episodeCount
TileMenu(title.canonicalName ?: title.name, title.caption(), onDismiss = { menuFor = null }) { TileMenu(title.displayName, title.caption(), onDismiss = { menuFor = null }) {
MenuRow(Icons.Default.PlayArrow, if (playlist.any { it.resumeAt != null }) "Resume" else "Play") { MenuRow(Icons.Default.PlayArrow, if (playlist.any { it.resumeAt != null }) "Resume" else "Play") {
onResume(title, nextUpIndex(playlist)) onResume(title, nextUpIndex(playlist))
} }
@@ -211,7 +225,7 @@ internal fun LibraryScreen(
} }
menuResume?.let { entry -> menuResume?.let { entry ->
TileMenu( TileMenu(
entry.title.canonicalName ?: entry.title.name, entry.title.displayName,
entry.episode.label, entry.episode.label,
onDismiss = { menuResume = null }, onDismiss = { menuResume = null },
) { ) {
@@ -224,6 +238,56 @@ internal fun LibraryScreen(
} }
} }
/**
* The grid's order, and whether a franchise is one tile or one per title —
* the same choices the desktop offers beside its grid, behind one icon here
* because a phone's bar has room for three.
*/
@Composable
private fun ArrangeMenu(sort: LibrarySort, grouped: Boolean, onArrange: (LibrarySort, Boolean) -> Unit) {
var open by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { open = true }) {
Icon(Icons.AutoMirrored.Filled.Sort, contentDescription = "Sort library")
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
LibrarySort.entries.forEach { option ->
DropdownMenuItem(
text = { Text(option.label()) },
leadingIcon = {
if (option == sort) Icon(Icons.Default.Check, contentDescription = null)
},
onClick = {
open = false
onArrange(option, grouped)
},
)
}
HorizontalDivider()
DropdownMenuItem(
text = { Text("One tile per franchise") },
leadingIcon = {
if (grouped) Icon(Icons.Default.Check, contentDescription = null)
},
onClick = {
open = false
onArrange(sort, !grouped)
},
)
}
}
}
/** What each order is called. The desktop says the same. */
private fun LibrarySort.label() = when (this) {
LibrarySort.NAME -> "A – Z"
LibrarySort.RECENT -> "Recently watched"
LibrarySort.ADDED -> "Recently added"
LibrarySort.RELEASE -> "Newest"
LibrarySort.RATING -> "Highest rated"
LibrarySort.POPULARITY -> "Most popular"
}
/** /**
* The bar while searching: back closes the search and clears it, and the field * The bar while searching: back closes the search and clears it, and the field
* takes the keyboard as soon as it opens, since opening it is asking to type. * takes the keyboard as soon as it opens, since opening it is asking to type.
@@ -321,6 +385,18 @@ private fun LibraryGrid(
onTitleMenu: (TitleRecord) -> Unit, onTitleMenu: (TitleRecord) -> Unit,
onResumeMenu: (Resumable) -> Unit, onResumeMenu: (Resumable) -> Unit,
) { ) {
// The arrangement names titles by key; before it has arrived, the titles
// as they came are the grid.
val byKey = remember(state.titles) { state.titles.associateBy { it.key } }
val tiles = remember(state.titles, state.arrangement) {
state.arrangement?.tiles?.mapNotNull { tile -> byKey[tile.key]?.let { Tile(it, tile.folded.toInt()) } }
?: state.titles.map { Tile(it, 0) }
}
val shelves = remember(state.titles, state.arrangement) {
state.arrangement?.shelves.orEmpty()
.map { shelf -> Shelf(shelf.label, shelf.keys.mapNotNull(byKey::get)) }
.filter { it.titles.isNotEmpty() }
}
// Posters, three across on a phone. Cropped backdrops at full width fitted // Posters, three across on a phone. Cropped backdrops at full width fitted
// two titles to a screen, which is a library read one row at a time. // two titles to a screen, which is a library read one row at a time.
LazyVerticalGrid( LazyVerticalGrid(
@@ -363,12 +439,42 @@ private fun LibraryGrid(
} }
} }
} }
item(key = "library-heading", span = { GridItemSpan(maxLineSpan) }) { }
Text("All titles", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp)) if (state.query.isBlank()) {
shelves.forEach { shelf ->
item(key = "shelf-${shelf.label}", span = { GridItemSpan(maxLineSpan) }) {
Column {
Text(shelf.label, style = MaterialTheme.typography.titleMedium)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(top = 10.dp),
) {
items(shelf.titles, key = { it.key }) { title ->
PosterTile(
title,
onClick = { onTitle(title) },
onLongClick = { onTitleMenu(title) },
modifier = Modifier.width(SHELF_TILE_WIDTH),
shared = false,
)
}
}
}
}
}
if (resumable.isNotEmpty() || shelves.isNotEmpty()) {
item(key = "library-heading", span = { GridItemSpan(maxLineSpan) }) {
Text("All titles", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp))
}
} }
} }
items(state.titles, key = { it.key }) { title -> items(tiles, key = { it.title.key }) { tile ->
PosterTile(title, onClick = { onTitle(title) }, onLongClick = { onTitleMenu(title) }) PosterTile(
tile.title,
onClick = { onTitle(tile.title) },
onLongClick = { onTitleMenu(tile.title) },
folded = tile.folded,
)
} }
} }
} }
@@ -399,6 +505,9 @@ private fun LibrarySkeleton() {
} }
} }
/** A shelf's covers: a little narrower than the grid's, so the row reads as a row. */
private val SHELF_TILE_WIDTH = 112.dp
/** Enough placeholder tiles to fill a tablet's first screen. */ /** Enough placeholder tiles to fill a tablet's first screen. */
private const val SKELETON_TILES = 18 private const val SKELETON_TILES = 18
@@ -421,7 +530,7 @@ private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int)
Box(Modifier.fillMaxWidth().aspectRatio(4f / 3f).clip(MaterialTheme.shapes.large)) { Box(Modifier.fillMaxWidth().aspectRatio(4f / 3f).clip(MaterialTheme.shapes.large)) {
RemoteArtwork( RemoteArtwork(
title.backdropUrl ?: title.posterUrl, title.backdropUrl ?: title.posterUrl,
title.canonicalName ?: title.name, title.displayName,
Modifier.fillMaxSize(), Modifier.fillMaxSize(),
fallback = title.thumbnailSource, fallback = title.thumbnailSource,
labelled = false, labelled = false,
@@ -433,7 +542,7 @@ private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int)
) )
Column(Modifier.align(Alignment.BottomStart).padding(16.dp)) { Column(Modifier.align(Alignment.BottomStart).padding(16.dp)) {
Text( Text(
title.canonicalName ?: title.name, title.displayName,
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
color = Color.White, color = Color.White,
maxLines = 2, maxLines = 2,
@@ -481,7 +590,7 @@ private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: ()
Box(Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(MaterialTheme.shapes.medium)) { Box(Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(MaterialTheme.shapes.medium)) {
RemoteArtwork( RemoteArtwork(
entry.episode.stillUrl ?: entry.title.backdropUrl, entry.episode.stillUrl ?: entry.title.backdropUrl,
entry.title.canonicalName ?: entry.title.name, entry.title.displayName,
Modifier.fillMaxSize(), Modifier.fillMaxSize(),
fallback = entry.episode.thumbnailSource, fallback = entry.episode.thumbnailSource,
) )
@@ -493,7 +602,7 @@ private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: ()
} }
} }
Text( Text(
entry.title.canonicalName ?: entry.title.name, entry.title.displayName,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@@ -509,23 +618,55 @@ private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: ()
} }
} }
/** One title: its poster, and its name and size under it rather than in a card. */ /**
* One title: its poster, and its name and size under it rather than in a card.
*
* [folded] is how many more titles of its franchise the tile stands for, said
* in the corner. [shared] is whether the poster grows into the title page —
* only one copy of a title on screen can, and a shelf may hold the same title
* as the grid.
*/
@Composable @Composable
private fun PosterTile(title: TitleRecord, onClick: () -> Unit, onLongClick: () -> Unit) { private fun PosterTile(
title: TitleRecord,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
folded: Int = 0,
shared: Boolean = true,
) {
Column( Column(
Modifier modifier
.clip(MaterialTheme.shapes.medium) .clip(MaterialTheme.shapes.medium)
.combinedClickable(onClick = onClick, onLongClick = onLongClick), .combinedClickable(onClick = onClick, onLongClick = onLongClick),
) { ) {
RemoteArtwork( Box {
title.posterUrl ?: title.backdropUrl, RemoteArtwork(
title.canonicalName ?: title.name, title.posterUrl ?: title.backdropUrl,
Modifier.sharedArt(title.key).fillMaxWidth().aspectRatio(2f / 3f).clip(MaterialTheme.shapes.medium), title.displayName,
fallback = title.thumbnailSource, (if (shared) Modifier.sharedArt(title.key) else Modifier)
) .fillMaxWidth()
.aspectRatio(2f / 3f)
.clip(MaterialTheme.shapes.medium),
fallback = title.thumbnailSource,
)
if (folded > 0) {
Text(
"+$folded",
style = MaterialTheme.typography.labelSmall,
color = Color.White,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(6.dp)
.clip(MaterialTheme.shapes.small)
.background(Color.Black.copy(alpha = 0.7f))
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
Text( Text(
title.canonicalName ?: title.name, title.displayName,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 2, maxLines = 2,
@@ -1,5 +1,16 @@
package io.narl.protonstream.ui package io.narl.protonstream.ui
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.background
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.fadeOut
import androidx.compose.animation.fadeIn
import androidx.compose.animation.AnimatedVisibility
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExperimentalSharedTransitionApi
@@ -98,7 +109,6 @@ private enum class Destination(val label: String, val icon: ImageVector) {
@Composable @Composable
fun ProtonStreamApp( fun ProtonStreamApp(
playerHost: LibmpvHost? = null, playerHost: LibmpvHost? = null,
inPictureInPicture: Boolean = false,
shareLink: String? = null, shareLink: String? = null,
onShareLinkTaken: () -> Unit = {}, onShareLinkTaken: () -> Unit = {},
model: AppViewModel = viewModel( model: AppViewModel = viewModel(
@@ -215,6 +225,7 @@ fun ProtonStreamApp(
body, body,
onMatchChanged = model::reloadAfterMetadataChange, onMatchChanged = model::reloadAfterMetadataChange,
onError = model::reportError, onError = model::reportError,
onArrange = model::arrange,
) )
} }
val titlePane = @Composable { selected: TitleRecord -> val titlePane = @Composable { selected: TitleRecord ->
@@ -231,6 +242,10 @@ fun ProtonStreamApp(
model::reloadAfterMetadataChange, model::reloadAfterMetadataChange,
model::setWatched, model::setWatched,
body, body,
franchise = selected.franchise.mapNotNull { key ->
state.titles.firstOrNull { it.key == key }
},
onOpenTitle = { selectedTitleKey = it.key },
) )
} }
if (maxWidth >= TWO_PANE_WIDTH) { if (maxWidth >= TWO_PANE_WIDTH) {
@@ -265,6 +280,18 @@ fun ProtonStreamApp(
shareLink, shareLink,
onShareLinkTaken, onShareLinkTaken,
body, body,
account = state.account,
accountActions = AccountActions(
signIn = model::signIn,
submitSecondFactor = model::submitSecondFactor,
submitMailboxPassword = model::submitMailboxPassword,
cancelSignIn = model::cancelSignIn,
dismissVerification = model::dismissVerification,
signOut = model::signOut,
syncNow = model::syncNow,
addFolder = model::addAccountFolder,
onError = model::reportError,
),
) )
Destination.History -> HistoryScreen( Destination.History -> HistoryScreen(
state, state,
@@ -292,6 +319,7 @@ fun ProtonStreamApp(
model::clearBlockCache, model::clearBlockCache,
model::removeAllOffline, model::removeAllOffline,
body, body,
onNamesChanged = model::reloadAfterMetadataChange,
) )
} }
} }
@@ -302,29 +330,34 @@ fun ProtonStreamApp(
onClose = { onClose = {
playingTitleKey = null playingTitleKey = null
playerMinimized = false playerMinimized = false
model.reloadAfterMetadataChange() model.playerClosed()
}, },
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.padding(bottom = padding.calculateBottomPadding() + 8.dp), .padding(bottom = padding.calculateBottomPadding() + 8.dp),
) )
} }
ActivityBanner(
state.activity,
Modifier
.align(Alignment.BottomCenter)
.padding(bottom = body.calculateBottomPadding() + 8.dp),
)
} }
} }
} }
// Drawn over the scaffold rather than instead of it. The player owns the // Drawn over the scaffold rather than instead of it. The player owns the
// whole window while it is up — it is a screen, not a dialog over one, which // whole window while it is up — it is a screen, not a dialog over one, which
// is what lets it draw into the cutout and hand the right window to // is what lets it draw into the cutout — but the page behind it stays
// Picture-in-Picture — but the page behind it stays composed, so leaving the // composed, so leaving the player finds the title page where it was rather
// player finds the title page where it was rather than rebuilt from the top. // than rebuilt from the top.
playing?.let { request -> playing?.let { request ->
PlayerScreen( PlayerScreen(
title = request.title, title = request.title,
episodes = request.episodes, episodes = request.episodes,
index = request.index, index = request.index,
host = playerHost, host = playerHost,
inPictureInPicture = inPictureInPicture,
minimized = playerMinimized, minimized = playerMinimized,
onIndexChange = { playingIndex = it }, onIndexChange = { playingIndex = it },
onSaveProgress = model::saveProgress, onSaveProgress = model::saveProgress,
@@ -332,7 +365,7 @@ fun ProtonStreamApp(
onClose = { onClose = {
playingTitleKey = null playingTitleKey = null
playerMinimized = false playerMinimized = false
model.reloadAfterMetadataChange() model.playerClosed()
}, },
) )
} }
@@ -382,3 +415,34 @@ private fun MiniTransport(
} }
} }
} }
/**
* What the app is busy with, for as long as it is: a spinner and one line.
* Not dismissible and not timed — it goes when the work does.
*/
@Composable
private fun ActivityBanner(activity: String?, modifier: Modifier = Modifier) {
AnimatedVisibility(
visible = activity != null,
modifier = modifier,
enter = fadeIn() + slideInVertically { it },
exit = fadeOut() + slideOutVertically { it },
) {
// Held through the exit, so the line does not blank before it fades.
var shown by remember { mutableStateOf(activity.orEmpty()) }
if (activity != null) shown = activity
Row(
Modifier
.padding(horizontal = 12.dp)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.shapes.small)
.padding(horizontal = 16.dp, vertical = 12.dp)
.semantics { liveRegion = LiveRegionMode.Polite },
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
Text(shown, style = MaterialTheme.typography.bodyMedium, maxLines = 2)
}
}
}
@@ -71,6 +71,7 @@ import io.narl.protonstream.ui.theme.TonalButton
import uniffi.pstr_android.MetadataProvider import uniffi.pstr_android.MetadataProvider
import uniffi.pstr_android.FlavorChoice import uniffi.pstr_android.FlavorChoice
import uniffi.pstr_android.AppearanceRecord import uniffi.pstr_android.AppearanceRecord
import uniffi.pstr_android.TitleNamesChoice
import uniffi.pstr_android.AccentChoice import uniffi.pstr_android.AccentChoice
import uniffi.pstr_android.PlaybackPrefsRecord import uniffi.pstr_android.PlaybackPrefsRecord
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
@@ -85,7 +86,8 @@ import io.narl.protonstream.ui.theme.AccentProgress
@Composable @Composable
internal fun SettingsScreen( internal fun SettingsScreen(
state: AppUiState, state: AppUiState,
onSaveMetadata: (Boolean, MetadataProvider, String, String) -> Unit, /** Resolves to why the settings were not stored, or null once they are. */
onSaveMetadata: suspend (Boolean, MetadataProvider, String, String) -> String?,
onMatchAgain: () -> Unit, onMatchAgain: () -> Unit,
onClearCache: () -> Unit, onClearCache: () -> Unit,
onRemoveAllOffline: () -> Unit, onRemoveAllOffline: () -> Unit,
@@ -94,6 +96,8 @@ internal fun SettingsScreen(
// A parameter so the screenshot tests can pin it: read from the package, // A parameter so the screenshot tests can pin it: read from the package,
// every version bump changed the committed image of this page. // every version bump changed the committed image of this page.
version: String = appVersion(), version: String = appVersion(),
/** The library's names changed, and it has to be read again to show them. */
onNamesChanged: () -> Unit = {},
) { ) {
val context = LocalContext.current val context = LocalContext.current
val settings = remember { SettingsStore(context) } val settings = remember { SettingsStore(context) }
@@ -264,7 +268,7 @@ internal fun SettingsScreen(
) )
} }
SettingsPage.Appearance -> SettingsSubPage(SettingsPage.Appearance, padding, back) { SettingsPage.Appearance -> SettingsSubPage(SettingsPage.Appearance, padding, back) {
Column(Modifier.padding(horizontal = 16.dp)) { AppearancePicker() } Column(Modifier.padding(horizontal = 16.dp)) { AppearancePicker(onNamesChanged) }
} }
SettingsPage.Metadata -> SettingsSubPage(SettingsPage.Metadata, padding, back) { SettingsPage.Metadata -> SettingsSubPage(SettingsPage.Metadata, padding, back) {
SettingAction( SettingAction(
@@ -324,10 +328,7 @@ internal fun SettingsScreen(
MetadataSettingsDialog( MetadataSettingsDialog(
current = state.metadataSettings, current = state.metadataSettings,
onDismiss = { showMetadata = false }, onDismiss = { showMetadata = false },
onSave = { enabled, provider, language, key -> onSave = onSaveMetadata,
onSaveMetadata(enabled, provider, language, key)
showMetadata = false
},
) )
} }
} }
@@ -408,19 +409,23 @@ private fun SettingAction(
private fun MetadataSettingsDialog( private fun MetadataSettingsDialog(
current: uniffi.pstr_android.MetadataSettingsRecord, current: uniffi.pstr_android.MetadataSettingsRecord,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSave: (Boolean, MetadataProvider, String, String) -> Unit, onSave: suspend (Boolean, MetadataProvider, String, String) -> String?,
) { ) {
var enabled by remember { mutableStateOf(current.enabled) } var enabled by remember { mutableStateOf(current.enabled) }
var provider by remember { mutableStateOf(current.provider) } var provider by remember { mutableStateOf(current.provider) }
var language by remember { mutableStateOf(current.language) } var language by remember { mutableStateOf(current.language) }
var apiKey by remember { mutableStateOf("") } var apiKey by remember { mutableStateOf("") }
var saving by remember { mutableStateOf(false) }
var refusal by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
val needsKey = enabled && provider == MetadataProvider.TMDB && !current.ready && apiKey.isBlank()
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = { if (!saving) onDismiss() },
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
title = { Text("Metadata enrichment") }, title = { Text("Metadata enrichment") },
text = { text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Off by default: enabling sends the titles in your library to a third party, associated with your IP address and subject to their privacy policy.") Text("Off by default: enabling sends the titles in your library to a third party, associated with your IP address and subject to their privacy policy. With AniList, the matched ids also go to ani.zip for episode names and wide artwork.")
SettingToggle("Enable enrichment", enabled) { enabled = it } SettingToggle("Enable enrichment", enabled) { enabled = it }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MetadataProvider.entries.forEach { option -> MetadataProvider.entries.forEach { option ->
@@ -444,15 +449,25 @@ private fun MetadataSettingsDialog(
singleLine = true, singleLine = true,
) )
} }
if (needsKey) Text("Enter a TMDB API key to save.", color = MaterialTheme.colorScheme.error)
refusal?.let { Text(it, color = MaterialTheme.colorScheme.error) }
} }
}, },
confirmButton = { confirmButton = {
AccentButton( AccentButton(
enabled = !enabled || provider != MetadataProvider.TMDB || current.ready || apiKey.isNotBlank(), enabled = !needsKey && !saving,
onClick = { onSave(enabled, provider, language.ifBlank { "en" }, apiKey) }, onClick = {
) { Text("Save") } saving = true
refusal = null
scope.launch {
refusal = onSave(enabled, provider, language.ifBlank { "en" }, apiKey)
saving = false
if (refusal == null) onDismiss()
}
},
) { Text(if (saving) "Saving…" else "Save") }
}, },
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } }, dismissButton = { TonalButton(onClick = onDismiss, enabled = !saving) { Text("Cancel") } },
) )
} }
@@ -502,7 +517,7 @@ internal fun SettingToggle(label: String, checked: Boolean, onChecked: (Boolean)
* the file both clients read. * the file both clients read.
*/ */
@Composable @Composable
private fun AppearancePicker() { private fun AppearancePicker(onNamesChanged: () -> Unit) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var choice by remember { mutableStateOf<AppearanceRecord?>(null) } var choice by remember { mutableStateOf<AppearanceRecord?>(null) }
var swatches by remember { mutableStateOf<Map<AccentChoice, Color>>(emptyMap()) } var swatches by remember { mutableStateOf<Map<AccentChoice, Color>>(emptyMap()) }
@@ -571,6 +586,26 @@ private fun AppearancePicker() {
) )
} }
} }
Text("Title names", style = MaterialTheme.typography.bodyMedium)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 8.dp)) {
items(TitleNamesChoice.entries.toList(), key = { it.name }) { names ->
if (names == current.names) {
AccentButton(onClick = {}) { Text(names.label()) }
} else {
EdgedButton(onClick = {
scope.launch {
repaint(current.copy(names = names), store = true)
onNamesChanged()
}
}) { Text(names.label()) }
}
}
}
Text(
"What a matched title is called. Its other names still find it in search.",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 8.dp),
)
SettingToggle("Paint the accent as a gradient", current.gradients) { on -> SettingToggle("Paint the accent as a gradient", current.gradients) { on ->
scope.launch { repaint(current.copy(gradients = on), store = true) } scope.launch { repaint(current.copy(gradients = on), store = true) }
} }
@@ -582,6 +617,13 @@ private fun AppearancePicker() {
) )
} }
/** What each name setting is called. The desktop client says the same. */
private fun TitleNamesChoice.label() = when (this) {
TitleNamesChoice.LIBRARY -> "As in the share"
TitleNamesChoice.ENGLISH -> "English"
TitleNamesChoice.ROMAJI -> "Romaji"
}
/** What each palette family is called. The desktop client says the same. */ /** What each palette family is called. The desktop client says the same. */
private fun FlavorChoice.label() = when (this) { private fun FlavorChoice.label() = when (this) {
FlavorChoice.PROTON -> "Proton" FlavorChoice.PROTON -> "Proton"
@@ -1,5 +1,10 @@
package io.narl.protonstream.ui package io.narl.protonstream.ui
import kotlinx.coroutines.launch
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@@ -32,6 +37,7 @@ import androidx.compose.ui.window.SecureFlagPolicy
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.narl.protonstream.ui.theme.AccentButton import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.TonalButton import io.narl.protonstream.ui.theme.TonalButton
import uniffi.pstr_android.AccountState
import uniffi.pstr_android.ShareRecord import uniffi.pstr_android.ShareRecord
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
@@ -49,23 +55,48 @@ import androidx.compose.ui.graphics.Color
@Composable @Composable
internal fun SharesScreen( internal fun SharesScreen(
shares: List<ShareRecord>, shares: List<ShareRecord>,
onAdd: (String, String, String?) -> Unit, /** Resolves to why the link was refused, or null once it is added. */
onAdd: suspend (String, String, String?) -> String?,
onRepair: (String, String, String?) -> Unit, onRepair: (String, String, String?) -> Unit,
onRefresh: (String) -> Unit, onRefresh: (String) -> Unit,
onRemove: (String) -> Unit, onRemove: (String) -> Unit,
incomingLink: String?, incomingLink: String?,
onIncomingLinkTaken: () -> Unit, onIncomingLinkTaken: () -> Unit,
padding: PaddingValues, padding: PaddingValues,
account: AccountUiState = AccountUiState(),
accountActions: AccountActions? = null,
) { ) {
var showAdd by remember { mutableStateOf(false) } var showAdd by remember { mutableStateOf(false) }
var signingIn by remember { mutableStateOf(false) }
var browsing by remember { mutableStateOf(false) }
var repairing by remember { mutableStateOf<ShareRecord?>(null) } var repairing by remember { mutableStateOf<ShareRecord?>(null) }
var removing by remember { mutableStateOf<ShareRecord?>(null) } var removing by remember { mutableStateOf<ShareRecord?>(null) }
TabPage("Shares", padding) { body -> TabPage("Shares", padding) { body ->
Box(Modifier.fillMaxSize().padding(body)) { Box(Modifier.fillMaxSize().padding(body)) {
if (shares.isEmpty()) { if (shares.isEmpty() && account.state == null) {
EmptyState("No shares yet", "Add a Proton Drive public link to build your library.") EmptyState("No shares yet", "Add a Proton Drive public link to build your library.")
} else { } else {
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) { LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
if (accountActions != null) {
item(key = "account") {
AccountCard(
account,
accountActions,
onSignIn = { signingIn = true },
onBrowse = { browsing = true },
)
}
}
if (shares.isEmpty()) {
item(key = "empty") {
Text(
"No shares yet. Add a public link, or a folder of your Drive once signed in.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
)
}
}
items(shares, key = { it.id }) { share -> items(shares, key = { it.id }) { share ->
ShareRow( ShareRow(
share = share, share = share,
@@ -95,6 +126,14 @@ internal fun SharesScreen(
onAdd = onAdd, onAdd = onAdd,
) )
} }
if (accountActions != null && (signingIn || account.state == AccountState.SecondFactor ||
account.state == AccountState.MailboxPassword)
) {
SignInDialog(account, accountActions, onDismiss = { signingIn = false })
}
if (accountActions != null && browsing) {
DriveBrowserDialog(accountActions, onDismiss = { browsing = false })
}
repairing?.let { share -> repairing?.let { share ->
RepairShareDialog( RepairShareDialog(
share = share, share = share,
@@ -103,15 +142,15 @@ internal fun SharesScreen(
) )
} }
removing?.let { share -> removing?.let { share ->
// Asked, because it cannot be undone: the link's secret, its offline // Asked, because it cannot be undone: the link's secret and its
// files and its watch positions all go (`Catalog::remove_share`). // offline files go. Watch positions stay (`Catalog::remove_share`).
AlertDialog( AlertDialog(
onDismissRequest = { removing = null }, onDismissRequest = { removing = null },
title = { Text("Remove ${share.name}?") }, title = { Text("Remove ${share.name}?") },
text = { text = {
Text( Text(
"Its titles leave the library, and its offline episodes and watch " + "Its titles leave the library and its offline episodes are deleted. " +
"progress are deleted. Adding the link again starts it fresh.", "Watch progress is kept, so adding the link again picks up where you left off.",
) )
}, },
confirmButton = { confirmButton = {
@@ -139,7 +178,13 @@ private fun ShareRow(
ListItem( ListItem(
headlineContent = { Text(share.name, fontWeight = FontWeight.SemiBold) }, headlineContent = { Text(share.name, fontWeight = FontWeight.SemiBold) },
supportingContent = { supportingContent = {
Text(if (share.hasCustomPassword) "Link and custom password, stored securely" else "Public link") Text(
when {
share.fromAccount -> "Folder of your Drive"
share.hasCustomPassword -> "Link and custom password, stored securely"
else -> "Public link"
},
)
}, },
leadingContent = { leadingContent = {
Icon( Icon(
@@ -164,11 +209,13 @@ private fun ShareRow(
// Not a remove: a share whose secret has become unreadable // Not a remove: a share whose secret has become unreadable
// cannot be removed either, since removal deletes a secret // cannot be removed either, since removal deletes a secret
// the store can no longer touch. // the store can no longer touch.
DropdownMenuItem( if (!share.fromAccount) {
text = { Text("Re-enter link") }, DropdownMenuItem(
leadingIcon = { Icon(Icons.Default.Key, contentDescription = null) }, text = { Text("Re-enter link") },
onClick = { menu = false; onRepair() }, leadingIcon = { Icon(Icons.Default.Key, contentDescription = null) },
) onClick = { menu = false; onRepair() },
)
}
DropdownMenuItem( DropdownMenuItem(
text = { Text("Remove") }, text = { Text("Remove") },
leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) }, leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) },
@@ -236,17 +283,25 @@ private fun RepairShareDialog(
) )
} }
/**
* Add a link. The form stays up while the link is opened, and with the reason
* under it when the link is refused, so fixing a typo is a retry rather than
* starting over.
*/
@Composable @Composable
private fun AddShareDialog( private fun AddShareDialog(
initialUrl: String, initialUrl: String,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onAdd: (String, String, String?) -> Unit, onAdd: suspend (String, String, String?) -> String?,
) { ) {
var name by remember { mutableStateOf("") } var name by remember { mutableStateOf("") }
var url by remember(initialUrl) { mutableStateOf(initialUrl) } var url by remember(initialUrl) { mutableStateOf(initialUrl) }
var password by remember { mutableStateOf("") } var password by remember { mutableStateOf("") }
var adding by remember { mutableStateOf(false) }
var refusal by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = { if (!adding) onDismiss() },
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
title = { Text("Add Proton Drive share") }, title = { Text("Add Proton Drive share") },
text = { text = {
@@ -271,14 +326,30 @@ private fun AddShareDialog(
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
) )
if (adding) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
Text("Opening the link…")
}
} else {
refusal?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
} }
}, },
confirmButton = { confirmButton = {
AccentButton( AccentButton(
onClick = { onAdd(name.trim(), url.trim(), password); onDismiss() }, onClick = {
enabled = name.isNotBlank() && url.isNotBlank(), adding = true
) { Text("Add") } refusal = null
scope.launch {
refusal = onAdd(name.trim(), url.trim(), password)
adding = false
if (refusal == null) onDismiss()
}
},
enabled = name.isNotBlank() && url.isNotBlank() && !adding,
) { Text(if (adding) "Adding…" else "Add") }
}, },
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } }, dismissButton = { TonalButton(onClick = onDismiss, enabled = !adding) { Text("Cancel") } },
) )
} }
@@ -73,7 +73,9 @@ import io.narl.protonstream.ui.theme.EdgedButton
import io.narl.protonstream.ui.theme.QuietButton import io.narl.protonstream.ui.theme.QuietButton
import io.narl.protonstream.ui.theme.TonalButton import io.narl.protonstream.ui.theme.TonalButton
import io.narl.protonstream.ui.theme.solid import io.narl.protonstream.ui.theme.solid
import androidx.compose.foundation.border
import uniffi.pstr_android.TitleRecord import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType
import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.SeasonRecord import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.MatchRecord import uniffi.pstr_android.MatchRecord
@@ -95,6 +97,9 @@ internal fun TitleScreen(
onMetadataChanged: () -> Unit, onMetadataChanged: () -> Unit,
onSetWatched: (EpisodeRecord, Boolean) -> Unit, onSetWatched: (EpisodeRecord, Boolean) -> Unit,
padding: PaddingValues, padding: PaddingValues,
/** Every title of its franchise, in release order, this one included. */
franchise: List<TitleRecord> = emptyList(),
onOpenTitle: (TitleRecord) -> Unit = {},
) { ) {
val context = LocalContext.current val context = LocalContext.current
var showMatch by remember(title.key) { mutableStateOf(false) } var showMatch by remember(title.key) { mutableStateOf(false) }
@@ -165,7 +170,7 @@ internal fun TitleScreen(
Box(Modifier.sharedArt(title.key).fillMaxWidth().height(backdropHeight)) { Box(Modifier.sharedArt(title.key).fillMaxWidth().height(backdropHeight)) {
RemoteArtwork( RemoteArtwork(
title.backdropUrl ?: title.posterUrl, title.backdropUrl ?: title.posterUrl,
title.canonicalName ?: title.name, title.displayName,
Modifier.fillMaxSize(), Modifier.fillMaxSize(),
fallback = title.thumbnailSource, fallback = title.thumbnailSource,
labelled = false, labelled = false,
@@ -192,21 +197,40 @@ internal fun TitleScreen(
} }
item(key = "heading") { item(key = "heading") {
Column(Modifier.padding(horizontal = side + 16.dp)) { Column(Modifier.padding(horizontal = side + 16.dp)) {
Text(title.canonicalName ?: title.name, style = MaterialTheme.typography.headlineMedium) Text(title.displayName, style = MaterialTheme.typography.headlineMedium)
title.originalName?.takeIf { it != title.canonicalName }?.let { // The other names it goes by: the share's when the shown one
// is the provider's, and the original-language one.
listOfNotNull(
title.name.takeIf { it != title.displayName },
title.originalName?.takeIf { it != title.displayName && it != title.canonicalName },
).joinToString(" · ").takeIf(String::isNotEmpty)?.let {
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
Text( Text(
listOfNotNull( listOfNotNull(
(title.metadataYear ?: title.year)?.toString(), title.formatLabel,
title.seasonLabel ?: (title.metadataYear ?: title.year)?.toString(),
title.rating?.let { "★ %.1f".format(it) }, title.rating?.let { "★ %.1f".format(it) },
title.genres.takeIf { it.isNotEmpty() }?.take(3)?.joinToString(", "),
"${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL }, "${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL },
).joinToString(" · "), ).joinToString(" · "),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp), modifier = Modifier.padding(top = 6.dp),
) )
// Who made it, and whether more is coming — the desktop's
// line of facts, on a line of its own so neither wraps.
listOfNotNull(
title.studios.takeIf { it.isNotEmpty() }?.take(2)?.joinToString(", "),
title.directors.firstOrNull()?.let { "dir. $it" },
airingNote(title),
).joinToString(" · ").takeIf(String::isNotEmpty)?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
// The one thing the page is for, full width and naming what it // The one thing the page is for, full width and naming what it
// will play: "Resume" alone left the viewer guessing which // will play: "Resume" alone left the viewer guessing which
// episode of forty it meant. // episode of forty it meant.
@@ -271,6 +295,17 @@ internal fun TitleScreen(
.clickable { overviewOpen = !overviewOpen }, .clickable { overviewOpen = !overviewOpen },
) )
} }
// Genres as chips, then the provider's tags quieter after
// them: a tag is its community describing it, not what it
// is filed as.
if (title.genres.isNotEmpty() || title.tags.isNotEmpty()) {
Text(
(title.genres + title.tags.take(4)).joinToString(" · "),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 10.dp),
)
}
// Said plainly, because it changes what a re-match will do: a // Said plainly, because it changes what a re-match will do: a
// hand-picked match is not overwritten by an automatic pass. // hand-picked match is not overwritten by an automatic pass.
if (title.manualMatch) { if (title.manualMatch) {
@@ -283,6 +318,25 @@ internal fun TitleScreen(
} }
} }
} }
if (franchise.size > 1) {
item(key = "franchise") {
Column(Modifier.padding(top = 12.dp)) {
Text(
"In this franchise",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = side + 16.dp),
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(start = side + 16.dp, end = side + 16.dp, top = 10.dp),
) {
items(franchise, key = { it.key }) { member ->
FranchiseTile(member, here = member.key == title.key) { onOpenTitle(member) }
}
}
}
}
}
item(key = "seasons") { item(key = "seasons") {
Row( Row(
Modifier.fillMaxWidth().padding(start = side + 16.dp, end = side + 4.dp, top = 12.dp), Modifier.fillMaxWidth().padding(start = side + 16.dp, end = side + 4.dp, top = 12.dp),
@@ -336,6 +390,68 @@ internal fun TitleScreen(
} }
} }
/**
* One part of the franchise: its cover, what it is and when. This title's own
* is marked rather than left out, so the row says where it sits in the story.
*/
@Composable
private fun FranchiseTile(title: TitleRecord, here: Boolean, onClick: () -> Unit) {
Column(
Modifier
.width(96.dp)
.clip(MaterialTheme.shapes.medium)
.clickable(enabled = !here, onClick = onClick),
) {
RemoteArtwork(
title.posterUrl ?: title.backdropUrl,
title.displayName,
Modifier
.fillMaxWidth()
.aspectRatio(2f / 3f)
.clip(MaterialTheme.shapes.medium)
.then(
if (here) {
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.medium)
} else {
Modifier
},
),
fallback = title.thumbnailSource,
)
Text(
title.displayName,
style = MaterialTheme.typography.labelMedium,
fontWeight = if (here) FontWeight.SemiBold else null,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp),
)
Text(
listOfNotNull(
title.formatLabel ?: if (title.kind == TitleType.FILM) "Film" else "Series",
(title.metadataYear ?: title.year)?.toString(),
).joinToString(" · "),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
/**
* "Episode 7 on Sat 11 Oct" while one is due, "Airing" while it is airing with
* no date yet, else nothing.
*/
private fun airingNote(title: TitleRecord): String? {
val at = title.nextAiringAt
val episode = title.nextEpisode
if (at != null && episode != null) {
val day = java.time.Instant.ofEpochSecond(at).atZone(java.time.ZoneId.systemDefault())
return "episode $episode on " + day.format(java.time.format.DateTimeFormatter.ofPattern("EEE d MMM"))
}
return if (title.airing) "airing" else null
}
/** How wide the page's text and controls run on a window wider than a phone. */ /** How wide the page's text and controls run on a window wider than a phone. */
private val READABLE_WIDTH = 720.dp private val READABLE_WIDTH = 720.dp
@@ -520,8 +636,7 @@ private fun EpisodeRow(
fill = solid(MaterialTheme.colorScheme.tertiary), fill = solid(MaterialTheme.colorScheme.tertiary),
) )
Text( Text(
"${active.status.replaceFirstChar { it.uppercase() }} · " + downloadStatusLine(active),
"${formatBytes(active.downloaded.toULong())} of ${formatBytes(active.total.toULong())}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -538,6 +653,8 @@ internal fun ChangeMatchDialog(
) { ) {
var term by remember(title.key) { mutableStateOf(title.canonicalName ?: title.name) } var term by remember(title.key) { mutableStateOf(title.canonicalName ?: title.name) }
var searching by remember { mutableStateOf(false) } var searching by remember { mutableStateOf(false) }
// Choosing fetches the entry's episodes too, which is a few requests.
var choosing by remember { mutableStateOf(false) }
var options by remember { mutableStateOf<List<MatchRecord>>(emptyList()) } var options by remember { mutableStateOf<List<MatchRecord>>(emptyList()) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
AlertDialog( AlertDialog(
@@ -556,16 +673,26 @@ internal fun ChangeMatchDialog(
searching = false searching = false
} }
}) { Text(if (searching) "Searching…" else "Search") } }) { Text(if (searching) "Searching…" else "Search") }
if (choosing) {
Text(
"Fetching its episodes…",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 8.dp),
)
}
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 300.dp)) { LazyColumn(Modifier.fillMaxWidth().heightIn(max = 300.dp)) {
items(options, key = { "${it.provider}:${it.remoteId}" }) { option -> items(options, key = { "${it.provider}:${it.remoteId}" }) { option ->
EdgedButton( EdgedButton(
onClick = { onClick = {
choosing = true
scope.launch { scope.launch {
runCatching { runCatching {
withContext(Dispatchers.IO) { NativeRuntime.engine().chooseMatch(title.key, option) } withContext(Dispatchers.IO) { NativeRuntime.engine().chooseMatch(title.key, option) }
}.onSuccess { onChanged() }.onFailure(onError) }.onSuccess { onChanged() }.onFailure(onError)
choosing = false
} }
}, },
enabled = !choosing,
modifier = Modifier.fillMaxWidth().padding(top = 6.dp), modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
) { ) {
Column(Modifier.fillMaxWidth()) { Column(Modifier.fillMaxWidth()) {
@@ -2,6 +2,7 @@ package io.narl.protonstream.ui.theme
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -9,6 +10,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.NavigationDrawerItemDefaults import androidx.compose.material3.NavigationDrawerItemDefaults
@@ -18,14 +20,18 @@ import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaul
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItemColors import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItemColors
import androidx.compose.material3.SliderState import androidx.compose.material3.SliderState
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
/** /**
@@ -68,17 +74,30 @@ internal fun AccentButton(
content: @Composable RowScope.() -> Unit, content: @Composable RowScope.() -> Unit,
) { ) {
val shape = MaterialTheme.shapes.small val shape = MaterialTheme.shapes.small
Button( // The button pads itself out to the 48dp minimum touch target, and a
onClick = onClick, // background on its modifier fills that whole target — 8dp taller than
// Only while it is live: a disabled button keeps Material's flat // the 40dp container every other button draws. So the padding is taken
// disabled container, because a greyed control that still wears the // here, outside the ramp, and the button inside is told not to add its
// one strong colour in the window reads as pressable. // own. The box hands its size down, so a `fillMaxWidth` still reaches it.
modifier = if (enabled) modifier.background(accentBrush(), shape) else modifier, Box(
enabled = enabled, modifier.minimumInteractiveComponentSize(),
shape = shape, contentAlignment = Alignment.Center,
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent), propagateMinConstraints = true,
content = content, ) {
) CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
Button(
onClick = onClick,
// Only while it is live: a disabled button keeps Material's flat
// disabled container, because a greyed control that still wears the
// one strong colour in the window reads as pressable.
modifier = if (enabled) Modifier.background(accentBrush(), shape) else Modifier,
enabled = enabled,
shape = shape,
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
content = content,
)
}
}
} }
@Composable @Composable
@@ -56,6 +56,12 @@ object AppearanceState {
.onSuccess { current.value = it } .onSuccess { current.value = it }
} }
/** Read the stored choice again, after sync rewrote it. */
suspend fun reload() {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().palette() } }
.onSuccess { current.value = it }
}
/** Repaint immediately, before the write that stores the choice lands. */ /** Repaint immediately, before the write that stores the choice lands. */
fun apply(palette: PaletteRecord) { fun apply(palette: PaletteRecord) {
current.value = palette current.value = palette
@@ -0,0 +1,37 @@
package io.narl.protonstream.download
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class TransferRateTest {
@Test
fun `the first sample has no rate to report`() {
assertEquals(0L, TransferRate().sample(4L * 1024 * 1024, 1_000))
}
@Test
fun `a steady transfer reports its rate`() {
val rate = TransferRate()
rate.sample(0, 0)
assertEquals(1_000_000L, rate.sample(1_000_000, 1_000))
assertEquals(1_000_000L, rate.sample(2_000_000, 2_000))
}
@Test
fun `one slow block moves the rate without halving it`() {
val rate = TransferRate()
rate.sample(0, 0)
rate.sample(1_000_000, 1_000)
val after = rate.sample(2_000_000, 3_000)
assertTrue(after in 600_000L..999_999L)
}
@Test
fun `a resumed download starting below the last sample starts over`() {
val rate = TransferRate()
rate.sample(0, 0)
rate.sample(1_000_000, 1_000)
assertEquals(1_000_000L, rate.sample(0, 2_000))
}
}
@@ -0,0 +1,38 @@
package io.narl.protonstream.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AccountUiTest {
@Test
fun `the sync line says how long ago and what came in`() {
val minute = 60_000L
assertEquals("Library, settings and watch history sync through your Drive", syncLine(AccountUiState(), 0))
assertEquals("Synced just now", syncLine(AccountUiState(syncedAt = 0), 30_000))
assertEquals("Synced 5 min ago", syncLine(AccountUiState(syncedAt = 0), 5 * minute))
assertEquals(
"Synced 2 h ago · 3 from other devices",
syncLine(AccountUiState(syncedAt = 0, applied = 3), 120 * minute),
)
}
@Test
fun `a failed sync is said in place of when it last worked`() {
assertEquals(
"Did not sync: offline",
syncLine(AccountUiState(syncedAt = 0, syncError = "offline"), 0),
)
}
@Test
fun `the verification page may only navigate within Proton over https`() {
assertTrue(isProtonPage("https", "verify.proton.me"))
assertTrue(isProtonPage("https", "proton.me"))
assertFalse(isProtonPage("http", "verify.proton.me"))
assertFalse(isProtonPage("https", "proton.me.example.com"))
assertFalse(isProtonPage("https", "notproton.me"))
assertFalse(isProtonPage("https", null))
}
}
@@ -23,6 +23,9 @@ class HistoryTest {
metadataProvider = null, metadataId = null, metadataYear = null, metadataKind = null, posterUrl = null, metadataProvider = null, metadataId = null, metadataYear = null, metadataKind = null, posterUrl = null,
backdropUrl = null, rating = null, genres = emptyList(), providerEpisodeCount = null, externalUrl = null, backdropUrl = null, rating = null, genres = emptyList(), providerEpisodeCount = null, externalUrl = null,
manualMatch = false, seasons = listOf(SeasonRecord(1u, "Season 1", episodes.toList())), manualMatch = false, seasons = listOf(SeasonRecord(1u, "Season 1", episodes.toList())),
displayName = key, wideUrl = null, formatLabel = null, seasonLabel = null, studios = emptyList(),
directors = emptyList(), tags = emptyList(), airing = false, nextEpisode = null, nextAiringAt = null,
franchise = emptyList(),
) )
@Test @Test
@@ -25,12 +25,15 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode import org.robolectric.annotation.GraphicsMode
import uniffi.pstr_android.ArrangementRecord
import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.OfflineRecord import uniffi.pstr_android.OfflineRecord
import uniffi.pstr_android.PaletteRecord import uniffi.pstr_android.PaletteRecord
import uniffi.pstr_android.SeasonRecord import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.ShareRecord import uniffi.pstr_android.ShareRecord
import uniffi.pstr_android.ShelfRecord
import uniffi.pstr_android.StorageUsageRecord import uniffi.pstr_android.StorageUsageRecord
import uniffi.pstr_android.TileRecord
import uniffi.pstr_android.TitleRecord import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType import uniffi.pstr_android.TitleType
@@ -129,27 +132,27 @@ class ScreenshotTest {
@Test @Test
fun `a title with seasons`() = capture("title-phone") { fun `a title with seasons`() = capture("title-phone") {
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues()) TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues(), franchise = listOf(SERIES, SEQUEL))
} }
@Test @Test
fun `the shares page`() = capture("shares-phone") { fun `the shares page`() = capture("shares-phone") {
SharesScreen(LIBRARY.shares, { _, _, _ -> }, { _, _, _ -> }, {}, {}, null, {}, PaddingValues()) SharesScreen(LIBRARY.shares, { _, _, _ -> null }, { _, _, _ -> }, {}, {}, null, {}, PaddingValues())
} }
@Test @Test
fun `the settings page`() = capture("settings-phone") { fun `the settings page`() = capture("settings-phone") {
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), version = "1.0.0") SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), version = "1.0.0")
} }
@Test @Test
fun `the playback settings`() = capture("settings-playback") { fun `the playback settings`() = capture("settings-playback") {
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), SettingsPage.Playback) SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), SettingsPage.Playback)
} }
@Test @Test
fun `the storage settings`() = capture("settings-storage") { fun `the storage settings`() = capture("settings-storage") {
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), SettingsPage.Storage) SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), SettingsPage.Storage)
} }
@Test @Test
@@ -250,6 +253,17 @@ class ScreenshotTest {
externalUrl = null, externalUrl = null,
manualMatch = false, manualMatch = false,
seasons = seasons, seasons = seasons,
displayName = name,
wideUrl = null,
formatLabel = null,
seasonLabel = null,
studios = emptyList(),
directors = emptyList(),
tags = emptyList(),
airing = false,
nextEpisode = null,
nextAiringAt = null,
franchise = emptyList(),
) )
val SERIES = title( val SERIES = title(
@@ -260,8 +274,19 @@ class ScreenshotTest {
SeasonRecord(2u, "Season 2", (1u..3u).map { episode(2u, it, progress = if (it == 1u) 0.4 else null) }), SeasonRecord(2u, "Season 2", (1u..3u).map { episode(2u, it, progress = if (it == 1u) 0.4 else null) }),
), ),
overview = "A doctor in a countryside clinic is reborn as the child of the idol he followed.", overview = "A doctor in a countryside clinic is reborn as the child of the idol he followed.",
).copy(
formatLabel = "TV",
seasonLabel = "Spring 2023",
studios = listOf("Doga Kobo"),
directors = listOf("Daisuke Hiramaki"),
tags = listOf("Idol", "Reincarnation"),
franchise = listOf("oshi-no-ko", "oshi-no-ko-film"),
) )
/** The rest of [SERIES]'s franchise, for the title page's row of it. */
val SEQUEL = title("oshi-no-ko-film", "Oshi no Ko: The Final Act", listOf(SeasonRecord(null, "Film", listOf(episode(1u, 1u)))))
.copy(kind = TitleType.FILM, year = 2024u, formatLabel = "Film", franchise = SERIES.franchise)
val FILM = title("akira", "Akira", listOf(SeasonRecord(null, "Film", listOf(episode(1u, 1u))))) val FILM = title("akira", "Akira", listOf(SeasonRecord(null, "Film", listOf(episode(1u, 1u)))))
.copy(kind = TitleType.FILM, year = 1988u) .copy(kind = TitleType.FILM, year = 1988u)
@@ -273,6 +298,14 @@ class ScreenshotTest {
title("ano-hana", "Ano Hi Mita Hana no Namae o Bokutachi wa Mada Shiranai.", listOf(SeasonRecord(1u, "Season 1", (1u..11u).map { episode(1u, it) }))), title("ano-hana", "Ano Hi Mita Hana no Namae o Bokutachi wa Mada Shiranai.", listOf(SeasonRecord(1u, "Season 1", (1u..11u).map { episode(1u, it) }))),
title("bleach", "Bleach", listOf(SeasonRecord(1u, "Season 1", (1u..2u).map { episode(1u, it) }))), title("bleach", "Bleach", listOf(SeasonRecord(1u, "Season 1", (1u..2u).map { episode(1u, it) }))),
), ),
// Bleach stands for one more title of its franchise, and one shelf
// sits above the grid: what the bridge's arrangement would give.
arrangement = ArrangementRecord(
tiles = listOf("akira", "ano-hana", "bleach", "oshi-no-ko").map {
TileRecord(it, if (it == "bleach") 1u else 0u)
},
shelves = listOf(ShelfRecord("From MAPPA", listOf("oshi-no-ko", "bleach", "akira"))),
),
shares = listOf( shares = listOf(
ShareRecord("anime", "anime", true), ShareRecord("anime", "anime", true),
ShareRecord("films", "films", false), ShareRecord("films", "films", false),
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 814 KiB

After

Width:  |  Height:  |  Size: 813 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 781 KiB

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 821 KiB

After

Width:  |  Height:  |  Size: 806 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 628 KiB

After

Width:  |  Height:  |  Size: 631 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 649 KiB

After

Width:  |  Height:  |  Size: 669 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

+1
View File
@@ -24,6 +24,7 @@ pstr-core.workspace = true
pstr-meta.workspace = true pstr-meta.workspace = true
pstr-stream.workspace = true pstr-stream.workspace = true
tokio.workspace = true tokio.workspace = true
futures.workspace = true
parking_lot.workspace = true parking_lot.workspace = true
thiserror.workspace = true thiserror.workspace = true
uniffi.workspace = true uniffi.workspace = true
File diff suppressed because it is too large. Load diff
+5
View File
@@ -33,6 +33,11 @@ serde_json.workspace = true
directories.workspace = true directories.workspace = true
chrono.workspace = true chrono.workspace = true
souvlaki.workspace = true souvlaki.workspace = true
wry.workspace = true
tao.workspace = true
[build-dependencies]
embed-resource.workspace = true
[dev-dependencies] [dev-dependencies]
skrifa.workspace = true skrifa.workspace = true
+15
View File
@@ -0,0 +1,15 @@
//! Compiles `proton-stream.rc` into the Windows exe; nothing on any other
//! target. `rc.exe` on an MSVC build, mingw's `windres` on the cross build
//! from Linux.
fn main() {
println!("cargo:rerun-if-changed=proton-stream.rc");
println!("cargo:rerun-if-changed=../../packaging/windows/proton-stream.ico");
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return;
}
// Only the GUI gets the icon; `pstr` is a console tool.
embed_resource::compile_for("proton-stream.rc", ["proton-stream"], embed_resource::NONE)
.manifest_optional()
.expect("compile the icon resource");
}
+4
View File
@@ -0,0 +1,4 @@
// The exe's icon. The window sets one at runtime too, but that only lasts as
// long as the window: Explorer, the Start menu and a taskbar pin read the icon
// out of the file, and without this one they fall back to the generic one.
1 ICON "../../packaging/windows/proton-stream.ico"
+245 -50
View File
@@ -12,10 +12,11 @@ use pstr_core::Share;
use pstr_core::appearance::Appearance; use pstr_core::appearance::Appearance;
use pstr_core::config::AppDirs; use pstr_core::config::AppDirs;
use pstr_core::library::Library; use pstr_core::library::Library;
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord}; use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, TitleNames};
use crate::engine::{ use crate::engine::{
DownloadItem, DownloadKey, Engine, Event, ImageCache, describe_failures, watch_state, AccountStatus, DownloadItem, DownloadKey, Engine, Event, ImageCache, describe_failures,
watch_state,
}; };
use crate::pacing::Pacer; use crate::pacing::Pacer;
use crate::playback::{Playback, PlaybackTarget}; use crate::playback::{Playback, PlaybackTarget};
@@ -126,6 +127,25 @@ pub enum Action {
url: String, url: String,
password: Option<String>, password: Option<String>,
}, },
/// Add a folder of the signed-in account as a share.
AddAccountFolder {
name: String,
uid: pstr_core::proton_sdk::ids::NodeUid,
},
SignIn {
username: String,
password: String,
},
SubmitSecondFactor(String),
SubmitMailboxPassword(String),
CancelSignIn,
SignOut,
/// Sync watch history with the account's Drive now.
SyncNow,
/// List the account's top-level places in the Drive browser.
BrowsePlaces,
/// List one folder in the Drive browser.
BrowseFolder(pstr_core::proton_sdk::ids::NodeUid),
/// Ask whether to forget a share. [`Action::ForgetShare`] does it. /// Ask whether to forget a share. [`Action::ForgetShare`] does it.
RemoveShare(String), RemoveShare(String),
ForgetShare(String), ForgetShare(String),
@@ -213,6 +233,8 @@ pub enum Action {
FocusSearch, FocusSearch,
/// Show a different slice of the library, or in a different order. /// Show a different slice of the library, or in a different order.
SetShelf(Filter, Sort), SetShelf(Filter, Sort),
/// Fold each franchise into one tile, or show every title.
SetGrouped(bool),
/// Open or close the list of keyboard shortcuts. /// Open or close the list of keyboard shortcuts.
ToggleShortcuts, ToggleShortcuts,
/// One page back: a title to the library, the library to nowhere. /// One page back: a title to the library, the library to nowhere.
@@ -302,25 +324,55 @@ impl Matcher {
/// every title by when it was last played. Neither is slow once; both were /// every title by when it was last played. Neither is slow once; both were
/// being done on every frame, and while a film plays under the transport bar /// being done on every frame, and while a film plays under the transport bar
/// that is the film's frame rate. /// that is the film's frame rate.
#[derive(Default)]
pub struct LibraryView { pub struct LibraryView {
/// The query `matches` answers, as typed. /// The query `matches` answers, as typed.
query: String, query: String,
/// Which titles the grid shows, and in what order. /// Which titles the grid shows, and in what order.
pub filter: Filter, pub filter: Filter,
pub sort: Sort, pub sort: Sort,
/// The filter and order `matches` was built with. /// One tile per franchise rather than one per title. See
built_with: (Filter, Sort), /// `pstr_core::franchise`.
pub grouped: bool,
/// What `matches` was built with.
built_with: Option<(Filter, Sort, bool, TitleNames)>,
/// Indices into [`Library::titles`]. /// Indices into [`Library::titles`].
pub matches: Vec<usize>, pub matches: Vec<usize>,
/// In a grouped grid, how many other titles a tile stands for, by index.
pub folded: HashMap<usize, usize>,
/// Every title in a franchise of more than one, by key: the keys of the
/// whole franchise in release order, its own included.
pub franchises: HashMap<String, Vec<String>>,
/// The rows above the grid, as a heading and indices.
pub shelves: Vec<(String, Vec<usize>)>,
/// Every played episode, newest first, for the history page. /// Every played episode, newest first, for the history page.
pub history: Vec<ui::history::Entry>, pub history: Vec<ui::history::Entry>,
/// The "Continue watching" shelf, most recent first, as indices. /// The "Continue watching" shelf, most recent first, as indices.
pub resumable: Vec<usize>, pub resumable: Vec<usize>,
/// The library changed under the lists. /// The library or what is known about it changed under the lists.
stale: bool, stale: bool,
} }
impl Default for LibraryView {
fn default() -> Self {
Self {
query: String::new(),
filter: Filter::default(),
sort: Sort::default(),
// On: a share holding a series and its three films is four tiles
// of the same picture otherwise.
grouped: true,
built_with: None,
matches: Vec::new(),
folded: HashMap::new(),
franchises: HashMap::new(),
shelves: Vec::new(),
history: Vec::new(),
resumable: Vec::new(),
stale: false,
}
}
}
/// Which titles the grid shows. /// Which titles the grid shows.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Filter { pub enum Filter {
@@ -350,43 +402,95 @@ impl Filter {
} }
} }
/// What order the grid is in. pub use pstr_core::browse::Sort;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Sort { /// What the library view is built from, besides the library itself.
/// The library's own order: by name, ignoring a leading article. pub struct ViewInputs<'a> {
#[default] pub query: &'a str,
Name, pub metadata: &'a HashMap<String, MetadataRecord>,
/// Most recently played first. pub names: TitleNames,
Recent, /// When each title last gained a file, by key.
/// Newest first, undated last. pub added: &'a HashMap<String, i64>,
Year,
} }
/// How many shelves the library shows above the grid.
const SHELVES: usize = 4;
impl LibraryView { impl LibraryView {
fn refresh(&mut self, library: &Library, query: &str) { fn refresh(&mut self, library: &Library, inputs: ViewInputs<'_>) {
if !self.stale && self.query == query && self.built_with == (self.filter, self.sort) { use pstr_core::library::Title;
let built_with = (self.filter, self.sort, self.grouped, inputs.names);
if !self.stale && self.query == inputs.query && self.built_with == Some(built_with) {
return; return;
} }
let index_of = |title: &pstr_core::library::Title| { let metadata = inputs.metadata;
if self.stale {
let all: Vec<&Title> = library.titles.iter().collect();
self.franchises = pstr_core::franchise::group(&all, metadata)
.into_iter()
.filter(|franchise| franchise.members.len() > 1)
.flat_map(|franchise| {
let keys: Vec<String> = franchise
.members
.iter()
.map(|&index| all[index].key.clone())
.collect();
keys.clone().into_iter().map(move |key| (key, keys.clone()))
})
.collect();
let index: HashMap<&str, usize> = library
.titles
.iter()
.enumerate()
.map(|(at, title)| (title.key.as_str(), at))
.collect();
self.shelves = pstr_core::shelves::pick(&library.titles, metadata, SHELVES)
.into_iter()
.map(|shelf| {
let indices = shelf
.keys
.iter()
.filter_map(|key| index.get(key.as_str()).copied())
.collect();
(shelf.label, indices)
})
.collect();
}
let titles: Vec<&Title> = pstr_core::browse::search(library, metadata, inputs.query)
.into_iter()
.filter(|title| self.filter.admits(title))
.collect();
let tiles = pstr_core::browse::arrange(
&titles,
pstr_core::browse::Order {
sort: self.sort,
grouped: self.grouped,
names: inputs.names,
metadata,
added: inputs.added,
},
);
let index_of = |title: &Title| {
library library
.titles .titles
.iter() .iter()
.position(|candidate| std::ptr::eq(candidate, title)) .position(|candidate| std::ptr::eq(candidate, title))
}; };
let mut matches: Vec<&pstr_core::library::Title> = library // Indices into what was arranged, made indices into the library.
.search(query) let entries: Vec<(usize, usize)> = tiles
.into_iter() .iter()
.filter(|title| self.filter.admits(title)) .filter_map(|tile| Some((index_of(titles[tile.lead])?, tile.members.len())))
.collect(); .collect();
match self.sort {
Sort::Name => {} self.folded = entries
// Stable, so titles never played keep their alphabetical order .iter()
// after the ones that have been. .filter(|(_, members)| *members > 1)
Sort::Recent => matches.sort_by_key(|title| std::cmp::Reverse(title.last_played())), .map(|(lead, members)| (*lead, members - 1))
Sort::Year => matches.sort_by_key(|title| std::cmp::Reverse(title.year.unwrap_or(0))), .collect();
} self.matches = entries.into_iter().map(|(lead, _)| lead).collect();
self.matches = matches.into_iter().filter_map(index_of).collect(); self.built_with = Some(built_with);
self.built_with = (self.filter, self.sort);
if self.stale { if self.stale {
self.history = ui::history::entries(library); self.history = ui::history::entries(library);
self.resumable = library self.resumable = library
@@ -395,7 +499,7 @@ impl LibraryView {
.filter_map(index_of) .filter_map(index_of)
.collect(); .collect();
} }
self.query = query.to_owned(); self.query = inputs.query.to_owned();
self.stale = false; self.stale = false;
} }
} }
@@ -428,6 +532,8 @@ pub struct App {
pub posters: ImageCache, pub posters: ImageCache,
/// What the providers have said, keyed by title key. /// What the providers have said, keyed by title key.
pub metadata: HashMap<String, MetadataRecord>, pub metadata: HashMap<String, MetadataRecord>,
/// When each title last gained a file, by key — for "Recently added".
pub added: HashMap<String, i64>,
/// What they said about the episodes under those titles. /// What they said about the episodes under those titles.
pub episodes: HashMap<String, EpisodeGuide>, pub episodes: HashMap<String, EpisodeGuide>,
pub settings: MetadataConfig, pub settings: MetadataConfig,
@@ -449,6 +555,8 @@ pub struct App {
/// library means "not loaded", not "nothing in it". /// library means "not loaded", not "nothing in it".
pub loaded: bool, pub loaded: bool,
pub form: ShareForm, pub form: ShareForm,
/// The Proton account section of the shares page.
pub account: ui::account::AccountPanel,
pub playback: Option<Playback>, pub playback: Option<Playback>,
/// Whether the player page's controls are showing, and why. /// Whether the player page's controls are showing, and why.
pub overlay: ui::player::Overlay, pub overlay: ui::player::Overlay,
@@ -517,6 +625,7 @@ impl App {
// that was crawled yesterday is on screen before the shares open. // that was crawled yesterday is on screen before the shares open.
engine.load_shares(); engine.load_shares();
engine.load_library(); engine.load_library();
engine.forget_orphaned_shares();
engine.load_metadata(); engine.load_metadata();
engine.connect(); engine.connect();
let settings = engine.metadata_config(); let settings = engine.metadata_config();
@@ -531,6 +640,7 @@ impl App {
thumbs: ImageCache::default(), thumbs: ImageCache::default(),
posters: ImageCache::default(), posters: ImageCache::default(),
metadata: HashMap::new(), metadata: HashMap::new(),
added: HashMap::new(),
episodes: HashMap::new(), episodes: HashMap::new(),
settings, settings,
api_key: String::new(), api_key: String::new(),
@@ -543,6 +653,7 @@ impl App {
view: LibraryView::default(), view: LibraryView::default(),
loaded: false, loaded: false,
form: ShareForm::default(), form: ShareForm::default(),
account: ui::account::AccountPanel::default(),
playback: None, playback: None,
overlay: ui::player::Overlay::default(), overlay: ui::player::Overlay::default(),
up_next: UpNext::default(), up_next: UpNext::default(),
@@ -612,8 +723,9 @@ impl App {
self.connecting = false; self.connecting = false;
self.note(ctx, format!("could not open the shares: {error}"), true); self.note(ctx, format!("could not open the shares: {error}"), true);
} }
Event::LibraryLoaded(library) => { Event::LibraryLoaded(library, added) => {
self.library = library; self.library = library;
self.added = added;
self.loaded = true; self.loaded = true;
self.view.stale = true; self.view.stale = true;
} }
@@ -678,8 +790,14 @@ impl App {
self.posters.forget(&key); self.posters.forget(&key);
} }
self.metadata = records; self.metadata = records;
// Names, franchises and shelves all come from it.
self.view.stale = true;
} }
Event::EpisodeMetadata(episodes) => self.episodes = episodes, Event::EpisodeMetadata(episodes) => self.episodes = episodes,
Event::Appearance(appearance) => {
theme::apply(ctx, appearance);
self.view.stale = true;
}
Event::MetadataConfig(config) => { Event::MetadataConfig(config) => {
self.settings = config; self.settings = config;
self.posters.clear(); self.posters.clear();
@@ -702,6 +820,7 @@ impl App {
} }
self.note(ctx, text, failed > 0); self.note(ctx, text, failed > 0);
} }
Event::MatchStarted => self.matching = true,
Event::MatchFinished => self.matching = false, Event::MatchFinished => self.matching = false,
// Both of these are addressed to a dialog, and the viewer may // Both of these are addressed to a dialog, and the viewer may
// have closed it or opened another one while the request was // have closed it or opened another one while the request was
@@ -792,6 +911,9 @@ impl App {
None => Page::Library, None => Page::Library,
}; };
} }
// Where it stopped is the position another device
// wants next, so it goes now rather than on the timer.
self.engine.sync_watch_history();
} }
} }
Event::Watched { Event::Watched {
@@ -815,6 +937,23 @@ impl App {
} }
} }
Event::OfflineFiles(files) => self.offline_files = files, Event::OfflineFiles(files) => self.offline_files = files,
Event::Account(status) => {
if let AccountStatus::SignedIn(username) = &status
&& self.account.busy
{
self.note(ctx, format!("signed in as {username}"), false);
}
self.account.set_status(status);
}
Event::SignInFailed(error) => self.account.refused(error),
Event::Synced(report) => {
self.account.sync_error = None;
self.account.synced = Some((ui::account::unix_now(), report));
}
Event::SyncFailed(error) => self.account.sync_error = Some(error),
Event::Places(places) => self.account.browser.places_arrived(places),
Event::Folder { uid, entries } => self.account.browser.folder_arrived(uid, entries),
Event::BrowseFailed(error) => self.account.browser.failed(error),
} }
} }
// Pictures become textures a few per frame; the rest wait for the next. // Pictures become textures a few per frame; the rest wait for the next.
@@ -975,6 +1114,7 @@ impl App {
posters: &mut self.posters, posters: &mut self.posters,
metadata: &self.metadata, metadata: &self.metadata,
episodes: &self.episodes, episodes: &self.episodes,
names: self.engine.appearance().names,
} }
.still(&title.key, episode) .still(&title.key, episode)
}); });
@@ -1220,6 +1360,7 @@ impl App {
self.view.filter = filter; self.view.filter = filter;
self.view.sort = sort; self.view.sort = sort;
} }
Action::SetGrouped(grouped) => self.view.grouped = grouped,
Action::Back => match self.page { Action::Back => match self.page {
Page::Title(_) => self.page = Page::Library, Page::Title(_) => self.page = Page::Library,
// Back out of a search before out of anything else: it is the // Back out of a search before out of anything else: it is the
@@ -1277,6 +1418,20 @@ impl App {
self.form.error = None; self.form.error = None;
self.engine.add_share(name, url, password); self.engine.add_share(name, url, password);
} }
Action::AddAccountFolder { name, uid } => {
self.crawling = true;
self.engine.add_account_folder(name, uid);
}
Action::SignIn { username, password } => self.engine.sign_in(username, password),
Action::SubmitSecondFactor(code) => self.engine.submit_second_factor(code),
Action::SubmitMailboxPassword(password) => {
self.engine.submit_mailbox_password(password)
}
Action::CancelSignIn => self.engine.cancel_sign_in(),
Action::SignOut => self.engine.sign_out(),
Action::SyncNow => self.engine.sync_watch_history(),
Action::BrowsePlaces => self.engine.browse_places(),
Action::BrowseFolder(uid) => self.engine.browse_folder(uid),
Action::RemoveShare(id) => self.confirm_remove_share = Some(id), Action::RemoveShare(id) => self.confirm_remove_share = Some(id),
Action::ForgetShare(id) => { Action::ForgetShare(id) => {
self.confirm_remove_share = None; self.confirm_remove_share = None;
@@ -1652,6 +1807,7 @@ impl eframe::App for App {
posters, posters,
metadata, metadata,
episodes, episodes,
names: engine.appearance().names,
}, },
open: *episode_drawer, open: *episode_drawer,
}); });
@@ -1750,7 +1906,15 @@ impl eframe::App for App {
}); });
self.search = search; self.search = search;
self.focus_search = false; self.focus_search = false;
self.view.refresh(&self.library, &self.search); self.view.refresh(
&self.library,
ViewInputs {
query: &self.search,
metadata: &self.metadata,
names: self.engine.appearance().names,
added: &self.added,
},
);
let App { let App {
engine, engine,
@@ -1767,6 +1931,7 @@ impl eframe::App for App {
view, view,
loaded, loaded,
form, form,
account,
matcher, matcher,
playback, playback,
opening, opening,
@@ -1836,6 +2001,7 @@ impl eframe::App for App {
posters: &mut *posters, posters: &mut *posters,
metadata, metadata,
episodes, episodes,
names: engine.appearance().names,
}; };
match page { match page {
Page::Library => ui::library::show( Page::Library => ui::library::show(
@@ -1846,6 +2012,7 @@ impl eframe::App for App {
view, view,
loaded: *loaded, loaded: *loaded,
search, search,
tiles: engine.appearance().tiles,
}, },
&mut actions, &mut actions,
), ),
@@ -1854,13 +2021,16 @@ impl eframe::App for App {
&mut art, &mut art,
library, library,
key, key,
view.franchises.get(key.as_str()).map(Vec::as_slice),
ui::title::OfflineView { ui::title::OfflineView {
downloads, downloads,
files: offline_files, files: offline_files,
}, },
&mut actions, &mut actions,
), ),
Page::Shares => ui::shares::show(ui, shares, library, form, &mut actions), Page::Shares => {
ui::shares::show(ui, shares, library, form, account, &mut actions)
}
Page::Settings => { Page::Settings => {
ui::settings::show(ui, settings, prefs, api_key, &mut actions) ui::settings::show(ui, settings, prefs, api_key, &mut actions)
} }
@@ -1883,6 +2053,7 @@ impl eframe::App for App {
posters, posters,
metadata, metadata,
episodes, episodes,
names: engine.appearance().names,
}; };
ui::matcher::show(ctx, open, &mut art, settings.provider, &mut actions); ui::matcher::show(ctx, open, &mut art, settings.provider, &mut actions);
} }
@@ -1953,6 +2124,9 @@ impl eframe::App for App {
self.playback = None; self.playback = None;
self.engine self.engine
.shutdown_downloads(std::time::Duration::from_secs(3)); .shutdown_downloads(std::time::Duration::from_secs(3));
// After the player: its last position is saved on the way out.
self.engine
.sync_before_exit(std::time::Duration::from_secs(3));
} }
} }
@@ -2036,7 +2210,8 @@ fn crawl_label(progress: &std::collections::BTreeMap<String, usize>) -> String {
} }
} }
/// Titles whose tile art is not what it was. /// Texture keys whose picture is not what it was: a title's tile art, its cover
/// and its wide art each.
/// ///
/// Compared by URL: the same URL is the same picture, and anything else — /// Compared by URL: the same URL is the same picture, and anything else —
/// a different one, a new one, or none any more — has to be fetched or /// a different one, a new one, or none any more — has to be fetched or
@@ -2045,19 +2220,27 @@ fn changed_art(
before: &HashMap<String, MetadataRecord>, before: &HashMap<String, MetadataRecord>,
after: &HashMap<String, MetadataRecord>, after: &HashMap<String, MetadataRecord>,
) -> Vec<String> { ) -> Vec<String> {
let url = |records: &HashMap<String, MetadataRecord>, key: &str| { // Every picture a title is drawn with, in the order of the keys below.
records let urls = |records: &HashMap<String, MetadataRecord>, key: &str| {
.get(key) let found = records.get(key).and_then(|record| record.metadata.as_ref());
.and_then(|record| record.metadata.as_ref()) [
.and_then(|metadata| metadata.tile_art()) found
.map(|(url, _)| url.to_owned()) .and_then(|metadata| metadata.tile_art())
.map(|(url, _)| url.to_owned()),
found.and_then(|metadata| metadata.poster_url.clone()),
found.and_then(|metadata| metadata.wide_art().map(str::to_owned)),
]
}; };
let mut keys: Vec<String> = before let mut keys: Vec<String> = Vec::new();
.keys() for key in before.keys().chain(after.keys()) {
.chain(after.keys()) let (old, new) = (urls(before, key), urls(after, key));
.filter(|key| url(before, key) != url(after, key)) let texture_keys = [key.clone(), ui::poster_key(key), ui::wide_key(key)];
.cloned() for ((old, new), texture) in old.iter().zip(&new).zip(texture_keys) {
.collect(); if old != new {
keys.push(texture);
}
}
}
keys.sort_unstable(); keys.sort_unstable();
keys.dedup(); keys.dedup();
keys keys
@@ -2306,6 +2489,7 @@ mod tests {
genres: Vec::new(), genres: Vec::new(),
episodes: None, episodes: None,
url: None, url: None,
details: None,
}); });
( (
key.into(), key.into(),
@@ -2395,9 +2579,20 @@ mod tests {
record("added", Some("w")), record("added", Some("w")),
] ]
.into(); .into();
// The tile and the cover are both drawn from the poster here; the
// wide art has nothing to be.
assert_eq!( assert_eq!(
changed_art(&before, &after), changed_art(&before, &after),
vec!["added", "dropped", "found", "moved"] vec![
"added",
"dropped",
"found",
"moved",
"poster:added",
"poster:dropped",
"poster:found",
"poster:moved"
]
); );
} }
} }
+558 -135
View File
@@ -25,6 +25,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel}; use std::sync::mpsc::{Receiver, Sender, channel};
use parking_lot::Mutex; use parking_lot::Mutex;
use pstr_core::account::{DriveEntry, DrivePlace, PendingSignIn, SignIn};
use pstr_core::appearance::Appearance; use pstr_core::appearance::Appearance;
use pstr_core::catalog::{ use pstr_core::catalog::{
Catalog, CatalogNode, OfflineFile, TitleTrackPrefs, WatchState, build_rows, Catalog, CatalogNode, OfflineFile, TitleTrackPrefs, WatchState, build_rows,
@@ -33,15 +34,19 @@ use pstr_core::config::AppDirs;
use pstr_core::library::{Library, Title, TitleKind}; use pstr_core::library::{Library, Title, TitleKind};
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId}; use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId};
use pstr_core::prefs::PlaybackPrefs; use pstr_core::prefs::PlaybackPrefs;
use pstr_core::profile::ProfileChanges;
use pstr_core::proton_drive_rs::ThumbnailType; use pstr_core::proton_drive_rs::ThumbnailType;
use pstr_core::proton_sdk::api::HumanVerificationCredential;
use pstr_core::proton_sdk::ids::{LinkId, NodeUid, VolumeId}; use pstr_core::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
use pstr_core::{Share, ShareStore, SharedLibrary}; use pstr_core::shares::AccountFolder;
use pstr_core::sync::{SyncReport, WatchSync};
use pstr_core::{Account, AccountStore, KeyringSecretStore, Share, ShareStore, SharedLibrary};
use pstr_meta::MetadataService; use pstr_meta::MetadataService;
use crate::desktop_prefs::{DesktopPrefs, DesktopPrefsFile}; use crate::desktop_prefs::{DesktopPrefs, DesktopPrefsFile};
use pstr_stream::{ use pstr_stream::{
BlockSource, DiskCacheConfig, FileBlocks, LibraryOpener, StreamConfig, StreamSource, BlockSource, COPY_BLOCKS_IN_FLIGHT, DiskCacheConfig, FileBlocks, LibraryOpener, StreamConfig,
VideoStream, StreamSource, VideoStream,
}; };
use tokio::runtime::Runtime; use tokio::runtime::Runtime;
@@ -160,6 +165,11 @@ struct PartialMarker {
/// difference is VRAM held for every title in the library. /// difference is VRAM held for every title in the library.
const THUMBNAIL_MAX_EDGE: u32 = 640; const THUMBNAIL_MAX_EDGE: u32 = 640;
/// The longest edge of a picture drawn across the whole window. 1920 is what
/// a provider's fanart is, so nothing is scaled up and a 4K screen gets the
/// best there is.
pub const WIDE_MAX_EDGE: u32 = 1920;
/// What the background side tells the UI. /// What the background side tells the UI.
pub enum Event { pub enum Event {
/// The share list changed on disk. /// The share list changed on disk.
@@ -175,8 +185,9 @@ pub enum Event {
}, },
/// Shares could not be opened at all. /// Shares could not be opened at all.
ConnectFailed(String), ConnectFailed(String),
/// The catalog was re-read. /// The catalog was re-read: the library, and when each title last
LibraryLoaded(Library), /// gained a file.
LibraryLoaded(Library, HashMap<String, i64>),
/// A crawl finished for one share. /// A crawl finished for one share.
Crawled { Crawled {
share_id: String, share_id: String,
@@ -205,12 +216,16 @@ pub enum Event {
EpisodeMetadata(HashMap<String, EpisodeGuide>), EpisodeMetadata(HashMap<String, EpisodeGuide>),
/// The enrichment settings changed, on disk. /// The enrichment settings changed, on disk.
MetadataConfig(MetadataConfig), MetadataConfig(MetadataConfig),
/// Another device's appearance arrived through sync.
Appearance(Appearance),
/// A matching run finished, with how it went. /// A matching run finished, with how it went.
Matched { Matched {
matched: usize, matched: usize,
unmatched: usize, unmatched: usize,
failed: usize, failed: usize,
}, },
/// A matching run has work to do — one asked for, or one after a crawl.
MatchStarted,
/// Every requested match is done. /// Every requested match is done.
MatchFinished, MatchFinished,
/// What the provider answered a hand search with, unscored and in its own /// What the provider answered a hand search with, unscored and in its own
@@ -263,6 +278,38 @@ pub enum Event {
DownloadProgress(Box<DownloadItem>), DownloadProgress(Box<DownloadItem>),
/// Completed local copies, for badges and online-only actions. /// Completed local copies, for badges and online-only actions.
OfflineFiles(HashSet<DownloadKey>), OfflineFiles(HashSet<DownloadKey>),
/// The Proton account signed in or out, or a sign-in wants more.
Account(AccountStatus),
/// A sign-in step was refused, with why.
SignInFailed(String),
/// Watch history synced with the account's Drive.
Synced(SyncReport),
/// Sync did not run, with why. Not a toast: it is retried on a
/// timer, and an offline evening would be a stack of them.
SyncFailed(String),
/// The account's top-level places, for the Drive browser.
Places(Vec<DrivePlace>),
/// One folder of the account, for the Drive browser.
Folder {
uid: NodeUid,
entries: Vec<DriveEntry>,
},
/// The Drive browser's last request failed.
BrowseFailed(String),
}
/// Where the Proton account stands.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum AccountStatus {
/// The stored session has not been read yet.
#[default]
Unknown,
SignedOut,
SignedIn(String),
/// A sign-in is waiting for a second-factor code.
SecondFactor,
/// A sign-in is waiting for the mailbox password.
MailboxPassword,
} }
/// The share clients and the block layer, once they exist. /// The share clients and the block layer, once they exist.
@@ -329,6 +376,17 @@ pub struct Engine {
desktop_file: Arc<DesktopPrefsFile>, desktop_file: Arc<DesktopPrefsFile>,
/// Bumped to stop the crawls in flight; each watches it from its start. /// Bumped to stop the crawls in flight; each watches it from its start.
crawl_cancel: Arc<tokio::sync::watch::Sender<u64>>, crawl_cancel: Arc<tokio::sync::watch::Sender<u64>>,
accounts: AccountStore,
/// The signed-in Proton account, if any.
account: Arc<Mutex<Option<Account>>>,
/// False until the stored session has been read. Opening the shares waits
/// on it, or a launch would open the account's folders before the account
/// and report every one of them as failing to.
account_settled: Arc<tokio::sync::watch::Sender<bool>>,
/// A sign-in waiting for a code or the mailbox password.
pending_sign_in: Arc<Mutex<Option<PendingSignIn>>>,
/// `None` when this installation's sync identity could not be written.
watch_sync: Option<Arc<WatchSync>>,
events: Sender<Event>, events: Sender<Event>,
ctx: egui::Context, ctx: egui::Context,
} }
@@ -339,10 +397,10 @@ const THUMBNAIL_CONCURRENCY: usize = 6;
/// How many provider lookups may be in flight at once. See [`Engine::lookups`]. /// How many provider lookups may be in flight at once. See [`Engine::lookups`].
const LOOKUP_CONCURRENCY: usize = 2; const LOOKUP_CONCURRENCY: usize = 2;
const DOWNLOAD_CONCURRENCY: usize = 3; const DOWNLOAD_CONCURRENCY: usize = 3;
/// How many blocks of one download are fetched at once. Four is what the
/// stream layer itself allows a single read, for the same reason: enough to /// How often watch history is synced while the app is open, on top of at
/// hide a round trip, few enough that three downloads do not swamp a player. /// launch and whenever a player stops.
const DOWNLOAD_BLOCKS_IN_FLIGHT: usize = 4; const SYNC_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5 * 60);
/// Enrichment as currently configured. /// Enrichment as currently configured.
struct Enrichment { struct Enrichment {
@@ -421,6 +479,11 @@ impl Engine {
Enrichment::build(config.clone()) Enrichment::build(config.clone())
}; };
let watch_sync = WatchSync::open(&dirs)
.inspect_err(|error| tracing::warn!("watch-history sync unavailable: {error}"))
.ok()
.map(Arc::new);
let track_prefs = catalog.all_title_track_prefs().unwrap_or_else(|error| { let track_prefs = catalog.all_title_track_prefs().unwrap_or_else(|error| {
tracing::warn!("read show track preferences: {error}"); tracing::warn!("read show track preferences: {error}");
HashMap::new() HashMap::new()
@@ -443,6 +506,11 @@ impl Engine {
download_permits: Arc::new(tokio::sync::Semaphore::new(DOWNLOAD_CONCURRENCY)), download_permits: Arc::new(tokio::sync::Semaphore::new(DOWNLOAD_CONCURRENCY)),
picture_shown: Arc::new(AtomicBool::new(false)), picture_shown: Arc::new(AtomicBool::new(false)),
crawl_cancel: Arc::new(tokio::sync::watch::channel(0).0), crawl_cancel: Arc::new(tokio::sync::watch::channel(0).0),
accounts: AccountStore::new(Arc::new(KeyringSecretStore)),
account: Arc::new(Mutex::new(None)),
account_settled: Arc::new(tokio::sync::watch::channel(false).0),
pending_sign_in: Arc::new(Mutex::new(None)),
watch_sync,
desktop: Arc::new(Mutex::new(desktop)), desktop: Arc::new(Mutex::new(desktop)),
desktop_file: Arc::new(desktop_file), desktop_file: Arc::new(desktop_file),
events, events,
@@ -454,6 +522,8 @@ impl Engine {
deferred.install_enrichment(Enrichment::build(config), true); deferred.install_enrichment(Enrichment::build(config), true);
}); });
} }
engine.load_account();
engine.sync_periodically();
Ok((engine, receiver)) Ok((engine, receiver))
} }
@@ -575,24 +645,396 @@ impl Engine {
} }
/// Add a share, then open and crawl it — which is what "add" means to /// Add a share, then open and crawl it — which is what "add" means to
/// someone who just pasted a link. /// someone who just pasted a link. A link that will not open is refused
/// before anything is stored, so trying again is not "already configured".
pub fn add_share(&self, name: String, url: String, password: Option<String>) { pub fn add_share(&self, name: String, url: String, password: Option<String>) {
let engine = self.clone(); let engine = self.clone();
self.runtime.spawn(async move { self.runtime.spawn(async move {
let share = match engine.store.add(&name, &url, password.as_deref()) { let share = match engine
.store
.add_verified(&name, &url, password.as_deref())
.await
{
Ok((share, _)) => share,
Err(error) => return engine.emit(Event::ShareRejected(error.to_string())),
};
engine.emit(Event::ShareAdded(share.name.clone()));
engine.load_shares();
// The open connection predates the share, and crawling it through
// that would find it "not open".
engine.connection.lock().take();
engine.connect_and_crawl(Some(share.id)).await;
});
}
/// Add a folder of the signed-in account as a share, then crawl it.
pub fn add_account_folder(&self, name: String, folder: NodeUid) {
let engine = self.clone();
self.runtime.spawn(async move {
let folder = AccountFolder {
volume_id: folder.volume_id.as_str().to_owned(),
link_id: folder.link_id.as_str().to_owned(),
};
let share = match engine.store.add_folder(&name, folder) {
Ok(share) => share, Ok(share) => share,
Err(error) => return engine.emit(Event::ShareRejected(error.to_string())), Err(error) => return engine.emit(Event::ShareRejected(error.to_string())),
}; };
engine.emit(Event::ShareAdded(share.name.clone())); engine.emit(Event::ShareAdded(share.name.clone()));
engine.load_shares(); engine.load_shares();
engine.connection.lock().take();
engine.connect_and_crawl(Some(share.id)).await; engine.connect_and_crawl(Some(share.id)).await;
}); });
} }
// --------------------------------------------------------------- account
/// Read the stored session, off the UI thread — a keyring read can be a
/// D-Bus round trip to a keyring that has to unlock first.
fn load_account(&self) {
let engine = self.clone();
self.runtime.spawn_blocking(move || {
match engine.accounts.resume() {
Ok(Some(account)) => {
let username = account.username().to_owned();
*engine.account.lock() = Some(account);
engine.emit(Event::Account(AccountStatus::SignedIn(username)));
engine.sync_watch_history();
}
Ok(None) => engine.emit(Event::Account(AccountStatus::SignedOut)),
Err(error) => {
engine.emit(Event::Account(AccountStatus::SignedOut));
engine.fail("read the Proton session", error);
}
}
engine.account_settled.send_replace(true);
});
}
/// Start signing in. What happens next arrives as [`Event::Account`] or
/// [`Event::SignInFailed`].
pub fn sign_in(&self, username: String, password: String) {
let engine = self.clone();
self.runtime.spawn(async move {
let mut verification = None;
// Twice at most: once as asked, and once more with a solved
// CAPTCHA if Proton wanted one. A second gate after that is
// Proton refusing the answer, not a puzzle worth repeating.
for _ in 0..2 {
match engine
.accounts
.sign_in(&username, &password, verification.take())
.await
{
Ok(step) => return engine.sign_in_step(step),
Err(pstr_core::Error::HumanVerification(challenge)) => {
let url = challenge.verification_url();
let token = tokio::task::spawn_blocking(move || crate::verify::solve(&url))
.await
.ok()
.flatten();
let Some(token) = token else {
return engine.emit(Event::SignInFailed(
"Proton wants you to confirm you are human before it accepts \
this sign-in. Sign in again to solve the puzzle."
.to_owned(),
));
};
verification = Some(HumanVerificationCredential::captcha(token));
}
Err(error) => return engine.emit(Event::SignInFailed(error.to_string())),
}
}
engine.emit(Event::SignInFailed(
"Proton did not accept the verification. Try again in a while.".to_owned(),
));
});
}
pub fn submit_second_factor(&self, code: String) {
let Some(pending) = self.pending_sign_in.lock().take() else {
return self.emit(Event::Account(AccountStatus::SignedOut));
};
let engine = self.clone();
self.runtime.spawn(async move {
match pending.second_factor(&code).await {
Ok(step) => engine.sign_in_step(step),
Err(error) => {
// The half-made session is spent; the viewer starts over.
engine.emit(Event::Account(AccountStatus::SignedOut));
engine.emit(Event::SignInFailed(error.to_string()));
}
}
});
}
pub fn submit_mailbox_password(&self, password: String) {
let engine = self.clone();
self.runtime.spawn(async move {
let Some(pending) = engine.pending_sign_in.lock().take() else {
return engine.emit(Event::Account(AccountStatus::SignedOut));
};
match pending.mailbox_password(&password).await {
Ok(account) => engine.signed_in(account),
Err(error) => {
// Kept: a mistyped mailbox password can be typed again.
*engine.pending_sign_in.lock() = Some(pending);
engine.emit(Event::SignInFailed(error.to_string()));
}
}
});
}
pub fn cancel_sign_in(&self) {
self.pending_sign_in.lock().take();
self.emit(Event::Account(AccountStatus::SignedOut));
}
fn sign_in_step(&self, step: SignIn) {
match step {
SignIn::Done(account) => self.signed_in(account),
SignIn::SecondFactor(pending) => {
*self.pending_sign_in.lock() = Some(pending);
self.emit(Event::Account(AccountStatus::SecondFactor));
}
SignIn::MailboxPassword(pending) => {
*self.pending_sign_in.lock() = Some(pending);
self.emit(Event::Account(AccountStatus::MailboxPassword));
}
}
}
fn signed_in(&self, account: Account) {
let username = account.username().to_owned();
*self.account.lock() = Some(account);
if let Some(sync) = &self.watch_sync {
sync.reset();
}
// Folders of the account that failed to open before are openable now.
self.connection.lock().take();
self.emit(Event::Account(AccountStatus::SignedIn(username)));
self.connect();
self.sync_watch_history();
}
/// Sign out and forget the stored session. The library keeps everything
/// it has; the account's folders stop opening until the next sign-in.
pub fn sign_out(&self) {
let engine = self.clone();
self.runtime.spawn(async move {
let account = engine.account.lock().take();
if let Err(error) = engine.accounts.sign_out(account.as_ref()).await {
engine.fail("forget the Proton session", error);
}
engine.signed_out();
});
}
fn signed_out(&self) {
self.account.lock().take();
if let Some(sync) = &self.watch_sync {
sync.reset();
}
self.connection.lock().take();
self.emit(Event::Account(AccountStatus::SignedOut));
}
/// Sync watch history now, if an account is signed in.
pub fn sync_watch_history(&self) {
let (Some(account), Some(sync)) = (self.account.lock().clone(), self.watch_sync.clone())
else {
return;
};
let engine = self.clone();
self.runtime.spawn(async move {
match sync.sync(&account, &engine.catalog, &engine.store).await {
Ok(report) => {
engine.take_synced_profile(&report.profile).await;
if report.applied > 0 || report.profile.titles > 0 {
engine.load_library();
}
engine.emit(Event::Synced(report));
}
Err(error) if error.is_signed_out() => {
// The refresh token died — revoked from another device, or
// unused for too long. Signing in again is the only fix,
// and a stored session that can never resume only makes
// every later launch fail the same way.
let _ = engine.accounts.sign_out(None).await;
engine.signed_out();
engine.emit(Event::Error(
"Your Proton session ended. Sign in again to keep syncing watch history."
.to_owned(),
));
}
Err(error) => {
tracing::warn!("sync watch history: {error}");
engine.emit(Event::SyncFailed(error.to_string()));
}
}
});
}
/// Sync once more before the app exits, waiting at most `timeout`.
///
/// Where the viewer stopped is what another device wants next, and the
/// timer would not get to it. Bounded, because quitting must not hang on
/// a slow network; a sync cut short loses nothing, the next launch sends
/// the same table.
pub fn sync_before_exit(&self, timeout: std::time::Duration) {
let (Some(account), Some(sync)) = (self.account.lock().clone(), self.watch_sync.clone())
else {
return;
};
let catalog = Arc::clone(&self.catalog);
let store = Arc::clone(&self.store);
self.runtime.block_on(async move {
match tokio::time::timeout(timeout, sync.sync(&account, &catalog, &store)).await {
Ok(Ok(_)) => {}
Ok(Err(error)) => tracing::warn!("sync watch history on exit: {error}"),
Err(_) => tracing::warn!("sync watch history on exit: timed out"),
}
});
}
/// Act on what sync brought from other devices: reread the settings it
/// rewrote, crawl the shares it added, drop the ones it removed.
async fn take_synced_profile(&self, profile: &ProfileChanges) {
if profile.settings {
match pstr_core::prefs::load(&self.dirs) {
// The volume is this device's and was not synced; the file
// still holds it.
Ok(prefs) => *self.prefs.lock() = prefs,
Err(error) => tracing::warn!("reread the playback preferences: {error}"),
}
match pstr_core::appearance::load(&self.dirs) {
Ok(appearance) => {
*self.appearance.lock() = appearance;
self.emit(Event::Appearance(appearance));
}
Err(error) => tracing::warn!("reread the appearance: {error}"),
}
match pstr_meta::settings::load(&self.dirs) {
Ok(config) => {
let engine = self.clone();
let _ = self
.runtime
.spawn_blocking(move || {
engine.install_enrichment(Enrichment::build(config.clone()), false);
engine.emit(Event::MetadataConfig(config));
})
.await;
}
Err(error) => tracing::warn!("reread the metadata settings: {error}"),
}
}
if profile.titles > 0 {
match self.catalog.lock().all_title_track_prefs() {
Ok(prefs) => *self.track_prefs.lock() = prefs,
Err(error) => tracing::warn!("reread show track preferences: {error}"),
}
self.load_metadata();
}
for id in &profile.shares_removed {
self.forget_share(id.clone(), false).await;
}
if !profile.shares() {
return;
}
self.connection.lock().take();
self.load_shares();
self.load_library();
for id in profile.shares_added.iter().chain(&profile.shares_changed) {
self.connect_and_crawl(Some(id.clone())).await;
}
}
fn sync_periodically(&self) {
let engine = self.clone();
self.runtime.spawn(async move {
let mut ticks = tokio::time::interval(SYNC_INTERVAL);
// The first tick is immediate, and launch already syncs.
ticks.tick().await;
loop {
ticks.tick().await;
engine.sync_watch_history();
}
});
}
/// List the account's top-level places, for the Drive browser.
pub fn browse_places(&self) {
let Some(account) = self.account.lock().clone() else {
return self.emit(Event::BrowseFailed("not signed in".to_owned()));
};
let engine = self.clone();
self.runtime.spawn(async move {
match account.places().await {
Ok(places) => engine.emit(Event::Places(places)),
Err(error) => engine.emit(Event::BrowseFailed(error.to_string())),
}
});
}
/// List one folder of the account, for the Drive browser.
pub fn browse_folder(&self, uid: NodeUid) {
let Some(account) = self.account.lock().clone() else {
return self.emit(Event::BrowseFailed("not signed in".to_owned()));
};
let engine = self.clone();
self.runtime.spawn(async move {
match account.folder_children(&uid).await {
Ok(entries) => engine.emit(Event::Folder { uid, entries }),
Err(error) => engine.emit(Event::BrowseFailed(error.to_string())),
}
});
}
/// Forget a share and its stored secrets. /// Forget a share and its stored secrets.
pub fn remove_share(&self, id: String) { pub fn remove_share(&self, id: String) {
let engine = self.clone(); let engine = self.clone();
self.runtime.spawn(async move { self.runtime.spawn(async move {
engine.forget_share(id, true).await;
engine.connection.lock().take();
engine.load_library();
engine.emit(Event::Status("removed".into()));
});
}
/// Forget every share the catalog holds files for that the share list no
/// longer names — what an older `pstr remove` left behind. Its files would
/// otherwise sit in the library beside whatever replaced it, every episode
/// twice.
pub fn forget_orphaned_shares(&self) {
let engine = self.clone();
self.runtime.spawn(async move {
let configured = match engine.store.list() {
Ok(shares) => shares,
Err(error) => return engine.fail("read the share list", error),
};
let orphans: Vec<String> = match engine.catalog.lock().share_ids() {
Ok(ids) => ids
.into_iter()
.filter(|id| !configured.iter().any(|share| &share.id == id))
.collect(),
Err(error) => return engine.fail("read the catalog", error),
};
if orphans.is_empty() {
return;
}
for id in orphans {
tracing::info!(share = %id, "forgetting the catalog rows of a removed share");
engine.forget_share(id, false).await;
}
engine.load_library();
});
}
/// Stop a share's downloads and delete its files and catalog rows, and,
/// when it is still `configured` — not already removed by sync — its
/// entry and secrets.
async fn forget_share(&self, id: String, configured: bool) {
let engine = self;
{
let keys: Vec<_> = engine let keys: Vec<_> = engine
.downloads .downloads
.lock() .lock()
@@ -635,7 +1077,7 @@ impl Engine {
if let Err(error) = engine.catalog.lock().remove_share(&id) { if let Err(error) = engine.catalog.lock().remove_share(&id) {
return engine.fail("drop that share's catalog rows", error); return engine.fail("drop that share's catalog rows", error);
} }
if let Err(error) = engine.store.remove(&id) { if configured && let Err(error) = engine.store.remove(&id) {
return engine.fail("remove that share", error); return engine.fail("remove that share", error);
} }
{ {
@@ -647,9 +1089,7 @@ impl Engine {
engine.load_shares(); engine.load_shares();
// Its clients are stale now; the next action reopens what is left. // Its clients are stale now; the next action reopens what is left.
engine.connection.lock().take(); engine.connection.lock().take();
engine.load_library(); }
engine.emit(Event::Status("removed".into()));
});
} }
// ------------------------------------------------------------ connection // ------------------------------------------------------------ connection
@@ -690,7 +1130,11 @@ impl Engine {
return Some(opened); return Some(opened);
} }
let (library, failures) = match SharedLibrary::open_all(&self.store).await { let mut settled = self.account_settled.subscribe();
let _ = settled.wait_for(|settled| *settled).await;
let account = self.account.lock().clone();
let (library, failures) = match SharedLibrary::open_all(&self.store, account.as_ref()).await
{
Ok(opened) => opened, Ok(opened) => opened,
Err(error) => { Err(error) => {
self.emit(Event::ConnectFailed(error.to_string())); self.emit(Event::ConnectFailed(error.to_string()));
@@ -739,15 +1183,16 @@ impl Engine {
self.runtime.spawn_blocking(move || { self.runtime.spawn_blocking(move || {
let result = { let result = {
let catalog = engine.catalog.lock(); let catalog = engine.catalog.lock();
catalog catalog.all_files().and_then(|files| {
.all_files() Ok((files, catalog.all_watch_states()?, catalog.first_seen()?))
.and_then(|files| Ok((files, catalog.all_watch_states()?))) })
}; };
match result { match result {
Ok((files, watch)) => { Ok((files, watch, first_seen)) => {
let library = Library::build(files, &watch); let library = Library::build(files, &watch);
let targets = download_targets(&library); let targets = download_targets(&library);
engine.emit(Event::LibraryLoaded(library)); let added = library.added_at(&first_seen);
engine.emit(Event::LibraryLoaded(library, added));
engine.refresh_offline(Some(targets)); engine.refresh_offline(Some(targets));
} }
Err(error) => engine.fail("read the catalog", error), Err(error) => engine.fail("read the catalog", error),
@@ -857,6 +1302,32 @@ impl Engine {
) )
.await; .await;
self.emit(Event::CrawlFinished); self.emit(Event::CrawlFinished);
self.match_after_crawl().await;
}
/// Look up whatever the crawl brought that has no answer yet — a new show,
/// or a new episode the stored guide cannot name. Silent when there is
/// nothing to ask, which is almost every time: `plan` makes no requests for
/// a library that is already matched.
async fn match_after_crawl(&self) {
if !self.metadata_config().enabled {
return;
}
let titles = {
let catalog = self.catalog.lock();
match catalog
.all_files()
.and_then(|files| Ok(Library::build(files, &catalog.all_watch_states()?)))
{
Ok(library) => library.titles,
Err(error) => return self.fail("read the catalog", error),
}
};
if titles.is_empty() {
return;
}
self.run_match(titles, false, true).await;
self.emit(Event::MatchFinished);
} }
/// Stop every crawl still listing. Shares already past their listing /// Stop every crawl still listing. Shares already past their listing
@@ -1548,8 +2019,8 @@ impl Engine {
return Ok(DownloadEnd::Cancelled); return Ok(DownloadEnd::Cancelled);
} }
let size = block_sizes[block_index]; let size = block_sizes[block_index];
let pending = let pending = fetches
fetches.get_or_insert_with(|| fetch_in_order(&stream, &block_sizes, block_index)); .get_or_insert_with(|| stream.blocks_in_order(block_index, COPY_BLOCKS_IN_FLIGHT));
let Some(bytes) = pending.try_next().await? else { let Some(bytes) = pending.try_next().await? else {
anyhow::bail!("block {block_index} never arrived"); anyhow::bail!("block {block_index} never arrived");
}; };
@@ -1892,58 +2363,48 @@ impl Engine {
pub fn match_titles(&self, titles: Vec<Title>, force: bool) { pub fn match_titles(&self, titles: Vec<Title>, force: bool) {
let engine = self.clone(); let engine = self.clone();
self.runtime.spawn(async move { self.runtime.spawn(async move {
engine.run_match(titles, force).await; engine.run_match(titles, force, false).await;
engine.emit(Event::MatchFinished); engine.emit(Event::MatchFinished);
}); });
} }
async fn run_match(&self, titles: Vec<Title>, force: bool) { /// `automatic` is a run nobody asked for, after a crawl: it says nothing
/// when there is nothing to do, or no provider to do it with.
async fn run_match(&self, titles: Vec<Title>, force: bool, automatic: bool) {
let Some(service) = self.metadata_service().await else { let Some(service) = self.metadata_service().await else {
if automatic {
return;
}
return self.emit(Event::Error(self.no_service_reason())); return self.emit(Event::Error(self.no_service_reason()));
}; };
let provider = service.provider(); let provider = service.provider();
let (stored, listed) = { let (stored, enriched) = {
let catalog = self.catalog.lock(); let catalog = self.catalog.lock();
match catalog match catalog
.all_metadata() .all_metadata()
.and_then(|stored| Ok((stored, catalog.episode_metadata_ages()?))) .and_then(|stored| Ok((stored, catalog.enrichment_ages()?)))
{ {
Ok(pair) => pair, Ok(pair) => pair,
Err(error) => return self.fail("read stored metadata", error), Err(error) => return self.fail("read stored metadata", error),
} }
}; };
// Two kinds of work, and the difference is a request saved: a title let pending = pstr_meta::service::plan(
// that already has a good match but no episode list needs the episode titles,
// request only — searching for it again would ask the provider &stored,
// something it has already answered. &enriched,
let mut pending: Vec<Work> = Vec::new(); provider,
for title in titles { service.has_details(),
let record = stored.get(&title.key); force,
// A hand-picked entry is never searched for again, not even by );
// "match again" — that button means "the automatic answers are
// wrong", and re-deciding the one title the viewer already fixed by
// hand is the opposite of what they asked for. Its episode list is
// still fetched below if it is missing.
let pinned = record.is_some_and(|record| record.manual && record.provider == provider);
if !pinned && (force || !pstr_meta::service::is_usable(record, provider)) {
pending.push(Work::Match(title));
continue;
}
let has_episodes = listed
.get(&title.key)
.is_some_and(|(asked, _)| *asked == provider);
if let Some(found) = record.and_then(|record| record.metadata.clone())
&& !has_episodes
{
pending.push(Work::Episodes(title, Box::new(found)));
}
}
if pending.is_empty() { if pending.is_empty() {
if automatic {
return;
}
return self.emit(Event::Status("everything is already matched".into())); return self.emit(Event::Status("everything is already matched".into()));
} }
self.emit(Event::MatchStarted);
self.emit(Event::Status(format!( self.emit(Event::Status(format!(
"matching {} titles against {}…", "matching {} titles against {}…",
pending.len(), pending.len(),
@@ -1959,25 +2420,7 @@ impl Engine {
let Ok(_permit) = permits.acquire().await else { let Ok(_permit) = permits.acquire().await else {
return None; return None;
}; };
Some(match work { Some(service.work(work).await)
Work::Match(title) => match service.record(title).await {
Ok(record) => {
// Only for a title that matched: there is no id to
// ask about otherwise.
let episodes = match &record.metadata {
Some(found) => service.title_episodes(title, found).await,
None => Vec::new(),
};
Ok((record.title_key.clone(), Some(record), episodes))
}
Err(error) => Err(error),
},
Work::Episodes(title, found) => Ok((
title.key.clone(),
None,
service.title_episodes(title, found).await,
)),
})
}); });
} }
@@ -1985,8 +2428,8 @@ impl Engine {
let mut episodes_found = 0usize; let mut episodes_found = 0usize;
while let Some(result) = lookups.next().await { while let Some(result) = lookups.next().await {
match result { match result {
Some(Ok((title_key, record, episodes))) => { Some(Ok(outcome)) => {
if let Some(record) = &record { if let Some(record) = &outcome.record {
if record.metadata.is_some() { if record.metadata.is_some() {
matched += 1; matched += 1;
} else { } else {
@@ -1998,16 +2441,16 @@ impl Engine {
tracing::warn!("store metadata for {}: {error}", record.title_key); tracing::warn!("store metadata for {}: {error}", record.title_key);
} }
} }
if !episodes.is_empty() { if let Some(enrichment) = &outcome.enrichment {
episodes_found += episodes.len(); episodes_found += enrichment.episodes.len();
let stored = self.catalog.lock().set_episode_metadata( let stored = self.catalog.lock().set_enrichment(
&title_key, &outcome.title_key,
provider, provider,
now(), now(),
&episodes, enrichment,
); );
if let Err(error) = stored { if let Err(error) = stored {
tracing::warn!("store episodes for {title_key}: {error}"); tracing::warn!("store enrichment for {}: {error}", outcome.title_key);
} }
} }
} }
@@ -2073,21 +2516,23 @@ impl Engine {
return engine.emit(Event::Error(engine.no_service_reason())); return engine.emit(Event::Error(engine.no_service_reason()));
}; };
let name = found.name.clone(); let name = found.name.clone();
let record = service.chosen(title.key.clone(), found.clone()); let outcome = service.choose(&title, found).await;
if let Err(error) = engine.catalog.lock().set_metadata(&record) { if let Some(record) = &outcome.record
&& let Err(error) = engine.catalog.lock().set_metadata(record)
{
return engine.fail("store the match", error); return engine.fail("store the match", error);
} }
// A failure leaves the title un-enriched, and the next match run
let episodes = service.title_episodes(&title, &found).await; // asks again.
if !episodes.is_empty() { if let Some(enrichment) = &outcome.enrichment {
let stored = engine.catalog.lock().set_episode_metadata( let stored = engine.catalog.lock().set_enrichment(
&title.key, &title.key,
service.provider(), service.provider(),
now(), now(),
&episodes, enrichment,
); );
if let Err(error) = stored { if let Err(error) = stored {
tracing::warn!("store episodes for {}: {error}", title.key); tracing::warn!("store enrichment for {}: {error}", title.key);
} }
} }
@@ -2128,15 +2573,26 @@ impl Engine {
/// paths invalidates by itself and the same picture shared by two titles is /// paths invalidates by itself and the same picture shared by two titles is
/// fetched once. /// fetched once.
pub fn request_poster(&self, title_key: String, url: String) { pub fn request_poster(&self, title_key: String, url: String) {
self.request_art(title_key, url, THUMBNAIL_MAX_EDGE);
}
/// [`Engine::request_poster`], for a picture drawn across the window —
/// the library's hero, the title page's band — kept at up to `max_edge`
/// pixels rather than a tile's 640. A 640 px still stretched to 1600 was
/// most of why those looked soft.
pub fn request_art(&self, title_key: String, url: String, max_edge: u32) {
let engine = self.clone(); let engine = self.clone();
let path = self // A size of its own on disk: the tile-sized copy of the same URL is
.dirs // already there under the plain name, scaled down.
.poster_cache() let name = match max_edge {
.join(format!("{}.img", digest(&url))); THUMBNAIL_MAX_EDGE => format!("{}.img", digest(&url)),
edge => format!("{}-{edge}.img", digest(&url)),
};
let path = self.dirs.poster_cache().join(name);
self.runtime.spawn(async move { self.runtime.spawn(async move {
if let Ok(bytes) = tokio::fs::read(&path).await { if let Ok(bytes) = tokio::fs::read(&path).await {
match decode_thumbnail(&bytes) { match decode_scaled(&bytes, max_edge) {
Ok(decoded) => { Ok(decoded) => {
if let Some(smaller) = &decoded.smaller { if let Some(smaller) = &decoded.smaller {
store_image(&path, smaller).await; store_image(&path, smaller).await;
@@ -2163,7 +2619,7 @@ impl Engine {
} }
}; };
match decode_thumbnail(&bytes) { match decode_scaled(&bytes, max_edge) {
Ok(decoded) => { Ok(decoded) => {
store_image(&path, decoded.smaller.as_deref().unwrap_or(&bytes)).await; store_image(&path, decoded.smaller.as_deref().unwrap_or(&bytes)).await;
engine.emit(Event::Poster { engine.emit(Event::Poster {
@@ -2202,15 +2658,6 @@ fn digest(url: &str) -> String {
.collect() .collect()
} }
/// The cache and texture key for a file's poster.
/// One title's worth of work in a match run.
enum Work {
/// Search for it, and take its episodes if it matches.
Match(Title),
/// It is already matched; only the episode list is missing.
Episodes(Title, Box<pstr_core::metadata::TitleMetadata>),
}
/// Unix seconds, for the "when was this asked" columns. /// Unix seconds, for the "when was this asked" columns.
fn now() -> i64 { fn now() -> i64 {
std::time::SystemTime::now() std::time::SystemTime::now()
@@ -2247,35 +2694,6 @@ fn resume_position(existing: u64, block_sizes: &[u64]) -> (usize, u64) {
(block_sizes.len(), offset) (block_sizes.len(), offset)
} }
/// A file's blocks from `first` on, fetched several at a time and yielded in
/// file order — which is what the resume marker's "the length is the
/// boundary" rule needs. One at a time, a download runs at one block per round
/// trip whatever the link can carry.
fn fetch_in_order(
stream: &VideoStream,
block_sizes: &[u64],
first: usize,
) -> futures::stream::BoxStream<'static, pstr_stream::Result<Vec<u8>>> {
use futures::StreamExt as _;
let start: u64 = block_sizes[..first].iter().sum();
let ranges: Vec<(u64, u64)> = block_sizes[first..]
.iter()
.scan(start, |offset, size| {
let range = (*offset, *size);
*offset += size;
Some(range)
})
.collect();
let stream = stream.clone();
futures::stream::iter(ranges)
.map(move |(offset, size)| {
let stream = stream.clone();
async move { stream.read_range(offset, size).await }
})
.buffered(DOWNLOAD_BLOCKS_IN_FLIGHT)
.boxed()
}
async fn wait_until_runnable(command: &mut tokio::sync::watch::Receiver<DownloadCommand>) -> bool { async fn wait_until_runnable(command: &mut tokio::sync::watch::Receiver<DownloadCommand>) -> bool {
loop { loop {
let current = *command.borrow_and_update(); let current = *command.borrow_and_update();
@@ -2348,9 +2766,14 @@ struct Decoded {
/// copy is handed back so the cache holds that instead, and the next launch /// copy is handed back so the cache holds that instead, and the next launch
/// decodes a picture already the size it is drawn at. /// decodes a picture already the size it is drawn at.
fn decode_thumbnail(bytes: &[u8]) -> anyhow::Result<Decoded> { fn decode_thumbnail(bytes: &[u8]) -> anyhow::Result<Decoded> {
decode_scaled(bytes, THUMBNAIL_MAX_EDGE)
}
/// [`decode_thumbnail`], to `max_edge` rather than a tile's size.
fn decode_scaled(bytes: &[u8], max_edge: u32) -> anyhow::Result<Decoded> {
let decoded = image::load_from_memory(bytes)?; let decoded = image::load_from_memory(bytes)?;
let (decoded, smaller) = if decoded.width().max(decoded.height()) > THUMBNAIL_MAX_EDGE { let (decoded, smaller) = if decoded.width().max(decoded.height()) > max_edge {
let scaled = decoded.thumbnail(THUMBNAIL_MAX_EDGE, THUMBNAIL_MAX_EDGE); let scaled = decoded.thumbnail(max_edge, max_edge);
let encoded = encode_for_cache(&scaled); let encoded = encode_for_cache(&scaled);
(scaled, encoded) (scaled, encoded)
} else { } else {
+1
View File
@@ -30,5 +30,6 @@ pub mod pacing;
pub mod playback; pub mod playback;
pub mod theme; pub mod theme;
pub mod ui; pub mod ui;
pub mod verify;
pub mod video; pub mod video;
pub mod window; pub mod window;
+6
View File
@@ -16,6 +16,12 @@ use anyhow::Context;
use pstr_core::config::AppDirs; use pstr_core::config::AppDirs;
fn main() -> anyhow::Result<()> { fn main() -> anyhow::Result<()> {
// Before anything else: a verification window is a child process of the
// app, and must not claim the single instance or open a library.
if let Some(code) = pstr_app::verify::run_if_requested() {
std::process::exit(code);
}
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter( .with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env() tracing_subscriber::EnvFilter::try_from_default_env()
+6
View File
@@ -35,6 +35,12 @@ pub const CARD_WIDTH: f32 = 232.0;
/// Video stills are 16:9. A poster shape would letterbox every one of them. /// Video stills are 16:9. A poster shape would letterbox every one of them.
pub const CARD_ASPECT: f32 = 9.0 / 16.0; pub const CARD_ASPECT: f32 = 9.0 / 16.0;
pub const CARD_GAP: f32 = 14.0; pub const CARD_GAP: f32 = 14.0;
/// The poster grid's minimum width: narrower than a still, because a cover is
/// read by its art rather than by detail, and six across a laptop screen is
/// what makes a wall of covers look like a shelf.
pub const POSTER_WIDTH: f32 = 168.0;
/// Covers are 2:3.
pub const POSTER_ASPECT: f32 = 3.0 / 2.0;
/// The type ramp, named once. /// The type ramp, named once.
/// ///
+605
View File
@@ -0,0 +1,605 @@
//! The Proton account, on the shares page: signing in, watch-history sync, and
//! picking folders of the viewer's own Drive as library shares.
//!
//! Optional and said so: public links need no account, and the page reads the
//! same without one. What signing in adds is spelled out next to the form,
//! because "sign in" alone does not say why an app that streams public links
//! would want a password.
use pstr_core::account::{DriveEntry, DrivePlace, PlaceKind};
use pstr_core::proton_sdk::ids::NodeUid;
use pstr_core::sync::SyncReport;
use crate::app::Action;
use crate::engine::AccountStatus;
use crate::theme;
use crate::ui;
/// What the account section holds between frames.
#[derive(Default)]
pub struct AccountPanel {
pub status: AccountStatus,
pub username: String,
pub password: String,
pub code: String,
pub mailbox: String,
/// A request is out and not answered yet.
pub busy: bool,
/// Why the last sign-in step was refused.
pub error: Option<String>,
/// When watch history last synced, in Unix seconds, and what it did.
pub synced: Option<(i64, SyncReport)>,
/// Why the last sync failed, until one succeeds.
pub sync_error: Option<String>,
pub browser: DriveBrowser,
}
impl AccountPanel {
/// Apply where the account now stands. The typed secrets go as soon as
/// they are not needed: a password has no business sitting in a struct for
/// the rest of the session.
pub fn set_status(&mut self, status: AccountStatus) {
self.busy = false;
match &status {
AccountStatus::SignedIn(_) | AccountStatus::SignedOut => {
self.password.clear();
self.code.clear();
self.mailbox.clear();
if matches!(status, AccountStatus::SignedIn(_)) {
self.error = None;
} else {
self.browser = DriveBrowser::default();
self.synced = None;
self.sync_error = None;
}
}
AccountStatus::SecondFactor => self.error = None,
AccountStatus::MailboxPassword => {
self.code.clear();
self.error = None;
}
AccountStatus::Unknown => {}
}
self.status = status;
}
pub fn refused(&mut self, error: String) {
self.busy = false;
self.error = Some(error);
}
}
/// Where the Drive browser is.
#[derive(Default)]
pub struct DriveBrowser {
pub open: bool,
/// The folders descended through, each by name. Empty means the places.
pub trail: Vec<(String, NodeUid)>,
pub places: Option<Vec<DrivePlace>>,
/// The listing of the folder at the end of the trail, once it arrives.
pub entries: Option<(NodeUid, Vec<DriveEntry>)>,
pub loading: bool,
pub error: Option<String>,
}
impl DriveBrowser {
pub fn places_arrived(&mut self, places: Vec<DrivePlace>) {
self.loading = false;
self.error = None;
self.places = Some(places);
}
/// Take a listing, unless the viewer has already moved on from it.
pub fn folder_arrived(&mut self, uid: NodeUid, entries: Vec<DriveEntry>) {
if self
.trail
.last()
.is_some_and(|(_, current)| *current == uid)
{
self.loading = false;
self.error = None;
self.entries = Some((uid, entries));
}
}
pub fn failed(&mut self, error: String) {
self.loading = false;
self.error = Some(error);
}
fn enter(&mut self, name: String, uid: NodeUid, actions: &mut Vec<Action>) {
self.trail.push((name, uid.clone()));
self.entries = None;
self.loading = true;
self.error = None;
actions.push(Action::BrowseFolder(uid));
}
fn up(&mut self, actions: &mut Vec<Action>) {
self.trail.pop();
self.entries = None;
self.error = None;
match self.trail.last() {
Some((_, uid)) => {
self.loading = true;
actions.push(Action::BrowseFolder(uid.clone()));
}
None => self.loading = self.places.is_none(),
}
}
}
/// The account card: a sign-in form, or who is signed in and how sync is doing.
pub fn section(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Action>) {
ui::section(ui, "Proton account");
ui.label(ui::muted(
"Optional. Signed in, your shares, settings and watch history are kept in your own \
Drive, so every device shows the same library and resumes where another left off, \
and folders of your Drive — your files, other devices, what was shared with you — \
can be added to the library.",
));
ui.add_space(theme::space::L);
card(ui, |ui| match panel.status.clone() {
AccountStatus::Unknown => {
ui.horizontal(|ui| {
ui.add(egui::Spinner::new().size(14.0));
ui.label(ui::muted("reading the stored session…"));
});
}
AccountStatus::SignedOut => sign_in_form(ui, panel, actions),
AccountStatus::SecondFactor => code_form(ui, panel, actions),
AccountStatus::MailboxPassword => mailbox_form(ui, panel, actions),
AccountStatus::SignedIn(username) => signed_in(ui, panel, &username, actions),
});
}
fn card(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) {
egui::Frame::new()
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(theme::radius::MD))
.inner_margin(egui::Margin::same(14))
.show(ui, |ui| {
ui.set_width(ui.available_width().min(640.0));
add(ui);
});
}
fn sign_in_form(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Action>) {
ui.label(ui::muted("Address"));
ui.add(
egui::TextEdit::singleline(&mut panel.username)
.hint_text("you@proton.me")
.desired_width(f32::INFINITY),
);
ui.add_space(theme::space::M);
ui.label(ui::muted("Password"));
let field = ui.add(
egui::TextEdit::singleline(&mut panel.password)
.password(true)
.desired_width(f32::INFINITY),
);
ui.add_space(theme::space::L);
error_line(ui, panel);
let ready = !panel.username.trim().is_empty() && !panel.password.is_empty() && !panel.busy;
let submitted = field.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter));
ui.horizontal(|ui| {
let clicked = ui
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Sign in"))
.inner
.clicked();
if ready && (clicked || submitted) {
panel.busy = true;
panel.error = None;
actions.push(Action::SignIn {
username: panel.username.trim().to_owned(),
password: panel.password.clone(),
});
}
busy_line(ui, panel, "signing in…");
});
}
fn code_form(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Action>) {
ui.label(ui::muted("Two-factor code from your authenticator app"));
let field = ui.add(
egui::TextEdit::singleline(&mut panel.code)
.hint_text("123456")
.desired_width(160.0),
);
field.request_focus();
ui.add_space(theme::space::S);
// Security keys need WebAuthn, which the sign-in here does not speak. An
// account with nothing else is refused before this form (`AccountStore`).
ui.label(ui::muted(
"Security keys are not supported here — use the code from your authenticator app.",
));
ui.add_space(theme::space::L);
error_line(ui, panel);
let ready = !panel.code.trim().is_empty() && !panel.busy;
let submitted = field.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter));
ui.horizontal(|ui| {
let clicked = ui
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Continue"))
.inner
.clicked();
if ready && (clicked || submitted) {
panel.busy = true;
actions.push(Action::SubmitSecondFactor(panel.code.trim().to_owned()));
}
if ui.button("Cancel").clicked() {
actions.push(Action::CancelSignIn);
}
busy_line(ui, panel, "checking…");
});
}
fn mailbox_form(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Action>) {
ui.label(ui::muted(
"This account has a separate mailbox password. It unlocks your files, and is kept \
in your system keyring.",
));
ui.add_space(theme::space::S);
let field = ui.add(
egui::TextEdit::singleline(&mut panel.mailbox)
.password(true)
.hint_text("Mailbox password")
.desired_width(f32::INFINITY),
);
ui.add_space(theme::space::L);
error_line(ui, panel);
let ready = !panel.mailbox.is_empty() && !panel.busy;
let submitted = field.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter));
ui.horizontal(|ui| {
let clicked = ui
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Unlock"))
.inner
.clicked();
if ready && (clicked || submitted) {
panel.busy = true;
actions.push(Action::SubmitMailboxPassword(panel.mailbox.clone()));
}
if ui.button("Cancel").clicked() {
actions.push(Action::CancelSignIn);
}
busy_line(ui, panel, "unlocking…");
});
}
fn signed_in(
ui: &mut egui::Ui,
panel: &mut AccountPanel,
username: &str,
actions: &mut Vec<Action>,
) {
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.label(
egui::RichText::new(format!(
"{} {username}",
egui_phosphor::regular::USER_CIRCLE
))
.strong(),
);
let status = match (&panel.sync_error, &panel.synced) {
(Some(error), _) => format!("Did not sync: {error}"),
(None, Some((at, report))) => sync_line(*at, report, unix_now()),
(None, None) => {
"Library, settings and watch history sync through your Drive".to_owned()
}
};
ui.label(ui::muted(status));
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(
egui::Button::new(format!("{} Sign out", egui_phosphor::regular::SIGN_OUT))
.fill(theme::card_hover()),
)
.clicked()
{
actions.push(Action::SignOut);
}
if ui
.button(format!(
"{} Sync now",
egui_phosphor::regular::ARROWS_CLOCKWISE
))
.clicked()
{
actions.push(Action::SyncNow);
}
});
});
}
/// "Synced 3 min ago · 2 positions from other devices".
fn sync_line(at: i64, report: &SyncReport, now: i64) -> String {
let ago = (now - at).max(0);
let when = match ago {
0..60 => "just now".to_owned(),
60..3600 => format!("{} min ago", ago / 60),
_ => format!("{} h ago", ago / 3600),
};
let mut line = format!("Synced {when}");
if report.applied > 0 {
line.push_str(&format!(
" · {} from other devices",
ui::library::plural(report.applied, "position")
));
}
line
}
fn error_line(ui: &mut egui::Ui, panel: &AccountPanel) {
if let Some(error) = &panel.error {
ui.label(theme::Role::Caption.rich(error).color(theme::danger()));
ui.add_space(theme::space::S);
}
}
fn busy_line(ui: &mut egui::Ui, panel: &AccountPanel, text: &str) {
if panel.busy {
ui.add(egui::Spinner::new().size(14.0));
ui.label(ui::muted(text));
}
}
/// Browse the account's Drive and add a folder of it to the library.
pub fn browser(
ui: &mut egui::Ui,
panel: &mut AccountPanel,
shares: &[pstr_core::Share],
actions: &mut Vec<Action>,
) {
if !matches!(panel.status, AccountStatus::SignedIn(_)) {
return;
}
ui.add_space(theme::space::XXL);
ui::section(ui, "Add from your Drive");
let browser = &mut panel.browser;
if !browser.open {
ui.label(ui::muted(
"A folder of your own Drive becomes a share like any link: crawled, browsable and \
streamed through your account.",
));
ui.add_space(theme::space::L);
if ui
.button(format!(
"{} Browse your Drive",
egui_phosphor::regular::FOLDER
))
.clicked()
{
browser.open = true;
if browser.places.is_none() {
browser.loading = true;
actions.push(Action::BrowsePlaces);
}
}
return;
}
card(ui, |ui| {
ui.horizontal(|ui| {
if !browser.trail.is_empty()
&& ui
.button(egui_phosphor::regular::ARROW_LEFT)
.on_hover_text("Up a folder")
.clicked()
{
browser.up(actions);
}
let mut crumbs = vec!["Drive".to_owned()];
crumbs.extend(browser.trail.iter().map(|(name, _)| name.clone()));
ui.label(
egui::RichText::new(
crumbs.join(&format!(" {} ", egui_phosphor::regular::CARET_RIGHT)),
)
.strong(),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Close").clicked() {
browser.open = false;
}
});
});
ui.add_space(theme::space::M);
if let Some(error) = &browser.error {
ui.label(theme::Role::Caption.rich(error).color(theme::danger()));
ui.add_space(theme::space::S);
}
if browser.loading {
ui.horizontal(|ui| {
ui.add(egui::Spinner::new().size(14.0));
ui.label(ui::muted("listing…"));
});
return;
}
let mut entered = None;
egui::ScrollArea::vertical()
.max_height(320.0)
.auto_shrink([false, true])
.show(ui, |ui| match browser.trail.last() {
None => {
for place in browser.places.iter().flatten() {
let icon = match place.kind {
PlaceKind::MyFiles => egui_phosphor::regular::HARD_DRIVES,
PlaceKind::Device => egui_phosphor::regular::DESKTOP,
PlaceKind::SharedWithMe => egui_phosphor::regular::USERS,
};
if row(ui, icon, &place.name, None, true).clicked() {
entered = Some((place.name.clone(), place.uid.clone()));
}
}
}
Some(_) => {
let entries = browser
.entries
.as_ref()
.map(|(_, entries)| entries.as_slice());
if entries.is_some_and(<[DriveEntry]>::is_empty) {
ui.label(ui::muted("This folder is empty."));
}
for entry in entries.unwrap_or_default() {
let (icon, detail) = if entry.is_folder {
(egui_phosphor::regular::FOLDER, None)
} else if entry
.media_type
.as_deref()
.is_some_and(|media| media.starts_with("video/"))
{
(
egui_phosphor::regular::FILE_VIDEO,
entry.size.map(format_size),
)
} else {
(egui_phosphor::regular::FILE, entry.size.map(format_size))
};
if row(ui, icon, &entry.name, detail.as_deref(), entry.is_folder).clicked()
&& entry.is_folder
{
entered = Some((entry.name.clone(), entry.uid.clone()));
}
}
}
});
if let Some((name, uid)) = entered {
browser.enter(name, uid, actions);
}
if let Some((name, uid)) = browser.trail.last() {
ui.add_space(theme::space::L);
let added = shares.iter().any(|share| {
share
.folder
.as_ref()
.is_some_and(|folder| folder.uid() == *uid)
});
ui.horizontal(|ui| {
if added {
ui.label(ui::muted("This folder is already in the library."));
} else if ui::accent_button(ui, "Add this folder").clicked() {
actions.push(Action::AddAccountFolder {
name: name.clone(),
uid: uid.clone(),
});
browser.open = false;
}
});
}
});
}
/// One line of the browser. Only folders and places are clickable.
fn row(
ui: &mut egui::Ui,
icon: &str,
name: &str,
detail: Option<&str>,
clickable: bool,
) -> egui::Response {
let mut text = egui::text::LayoutJob::default();
let body = ui.style().text_styles[&egui::TextStyle::Body].clone();
text.append(
&format!("{icon} {name}"),
0.0,
egui::TextFormat::simple(
body.clone(),
if clickable {
theme::text()
} else {
theme::muted()
},
),
);
if let Some(detail) = detail {
text.append(
&format!(" {detail}"),
0.0,
egui::TextFormat::simple(body, theme::muted()),
);
}
let sense = if clickable {
egui::Sense::click()
} else {
egui::Sense::hover()
};
let response = ui.add(egui::Label::new(text).sense(sense).truncate());
if clickable && response.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
response
}
fn format_size(bytes: i64) -> String {
let bytes = bytes.max(0) as f64;
const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
const MIB: f64 = 1024.0 * 1024.0;
if bytes >= GIB {
format!("{:.1} GiB", bytes / GIB)
} else {
format!("{:.0} MiB", bytes / MIB)
}
}
pub(crate) fn unix_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|since| since.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_sync_line_says_how_long_ago_and_what_came_in() {
let quiet = SyncReport::default();
assert_eq!(sync_line(100, &quiet, 130), "Synced just now");
assert_eq!(sync_line(100, &quiet, 400), "Synced 5 min ago");
let busy = SyncReport {
applied: 2,
..SyncReport::default()
};
assert_eq!(
sync_line(0, &busy, 7200),
"Synced 2 h ago · 2 positions from other devices"
);
}
#[test]
fn signing_out_forgets_what_was_typed_and_browsed() {
let mut panel = AccountPanel {
password: "secret".into(),
mailbox: "also secret".into(),
..AccountPanel::default()
};
panel.browser.open = true;
panel.set_status(AccountStatus::SignedOut);
assert!(panel.password.is_empty() && panel.mailbox.is_empty());
assert!(!panel.browser.open);
}
#[test]
fn a_listing_for_a_folder_already_left_is_dropped() {
let uid = |link: &str| {
NodeUid::new(
pstr_core::proton_sdk::ids::VolumeId::new("v".to_owned()),
pstr_core::proton_sdk::ids::LinkId::new(link.to_owned()),
)
};
let mut browser = DriveBrowser {
trail: vec![("b".into(), uid("b"))],
loading: true,
..DriveBrowser::default()
};
browser.folder_arrived(uid("a"), Vec::new());
assert!(browser.loading && browser.entries.is_none());
browser.folder_arrived(uid("b"), Vec::new());
assert!(!browser.loading && browser.entries.is_some());
}
}
+129 -30
View File
@@ -1,5 +1,6 @@
//! The library page: what to watch, as a wall of stills. //! The library page: what to watch, as a wall of stills.
use pstr_core::appearance::Tiles;
use pstr_core::library::{Library, Title, TitleKind}; use pstr_core::library::{Library, Title, TitleKind};
use crate::app::{Action, Filter, LibraryView, Page, Sort}; use crate::app::{Action, Filter, LibraryView, Page, Sort};
@@ -16,6 +17,8 @@ pub struct Shelves<'a> {
/// Whether the catalog has been read yet. /// Whether the catalog has been read yet.
pub loaded: bool, pub loaded: bool,
pub search: &'a str, pub search: &'a str,
/// The grid's shape.
pub tiles: Tiles,
} }
pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions: &mut Vec<Action>) { pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions: &mut Vec<Action>) {
@@ -24,6 +27,7 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
view, view,
loaded, loaded,
search, search,
tiles,
} = shelves; } = shelves;
if !loaded { if !loaded {
return placeholder(ui); return placeholder(ui);
@@ -55,6 +59,16 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
continue_row(ui, art, &resumable, actions); continue_row(ui, art, &resumable, actions);
ui.add_space(theme::space::XL); ui.add_space(theme::space::XL);
} }
// Only over the whole library: under a filter, a shelf of
// titles the grid below is hiding reads as the filter not
// working.
if view.filter == Filter::All {
for (label, indices) in &view.shelves {
ui::section(ui, label);
shelf_row(ui, art, label, &pick(indices), tiles, actions);
ui.add_space(theme::space::XL);
}
}
} }
ui.horizontal(|ui| { ui.horizontal(|ui| {
@@ -86,7 +100,7 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
return; return;
} }
grid(ui, art, &matches, actions); grid(ui, art, &matches, view, tiles, actions);
}); });
} }
@@ -121,7 +135,7 @@ fn featured<'a>(
metadata metadata
.get(&title.key) .get(&title.key)
.and_then(|record| record.metadata.as_ref()) .and_then(|record| record.metadata.as_ref())
.is_some_and(|found| found.backdrop_url.is_some()) .is_some_and(|found| found.wide_art().is_some())
}) })
.collect(); .collect();
if pictured.is_empty() { if pictured.is_empty() {
@@ -144,7 +158,7 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
return; return;
} }
let radius = egui::CornerRadius::same(theme::radius::LG); let radius = egui::CornerRadius::same(theme::radius::LG);
let picture = art.of(title); let picture = art.wide(title);
let found = art let found = art
.metadata .metadata
.get(&title.key) .get(&title.key)
@@ -252,7 +266,7 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
ui.add( ui.add(
egui::Label::new( egui::Label::new(
theme::Role::Display theme::Role::Display
.rich(&title.name) .rich(art.name(title))
.strong() .strong()
.color(theme::text()), .color(theme::text()),
) )
@@ -266,24 +280,20 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
} }
} }
/// The filter pills and the order, at the right of the grid's heading. /// The filter pills, the order and the grouping, at the right of the grid's
/// heading.
fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec<Action>) { fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec<Action>) {
// Right to left: the order first, so it ends up last. // Right to left: the order first, so it ends up last.
let sort = egui::ComboBox::from_id_salt("library-sort") let sort = egui::ComboBox::from_id_salt("library-sort")
.selected_text(match view.sort { .selected_text(view.sort.label())
Sort::Name => "A – Z",
Sort::Recent => "Recently watched",
Sort::Year => "Newest",
})
.width(150.0) .width(150.0)
.show_ui(ui, |ui| { .show_ui(ui, |ui| {
let mut picked = None; let mut picked = None;
for (sort, label) in [ for sort in Sort::ALL {
(Sort::Name, "A – Z"), if ui
(Sort::Recent, "Recently watched"), .selectable_label(view.sort == sort, sort.label())
(Sort::Year, "Newest"), .clicked()
] { {
if ui.selectable_label(view.sort == sort, label).clicked() {
picked = Some(sort); picked = Some(sort);
} }
} }
@@ -292,6 +302,19 @@ fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec<Actio
if let Some(Some(sort)) = sort.inner { if let Some(Some(sort)) = sort.inner {
actions.push(Action::SetShelf(view.filter, sort)); actions.push(Action::SetShelf(view.filter, sort));
} }
ui.add_space(theme::space::S);
let mut grouped = view.grouped;
if ui
.toggle_value(&mut grouped, egui_phosphor::regular::STACK)
.on_hover_text(if view.grouped {
"One tile per franchise. Click to show every title"
} else {
"Every title. Click to fold each franchise into one tile"
})
.changed()
{
actions.push(Action::SetGrouped(grouped));
}
ui.add_space(theme::space::L); ui.add_space(theme::space::L);
if let Some(filter) = ui::widgets::segmented( if let Some(filter) = ui::widgets::segmented(
ui, ui,
@@ -308,6 +331,57 @@ fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec<Actio
} }
} }
/// One shelf: titles that share something, scrolling sideways, in the grid's
/// own shape.
fn shelf_row(
ui: &mut egui::Ui,
art: &mut Art<'_>,
label: &str,
titles: &[&Title],
tiles: Tiles,
actions: &mut Vec<Action>,
) {
let (width, aspect) = tile_shape(tiles);
egui::ScrollArea::horizontal()
.id_salt(("shelf", label))
.auto_shrink([false, true])
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
for title in titles {
let picture = match tiles {
Tiles::Posters => art.poster(title),
Tiles::Stills => art.of(title),
};
let response = ui::card(
ui,
Card {
art: picture,
name: art.name(title),
subtitle: subtitle(title),
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
badge: None,
width,
aspect,
},
);
tile_menu(&response, title, false, actions);
if response.clicked() {
actions.push(Action::Goto(Page::Title(title.key.clone())));
}
}
});
});
}
/// The minimum width and the picture's shape of a tile.
fn tile_shape(tiles: Tiles) -> (f32, f32) {
match tiles {
Tiles::Posters => (theme::POSTER_WIDTH, theme::POSTER_ASPECT),
Tiles::Stills => (theme::CARD_WIDTH, theme::CARD_ASPECT),
}
}
/// The top shelf: one card per part-watched title, scrolling sideways. /// The top shelf: one card per part-watched title, scrolling sideways.
fn continue_row( fn continue_row(
ui: &mut egui::Ui, ui: &mut egui::Ui,
@@ -337,17 +411,20 @@ fn continue_row(
}) })
.unwrap_or_else(|| episode.label()); .unwrap_or_else(|| episode.label());
let picture = art.of(title);
let response = ui::card( let response = ui::card(
ui, ui,
Card { Card {
art: art.of(title), art: picture,
name: &title.name, name: art.name(title),
subtitle: remaining, subtitle: remaining,
progress: episode.progress().map(|value| value as f32), progress: episode.progress().map(|value| value as f32),
badge: episode.numbering(), badge: episode.numbering(),
// A shelf that scrolls sideways has no right edge // A shelf that scrolls sideways has no right edge
// to reach, so nothing to flex to. // to reach, so nothing to flex to. Always a
// still: it is a frame from where they stopped.
width: theme::CARD_WIDTH, width: theme::CARD_WIDTH,
aspect: theme::CARD_ASPECT,
}, },
); );
tile_menu(&response, title, true, actions); tile_menu(&response, title, true, actions);
@@ -361,10 +438,18 @@ fn continue_row(
} }
/// Every title, wrapped to the window. /// Every title, wrapped to the window.
fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut Vec<Action>) { fn grid(
let grid = ui::columns(ui.available_width()); ui: &mut egui::Ui,
let row_height = ui::card_height(grid.width); art: &mut Art<'_>,
for row in titles.chunks(grid.columns) { titles: &[&Title],
view: &LibraryView,
tiles: Tiles,
actions: &mut Vec<Action>,
) {
let (minimum, aspect) = tile_shape(tiles);
let grid = ui::columns_of(ui.available_width(), minimum);
let row_height = ui::card_height(grid.width, aspect);
for (row, chunk) in titles.chunks(grid.columns).enumerate() {
// A row scrolled out of view is only its height. Laying it out anyway // A row scrolled out of view is only its height. Laying it out anyway
// costs a subtitle and an artwork lookup per card per frame, and while // costs a subtitle and an artwork lookup per card per frame, and while
// a film plays under the transport bar a frame is every frame. // a film plays under the transport bar a frame is every frame.
@@ -385,18 +470,32 @@ fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut V
// the difference times the column count is a visible drift towards // the difference times the column count is a visible drift towards
// the left edge. // the left edge.
ui.spacing_mut().item_spacing.x = theme::CARD_GAP; ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
for title in row { for (column, title) in chunk.iter().enumerate() {
let picture = match tiles {
Tiles::Posters => art.poster(title),
Tiles::Stills => art.of(title),
};
let folded = view
.matches
.get(row * grid.columns + column)
.and_then(|index| view.folded.get(index))
.copied();
let response = ui::card( let response = ui::card(
ui, ui,
Card { Card {
art: art.of(title), art: picture,
name: &title.name, name: art.name(title),
subtitle: subtitle(title), subtitle: match folded {
Some(more) => format!("{} · +{more} more", subtitle(title)),
None => subtitle(title),
},
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32), progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
// What a film is says itself in the subtitle below the // What a film is says itself in the subtitle below the
// card; a second `Film` over the poster is noise. // card; a second `Film` over the poster is noise. A
badge: None, // folded franchise says how much is under it.
badge: folded.map(|more| format!("+{more}")),
width: grid.width, width: grid.width,
aspect,
}, },
); );
tile_menu(&response, title, false, actions); tile_menu(&response, title, false, actions);
@@ -517,7 +616,7 @@ fn subtitle(title: &Title) -> String {
/// fraction is a flash of something false. /// fraction is a flash of something false.
fn placeholder(ui: &mut egui::Ui) { fn placeholder(ui: &mut egui::Ui) {
let grid = ui::columns(ui.available_width()); let grid = ui::columns(ui.available_width());
let height = ui::card_height(grid.width); let height = ui::card_height(grid.width, theme::CARD_ASPECT);
ui::section(ui, "Library"); ui::section(ui, "Library");
for _ in 0..2 { for _ in 0..2 {
ui.horizontal(|ui| { ui.horizontal(|ui| {
+97 -11
View File
@@ -3,6 +3,7 @@
//! mutating the library it is iterating: pages collect [`Action`]s and the app //! mutating the library it is iterating: pages collect [`Action`]s and the app
//! applies them after the frame is drawn. //! applies them after the frame is drawn.
pub mod account;
pub mod downloads; pub mod downloads;
pub mod history; pub mod history;
pub mod library; pub mod library;
@@ -20,9 +21,9 @@ use std::collections::HashMap;
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Vec2}; use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Vec2};
use pstr_core::library::{Episode, Title}; use pstr_core::library::{Episode, Title};
use pstr_core::metadata::{ArtShape, EpisodeGuide, EpisodeMetadata, MetadataRecord}; use pstr_core::metadata::{ArtShape, EpisodeGuide, EpisodeMetadata, MetadataRecord, TitleNames};
use crate::engine::{Engine, ImageCache, thumbnail_key}; use crate::engine::{Engine, ImageCache, WIDE_MAX_EDGE, thumbnail_key};
use crate::theme; use crate::theme;
/// Everything the pages need to draw one title's picture. /// Everything the pages need to draw one title's picture.
@@ -40,8 +41,12 @@ pub struct Art<'a> {
pub metadata: &'a HashMap<String, MetadataRecord>, pub metadata: &'a HashMap<String, MetadataRecord>,
/// What they said about the episodes under those titles. /// What they said about the episodes under those titles.
pub episodes: &'a HashMap<String, EpisodeGuide>, pub episodes: &'a HashMap<String, EpisodeGuide>,
/// Which name a title goes by.
pub names: TitleNames,
} }
pub use pstr_core::browse::display_name as title_name;
impl Art<'_> { impl Art<'_> {
/// What the provider says about one file of a title, matched on the /// What the provider says about one file of a title, matched on the
/// numbering its name states. /// numbering its name states.
@@ -74,6 +79,58 @@ impl Art<'_> {
.texture(&thumbnail_key(node), || engine.request_thumbnail(node)) .texture(&thumbnail_key(node), || engine.request_thumbnail(node))
} }
/// The name to show for `title`. See [`title_name`].
pub fn name<'t>(&'t self, title: &'t Title) -> &'t str {
title_name(title, self.metadata, self.names)
}
/// A 2:3 cover for `title`, for the poster grid; whatever [`Art::of`]
/// has when there is none.
pub fn poster(&mut self, title: &Title) -> Option<(egui::TextureHandle, ArtShape)> {
let url = self
.metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
.and_then(|metadata| metadata.poster_url.clone());
if let Some(url) = url {
let key = poster_key(&title.key);
let engine = self.engine;
if let Some(texture) = self
.posters
.texture(&key, || engine.request_poster(key.clone(), url))
{
return Some((texture, ArtShape::Portrait));
}
}
self.of(title)
}
/// Art for a strip across the whole window, at full size: fanart, else
/// AniList's banner, else whatever [`Art::of`] has.
///
/// Its own texture rather than the tile's: a 640 px tile picture
/// stretched to a 1600 px hero is soft, and a 1920 px one in every tile
/// would be most of the texture budget.
pub fn wide(&mut self, title: &Title) -> Option<(egui::TextureHandle, ArtShape)> {
let url = self
.metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
.and_then(|metadata| metadata.wide_art())
.map(str::to_owned);
if let Some(url) = url {
let key = wide_key(&title.key);
let engine = self.engine;
if let Some(texture) = self
.posters
.texture(&key, || engine.request_art(key.clone(), url, WIDE_MAX_EDGE))
{
return Some((texture, ArtShape::Landscape));
}
}
self.of(title)
}
/// The picture for a title, and how to fit it. /// The picture for a title, and how to fit it.
/// ///
/// Provider artwork first, then Proton's still, then nothing. That order is /// Provider artwork first, then Proton's still, then nothing. That order is
@@ -104,6 +161,17 @@ impl Art<'_> {
} }
} }
/// The texture key of a title's cover, beside [`Art::of`]'s, which is the
/// title key itself.
pub fn poster_key(title_key: &str) -> String {
format!("poster:{title_key}")
}
/// The texture key of a title's full-size wide art.
pub fn wide_key(title_key: &str) -> String {
format!("wide:{title_key}")
}
/// `1:03:47`, or `4:12` for anything under an hour. /// `1:03:47`, or `4:12` for anything under an hour.
pub fn format_time(seconds: f64) -> String { pub fn format_time(seconds: f64) -> String {
if !seconds.is_finite() || seconds < 0.0 { if !seconds.is_finite() || seconds < 0.0 {
@@ -492,6 +560,9 @@ pub struct Card<'a> {
/// sideways-scrolling shelf has no edge to reach and passes /// sideways-scrolling shelf has no edge to reach and passes
/// [`theme::CARD_WIDTH`]. /// [`theme::CARD_WIDTH`].
pub width: f32, pub width: f32,
/// The picture's height over its width: [`theme::CARD_ASPECT`] for a
/// still, [`theme::POSTER_ASPECT`] for a cover.
pub aspect: f32,
} }
/// Two lines of name plus one of subtitle. Fixed, because the tile is allocated /// Two lines of name plus one of subtitle. Fixed, because the tile is allocated
@@ -499,10 +570,10 @@ pub struct Card<'a> {
/// different height by title length reads as broken. /// different height by title length reads as broken.
const CARD_TEXT_HEIGHT: f32 = 54.0; const CARD_TEXT_HEIGHT: f32 = 54.0;
/// How tall [`card`] draws a tile of this width, so a grid can step over rows /// How tall [`card`] draws a tile of this width and shape, so a grid can step
/// it does not draw. /// over rows it does not draw.
pub fn card_height(width: f32) -> f32 { pub fn card_height(width: f32, aspect: f32) -> f32 {
(width * theme::CARD_ASPECT).round() + CARD_TEXT_HEIGHT (width * aspect).round() + CARD_TEXT_HEIGHT
} }
/// Draw one tile and report whether it was clicked. /// Draw one tile and report whether it was clicked.
@@ -515,7 +586,10 @@ pub fn card_height(width: f32) -> f32 {
/// reads as deliberate in a way a stretched poster does not. /// reads as deliberate in a way a stretched poster does not.
pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response { pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
let width = card.width; let width = card.width;
let image_height = (width * theme::CARD_ASPECT).round(); let image_height = (width * card.aspect).round();
// A cover fitted into a still-shaped tile is drawn whole; into a tile its
// own shape, it fills it like anything else.
let fit = card.aspect < 1.0;
let text_height = CARD_TEXT_HEIGHT; let text_height = CARD_TEXT_HEIGHT;
let radius = CornerRadius::same(theme::radius::MD); let radius = CornerRadius::same(theme::radius::MD);
@@ -574,7 +648,7 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
.with_texture(texture.id(), cover_uv(size, image_rect.size())), .with_texture(texture.id(), cover_uv(size, image_rect.size())),
); );
} }
Some((texture, ArtShape::Portrait)) => { Some((texture, ArtShape::Portrait)) if fit => {
painter.add( painter.add(
egui::epaint::RectShape::filled( egui::epaint::RectShape::filled(
contain_rect(texture.size_vec2(), image_rect), contain_rect(texture.size_vec2(), image_rect),
@@ -587,6 +661,14 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
), ),
); );
} }
Some((texture, ArtShape::Portrait)) => {
painter.add(
egui::epaint::RectShape::filled(image_rect, radius, ink).with_texture(
texture.id(),
cover_uv(texture.size_vec2(), image_rect.size()),
),
);
}
None => {} None => {}
} }
@@ -777,9 +859,13 @@ pub struct Grid {
/// dead space at the right of every row, and on a wide window that reads as a /// dead space at the right of every row, and on a wide window that reads as a
/// layout that failed to line up rather than as a margin. /// layout that failed to line up rather than as a margin.
pub fn columns(available: f32) -> Grid { pub fn columns(available: f32) -> Grid {
let columns = (((available + theme::CARD_GAP) / (theme::CARD_WIDTH + theme::CARD_GAP)).floor() columns_of(available, theme::CARD_WIDTH)
as usize) }
.max(1);
/// [`columns`], for cards at least `minimum` wide.
pub fn columns_of(available: f32, minimum: f32) -> Grid {
let columns =
(((available + theme::CARD_GAP) / (minimum + theme::CARD_GAP)).floor() as usize).max(1);
// The gaps come out of the width before it is split. Floored, because a // The gaps come out of the width before it is split. Floored, because a
// fraction of a point times four columns is enough to push the last card // fraction of a point times four columns is enough to push the last card
// onto a row of its own. A window too narrow for even one full card gets a // onto a row of its own. A window too narrow for even one full card gets a
+40 -3
View File
@@ -5,8 +5,8 @@
//! that manages Proton Drive links, and a page that is half link management and //! that manages Proton Drive links, and a page that is half link management and
//! half preferences is two pages. //! half preferences is two pages.
use pstr_core::appearance::{Accent, Appearance, Flavor}; use pstr_core::appearance::{Accent, Appearance, Flavor, Tiles};
use pstr_core::metadata::{MetadataConfig, ProviderId}; use pstr_core::metadata::{MetadataConfig, ProviderId, TitleNames};
use crate::app::Action; use crate::app::Action;
use crate::desktop_prefs::{CACHE_CHOICES, DesktopPrefs}; use crate::desktop_prefs::{CACHE_CHOICES, DesktopPrefs};
@@ -102,6 +102,41 @@ fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<
} }
}); });
ui::widgets::settings_row(
ui,
"Library tiles",
Some("Covers, or 16:9 stills. Covers are the art every matched title has."),
|ui| {
let choices: Vec<(Tiles, &str)> = Tiles::ALL
.iter()
.map(|tiles| (*tiles, tiles.label()))
.collect();
if let Some(tiles) = ui::widgets::segmented(ui, appearance.tiles, &choices) {
actions.push(Action::SetAppearance(Appearance {
tiles,
..appearance
}));
}
},
);
ui::widgets::settings_row(
ui,
"Title names",
Some("The name a matched title goes by. Its other names still find it in search."),
|ui| {
let choices: Vec<(TitleNames, &str)> = TitleNames::ALL
.iter()
.map(|names| (*names, names.label()))
.collect();
if let Some(names) = ui::widgets::segmented(ui, appearance.names, &choices) {
actions.push(Action::SetAppearance(Appearance {
names,
..appearance
}));
}
},
);
let mut gradients = appearance.gradients; let mut gradients = appearance.gradients;
ui::widgets::settings_row( ui::widgets::settings_row(
ui, ui,
@@ -369,7 +404,9 @@ fn metadata_form(
"A share filled by the Proton Drive desktop client carries no thumbnails, so \ "A share filled by the Proton Drive desktop client carries no thumbnails, so \
without this every tile is a pair of initials. Turning it on sends the titles \ without this every tile is a pair of initials. Turning it on sends the titles \
in your library — not your files, and nothing about what you have watched — to \ in your library — not your files, and nothing about what you have watched — to \
the provider you choose, over HTTPS, each time a new one appears.", the provider you choose, over HTTPS, each time a new one appears. With AniList, \
the ids it matched — not the titles — then go to ani.zip for episode names and \
wide artwork.",
)); ));
ui.add_space(theme::space::L); ui.add_space(theme::space::L);
+9
View File
@@ -18,6 +18,7 @@ pub fn show(
shares: &[Share], shares: &[Share],
library: &Library, library: &Library,
form: &mut ShareForm, form: &mut ShareForm,
account: &mut ui::account::AccountPanel,
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
) { ) {
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
@@ -40,6 +41,10 @@ pub fn show(
ui.add_space(theme::space::XXL); ui.add_space(theme::space::XXL);
add_form(ui, shares, form, actions); add_form(ui, shares, form, actions);
ui.add_space(theme::space::XXL);
ui::account::section(ui, account, actions);
ui::account::browser(ui, account, shares, actions);
}); });
} }
@@ -56,6 +61,9 @@ fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec<
// What is in it, rather than what it is called inside the // What is in it, rather than what it is called inside the
// app. The id is still a hover away for a bug report. // app. The id is still a hover away for a bug report.
let mut detail = ui::library::plural(titles, "title"); let mut detail = ui::library::plural(titles, "title");
if share.folder.is_some() {
detail.push_str(" · from your Drive");
}
if share.has_custom_password { if share.has_custom_password {
detail.push_str(" · password protected"); detail.push_str(" · password protected");
} }
@@ -211,6 +219,7 @@ mod tests {
name: "anime".into(), name: "anime".into(),
token: token.into(), token: token.into(),
has_custom_password: false, has_custom_password: false,
folder: None,
} }
} }
+125 -15
View File
@@ -20,6 +20,7 @@ pub fn show(
art: &mut Art<'_>, art: &mut Art<'_>,
library: &Library, library: &Library,
key: &str, key: &str,
franchise: Option<&[String]>,
offline: OfflineView<'_>, offline: OfflineView<'_>,
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
) { ) {
@@ -41,6 +42,15 @@ pub fn show(
header(ui, art, title, &offline, actions); header(ui, art, title, &offline, actions);
ui.add_space(theme::space::XL); ui.add_space(theme::space::XL);
if let Some(keys) = franchise {
let members: Vec<&Title> = keys.iter().filter_map(|key| library.get(key)).collect();
if members.len() > 1 {
ui::section(ui, "In this franchise");
franchise_row(ui, art, title, &members, actions);
ui.add_space(theme::space::XL);
}
}
// One season at a time, picked from a row of pills, rather than // One season at a time, picked from a row of pills, rather than
// every season stacked as a collapsing header: a four-season show // every season stacked as a collapsing header: a four-season show
// was a page of headers to scroll past to reach the one being // was a page of headers to scroll past to reach the one being
@@ -100,6 +110,60 @@ pub fn show(
}); });
} }
/// Every title of the franchise, in release order, as covers — this one
/// marked rather than left out, so the row says where it sits in the story.
fn franchise_row(
ui: &mut egui::Ui,
art: &mut Art<'_>,
current: &Title,
members: &[&Title],
actions: &mut Vec<Action>,
) {
egui::ScrollArea::horizontal()
.id_salt(("franchise", &current.key))
.auto_shrink([false, true])
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
for member in members {
let here = member.key == current.key;
let picture = art.poster(member);
let found = art
.metadata
.get(&member.key)
.and_then(|record| record.metadata.as_ref());
let what = found
.and_then(|found| found.details.as_ref())
.and_then(|details| details.format_label())
.unwrap_or(match member.kind {
TitleKind::Series => "Series",
TitleKind::Film => "Film",
});
let year = found
.and_then(|found| found.year)
.or(member.year)
.map(|year| format!(" · {year}"))
.unwrap_or_default();
let response = ui::card(
ui,
Card {
art: picture,
name: art.name(member),
subtitle: format!("{what}{year}"),
progress: member.resume().and_then(|e| e.progress()).map(|v| v as f32),
badge: here.then(|| "Here".to_owned()),
width: 128.0,
aspect: theme::POSTER_ASPECT,
},
);
if !here && response.clicked() {
actions.push(Action::Goto(Page::Title(member.key.clone())));
}
}
});
});
}
/// A title's poster, at the poster's own shape, with how far into it the /// A title's poster, at the poster's own shape, with how far into it the
/// viewer is along its foot. /// viewer is along its foot.
fn poster(ui: &mut egui::Ui, texture: &egui::TextureHandle, progress: Option<f64>) { fn poster(ui: &mut egui::Ui, texture: &egui::TextureHandle, progress: Option<f64>) {
@@ -190,16 +254,19 @@ fn header(
let record = art.metadata.get(&title.key); let record = art.metadata.get(&title.key);
let found = record.and_then(|record| record.metadata.clone()); let found = record.and_then(|record| record.metadata.clone());
let by_hand = record.is_some_and(|record| record.manual); let by_hand = record.is_some_and(|record| record.manual);
// Asked for once and used twice: the same picture is the poster beside the // The cover beside the text, and the widest, largest picture there is
// text and the banner behind all of it. // behind all of it — two textures, because the band is drawn across the
let picture = art.of(title); // whole window and a tile-sized one stretched that far is soft.
let picture = art.poster(title);
let wide = art.wide(title);
// Reserved before the header is laid out, because the band is as tall as // Reserved before the header is laid out, because the band is as tall as
// whatever the header comes to — a fixed height would clip a long overview // whatever the header comes to — a fixed height would clip a long overview
// on one title and leave a gap under a short one on the next. // on one title and leave a gap under a short one on the next.
let backdrop = picture let backdrop = wide
.as_ref() .as_ref()
.map(|(texture, _)| (texture.clone(), ui.painter().add(egui::Shape::Noop))); .map(|(texture, _)| (texture.clone(), ui.painter().add(egui::Shape::Noop)));
let shown = art.name(title).to_owned();
ui.add_space(theme::space::S); ui.add_space(theme::space::S);
ui.horizontal_top(|ui| { ui.horizontal_top(|ui| {
@@ -214,11 +281,12 @@ fn header(
ui, ui,
Card { Card {
art: picture, art: picture,
name: &title.name, name: &shown,
subtitle: String::new(), subtitle: String::new(),
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32), progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
badge: None, badge: None,
width: theme::CARD_WIDTH, width: theme::CARD_WIDTH,
aspect: theme::CARD_ASPECT,
}, },
); );
} }
@@ -229,16 +297,24 @@ fn header(
// Text in a column a person can read, with the picture to the // Text in a column a person can read, with the picture to the
// right of it rather than under it. // right of it rather than under it.
ui.set_max_width(ui.available_width().min(820.0)); ui.set_max_width(ui.available_width().min(820.0));
ui.label(theme::Role::Display.rich(&title.name).strong()); ui.label(theme::Role::Display.rich(&shown).strong());
// The provider's name for it, when it is not the one the files use. // Whichever of the share's name and the provider's is not the one
// Worth showing rather than replacing the filename's: a viewer // shown. Worth showing rather than only replacing: a viewer should
// should be able to tell what the match actually matched. // be able to tell what the match actually matched, and find the
if let Some(found) = &found // folder it came from.
&& found.name != title.name let other = found
{ .as_ref()
.map(|found| {
if shown == found.name {
title.name.clone()
} else {
found.name.clone()
}
})
.filter(|other| *other != shown);
if let Some(other) = other {
ui.label(ui::muted(format!( ui.label(ui::muted(format!(
"also known as {}{}", "also known as {other}{}",
found.name,
if by_hand { " (chosen by hand)" } else { "" } if by_hand { " (chosen by hand)" } else { "" }
))); )));
} }
@@ -449,12 +525,22 @@ fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::S
/// What the provider had to say. Only ever drawn under a match. /// What the provider had to say. Only ever drawn under a match.
fn description(ui: &mut egui::Ui, found: &TitleMetadata) { fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
if !found.genres.is_empty() { let tags = found
.details
.as_ref()
.map(|details| details.tags.as_slice())
.unwrap_or_default();
if !found.genres.is_empty() || !tags.is_empty() {
ui.horizontal_wrapped(|ui| { ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 6.0; ui.spacing_mut().item_spacing.x = 6.0;
for genre in &found.genres { for genre in &found.genres {
ui::widgets::chip(ui, genre); ui::widgets::chip(ui, genre);
} }
// After the genres and quieter than them: a tag is the
// provider's community describing it, not what it is filed as.
for tag in tags.iter().take(5) {
ui.label(ui::muted(tag));
}
}); });
ui.add_space(theme::space::M); ui.add_space(theme::space::M);
} }
@@ -530,6 +616,30 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
parts.push(format!("{watched} watched")); parts.push(format!("{watched} watched"));
} }
if let Some(details) = found.and_then(|found| found.details.as_ref()) {
if let Some(season) = details.season_label() {
parts.push(season);
}
parts.extend(details.studios.iter().take(2).cloned());
if let Some(director) = details.directors.first() {
parts.push(format!("dir. {director}"));
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_secs() as i64);
if let Some((episode, at)) = details.next_airing(now) {
let when = chrono::DateTime::from_timestamp(at, 0)
.map(|at| {
at.with_timezone(&chrono::Local)
.format("%a %-d %b, %H:%M")
.to_string()
})
.unwrap_or_default();
parts.push(format!("episode {episode} airs {when}"));
} else if details.is_airing() {
parts.push("airing".to_owned());
}
}
if let Some(found) = found { if let Some(found) = found {
if let Some(rating) = found.rating { if let Some(rating) = found.rating {
parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR)); parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
+190
View File
@@ -0,0 +1,190 @@
//! Proton's CAPTCHA, in a window of its own.
//!
//! Proton puts a CAPTCHA in front of a sign-in it does not recognise — a new
//! network, a VPN exit. The page reports success by posting a message to its
//! host rather than by redirecting, so the system browser has nowhere to send
//! it back to: it needs a webview this app controls, as on Android and in
//! `proton-drive-linux`.
//!
//! The webview runs in a child process — this same executable, started with
//! [`ARG`] — because its windowing library and eframe's cannot both own the
//! event loop of one process. The child prints the solved token on stdout and
//! exits; closing its window exits with nothing printed, which is a cancel.
use std::io::{Read as _, Write as _};
/// The argument that starts this executable as the verification window,
/// followed by the page's URL.
pub const ARG: &str = "--human-verification";
/// Forwards every message the page posts to this process. The page targets
/// `window.parent`, which for a top-level page is the page itself, so a
/// listener on its own window hears it.
const BRIDGE: &str = r#"
window.addEventListener('message', function (event) {
try {
window.ipc.postMessage(typeof event.data === 'string' ? event.data : JSON.stringify(event.data));
} catch (e) {}
});
"#;
/// Run the verification window if this process was started for it, returning
/// the process's exit code; `None` when it was started as the app.
pub fn run_if_requested() -> Option<i32> {
let mut args = std::env::args().skip(1);
if args.next().as_deref() != Some(ARG) {
return None;
}
let Some(url) = args.next().filter(|url| is_proton_page(url)) else {
eprintln!("{ARG} needs a verification page on proton.me");
return Some(2);
};
Some(match window(&url) {
Ok(()) => 0,
Err(error) => {
eprintln!("the verification window failed: {error}");
1
}
})
}
/// Present `url` in the verification window and wait for the token it yields.
///
/// `None` when the viewer closed the window, or it could not be shown.
/// Blocking: call it off the UI thread and outside the async runtime.
pub fn solve(url: &str) -> Option<String> {
let exe = std::env::current_exe()
.inspect_err(|error| tracing::warn!("locate this executable: {error}"))
.ok()?;
let mut child = std::process::Command::new(exe)
.arg(ARG)
.arg(url)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.spawn()
.inspect_err(|error| tracing::warn!("open the verification window: {error}"))
.ok()?;
let mut out = String::new();
if let Some(mut stdout) = child.stdout.take() {
let _ = stdout.read_to_string(&mut out);
}
let status = child.wait().ok()?;
if !status.success() {
tracing::warn!("the verification window exited with {status}");
return None;
}
out.lines()
.map(str::trim)
.rfind(|line| !line.is_empty())
.map(str::to_owned)
}
fn window(url: &str) -> anyhow::Result<()> {
use tao::event::{Event, WindowEvent};
use tao::event_loop::{ControlFlow, EventLoopBuilder};
use tao::window::WindowBuilder;
use wry::{NewWindowResponse, WebViewBuilder};
let event_loop = EventLoopBuilder::<String>::with_user_event().build();
let window = WindowBuilder::new()
.with_title("Confirm you are human — Proton Stream")
.with_inner_size(tao::dpi::LogicalSize::new(440.0, 680.0))
.build(&event_loop)?;
let proxy = event_loop.create_proxy();
let builder = WebViewBuilder::new()
.with_url(url)
.with_initialization_script(BRIDGE)
.with_ipc_handler(move |request| {
// Every message the page posts lands here, most of them its own
// chatter; only a completion carries a token.
if let Some(token) = pstr_core::account::verification_token(request.body()) {
let _ = proxy.send_event(token);
}
})
// The bridge above is reachable by whatever the page shows, so the
// page stays on Proton.
.with_navigation_handler(|url| is_allowed_navigation(&url))
.with_new_window_req_handler(|_, _| NewWindowResponse::Deny);
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
let _webview = {
use tao::platform::unix::WindowExtUnix as _;
use wry::WebViewBuilderExtUnix as _;
let vbox = window
.default_vbox()
.ok_or_else(|| anyhow::anyhow!("the window has no GTK container"))?;
builder.build_gtk(vbox)?
};
#[cfg(any(target_os = "windows", target_os = "macos"))]
let _webview = builder.build(&window)?;
let mut answered = false;
event_loop.run(move |event, _, control_flow| {
*control_flow = ControlFlow::Wait;
match event {
Event::UserEvent(token) if !answered => {
answered = true;
let mut stdout = std::io::stdout().lock();
let _ = writeln!(stdout, "{token}");
let _ = stdout.flush();
*control_flow = ControlFlow::Exit;
}
Event::WindowEvent {
event: WindowEvent::CloseRequested,
..
} => *control_flow = ControlFlow::Exit,
_ => {}
}
})
}
/// Whether `url` is a page of Proton's own, over HTTPS.
fn is_proton_page(url: &str) -> bool {
let Some(rest) = url.strip_prefix("https://") else {
return false;
};
let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
// A user-info part would let `proton.me@evil.example` pass.
if authority.contains('@') {
return false;
}
let host = authority.split(':').next().unwrap_or_default();
host == "proton.me" || host.ends_with(".proton.me")
}
/// Whether the webview may go to `url`: Proton's pages, and the documents a
/// page makes for itself, which have no origin of their own to leave to.
fn is_allowed_navigation(url: &str) -> bool {
is_proton_page(url) || url == "about:blank" || url.starts_with("about:srcdoc")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_proton_pages_over_https_are_proton_pages() {
assert!(is_proton_page(
"https://verify.proton.me/?embed=1&methods=captcha&token=x"
));
assert!(is_proton_page("https://proton.me"));
assert!(!is_proton_page("http://verify.proton.me/"));
assert!(!is_proton_page("https://proton.me.evil.example/"));
assert!(!is_proton_page("https://evilproton.me/"));
assert!(!is_proton_page("https://proton.me@evil.example/"));
assert!(!is_proton_page(
"https://evil.example/?next=https://proton.me"
));
}
#[test]
fn the_window_stays_on_proton() {
assert!(is_allowed_navigation(
"https://verify-api.proton.me/core/v4/captcha"
));
assert!(is_allowed_navigation("about:blank"));
assert!(!is_allowed_navigation("https://example.com/"));
assert!(!is_allowed_navigation("file:///etc/passwd"));
}
}
+1
View File
@@ -23,5 +23,6 @@ tokio.workspace = true
anyhow.workspace = true anyhow.workspace = true
clap.workspace = true clap.workspace = true
rpassword.workspace = true rpassword.workspace = true
parking_lot.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
+305 -37
View File
@@ -8,9 +8,12 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use proton_sdk::ids::{LinkId, NodeUid, VolumeId}; use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
use pstr_core::account::{PlaceKind, SignIn};
use pstr_core::catalog::{Catalog, build_rows}; use pstr_core::catalog::{Catalog, build_rows};
use pstr_core::config::AppDirs; use pstr_core::config::AppDirs;
use pstr_core::{ShareStore, SharedLibrary}; use pstr_core::shares::AccountFolder;
use pstr_core::sync::WatchSync;
use pstr_core::{Account, AccountStore, KeyringSecretStore, ShareStore, SharedLibrary};
use pstr_player::{EndReason, Player, PlayerConfig, PlayerEvent}; use pstr_player::{EndReason, Player, PlayerConfig, PlayerEvent};
use pstr_stream::{DiskCacheConfig, LibraryOpener, StreamConfig, StreamSource}; use pstr_stream::{DiskCacheConfig, LibraryOpener, StreamConfig, StreamSource};
@@ -69,8 +72,38 @@ enum Command {
#[arg(long)] #[arg(long)]
custom_password: bool, custom_password: bool,
}, },
/// Add a folder of the signed-in account as a share.
///
/// The folder is named by the `VOLUME:LINK` id `browse` prints.
AddFolder {
/// What to call it in the UI.
#[arg(long)]
name: String,
/// The folder, as `VOLUME:LINK`.
folder: String,
},
/// List configured shares. /// List configured shares.
Shares, Shares,
/// Sign in to a Proton account, for watch history across devices and for
/// folders of your own Drive.
///
/// The password, a second-factor code and a mailbox password are prompted
/// for as the account needs them.
Login {
/// The account's address.
username: String,
},
/// Sign out and forget the stored session.
Logout,
/// Show which account is signed in.
Account,
/// List the account's Drive: its top-level places, or one folder.
Browse {
/// The folder to list, as `VOLUME:LINK`; the places when omitted.
folder: Option<String>,
},
/// Sync watch history with the signed-in account's Drive.
Sync,
/// Forget a share and its stored credentials. /// Forget a share and its stored credentials.
Remove { Remove {
/// The share id, as printed by `shares`. /// The share id, as printed by `shares`.
@@ -210,6 +243,7 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
let dirs = AppDirs::ensure().context("resolve app directories")?; let dirs = AppDirs::ensure().context("resolve app directories")?;
let store = ShareStore::new(dirs.clone()); let store = ShareStore::new(dirs.clone());
let accounts = AccountStore::new(std::sync::Arc::new(KeyringSecretStore));
match cli.command { match cli.command {
Command::Add { Command::Add {
@@ -222,12 +256,105 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
} else { } else {
None None
}; };
let share = store let (share, _) = store
.add(&name, &url, password.as_deref()) .add_verified(&name, &url, password.as_deref())
.await
.context("add share")?; .context("add share")?;
println!("added {} ({})", share.name, share.id); println!("added {} ({})", share.name, share.id);
} }
Command::AddFolder { name, folder } => {
let uid = parse_uid(&folder)?;
let share = store
.add_folder(
&name,
AccountFolder {
volume_id: uid.volume_id.as_str().to_owned(),
link_id: uid.link_id.as_str().to_owned(),
},
)
.context("add folder")?;
println!("added {} ({})", share.name, share.id);
}
Command::Login { username } => login(&accounts, &username).await?,
Command::Logout => {
let account = accounts.resume().ok().flatten();
accounts
.sign_out(account.as_ref())
.await
.context("sign out")?;
println!("signed out");
}
Command::Account => match accounts.username().context("read the account")? {
Some(username) => println!("signed in as {username}"),
None => println!("not signed in — sign in with `pstr login <address>`"),
},
Command::Browse { folder } => {
let account = signed_in(&accounts)?;
match folder {
None => {
for place in account.places().await.context("list places")? {
let kind = match place.kind {
PlaceKind::MyFiles => "files",
PlaceKind::Device => "device",
PlaceKind::SharedWithMe => "shared",
};
println!("{}\t{kind}\t{}", format_uid(&place.uid), place.name);
}
}
Some(folder) => {
let entries = account
.folder_children(&parse_uid(&folder)?)
.await
.context("list folder")?;
for entry in entries {
let kind = if entry.is_folder { "dir" } else { "file" };
println!("{}\t{kind}\t{}", format_uid(&entry.uid), entry.name);
}
}
}
}
Command::Sync => {
let account = signed_in(&accounts)?;
let sync = WatchSync::open(&dirs).context("open sync state")?;
let catalog =
parking_lot::Mutex::new(Catalog::open(&dirs.catalog_db()).context("open catalog")?);
let report = sync
.sync(&account, &catalog, &store)
.await
.context("sync")?;
let profile = &report.profile;
for id in &profile.shares_removed {
catalog
.lock()
.remove_share(id)
.context("drop a removed share")?;
}
println!(
"read {} other device(s), took {} newer position(s), \
{} share(s) added, {} removed, settings {}, {}",
report.devices,
report.applied,
profile.shares_added.len() + profile.shares_changed.len(),
profile.shares_removed.len(),
if profile.settings {
"updated"
} else {
"unchanged"
},
if report.uploaded {
"uploaded this device's history"
} else {
"this device's history was already up to date"
}
);
}
Command::Shares => { Command::Shares => {
let shares = store.list().context("list shares")?; let shares = store.list().context("list shares")?;
if shares.is_empty() { if shares.is_empty() {
@@ -239,17 +366,41 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
} else { } else {
"" ""
}; };
println!("{}\t{}{}", share.id, share.name, custom); let kind = if share.folder.is_some() {
" [account folder]"
} else {
""
};
println!("{}\t{}{}{}", share.id, share.name, custom, kind);
} }
} }
Command::Remove { id } => { Command::Remove { id } => {
store.remove(&id).context("remove share")?; store.remove(&id).context("remove share")?;
// Its catalog rows go too, or the library keeps listing its files —
// beside the new share's, when the same folder was added back
// another way.
let mut catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
let offline = catalog
.offline_files_for_share(&id)
.context("read the share's downloads")?;
for (link_id, file) in &offline {
match std::fs::remove_file(dirs.offline_file(&id, link_id, &file.revision_id)) {
Err(error) if error.kind() != std::io::ErrorKind::NotFound => {
return Err(error).context("delete a downloaded file");
}
_ => {}
}
}
catalog
.remove_share(&id)
.context("drop the share's catalog rows")?;
println!("removed {id}"); println!("removed {id}");
} }
Command::Crawl { share } => { Command::Crawl { share } => {
let (library, failures) = SharedLibrary::open_all(&store) let account = accounts.resume().context("resume the account")?;
let (library, failures) = SharedLibrary::open_all(&store, account.as_ref())
.await .await
.context("open shares")?; .context("open shares")?;
for (share, error) in &failures { for (share, error) in &failures {
@@ -483,18 +634,19 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
) )
.context("build the metadata provider")?; .context("build the metadata provider")?;
let catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?; let mut catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
let library = Library::build(catalog.all_files()?, &catalog.all_watch_states()?); let library = Library::build(catalog.all_files()?, &catalog.all_watch_states()?);
let stored = catalog.all_metadata()?; let stored = catalog.all_metadata()?;
let enriched = catalog.enrichment_ages()?;
let mut pending: Vec<_> = library let mut pending = pstr_meta::service::plan(
.titles library.titles,
.iter() &stored,
.filter(|title| { &enriched,
force settings.provider,
|| !pstr_meta::service::is_usable(stored.get(&title.key), settings.provider) service.has_details(),
}) force,
.collect(); );
if let Some(limit) = limit { if let Some(limit) = limit {
pending.truncate(limit); pending.truncate(limit);
} }
@@ -504,7 +656,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
return Ok(()); return Ok(());
} }
println!( println!(
"matching {} titles against {}…", "looking up {} titles against {}…",
pending.len(), pending.len(),
settings.provider.label() settings.provider.label()
); );
@@ -513,32 +665,71 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
// and the GUI already has the parallel path. What this command is // and the GUI already has the parallel path. What this command is
// for is reading the output. // for is reading the output.
let (mut matched, mut failed) = (0usize, 0usize); let (mut matched, mut failed) = (0usize, 0usize);
for title in pending { for work in pending {
match service.record(title).await { use pstr_meta::service::Work;
Ok(record) => { let name = &work.title().name;
match &record.metadata { // Whether this work asks for episodes at all — a refresh of an
Some(found) => { // already-enriched title does not, and saying they were "not
matched += 1; // fetched" there reads as a failure.
println!( let enriches = matches!(work, Work::Match(_) | Work::Refresh { enrich: true, .. });
" {:<40} → {} ({})", let outcome = match service.work(&work).await {
title.name, Ok(outcome) => outcome,
found.name,
found
.year
.map(|year| year.to_string())
.unwrap_or_else(|| "-".into())
);
}
None => println!(" {:<40} → no match", title.name),
}
catalog.set_metadata(&record)?;
}
Err(error) => { Err(error) => {
failed += 1; failed += 1;
// Not stored: a failure to ask is not an answer, and the // Not stored: a failure to ask is not an answer, and the
// title has to stay askable. See `pstr_meta::service`. // title has to stay askable. See `pstr_meta::service`.
println!(" {:<40} ! {error}", title.name); println!(" {name:<40} ! {error}");
continue;
} }
};
if let Some(record) = &outcome.record {
match &record.metadata {
Some(found) => {
matched += 1;
println!(
" {name:<40} → {} ({})",
found.name,
found
.year
.map(|year| year.to_string())
.unwrap_or_else(|| "-".into())
);
}
None => println!(" {name:<40} → no match"),
}
catalog.set_metadata(record)?;
}
match &outcome.enrichment {
Some(enrichment) => {
println!(
" {:<40} {} episodes{}",
"",
enrichment.episodes.len(),
if enrichment.backdrop_url.is_some() {
", fanart"
} else {
""
}
);
catalog.set_enrichment(
&outcome.title_key,
settings.provider,
unix_now(),
enrichment,
)?;
}
// Matched, but the episodes could not be asked for; the
// next run asks again.
None if enriches
&& outcome
.record
.as_ref()
.is_some_and(|r| r.metadata.is_some()) =>
{
println!(" {:<40} ! episodes not fetched", "");
}
None => {}
} }
} }
println!("{matched} matched, {failed} could not be looked up"); println!("{matched} matched, {failed} could not be looked up");
@@ -603,7 +794,10 @@ async fn open_target(
println!("file: {} ({})", target.name, target.share_id); println!("file: {} ({})", target.name, target.share_id);
let (library, failures) = SharedLibrary::open_all(store) let account = AccountStore::new(std::sync::Arc::new(KeyringSecretStore))
.resume()
.context("resume the account")?;
let (library, failures) = SharedLibrary::open_all(store, account.as_ref())
.await .await
.context("open shares")?; .context("open shares")?;
for (share, error) in &failures { for (share, error) in &failures {
@@ -926,3 +1120,77 @@ async fn play(
drop(opened.library); drop(opened.library);
Ok(()) Ok(())
} }
/// Walk an interactive sign-in through whatever the account asks for.
async fn login(accounts: &AccountStore, username: &str) -> Result<()> {
let password = read_password("Password: ")?;
let mut step = match accounts.sign_in(username, &password, None).await {
Ok(step) => step,
Err(pstr_core::Error::HumanVerification(challenge)) => {
// Solving a CAPTCHA needs a browser engine that can hand the
// token back, which a terminal does not have. The desktop app has
// one, and keeps its session in the same keyring entry.
anyhow::bail!(
"Proton wants a CAPTCHA before it accepts this sign-in. Sign in once in the \
desktop app on this computer, which shows it and shares its session with \
pstr, or try again later. ({})",
challenge.verification_url()
);
}
Err(error) => return Err(error).context("sign in"),
};
loop {
step = match step {
SignIn::Done(account) => {
println!("signed in as {}", account.username());
return Ok(());
}
SignIn::SecondFactor(pending) => {
// Security keys need WebAuthn, which this sign-in does not speak.
let code = read_password("Code from your authenticator app: ")?;
pending
.second_factor(&code)
.await
.context("second factor")?
}
SignIn::MailboxPassword(pending) => {
let mailbox = read_password("Mailbox password: ")?;
SignIn::Done(
pending
.mailbox_password(&mailbox)
.await
.context("mailbox password")?,
)
}
};
}
}
fn signed_in(accounts: &AccountStore) -> Result<Account> {
accounts
.resume()
.context("resume the account")?
.ok_or_else(|| anyhow::anyhow!("not signed in — sign in with `pstr login <address>`"))
}
/// A node as `browse` prints it and `add-folder` takes it.
fn format_uid(uid: &NodeUid) -> String {
format!("{}:{}", uid.volume_id.as_str(), uid.link_id.as_str())
}
fn parse_uid(text: &str) -> Result<NodeUid> {
let (volume, link) = text
.split_once(':')
.filter(|(volume, link)| !volume.is_empty() && !link.is_empty())
.ok_or_else(|| anyhow::anyhow!("expected VOLUME:LINK, as `pstr browse` prints"))?;
Ok(NodeUid::new(
VolumeId::new(volume.to_owned()),
LinkId::new(link.to_owned()),
))
}
fn unix_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_secs() as i64)
}
+2
View File
@@ -20,6 +20,8 @@ tracing.workspace = true
directories.workspace = true directories.workspace = true
rusqlite.workspace = true rusqlite.workspace = true
sha2.workspace = true sha2.workspace = true
parking_lot.workspace = true
getrandom.workspace = true
# The credential store, per platform. Without the target's feature `keyring` # The credential store, per platform. Without the target's feature `keyring`
# compiles fine and silently resolves to its in-memory `mock` store, which # compiles fine and silently resolves to its in-memory `mock` store, which
+673
View File
@@ -0,0 +1,673 @@
//! Signing in to a Proton account, and keeping the session.
//!
//! The rest of the app works without one — public links need no account. A
//! signed-in account adds two things: folders of the viewer's own Drive (My
//! Files, other devices, what was shared with them) as library shares, and
//! watch history kept in their Drive so every device resumes where another
//! left off ([`crate::sync`]).
//!
//! ## What is kept, and where
//!
//! One JSON blob in the [`SecretStore`] holding the session tokens and, per
//! account key, the passphrase that unlocks it. Those passphrases are derived
//! at sign-in from the mailbox password and the key salts, and kept *instead*
//! of the password: they unlock the key chain just the same, but a store that
//! leaks gives away this app's access to the account rather than the password
//! — which, on a single-password account, is the login password itself.
//! Deriving them later is not an option either way: `core/v4/keys/salts`
//! needs a scope only a password sign-in grants, and answers 403 after the
//! first token refresh.
//!
//! Proton refresh tokens are single-use, so every refresh is written back
//! (see [`persist_refreshed_tokens`]). A refresh that is not leaves the store
//! holding a dead token and the next launch signed out.
//!
//! ## Signing in is several steps
//!
//! Password, then maybe a second-factor code, then — on an account with a
//! separate mailbox password — that. Each front end asks for them in its own
//! way, so [`AccountStore::sign_in`] returns a [`SignIn`] saying what it needs
//! next and holding the half-made session until it is given.
use std::collections::HashMap;
use std::sync::Arc;
use proton_drive_rs::{NodeKind, ProtonDriveClient};
use proton_sdk::api::HumanVerificationCredential;
use proton_sdk::error::ProtonError;
use proton_sdk::ids::NodeUid;
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
use crate::shares::{SecretStore, client_configuration};
/// The secret-store key the session is kept under. Share secrets are keyed
/// `share-…`, so the two cannot collide.
const SESSION_KEY: &str = "proton-account";
/// Why an account whose only second factor is a security key cannot sign in.
const SECURITY_KEY_ONLY: &str = "This account's only second factor is a security key, which this \
app cannot use yet. Add an authenticator app in your Proton account settings, then sign in \
with its code.";
/// Blocks in flight for streaming from the account, as for a public link —
/// see `ShareStore::open`.
const MAX_INFLIGHT_BLOCKS: usize = 48;
/// Everything needed to resume the session unattended.
#[derive(Serialize, Deserialize, Clone)]
struct StoredSession {
session_id: String,
username: String,
user_id: String,
access_token: String,
refresh_token: String,
scopes: Vec<String>,
/// `1` single password, `2` separate mailbox password — Proton's values.
password_mode: u8,
/// Key id → the passphrase that unlocks it. ASCII: they are bcrypt output.
key_passphrases: HashMap<String, String>,
}
impl StoredSession {
fn capture(
session: &ProtonApiSession,
tokens: proton_sdk::http::Tokens,
key_passphrases: HashMap<String, String>,
) -> Self {
Self {
session_id: session.session_id().as_str().to_owned(),
username: session.username().to_owned(),
user_id: session.user_id().as_str().to_owned(),
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
scopes: session.scopes().to_vec(),
password_mode: match session.password_mode() {
PasswordMode::Single => 1,
PasswordMode::Dual => 2,
},
key_passphrases,
}
}
fn unlock(&self) -> HashMap<String, Vec<u8>> {
self.key_passphrases
.iter()
.map(|(id, passphrase)| (id.clone(), passphrase.as_bytes().to_vec()))
.collect()
}
fn resume_parameters(&self) -> ResumeParameters {
ResumeParameters {
session_id: self.session_id.clone().into(),
username: self.username.clone(),
user_id: self.user_id.clone().into(),
access_token: self.access_token.clone(),
refresh_token: self.refresh_token.clone(),
scopes: self.scopes.clone(),
is_waiting_for_second_factor_code: false,
password_mode: match self.password_mode {
1 => PasswordMode::Single,
_ => PasswordMode::Dual,
},
}
}
}
/// A signed-in account, ready for Drive work.
///
/// Cheap to clone; clones share one session and one set of caches.
#[derive(Clone)]
pub struct Account {
session: ProtonApiSession,
drive: ProtonDriveClient,
}
impl Account {
/// The address the viewer signed in with.
pub fn username(&self) -> &str {
self.session.username()
}
/// The Drive client the account's folders and watch history go through.
pub fn drive(&self) -> &ProtonDriveClient {
&self.drive
}
/// Where a library folder can come from: My Files, each device, and each
/// folder someone shared with the viewer.
///
/// The device this app keeps watch history in is left out — it holds
/// nothing to watch. A device or share whose name will not decrypt is
/// still listed, under a placeholder, rather than hidden: it is the
/// viewer's, and they can still open it.
pub async fn places(&self) -> Result<Vec<DrivePlace>> {
let mut places = Vec::new();
let my_files = self.drive.get_my_files_folder().await?;
places.push(DrivePlace {
kind: PlaceKind::MyFiles,
name: "My files".to_owned(),
uid: my_files.uid,
});
let mut devices = self.drive.enumerate_devices().await?;
devices.sort_by_key(|device| device.creation_time);
for device in devices {
let name = device.name.unwrap_or_else(|_| "Unnamed device".to_owned());
if name == crate::sync::DEVICE_NAME {
continue;
}
places.push(DrivePlace {
kind: PlaceKind::Device,
name,
uid: device.root_folder_uid,
});
}
let shared: Vec<NodeUid> = self
.drive
.enumerate_shared_with_me()
.await?
.into_iter()
.map(|item| item.uid)
.collect();
if !shared.is_empty() {
let mut nodes = self.drive.enumerate_nodes(&shared).await?;
nodes.retain(|node| node.is_folder() && !node.trashed);
nodes.sort_by_key(|node| node.name.to_lowercase());
places.extend(nodes.into_iter().map(|node| DrivePlace {
kind: PlaceKind::SharedWithMe,
name: node.name,
uid: node.uid,
}));
}
Ok(places)
}
/// What one folder of the account holds: folders first, then files, each
/// by name. Trashed nodes are left out.
pub async fn folder_children(&self, folder: &NodeUid) -> Result<Vec<DriveEntry>> {
let uids = self
.drive
.enumerate_folder_children_node_uids(folder)
.await?;
if uids.is_empty() {
return Ok(Vec::new());
}
let mut entries: Vec<DriveEntry> = self
.drive
.enumerate_nodes(&uids)
.await?
.into_iter()
.filter(|node| !node.trashed)
.map(|node| {
let (size, media_type) = match &node.kind {
NodeKind::File {
media_type,
claimed_size,
..
} => (*claimed_size, Some(media_type.clone())),
_ => (None, None),
};
DriveEntry {
is_folder: node.is_folder(),
uid: node.uid,
name: node.name,
size,
media_type,
}
})
.collect();
entries.sort_by(|a, b| {
b.is_folder
.cmp(&a.is_folder)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
Ok(entries)
}
/// End the session on Proton's side. Best-effort: a session that is
/// already dead has nothing to end.
async fn end(&self) {
if let Err(error) = self.session.end().await {
tracing::debug!(%error, "ending the Proton session failed");
}
}
}
/// Which kind of place a [`DrivePlace`] is.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlaceKind {
MyFiles,
Device,
SharedWithMe,
}
/// A top-level place in the account's Drive.
#[derive(Debug, Clone)]
pub struct DrivePlace {
pub kind: PlaceKind,
pub name: String,
pub uid: NodeUid,
}
/// One node of a folder listing.
#[derive(Debug, Clone)]
pub struct DriveEntry {
pub uid: NodeUid,
pub name: String,
pub is_folder: bool,
/// Plaintext size, when the uploader recorded one.
pub size: Option<i64>,
pub media_type: Option<String>,
}
/// Where a sign-in stands.
pub enum SignIn {
/// Signed in; the session is stored.
Done(Account),
/// The account has two-factor authentication: ask for a code and pass it to
/// [`PendingSignIn::second_factor`].
SecondFactor(PendingSignIn),
/// The account has a separate mailbox password: ask for it and pass it to
/// [`PendingSignIn::mailbox_password`].
MailboxPassword(PendingSignIn),
}
/// A sign-in waiting on the viewer.
pub struct PendingSignIn {
secrets: Arc<dyn SecretStore>,
session: ProtonApiSession,
password: String,
}
impl PendingSignIn {
/// Submit a second-factor code.
///
/// A wrong code fails here and leaves nothing behind; the front end starts
/// the sign-in over, because Proton does not accept a second try on the
/// same half-made session.
pub async fn second_factor(mut self, code: &str) -> Result<SignIn> {
self.session
.apply_second_factor_code(code.trim())
.await
.map_err(|error| match error {
ProtonError::Api(api) if (400..500).contains(&api.http_status) => {
Error::SignIn("that code was not accepted".to_owned())
}
other => other.into(),
})?;
self.after_second_factor().await
}
/// Submit the mailbox password, on an account that has one.
///
/// By reference, unlike the code: a mistyped mailbox password is checked
/// on this side, the session survives it, and the viewer can try again
/// without starting over.
pub async fn mailbox_password(&self, mailbox_password: &str) -> Result<Account> {
finish(
Arc::clone(&self.secrets),
self.session.clone(),
mailbox_password,
)
.await
}
async fn after_second_factor(self) -> Result<SignIn> {
if self.session.password_mode() == PasswordMode::Dual {
return Ok(SignIn::MailboxPassword(self));
}
let password = self.password.clone();
finish(self.secrets, self.session, &password)
.await
.map(SignIn::Done)
}
}
/// The signed-in account, if any, and signing in and out.
#[derive(Clone)]
pub struct AccountStore {
secrets: Arc<dyn SecretStore>,
}
impl AccountStore {
pub fn new(secrets: Arc<dyn SecretStore>) -> Self {
Self { secrets }
}
/// The address of the stored account, without touching the network.
pub fn username(&self) -> Result<Option<String>> {
Ok(self.load()?.map(|stored| stored.username))
}
/// Start signing in.
///
/// `verification` is the token from a solved CAPTCHA, after a first try
/// failed with [`Error::HumanVerification`]; the sign-in starts over with
/// it, because the gated attempt's SRP handshake is spent.
pub async fn sign_in(
&self,
username: &str,
password: &str,
verification: Option<HumanVerificationCredential>,
) -> Result<SignIn> {
let session = ProtonApiSession::begin_verified(
client_configuration(),
username.trim(),
password.as_bytes(),
verification.as_ref(),
)
.await
.map_err(classify_sign_in_failure)?;
let pending = PendingSignIn {
secrets: Arc::clone(&self.secrets),
session,
password: password.to_owned(),
};
if pending.session.is_waiting_for_second_factor() {
let methods = pending.session.second_factor_methods();
if methods.fido2 && !methods.totp {
// A security key needs WebAuthn, which this sign-in does not
// speak; asking for a code would only fail as a wrong one.
pending.session.end().await.ok();
return Err(Error::SignIn(SECURITY_KEY_ONLY.to_owned()));
}
return Ok(SignIn::SecondFactor(pending));
}
pending.after_second_factor().await
}
/// The stored account, resumed, or `None` when nobody is signed in.
///
/// Makes no request: a session whose refresh token has died is found out
/// by the first call that uses it, as [`Error::is_signed_out`].
pub fn resume(&self) -> Result<Option<Account>> {
let Some(stored) = self.load()? else {
return Ok(None);
};
let session = ProtonApiSession::resume(client_configuration(), stored.resume_parameters())?;
persist_refreshed_tokens(&self.secrets, &session, stored.key_passphrases.clone());
let drive = ProtonDriveClient::with_key_passphrases(&session, stored.unlock());
Ok(Some(account(session, drive)))
}
/// Forget the account: end its session and delete what was stored.
///
/// The stored session goes even when ending it fails — signing out on a
/// plane must still sign out.
pub async fn sign_out(&self, account: Option<&Account>) -> Result<()> {
if let Some(account) = account {
account.end().await;
}
self.secrets.delete(SESSION_KEY)
}
/// The stored session, or `None` for none — or for one this build cannot
/// read, such as the shape development builds before 1.2.0 stored, which
/// kept the mailbox password. Signing in again is the only fix for either,
/// and it replaces what is there.
fn load(&self) -> Result<Option<StoredSession>> {
let Some(encoded) = self.secrets.get(SESSION_KEY)? else {
return Ok(None);
};
Ok(serde_json::from_str(&encoded)
.inspect_err(|error| {
tracing::warn!(%error, "the stored Proton session is unreadable; sign in again");
})
.ok())
}
}
/// Unlock the key chain with `mailbox_password`, store the session, and hand
/// back the account.
///
/// The key passphrases are derived here, while the token still has the scope
/// the key salts need. Loading the addresses is what proves the mailbox
/// password: it unlocks the user keys, and a wrong password unlocks none of
/// them. Only the passphrases are stored; the password goes no further.
async fn finish(
secrets: Arc<dyn SecretStore>,
session: ProtonApiSession,
mailbox_password: &str,
) -> Result<Account> {
let probe = ProtonDriveClient::new(&session, mailbox_password.as_bytes().to_vec());
probe
.account()
.addresses()
.await
.map_err(|error| match error {
ProtonError::InvalidOperation(_) => {
Error::SignIn("that mailbox password did not unlock the account".to_owned())
}
other => other.into(),
})?;
let key_passphrases = probe
.account()
.key_passphrases()
.await?
.into_iter()
.map(|(id, passphrase)| {
String::from_utf8(passphrase)
.map(|passphrase| (id, passphrase))
.map_err(|_| Error::Config("a derived key passphrase is not text".to_owned()))
})
.collect::<Result<HashMap<_, _>>>()?;
let stored = StoredSession::capture(
&session,
session.current_tokens().await,
key_passphrases.clone(),
);
let encoded = serde_json::to_string(&stored)
.map_err(|e| Error::Config(format!("serialize the Proton session: {e}")))?;
secrets.set(SESSION_KEY, &encoded)?;
persist_refreshed_tokens(&secrets, &session, key_passphrases);
let drive = ProtonDriveClient::with_key_passphrases(&session, stored.unlock());
Ok(account(session, drive))
}
fn account(session: ProtonApiSession, drive: ProtonDriveClient) -> Account {
let drive = drive
.with_max_inflight_blocks(MAX_INFLIGHT_BLOCKS)
// A watch-history file is one small block; the single-request upload
// is one failure point where the draft dance is three.
.with_small_file_upload(true);
Account { session, drive }
}
/// Write every refreshed token pair back to the secret store.
///
/// Refresh tokens are single-use: once the HTTP client has spent the stored
/// one, only the pair it got back can resume the session.
fn persist_refreshed_tokens(
secrets: &Arc<dyn SecretStore>,
session: &ProtonApiSession,
key_passphrases: HashMap<String, String>,
) {
let secrets = Arc::clone(secrets);
let template = session.clone();
session.http().set_on_tokens_refreshed(move |tokens| {
let stored = StoredSession::capture(&template, tokens, key_passphrases.clone());
let written = serde_json::to_string(&stored)
.map_err(|e| Error::Config(e.to_string()))
.and_then(|encoded| secrets.set(SESSION_KEY, &encoded));
if let Err(error) = written {
tracing::warn!(%error, "the refreshed Proton session could not be stored");
}
});
}
/// Lift a CAPTCHA gate and a wrong password out of the generic API error.
///
/// Only a gate that can be solved in an app becomes
/// [`Error::HumanVerification`]; one that wants an e-mail or SMS code is a plain
/// failure, which reads better than an empty verification page.
fn classify_sign_in_failure(error: ProtonError) -> Error {
if let ProtonError::Api(api) = &error {
if let Some(challenge) = api.human_verification()
&& challenge.supports_captcha()
{
return Error::HumanVerification(Box::new(challenge));
}
if api.http_status == 422 && !api.is_human_verification_required() {
return Error::SignIn(if api.message.is_empty() {
"Proton did not accept that address and password".to_owned()
} else {
api.message.clone()
});
}
}
error.into()
}
/// The token out of a message posted by Proton's verification page, or `None`
/// for anything that is not a completion.
///
/// The page posts plenty besides — resize requests, readiness pings — and
/// taking an unrecognised message for success would retry the sign-in with a
/// garbage token. Both spellings of the success type have shipped, and some
/// hosts hand the message over stringified twice.
pub fn verification_token(raw: &str) -> Option<String> {
let mut value: serde_json::Value = serde_json::from_str(raw).ok()?;
if let Some(inner) = value.as_str()
&& let Ok(parsed) = serde_json::from_str(inner)
{
value = parsed;
}
let kind = value.get("type")?.as_str()?;
if !matches!(
kind,
"HUMAN_VERIFICATION_SUCCESS" | "human_verification_success"
) {
return None;
}
let token = value.get("payload")?.get("token")?.as_str()?;
(!token.is_empty()).then(|| token.to_owned())
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use parking_lot::Mutex;
use super::*;
#[derive(Default)]
struct MemorySecrets(Mutex<HashMap<String, String>>);
impl SecretStore for MemorySecrets {
fn set(&self, key: &str, value: &str) -> Result<()> {
self.0.lock().insert(key.to_owned(), value.to_owned());
Ok(())
}
fn get(&self, key: &str) -> Result<Option<String>> {
Ok(self.0.lock().get(key).cloned())
}
fn delete(&self, key: &str) -> Result<()> {
self.0.lock().remove(key);
Ok(())
}
}
fn stored() -> StoredSession {
StoredSession {
session_id: "session".to_owned(),
username: "viewer@proton.me".to_owned(),
user_id: "user".to_owned(),
access_token: "access".to_owned(),
refresh_token: "refresh".to_owned(),
scopes: vec!["drive".to_owned()],
password_mode: 2,
key_passphrases: HashMap::from([("key-1".to_owned(), "derived".to_owned())]),
}
}
#[test]
fn nobody_is_signed_in_until_a_session_is_stored() {
let store = AccountStore::new(Arc::new(MemorySecrets::default()));
assert_eq!(store.username().expect("read"), None);
assert!(store.resume().expect("resume").is_none());
}
#[test]
fn a_stored_session_resumes_as_its_account_without_a_request() {
let secrets = Arc::new(MemorySecrets::default());
secrets
.set(SESSION_KEY, &serde_json::to_string(&stored()).unwrap())
.unwrap();
let store = AccountStore::new(secrets);
assert_eq!(
store.username().expect("read").as_deref(),
Some("viewer@proton.me")
);
let account = store.resume().expect("resume").expect("signed in");
assert_eq!(account.username(), "viewer@proton.me");
}
#[tokio::test]
async fn signing_out_forgets_the_stored_session() {
let secrets = Arc::new(MemorySecrets::default());
secrets
.set(SESSION_KEY, &serde_json::to_string(&stored()).unwrap())
.unwrap();
let store = AccountStore::new(secrets);
store.sign_out(None).await.expect("sign out");
assert_eq!(store.username().expect("read"), None);
}
#[test]
fn the_stored_session_holds_no_password() {
let encoded = serde_json::to_string(&stored()).unwrap();
assert!(!encoded.contains("password\""), "{encoded}");
assert!(!encoded.contains("key_salts"), "{encoded}");
}
#[test]
fn a_session_stored_by_an_older_build_reads_as_signed_out() {
let secrets = Arc::new(MemorySecrets::default());
secrets
.set(
SESSION_KEY,
r#"{"session_id":"s","username":"u","user_id":"i","access_token":"a",
"refresh_token":"r","scopes":[],"password_mode":1,
"mailbox_password":"hunter2","key_salts":[]}"#,
)
.unwrap();
let store = AccountStore::new(secrets);
assert_eq!(store.username().expect("read"), None);
assert!(store.resume().expect("resume").is_none());
}
#[test]
fn a_stored_session_keeps_its_password_mode() {
let params = stored().resume_parameters();
assert_eq!(params.password_mode, PasswordMode::Dual);
assert!(!params.is_waiting_for_second_factor_code);
}
#[test]
fn a_verification_completion_yields_its_token() {
let raw =
r#"{"type":"HUMAN_VERIFICATION_SUCCESS","payload":{"token":"tok-1","type":"captcha"}}"#;
assert_eq!(verification_token(raw).as_deref(), Some("tok-1"));
let twice = r#""{\"type\":\"HUMAN_VERIFICATION_SUCCESS\",\"payload\":{\"token\":\"tok-1\",\"type\":\"captcha\"}}""#;
assert_eq!(verification_token(twice).as_deref(), Some("tok-1"));
}
#[test]
fn messages_that_are_not_a_verification_completion_are_ignored() {
for raw in [
r#"{"type":"resize","payload":{"height":400}}"#,
r#"{"type":"HUMAN_VERIFICATION_SUCCESS"}"#,
r#"{"type":"HUMAN_VERIFICATION_SUCCESS","payload":{"token":""}}"#,
"not json",
"",
] {
assert!(verification_token(raw).is_none(), "accepted: {raw}");
}
}
}
+34
View File
@@ -123,6 +123,30 @@ impl Accent {
} }
} }
/// What shape the library's grid is.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Tiles {
/// 2:3 covers. The default, because the art a provider has for every
/// title is a cover; a 16:9 still exists for fewer of them, and a grid of
/// letterboxed covers was the desktop's weakest page.
#[default]
Posters,
/// 16:9 stills: the provider's fanart, else a frame from the file.
Stills,
}
impl Tiles {
pub const ALL: [Self; 2] = [Self::Posters, Self::Stills];
pub fn label(self) -> &'static str {
match self {
Self::Posters => "Posters",
Self::Stills => "Stills",
}
}
}
/// The whole of the choice. /// The whole of the choice.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
// An older or hand-edited file is missing fields rather than invalid. // An older or hand-edited file is missing fields rather than invalid.
@@ -138,6 +162,10 @@ pub struct Appearance {
/// slow ramp across a wide bar bands visibly, and flat is better than /// slow ramp across a wide bar bands visibly, and flat is better than
/// striped. /// striped.
pub gradients: bool, pub gradients: bool,
/// The library grid's shape.
pub tiles: Tiles,
/// Which name a title goes by.
pub names: crate::metadata::TitleNames,
} }
impl Default for Appearance { impl Default for Appearance {
@@ -146,6 +174,8 @@ impl Default for Appearance {
flavor: Flavor::default(), flavor: Flavor::default(),
accent: Accent::default(), accent: Accent::default(),
gradients: true, gradients: true,
tiles: Tiles::default(),
names: crate::metadata::TitleNames::default(),
} }
} }
} }
@@ -615,6 +645,7 @@ mod tests {
flavor: Flavor::Mocha, flavor: Flavor::Mocha,
accent: Accent::PinkSky, accent: Accent::PinkSky,
gradients: false, gradients: false,
..Appearance::default()
}; };
save(&dirs, &appearance).unwrap(); save(&dirs, &appearance).unwrap();
assert_eq!(load(&dirs).unwrap(), appearance); assert_eq!(load(&dirs).unwrap(), appearance);
@@ -652,6 +683,7 @@ mod tests {
flavor, flavor,
accent, accent,
gradients: true, gradients: true,
..Appearance::default()
}); });
let ratio = worst_contrast(palette.accent, palette.accent_alt, palette.on_accent); let ratio = worst_contrast(palette.accent, palette.accent_alt, palette.on_accent);
assert!( assert!(
@@ -681,6 +713,7 @@ mod tests {
flavor: Flavor::Persona5, flavor: Flavor::Persona5,
accent: Accent::Red, accent: Accent::Red,
gradients: false, gradients: false,
..Appearance::default()
}); });
assert_eq!(palette.accent, PERSONA5.red); assert_eq!(palette.accent, PERSONA5.red);
assert!(!palette.light); assert!(!palette.light);
@@ -697,6 +730,7 @@ mod tests {
flavor, flavor,
accent: Accent::Red, accent: Accent::Red,
gradients: true, gradients: true,
..Appearance::default()
}); });
// The accent is the flavour's red — deepened on the light flavour, // The accent is the flavour's red — deepened on the light flavour,
// where it would otherwise be too bright to put a label on. // where it would otherwise be too bright to put a label on.
+395
View File
@@ -0,0 +1,395 @@
//! Finding and ordering titles: what both library grids show, and in what
//! order.
//!
//! Here rather than in a front end so the desktop and Android agree: the same
//! search finds the same titles, a franchise folds into the same tile, and
//! "Newest" means the same thing on both.
use std::collections::{HashMap, HashSet};
use crate::library::{Library, Title, sort_name};
use crate::metadata::{MetadataRecord, TitleNames};
/// What order a grid is in.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Sort {
/// By the name shown, ignoring a leading article.
#[default]
Name,
/// Most recently played first.
Recent,
/// Most recently given a new file first.
Added,
/// Newest first by when it aired, undated last.
Release,
/// Best rated first, unrated last.
Rating,
/// Most followed at the provider first.
Popularity,
}
impl Sort {
pub const ALL: [Self; 6] = [
Self::Name,
Self::Recent,
Self::Added,
Self::Release,
Self::Rating,
Self::Popularity,
];
pub fn label(self) -> &'static str {
match self {
Self::Name => "A – Z",
Self::Recent => "Recently watched",
Self::Added => "Recently added",
Self::Release => "Newest",
Self::Rating => "Highest rated",
Self::Popularity => "Most popular",
}
}
}
/// The name to show for `title`: the provider's, in the form the viewer
/// picked, else the share's.
pub fn display_name<'a>(
title: &'a Title,
metadata: &'a HashMap<String, MetadataRecord>,
names: TitleNames,
) -> &'a str {
metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
.and_then(|found| found.display_name(names))
.unwrap_or(&title.name)
}
/// The titles `query` finds, in library order.
///
/// By the share's name and every filename under it — a release group, an
/// episode name — and by every name the provider gave: a folder called
/// `Sousou no Frieren` is found by typing "Frieren: Beyond".
pub fn search<'a>(
library: &'a Library,
metadata: &HashMap<String, MetadataRecord>,
query: &str,
) -> Vec<&'a Title> {
let needle = query.trim().to_lowercase();
let by_files: HashSet<*const Title> = library
.search(query)
.into_iter()
.map(|title| title as *const Title)
.collect();
library
.titles
.iter()
.filter(|title| {
by_files.contains(&(*title as *const Title))
|| metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
.is_some_and(|found| {
std::iter::once(found.name.as_str())
.chain(found.original_name.as_deref())
.chain(
found
.details
.as_ref()
.and_then(|details| details.romaji.as_deref()),
)
.any(|name| name.to_lowercase().contains(&needle))
})
})
.collect()
}
/// How to order a grid.
#[derive(Debug, Clone, Copy)]
pub struct Order<'a> {
pub sort: Sort,
/// One tile per franchise rather than one per title.
pub grouped: bool,
pub names: TitleNames,
pub metadata: &'a HashMap<String, MetadataRecord>,
/// When each title last gained a file, by key — see
/// [`Library::added_at`].
pub added: &'a HashMap<String, i64>,
}
/// One tile: the title drawn, and every title it stands for — itself
/// included, in release order — as indices into what was arranged.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tile {
pub lead: usize,
pub members: Vec<usize>,
}
/// `titles` as tiles, in `order`.
///
/// Every sort is stable over the name order, so ties — and the undated,
/// unrated and unplayed — keep their alphabetical order.
pub fn arrange<'t>(titles: &[&'t Title], order: Order<'_>) -> Vec<Tile> {
let metadata = order.metadata;
let found = |title: &Title| {
metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
};
let mut by_name: Vec<usize> = (0..titles.len()).collect();
by_name
.sort_by_cached_key(|&index| sort_name(display_name(titles[index], metadata, order.names)));
let sorted: Vec<&Title> = by_name.iter().map(|&index| titles[index]).collect();
let mut tiles: Vec<Tile> = if order.grouped {
crate::franchise::group(&sorted, metadata)
.into_iter()
.map(|franchise| Tile {
lead: by_name[franchise.lead(&sorted)],
members: franchise
.members
.iter()
.map(|&member| by_name[member])
.collect(),
})
.collect()
} else {
by_name
.iter()
.map(|&index| Tile {
lead: index,
members: vec![index],
})
.collect()
};
// A folded franchise files under the name on its tile — the lead's — and
// not under whichever member sorted first: "Made in Abyss" belongs at M
// even when its film is called "Dawn of the Deep Soul".
tiles.sort_by_cached_key(|tile| {
sort_name(display_name(titles[tile.lead], metadata, order.names))
});
let members = |tile: &Tile| -> Vec<&'t Title> {
tile.members.iter().map(|&index| titles[index]).collect()
};
match order.sort {
Sort::Name => {}
Sort::Recent => tiles.sort_by_key(|tile| {
std::cmp::Reverse(members(tile).iter().map(|title| title.last_played()).max())
}),
Sort::Added => tiles.sort_by_key(|tile| {
std::cmp::Reverse(
members(tile)
.iter()
.filter_map(|title| order.added.get(&title.key))
.max()
.copied(),
)
}),
Sort::Release => tiles.sort_by_cached_key(|tile| {
std::cmp::Reverse(
members(tile)
.iter()
.map(|title| crate::franchise::release_key(title, metadata))
// `~` is how the key says "undated"; it sorts last here
// rather than first.
.filter(|key| key != "~")
.max(),
)
}),
Sort::Rating => tiles.sort_by_key(|tile| {
std::cmp::Reverse(
found(titles[tile.lead])
.and_then(|found| found.rating)
.map(|rating| (rating * 100.0) as i64),
)
}),
Sort::Popularity => tiles.sort_by_key(|tile| {
std::cmp::Reverse(
found(titles[tile.lead])
.and_then(|found| found.details.as_ref())
.and_then(|details| details.popularity),
)
}),
}
tiles
}
#[cfg(test)]
mod tests {
use super::*;
use crate::library::TitleKind;
use crate::metadata::{ProviderId, TitleDetails, TitleMetadata};
fn title(key: &str, name: &str, kind: TitleKind) -> Title {
Title {
key: key.into(),
name: name.into(),
year: None,
kind,
seasons: Vec::new(),
share_ids: Vec::new(),
}
}
fn matched(
key: &str,
id: &str,
english: &str,
start: &str,
rating: f32,
related: &[&str],
) -> (String, MetadataRecord) {
(
key.into(),
MetadataRecord {
title_key: key.into(),
provider: ProviderId::AniList,
metadata: Some(TitleMetadata {
provider: ProviderId::AniList,
remote_id: id.into(),
name: english.into(),
original_name: None,
overview: None,
year: None,
kind: TitleKind::Series,
poster_url: None,
backdrop_url: None,
rating: Some(rating),
genres: Vec::new(),
episodes: None,
url: None,
details: Some(TitleDetails {
romaji: Some(key.into()),
start_date: Some(start.into()),
related: related.iter().map(|id| id.to_string()).collect(),
..TitleDetails::default()
}),
}),
fetched_at: 0,
manual: false,
},
)
}
fn fixture() -> (Vec<Title>, HashMap<String, MetadataRecord>) {
let titles = vec![
title("abyss film", "Made in Abyss Dawn", TitleKind::Film),
title("made in abyss", "Made in Abyss", TitleKind::Series),
title("sousou no frieren", "Sousou no Frieren", TitleKind::Series),
];
let metadata = HashMap::from([
matched(
"abyss film",
"2",
"Dawn of the Deep Soul",
"2020-01-17",
8.5,
&["1"],
),
matched(
"made in abyss",
"1",
"Made in Abyss",
"2017-07-07",
8.4,
&["2"],
),
matched(
"sousou no frieren",
"3",
"Frieren: Beyond Journey's End",
"2023-09-29",
9.1,
&[],
),
]);
(titles, metadata)
}
fn keys(tiles: &[Tile], titles: &[&Title]) -> Vec<String> {
tiles
.iter()
.map(|tile| titles[tile.lead].key.clone())
.collect()
}
#[test]
fn a_franchise_folds_into_its_series_and_sorts_by_the_name_shown() {
let (owned, metadata) = fixture();
let titles: Vec<&Title> = owned.iter().collect();
let added = HashMap::new();
let order = Order {
sort: Sort::Name,
grouped: true,
names: TitleNames::English,
metadata: &metadata,
added: &added,
};
let tiles = arrange(&titles, order);
// "Frieren" before "Made in Abyss" in English; the film is folded.
assert_eq!(
keys(&tiles, &titles),
vec!["sousou no frieren", "made in abyss"]
);
assert_eq!(tiles[1].members, vec![1, 0]);
let tiles = arrange(
&titles,
Order {
names: TitleNames::Library,
grouped: false,
..order
},
);
assert_eq!(
keys(&tiles, &titles),
vec!["made in abyss", "abyss film", "sousou no frieren"]
);
}
#[test]
fn newest_and_best_rated_read_the_provider_and_put_the_unknown_last() {
let (mut owned, metadata) = fixture();
owned.push(title(
"game of thrones",
"Game of Thrones",
TitleKind::Series,
));
let titles: Vec<&Title> = owned.iter().collect();
let added = HashMap::new();
let order = Order {
sort: Sort::Release,
grouped: true,
names: TitleNames::Library,
metadata: &metadata,
added: &added,
};
// The Made in Abyss franchise dates from its newest part, 2020.
assert_eq!(
keys(&arrange(&titles, order), &titles),
vec!["sousou no frieren", "made in abyss", "game of thrones"]
);
let order = Order {
sort: Sort::Rating,
..order
};
assert_eq!(
keys(&arrange(&titles, order), &titles)
.last()
.map(String::as_str),
Some("game of thrones")
);
}
#[test]
fn search_finds_a_title_by_any_name_the_provider_gave_it() {
let (owned, metadata) = fixture();
let library = Library { titles: owned };
let found = search(&library, &metadata, "beyond journey");
assert_eq!(found.len(), 1);
assert_eq!(found[0].key, "sousou no frieren");
assert_eq!(search(&library, &metadata, "").len(), 3);
}
}
File diff suppressed because it is too large. Load diff
+6
View File
@@ -88,6 +88,12 @@ impl AppDirs {
self.config.join("shares.json") self.config.join("shares.json")
} }
/// This installation's identity for watch-history sync. See
/// [`crate::sync`].
pub fn sync_file(&self) -> PathBuf {
self.config.join("sync.json")
}
/// The catalog database. /// The catalog database.
pub fn catalog_db(&self) -> PathBuf { pub fn catalog_db(&self) -> PathBuf {
self.data.join("catalog.db") self.data.join("catalog.db")
+31
View File
@@ -30,6 +30,37 @@ pub enum Error {
/// The caller asked for something that does not exist or does not apply. /// The caller asked for something that does not exist or does not apply.
#[error("{0}")] #[error("{0}")]
NotFound(String), 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"))] #[cfg(not(target_os = "android"))]
+290
View File
@@ -0,0 +1,290 @@
//! Which titles in a library are one story.
//!
//! A share files a franchise however its uploader did: Made in Abyss as a
//! series folder and three film folders, Evangelion as a series, two films and
//! a folder of four more. The provider knows they belong together — AniList
//! records prequels, sequels, side stories, recaps and retellings as relations
//! — so the library can show one tile for the lot and the parts on its page.
//!
//! Grouping is by shared provider id, not by name. Two titles are one
//! franchise when one names the other as related, or when both name the same
//! entry: Mushishi's *Suzu no Shizuku* film relates to *Zoku Shou 2*, which the
//! library does not hold as a title of its own but which the series' episode
//! walk passed through (see [`crate::metadata::TitleDetails::chain`]).
use std::collections::HashMap;
use crate::library::{Title, TitleKind};
use crate::metadata::MetadataRecord;
/// One franchise: indices into the titles it was built from, in release order.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Franchise {
pub members: Vec<usize>,
}
impl Franchise {
/// The member that stands for the rest: the first series to air, or the
/// first film when there is no series. What a viewer means by "Made in
/// Abyss" is the show, not the recap film that came out after it.
pub fn lead(&self, titles: &[&Title]) -> usize {
self.members
.iter()
.copied()
.find(|&index| titles[index].kind == TitleKind::Series)
.unwrap_or(self.members[0])
}
}
/// Group `titles` into franchises, every title in exactly one.
///
/// Titles with no match are franchises of one. The groups come back in the
/// order of their first member in `titles`, so a sorted list stays sorted.
pub fn group(titles: &[&Title], metadata: &HashMap<String, MetadataRecord>) -> Vec<Franchise> {
let mut sets = DisjointSets::new(titles.len());
// The first title to name each provider id, for later titles naming it too.
let mut named: HashMap<(crate::metadata::ProviderId, &str), usize> = HashMap::new();
for (index, title) in titles.iter().enumerate() {
let Some(found) = metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
else {
continue;
};
let details = found.details.as_ref();
let ids = std::iter::once(found.remote_id.as_str())
.chain(details.into_iter().flat_map(|details| {
details
.related
.iter()
.chain(&details.chain)
.map(String::as_str)
}))
.filter(|id| !id.is_empty());
for id in ids {
match named.get(&(found.provider, id)) {
Some(&other) => sets.union(index, other),
None => {
named.insert((found.provider, id), index);
}
}
}
}
let mut groups: Vec<Franchise> = Vec::new();
let mut slot: HashMap<usize, usize> = HashMap::new();
for index in 0..titles.len() {
let root = sets.find(index);
match slot.get(&root) {
Some(&at) => groups[at].members.push(index),
None => {
slot.insert(root, groups.len());
groups.push(Franchise {
members: vec![index],
});
}
}
}
for franchise in &mut groups {
franchise
.members
.sort_by_cached_key(|&index| release_key(titles[index], metadata));
}
groups
}
/// What release order sorts on: the provider's start date, else the year the
/// files state, else last.
pub fn release_key(title: &Title, metadata: &HashMap<String, MetadataRecord>) -> String {
let found = metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref());
found
.and_then(|found| found.details.as_ref())
.and_then(|details| details.start_date.clone())
.or_else(|| {
found
.and_then(|found| found.year)
.map(|year| year.to_string())
})
.or_else(|| title.year.map(|year| year.to_string()))
// `~` sorts after every digit: undated goes last.
.unwrap_or_else(|| "~".to_string())
}
struct DisjointSets {
parent: Vec<usize>,
}
impl DisjointSets {
fn new(size: usize) -> Self {
Self {
parent: (0..size).collect(),
}
}
fn find(&mut self, mut index: usize) -> usize {
while self.parent[index] != index {
self.parent[index] = self.parent[self.parent[index]];
index = self.parent[index];
}
index
}
fn union(&mut self, left: usize, right: usize) {
let (left, right) = (self.find(left), self.find(right));
if left != right {
// The earlier title stays the root, which keeps `group` stable.
let (root, child) = (left.min(right), left.max(right));
self.parent[child] = root;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::metadata::{ProviderId, TitleDetails, TitleMetadata};
fn title(key: &str, kind: TitleKind) -> Title {
Title {
key: key.into(),
name: key.into(),
year: None,
kind,
seasons: Vec::new(),
share_ids: Vec::new(),
}
}
fn matched(
key: &str,
id: &str,
start: &str,
related: &[&str],
chain: &[&str],
) -> MetadataRecord {
MetadataRecord {
title_key: key.into(),
provider: ProviderId::AniList,
metadata: Some(TitleMetadata {
provider: ProviderId::AniList,
remote_id: id.into(),
name: key.into(),
original_name: None,
overview: None,
year: None,
kind: TitleKind::Series,
poster_url: None,
backdrop_url: None,
rating: None,
genres: Vec::new(),
episodes: None,
url: None,
details: Some(TitleDetails {
start_date: Some(start.into()),
related: related.iter().map(|id| id.to_string()).collect(),
chain: chain.iter().map(|id| id.to_string()).collect(),
..TitleDetails::default()
}),
}),
fetched_at: 0,
manual: false,
}
}
fn keys(groups: &[Franchise], titles: &[&Title]) -> Vec<Vec<String>> {
groups
.iter()
.map(|group| {
group
.members
.iter()
.map(|&index| titles[index].key.clone())
.collect()
})
.collect()
}
/// Made in Abyss: the films name the series, the series names the films.
/// Paprika names nothing in the library and stays alone.
#[test]
fn titles_that_name_each_other_are_one_franchise_in_release_order() {
let owned = [
title("dawn of the deep soul", TitleKind::Film),
title("made in abyss", TitleKind::Series),
title("paprika", TitleKind::Film),
];
let titles: Vec<&Title> = owned.iter().collect();
let metadata = HashMap::from([
(
"dawn of the deep soul".to_string(),
matched(
"dawn of the deep soul",
"100643",
"2020-01-17",
&["97986"],
&[],
),
),
(
"made in abyss".to_string(),
matched("made in abyss", "97986", "2017-07-07", &["100643"], &[]),
),
(
"paprika".to_string(),
matched("paprika", "1943", "2006-11-25", &[], &[]),
),
]);
let groups = group(&titles, &metadata);
assert_eq!(
keys(&groups, &titles),
vec![
vec!["made in abyss", "dawn of the deep soul"],
vec!["paprika"]
]
);
assert_eq!(titles[groups[0].lead(&titles)].key, "made in abyss");
}
/// Mushishi: the special names an entry the series only reached by
/// walking its sequels for episodes. That is still the same story.
#[test]
fn an_entry_the_series_walked_through_joins_what_names_it() {
let owned = [
title("mushishi", TitleKind::Series),
title("suzu no shizuku", TitleKind::Film),
];
let titles: Vec<&Title> = owned.iter().collect();
let metadata = HashMap::from([
(
"mushishi".to_string(),
matched(
"mushishi",
"457",
"2005-10-23",
&["20526"],
&["457", "20595", "20751"],
),
),
(
"suzu no shizuku".to_string(),
matched("suzu no shizuku", "21008", "2015-05-16", &["20751"], &[]),
),
]);
assert_eq!(group(&titles, &metadata).len(), 1);
}
/// Nothing matched, nothing to group by — and two unmatched titles are
/// not one franchise because they share an empty id.
#[test]
fn unmatched_titles_stand_alone() {
let owned = [
title("game of thrones", TitleKind::Series),
title("something else", TitleKind::Series),
];
let titles: Vec<&Title> = owned.iter().collect();
assert_eq!(group(&titles, &HashMap::new()).len(), 2);
}
}
+8 -1
View File
@@ -5,21 +5,28 @@
//! how a share is opened and how its contents are modelled. //! how a share is opened and how its contents are modelled.
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
pub mod account;
pub mod appearance; pub mod appearance;
pub mod browse;
pub mod catalog; pub mod catalog;
pub mod chapters; pub mod chapters;
pub mod config; pub mod config;
pub mod error; pub mod error;
pub mod franchise;
pub mod library; pub mod library;
pub mod metadata; pub mod metadata;
pub mod naming; pub mod naming;
pub mod prefs; pub mod prefs;
pub mod profile;
pub mod shares; pub mod shares;
pub mod shelves;
pub mod sync;
pub use account::{Account, AccountStore};
pub use error::{Error, Result}; pub use error::{Error, Result};
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
pub use shares::KeyringSecretStore; pub use shares::KeyringSecretStore;
pub use shares::{SecretStore, Share, ShareStore, SharedLibrary}; pub use shares::{SecretStore, Share, ShareClient, ShareStore, SharedLibrary};
pub use proton_drive_rs; pub use proton_drive_rs;
pub use proton_sdk; pub use proton_sdk;
+22 -1
View File
@@ -389,6 +389,27 @@ impl Library {
played played
} }
/// When each title last gained a file, Unix seconds — the newest
/// [`crate::catalog::Catalog::first_seen`] among its episodes. `0` for a
/// title whose files were all there before that was recorded.
pub fn added_at(&self, first_seen: &HashMap<(String, String), i64>) -> HashMap<String, i64> {
self.titles
.iter()
.map(|title| {
let newest = title
.episodes()
.filter_map(|episode| {
first_seen
.get(&(episode.node.share_id.clone(), episode.node.link_id.clone()))
})
.copied()
.max()
.unwrap_or(0);
(title.key.clone(), newest)
})
.collect()
}
/// Substring match over the display name and every filename under it, so /// Substring match over the display name and every filename under it, so
/// searching for a release group or an episode name finds its title. /// searching for a release group or an episode name finds its title.
pub fn search(&self, query: &str) -> Vec<&Title> { pub fn search(&self, query: &str) -> Vec<&Title> {
@@ -438,7 +459,7 @@ pub fn title_key(title: &str) -> String {
/// Sort key for the shelf: case-insensitive, and a leading article ignored, so /// Sort key for the shelf: case-insensitive, and a leading article ignored, so
/// `The Expanse` files under E where a viewer looks for it. /// `The Expanse` files under E where a viewer looks for it.
fn sort_name(name: &str) -> String { pub fn sort_name(name: &str) -> String {
let lower = name.to_lowercase(); let lower = name.to_lowercase();
for article in ["the ", "a ", "an "] { for article in ["the ", "a ", "an "] {
if let Some(rest) = lower.strip_prefix(article) { if let Some(rest) = lower.strip_prefix(article) {
+338 -25
View File
@@ -20,7 +20,7 @@
//! thumbnails at upload time and that client attaches none. See //! thumbnails at upload time and that client attaches none. See
//! `docs/DEVELOPMENT.md`. //! `docs/DEVELOPMENT.md`.
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -31,7 +31,7 @@ use crate::library::TitleKind;
/// Stored as a string in the catalog rather than an integer: a row whose /// Stored as a string in the catalog rather than an integer: a row whose
/// provider this build does not recognise should read as "some other provider", /// provider this build does not recognise should read as "some other provider",
/// which is a re-match, not a parse failure. /// which is a re-match, not a parse failure.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum ProviderId { pub enum ProviderId {
/// AniList. Anime only, no API key, no account. /// AniList. Anime only, no API key, no account.
@@ -62,7 +62,10 @@ impl ProviderId {
/// One line on what it covers and what it costs to use. /// One line on what it covers and what it costs to use.
pub fn description(self) -> &'static str { pub fn description(self) -> &'static str {
match self { match self {
Self::AniList => "Anime, with no API key and no account.", Self::AniList => {
"Anime, with no API key and no account. Episode names and wide art come from \
ani.zip, which is sent the matched ids."
}
Self::Tmdb => "Film and television. Needs a free API key from themoviedb.org.", Self::Tmdb => "Film and television. Needs a free API key from themoviedb.org.",
} }
} }
@@ -109,6 +112,156 @@ pub struct TitleMetadata {
pub episodes: Option<u32>, pub episodes: Option<u32>,
/// The title's page, for a viewer who wants the rest of it. /// The title's page, for a viewer who wants the rest of it.
pub url: Option<String>, pub url: Option<String>,
/// Everything else the provider said: what the library is sorted, grouped
/// and shelved by. `None` on a record stored before there was any of it,
/// which is what tells a match run to fetch it — see
/// `pstr_meta::service::plan`.
pub details: Option<TitleDetails>,
}
/// What a provider says about a title beyond what a tile draws.
///
/// Stored whole as one JSON column rather than a column each: nothing here is
/// ever queried by SQLite, every field is optional at every provider, and a
/// field added later has to read as absent from a row written before it, not
/// fail the row. TMDB fills none of it yet.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct TitleDetails {
/// The title in romaji — `Sousou no Frieren` — for a viewer who would
/// rather see that than the English one.
pub romaji: Option<String>,
/// AniList's ultra-wide banner, around 4.75:1. Never drawn into a 16:9
/// tile — it is what a strip that shape falls back to when there is no
/// fanart.
pub banner_url: Option<String>,
/// As the provider writes it: `TV`, `TV_SHORT`, `MOVIE`, `SPECIAL`, `OVA`,
/// `ONA`, `MUSIC`. [`TitleDetails::format_label`] is what is shown.
pub format: Option<String>,
/// The broadcast season: `WINTER`, `SPRING`, `SUMMER` or `FALL`.
pub season: Option<String>,
/// When it first aired, `YYYY-MM-DD` with whatever parts are known —
/// `2013-04`, `2013`. Sorts as text, which is why it is kept that way.
pub start_date: Option<String>,
/// The main animation studios.
pub studios: Vec<String>,
pub directors: Vec<String>,
/// The provider's descriptive tags, most relevant first, spoilers left out.
pub tags: Vec<String>,
/// How many people follow it at the provider. Only ever compared with
/// other titles from the same provider.
pub popularity: Option<u32>,
/// `FINISHED`, `RELEASING`, `NOT_YET_RELEASED`, `CANCELLED` or `HIATUS`.
pub status: Option<String>,
/// The next episode to air, and when — Unix seconds. As of when the
/// answer was fetched, so a time in the past means nothing.
pub next_episode: Option<u32>,
pub next_airing_at: Option<i64>,
/// The provider ids of entries in the same story — prequels, sequels,
/// side stories, recaps, retellings. What the library groups a franchise
/// by; see `crate::franchise`.
pub related: Vec<String>,
/// The entries enrichment walked through to find the title's episodes —
/// its own sequels, by provider id. Stored apart from the rest, because
/// it is enrichment's answer rather than the match's.
#[serde(skip)]
pub chain: Vec<String>,
}
impl TitleDetails {
/// `TV` → `TV`, `MOVIE` → `Film`, `TV_SHORT` → `TV short`.
pub fn format_label(&self) -> Option<&'static str> {
Some(match self.format.as_deref()? {
"TV" => "TV",
"TV_SHORT" => "TV short",
"MOVIE" => "Film",
"SPECIAL" => "Special",
"OVA" => "OVA",
"ONA" => "ONA",
"MUSIC" => "Music video",
_ => return None,
})
}
/// `Spring 2013`, or just the year.
pub fn season_label(&self) -> Option<String> {
let year = self.start_date.as_deref()?.get(..4)?;
let season = match self.season.as_deref() {
Some("WINTER") => "Winter",
Some("SPRING") => "Spring",
Some("SUMMER") => "Summer",
Some("FALL") => "Fall",
_ => return Some(year.to_string()),
};
Some(format!("{season} {year}"))
}
/// Whether it is still airing.
pub fn is_airing(&self) -> bool {
self.status.as_deref() == Some("RELEASING")
}
/// The next episode and when it airs, when that is still after `now`.
pub fn next_airing(&self, now: i64) -> Option<(u32, i64)> {
let at = self.next_airing_at.filter(|at| *at > now)?;
Some((self.next_episode?, at))
}
}
impl TitleMetadata {
/// Ultra-wide art: the backdrop where there is one, else the banner.
///
/// For the library's hero and the title page's band, which are much wider
/// than 16:9 — a backdrop cropped to them loses some sky, a banner fits
/// them as drawn.
pub fn wide_art(&self) -> Option<&str> {
self.backdrop_url.as_deref().or_else(|| {
self.details
.as_ref()
.and_then(|details| details.banner_url.as_deref())
})
}
/// The name to show, as the viewer asked for it.
pub fn display_name(&self, names: TitleNames) -> Option<&str> {
match names {
TitleNames::Library => None,
TitleNames::English => Some(&self.name),
TitleNames::Romaji => self
.details
.as_ref()
.and_then(|details| details.romaji.as_deref())
.or(Some(&self.name)),
}
}
}
/// Which name a title goes by in the library.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TitleNames {
/// The folder's — what the share calls it.
Library,
/// The provider's English title, where it has one. The default, because
/// it is what Android always showed, and a folder name is whatever an
/// uploader typed — `Kaijuu 8-gou`, `Fate Stay Night - Heaven s Feel -
/// THE MOVIE I presage flower`.
#[default]
English,
/// The provider's romaji title.
Romaji,
}
impl TitleNames {
pub const ALL: [Self; 3] = [Self::Library, Self::English, Self::Romaji];
pub fn label(self) -> &'static str {
match self {
Self::Library => "As in the share",
Self::English => "English",
Self::Romaji => "Romaji",
}
}
} }
impl TitleMetadata { impl TitleMetadata {
@@ -139,6 +292,23 @@ pub struct EpisodeMetadata {
/// anime releases name their files. /// anime releases name their files.
pub season: Option<u32>, pub season: Option<u32>,
pub number: u32, pub number: u32,
/// Its place counting straight through every season, when the provider
/// knows it. What an absolutely-numbered file inside a season folder —
/// `Attack on Titan/Season 2/… - 26.mkv` — is matched on.
#[serde(default)]
pub absolute: Option<u32>,
/// Its season and number counting each provider *entry* as a season: the
/// position of the entry it belongs to among the franchise's series
/// entries, and its number within that entry.
///
/// Not TVDB's season. A release that follows AniList rather than TVDB
/// names Mushishi's *Zoku Shou 2* `S03`, where TVDB files it as the second
/// half of season two; this is what that file is answered from. See
/// [`EpisodeGuide::get`] for when it is allowed to be.
#[serde(default)]
pub entry_season: Option<u32>,
#[serde(default)]
pub entry_number: Option<u32>,
pub name: Option<String>, pub name: Option<String>,
pub overview: Option<String>, pub overview: Option<String>,
/// A frame from the episode, around 16:9. /// A frame from the episode, around 16:9.
@@ -149,6 +319,23 @@ pub struct EpisodeMetadata {
pub air_date: Option<String>, pub air_date: Option<String>,
} }
/// What enriching a matched title found: its episodes, and art the search
/// answer did not carry.
///
/// Stored as one unit by `Catalog::set_enrichment`, and recorded as asked even
/// when both are empty — a film has no episode list, and without the record it
/// would be asked again on every match run.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Enrichment {
pub episodes: Vec<EpisodeMetadata>,
/// A 16:9 picture of the whole title, when one was found. `None` leaves
/// whatever backdrop the match already had.
pub backdrop_url: Option<String>,
/// Every provider entry the episodes were taken from, the match's own
/// included. See [`TitleDetails::chain`].
pub chain: Vec<String>,
}
/// Every episode a provider listed for one title, ready to look up. /// Every episode a provider listed for one title, ready to look up.
/// ///
/// The lookup is the whole reason this is a type rather than a `Vec`: a file /// The lookup is the whole reason this is a type rather than a `Vec`: a file
@@ -160,16 +347,33 @@ pub struct EpisodeGuide {
/// Keyed `(season, number)`, where `None` is the provider's own absolute /// Keyed `(season, number)`, where `None` is the provider's own absolute
/// numbering. /// numbering.
entries: HashMap<(Option<u32>, u32), EpisodeMetadata>, entries: HashMap<(Option<u32>, u32), EpisodeMetadata>,
/// [`EpisodeMetadata::absolute`] to the key of the entry holding it.
absolute: HashMap<u32, (Option<u32>, u32)>,
/// [`EpisodeMetadata::entry_season`] and `entry_number` to the key of the
/// entry holding them.
by_entry: HashMap<(u32, u32), (Option<u32>, u32)>,
/// Every season some entry is filed under, for deciding when the entry
/// numbering may answer.
stated: HashSet<u32>,
} }
impl EpisodeGuide { impl EpisodeGuide {
pub fn new(episodes: Vec<EpisodeMetadata>) -> Self { pub fn new(episodes: Vec<EpisodeMetadata>) -> Self {
Self { let mut guide = Self::default();
entries: episodes for episode in episodes {
.into_iter() let key = (episode.season, episode.number);
.map(|episode| ((episode.season, episode.number), episode)) if let Some(absolute) = episode.absolute {
.collect(), guide.absolute.insert(absolute, key);
}
if let (Some(season), Some(number)) = (episode.entry_season, episode.entry_number) {
guide.by_entry.insert((season, number), key);
}
if let Some(season) = episode.season {
guide.stated.insert(season);
}
guide.entries.insert(key, episode);
} }
guide
} }
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
@@ -182,20 +386,25 @@ impl EpisodeGuide {
/// What the provider says about the file numbered like this. /// What the provider says about the file numbered like this.
/// ///
/// Tried in order: the season and number the filename states; the /// Tried in order:
/// provider's absolute numbering; and, for a file with no season at all,
/// season one. That second fallback is what makes an absolutely-numbered
/// release line up with a provider that files everything under a single
/// season — the common case for anime on TMDB.
/// ///
/// **The absolute fallback stops at season one.** A provider that numbers /// 1. the season and number the filename states;
/// straight through is one that split nothing, so its episode seven is the /// 2. for a file in no season or in season one, the provider's own absolute
/// seventh of the show — which is season one's, not season two's. Answering /// list, and for a file in no season, season one — what makes `#057`
/// `S02E07` with it captions every episode of every later season with the /// line up with TMDB filing a 64-episode anime under one season;
/// wrong name, and a wrong caption does not look like a bug: it looks like /// 3. the absolute number, into the season the file states — what makes
/// the library is wrong. Season two upwards is answered from an entry that /// `Season 2/… - 26.mkv` the second season's first episode;
/// states that season or not at all — see /// 4. the entry numbering, for a season the provider files nothing under.
/// `Provider::seasons_are_separate_entries`. ///
/// **No fallback crosses into another season.** Step 2 stops at season one
/// and step 3 only answers inside the season the file named, because
/// answering `S02E07` with season one's seventh episode captions every
/// episode of every later season with the wrong name — and a wrong caption
/// does not look like a bug, it looks like the library is wrong. Step 4
/// only answers a season the provider has nothing filed under, so a release
/// numbered the provider's way is never re-read the other way: Mushoku
/// Tensei's second *entry* is the second half of season one, and its
/// `S02E01` is season two's.
pub fn get(&self, season: Option<u32>, number: u32) -> Option<&EpisodeMetadata> { pub fn get(&self, season: Option<u32>, number: u32) -> Option<&EpisodeMetadata> {
if let Some(found) = self.entries.get(&(season, number)) { if let Some(found) = self.entries.get(&(season, number)) {
return Some(found); return Some(found);
@@ -205,14 +414,31 @@ impl EpisodeGuide {
{ {
return Some(found); return Some(found);
} }
season if season.is_none()
.is_none() && let Some(found) = self.entries.get(&(Some(1), number))
.then(|| self.entries.get(&(Some(1), number)))? {
return Some(found);
}
if let Some(found) = self
.absolute
.get(&number)
.and_then(|key| self.entries.get(key))
&& (season.is_none() || found.season == season)
{
return Some(found);
}
let season = season?;
if self.stated.contains(&season) {
return None;
}
self.by_entry
.get(&(season, number))
.and_then(|key| self.entries.get(key))
} }
/// Which seasons this guide actually has answers for. `None` is the /// Which seasons this guide actually has answers for. `None` is the
/// provider's own absolute numbering. /// provider's own absolute numbering.
pub fn seasons(&self) -> std::collections::HashSet<Option<u32>> { pub fn seasons(&self) -> HashSet<Option<u32>> {
self.entries self.entries
.values() .values()
.map(|episode| episode.season) .map(|episode| episode.season)
@@ -328,6 +554,7 @@ mod tests {
genres: Vec::new(), genres: Vec::new(),
episodes: Some(26), episodes: Some(26),
url: None, url: None,
details: None,
} }
} }
@@ -335,6 +562,9 @@ mod tests {
EpisodeMetadata { EpisodeMetadata {
season, season,
number, number,
absolute: None,
entry_season: None,
entry_number: None,
name: Some(name.into()), name: Some(name.into()),
overview: None, overview: None,
still_url: None, still_url: None,
@@ -342,6 +572,89 @@ mod tests {
} }
} }
/// An episode as ani.zip describes one: TVDB's season and number, TVDB's
/// absolute number, and its place in the AniList entry it came from.
fn numbered(
(season, number): (u32, u32),
absolute: u32,
(entry_season, entry_number): (u32, u32),
name: &str,
) -> EpisodeMetadata {
EpisodeMetadata {
absolute: Some(absolute),
entry_season: Some(entry_season),
entry_number: Some(entry_number),
..episode(Some(season), number, name)
}
}
fn name_of(guide: &EpisodeGuide, season: Option<u32>, number: u32) -> Option<String> {
guide.get(season, number).and_then(|e| e.name.clone())
}
/// `Attack on Titan/Season 2/[Anime Time] Attack On Titan - 26.mkv`: a
/// season folder full of absolutely-numbered files.
#[test]
fn an_absolute_number_in_a_season_folder_finds_that_seasons_episode() {
let guide = EpisodeGuide::new(vec![
numbered((1, 1), 1, (1, 1), "To You, in 2000 Years"),
numbered((2, 1), 26, (2, 1), "Beast Titan"),
]);
assert_eq!(name_of(&guide, Some(2), 26).as_deref(), Some("Beast Titan"));
// And with no season at all, the absolute number is the whole answer.
assert_eq!(name_of(&guide, None, 26).as_deref(), Some("Beast Titan"));
}
/// The absolute fallback must not carry a file into a season it did not
/// name: `S03E13` of a show whose 13th episode is in season one is not that
/// episode.
#[test]
fn an_absolute_number_never_answers_for_another_season() {
let guide = EpisodeGuide::new(vec![
numbered((1, 13), 13, (1, 13), "Season one's thirteenth"),
numbered((3, 1), 38, (3, 1), "Smoke Signal"),
]);
assert_eq!(guide.get(Some(3), 13), None);
}
/// Mushishi: TVDB files both halves of *Zoku Shou* as season two, and the
/// release calls the second half `S03`. Season three exists nowhere at the
/// provider, so the entry numbering answers it.
#[test]
fn a_season_the_provider_does_not_have_is_answered_by_entry() {
let guide = EpisodeGuide::new(vec![
numbered((1, 1), 1, (1, 1), "The Green Seat"),
numbered((2, 1), 28, (2, 1), "The Hand That Caresses the Night"),
numbered((2, 11), 39, (3, 1), "Lightning's End"),
]);
assert_eq!(
name_of(&guide, Some(3), 1).as_deref(),
Some("Lightning's End")
);
assert_eq!(
name_of(&guide, Some(2), 1).as_deref(),
Some("The Hand That Caresses the Night")
);
}
/// Mushoku Tensei: AniList's second entry is the second half of TVDB's
/// season one. A release numbered TVDB's way names season two `S02`, and
/// the entry numbering must not answer it with that second half.
#[test]
fn the_entry_numbering_never_overrides_a_season_the_provider_has() {
let guide = EpisodeGuide::new(vec![
numbered((1, 12), 12, (2, 1), "Cour two's first"),
numbered((2, 1), 26, (3, 1), "Season two's first"),
]);
assert_eq!(
name_of(&guide, Some(2), 1).as_deref(),
Some("Season two's first")
);
// A second-season episode the provider does not list is unanswered,
// not answered from the entry numbering.
assert_eq!(guide.get(Some(2), 2), None);
}
#[test] #[test]
fn a_file_that_states_its_season_is_answered_from_that_season() { fn a_file_that_states_its_season_is_answered_from_that_season() {
let guide = EpisodeGuide::new(vec![ let guide = EpisodeGuide::new(vec![
+808
View File
@@ -0,0 +1,808 @@
//! The rest of what makes the app look the same on every device: the shares,
//! the settings, the hand-picked metadata matches and the per-show track
//! choices. Synced next to watch history by [`crate::sync`], in the same file.
//!
//! ## Registers
//!
//! Each synced thing is one *register*: a key (`share/<id>`,
//! `settings/playback`, `tracks/<title>`, …), its latest value, and when it
//! last changed. Merging is the same rule as watch history — the newer
//! `updated_at` wins, per key — so it is idempotent and order-free across any
//! number of devices. A removed share is a register with no value, so a
//! removal travels like any other change.
//!
//! ## Noticing changes
//!
//! Nothing that writes a setting or a share knows about sync. Instead
//! `sync-state.json` remembers every register as last synced, and each sync
//! compares it with what is on this device: whatever differs changed since,
//! and is stamped with the time of the sync. That costs a change made between
//! syncs its exact time, which matters only when two devices change the same
//! setting within one sync interval of each other.
//!
//! The first sync of an installation stamps what it finds at zero rather than
//! now. A fresh install holds only defaults, and those must not outrank the
//! library every other device already agreed on; what it has that nobody else
//! does is still added, since nothing older competes with it.
//!
//! ## What does not travel
//!
//! The volume and mute, which belong to the speakers in front of the viewer,
//! and every setting the front ends keep themselves: the cache budget, Wi-Fi
//! only, background audio, hardware decoding, window geometry.
//!
//! ## Secrets
//!
//! A share's register carries its link, fragment included, and its custom
//! password, and the TMDB API key travels too: without them another device
//! could list the share but never open it. The file holding them is in the
//! viewer's own Drive, end-to-end encrypted with their account's keys, which is
//! the same trust the credential store gives them locally; they are never
//! written anywhere else in plain text.
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::catalog::{Catalog, TitleTrackPrefs};
use crate::config::{AppDirs, read_json, write_json};
use crate::error::Result;
use crate::metadata::{MetadataConfig, MetadataRecord, ProviderId, TitleMetadata};
use crate::shares::{AccountFolder, Share, ShareSecrets, ShareStore};
const SHARE: &str = "share/";
const TRACKS: &str = "tracks/";
const MATCH: &str = "match/";
/// The settings files that travel, by register, and the fields in each that
/// stay on the device that wrote them.
const SETTINGS: [(&str, &str, &[&str]); 3] = [
("settings/playback", "playback.json", &["volume", "muted"]),
("settings/appearance", "appearance.json", &[]),
("settings/metadata", "metadata.json", &[]),
];
const METADATA_SETTINGS: &str = "settings/metadata";
/// One synced value and when it last changed.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct Register {
/// `None` is a removal.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<Value>,
pub updated_at: i64,
}
pub(crate) type Registers = BTreeMap<String, Register>;
/// What this installation last synced, kept beside its sync identity.
#[derive(Debug, Default, Serialize, Deserialize)]
struct State {
registers: Registers,
}
/// A share as it travels: everything another device needs to open it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct SyncedShare {
name: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
token: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
custom_password: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
folder: Option<AccountFolder>,
}
/// A match the viewer picked by hand. Automatic ones are not synced: every
/// device with enrichment on finds them again, and they expire anyway.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct SyncedMatch {
provider: ProviderId,
metadata: Option<TitleMetadata>,
fetched_at: i64,
}
/// What applying other devices' registers changed here, for the front end to
/// act on: reopen and crawl shares, repaint, reload the library.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProfileChanges {
/// Shares this device did not have, now configured. Not yet crawled.
pub shares_added: Vec<String>,
/// Shares whose name or secrets changed.
pub shares_changed: Vec<String>,
/// Shares removed elsewhere, now gone from the configuration here. Their
/// catalog rows and offline files are the front end's to drop, the same
/// way it does for a share removed on this device.
pub shares_removed: Vec<String>,
/// Whether any settings file or the TMDB key changed.
pub settings: bool,
/// Hand-picked matches and per-show track choices taken.
pub titles: usize,
}
impl ProfileChanges {
pub fn is_empty(&self) -> bool {
*self == Self::default()
}
pub fn shares(&self) -> bool {
!(self.shares_added.is_empty()
&& self.shares_changed.is_empty()
&& self.shares_removed.is_empty())
}
}
/// The local side of profile sync: where the state file lives, and the stores
/// registers are read from and applied to.
pub(crate) struct Profile<'a> {
pub store: &'a ShareStore,
pub catalog: &'a Mutex<Catalog>,
}
/// What is on this device, register by register.
#[derive(Debug, Default)]
struct Observed {
values: BTreeMap<String, Value>,
/// Keys that exist but could not be read — a share whose secret will not
/// decrypt. Neither a change nor a removal.
unknown: BTreeSet<String>,
/// Whether the share list itself could be read. Without it, a share
/// missing from `values` says nothing about whether it was removed.
shares_listed: bool,
}
fn state_file(dirs: &AppDirs) -> PathBuf {
dirs.config.join("sync-state.json")
}
fn tmdb_secret() -> String {
format!("metadata-{}", ProviderId::Tmdb.as_str())
}
const TMDB_KEY: &str = "secret/metadata-tmdb";
impl Profile<'_> {
fn dirs(&self) -> &AppDirs {
&self.store.dirs
}
/// Read the state, notice what changed here since, take what other
/// devices have newer, and write the state back. Returns the registers to
/// upload and what changed on this device.
pub fn sync<'r>(
&self,
remote: impl IntoIterator<Item = &'r Registers>,
now: i64,
) -> Result<(Registers, ProfileChanges)> {
let path = state_file(self.dirs());
let stored = read_json::<State>(&path)?;
let first = stored.is_none();
let mut state = stored.unwrap_or_default();
let observed = self.observe();
stamp(&mut state.registers, observed, if first { 0 } else { now });
let winners = merge(&state.registers, remote);
let changes = self.apply(&mut state.registers, winners);
write_json(&path, &state)?;
Ok((state.registers, changes))
}
fn observe(&self) -> Observed {
let mut observed = Observed::default();
for (key, file, local) in SETTINGS {
match read_json::<Value>(&self.dirs().config.join(file)) {
Ok(Some(value)) => {
observed
.values
.insert(key.to_owned(), without(value, local));
}
Ok(None) => {}
Err(error) => {
tracing::warn!(%error, file, "a settings file was not synced");
observed.unknown.insert(key.to_owned());
}
}
}
match self.store.secrets.get(&tmdb_secret()) {
Ok(Some(key)) => {
observed
.values
.insert(TMDB_KEY.to_owned(), Value::String(key));
}
Ok(None) => {}
Err(_) => {
observed.unknown.insert(TMDB_KEY.to_owned());
}
}
if let Ok(shares) = self.store.list() {
observed.shares_listed = true;
for share in shares {
let key = format!("{SHARE}{}", share.id);
let secrets = if share.folder.is_some() {
None
} else {
match self.store.secrets_of(&share.id) {
Ok(secrets) => Some(secrets),
Err(_) => {
observed.unknown.insert(key);
continue;
}
}
};
let synced = SyncedShare {
name: share.name,
token: share.token,
url: secrets.as_ref().map(|secrets| secrets.url.clone()),
custom_password: secrets.and_then(|secrets| secrets.custom_password),
folder: share.folder,
};
if let Ok(value) = serde_json::to_value(synced) {
observed.values.insert(key, value);
}
}
}
let catalog = self.catalog.lock();
if let Ok(tracks) = catalog.all_title_track_prefs() {
for (title, prefs) in tracks {
if let Ok(value) = serde_json::to_value(prefs) {
observed.values.insert(format!("{TRACKS}{title}"), value);
}
}
}
if let Ok(records) = catalog.all_metadata() {
for (title, record) in records {
if !record.manual {
continue;
}
let synced = SyncedMatch {
provider: record.provider,
metadata: record.metadata,
fetched_at: record.fetched_at,
};
if let Ok(value) = serde_json::to_value(synced) {
observed.values.insert(format!("{MATCH}{title}"), value);
}
}
}
observed
}
/// Apply each winning register here, recording it in `registers` once it
/// took. One that fails is left out, so the next sync tries it again.
fn apply(&self, registers: &mut Registers, winners: Registers) -> ProfileChanges {
let mut changes = ProfileChanges::default();
for (key, register) in winners {
match self.apply_one(&key, register.value.as_ref(), &mut changes) {
Ok(()) => {
registers.insert(key, register);
}
Err(error) => tracing::warn!(%error, key, "a synced change was not applied"),
}
}
changes
}
fn apply_one(
&self,
key: &str,
value: Option<&Value>,
changes: &mut ProfileChanges,
) -> Result<()> {
if let Some(id) = key.strip_prefix(SHARE) {
return self.apply_share(id, value, changes);
}
if let Some(title) = key.strip_prefix(TRACKS) {
if let Some(value) = value {
let prefs: TitleTrackPrefs = parse(value)?;
self.catalog.lock().set_title_track_prefs(title, &prefs)?;
changes.titles += 1;
}
return Ok(());
}
if let Some(title) = key.strip_prefix(MATCH) {
if let Some(value) = value {
let synced: SyncedMatch = parse(value)?;
self.catalog.lock().set_metadata(&MetadataRecord {
title_key: title.to_owned(),
provider: synced.provider,
metadata: synced.metadata,
fetched_at: synced.fetched_at,
manual: true,
})?;
changes.titles += 1;
}
return Ok(());
}
if key == TMDB_KEY {
match value.and_then(Value::as_str) {
Some(secret) => self.store.secrets.set(&tmdb_secret(), secret)?,
None => self.store.secrets.delete(&tmdb_secret())?,
}
changes.settings = true;
return Ok(());
}
if let Some((_, file, local)) = SETTINGS.iter().find(|(name, ..)| *name == key) {
let Some(Value::Object(incoming)) = value else {
return Ok(());
};
let path = self.dirs().config.join(file);
let before = read_json::<Value>(&path)?;
let mut merged = match &before {
Some(Value::Object(existing)) => existing.clone(),
_ => serde_json::Map::new(),
};
for (field, field_value) in incoming {
if !local.contains(&field.as_str()) {
merged.insert(field.clone(), field_value.clone());
}
}
let merged = Value::Object(merged);
if key == METADATA_SETTINGS {
self.metadata_settings_changing(before.as_ref(), &merged)?;
}
write_json(&path, &merged)?;
changes.settings = true;
return Ok(());
}
// A register a newer build writes. Kept, so it travels on.
Ok(())
}
fn apply_share(
&self,
id: &str,
value: Option<&Value>,
changes: &mut ProfileChanges,
) -> Result<()> {
let existing = self.store.list()?.into_iter().find(|share| share.id == id);
let Some(value) = value else {
if existing.is_some() {
self.store.remove(id)?;
changes.shares_removed.push(id.to_owned());
}
return Ok(());
};
let synced: SyncedShare = parse(value)?;
let expected = match (&synced.folder, &synced.url) {
(Some(folder), _) => folder.share_id(),
(None, Some(_)) => format!("share-{}", synced.token),
(None, None) => return Err(invalid("a synced link has no URL")),
};
if expected != id {
return Err(invalid("a synced share's id does not match what it opens"));
}
let secrets = synced.url.map(|url| ShareSecrets {
url,
custom_password: synced.custom_password,
});
let share = Share {
id: id.to_owned(),
name: synced.name,
token: synced.token,
has_custom_password: secrets
.as_ref()
.is_some_and(|secrets| secrets.custom_password.is_some()),
folder: synced.folder,
};
if existing.as_ref() == Some(&share)
&& secrets.as_ref().is_none_or(|secrets| {
self.store
.secrets_of(id)
.is_ok_and(|stored| &stored == secrets)
})
{
return Ok(());
}
self.store.put_synced(share, secrets.as_ref())?;
if existing.is_some() {
changes.shares_changed.push(id.to_owned());
} else {
changes.shares_added.push(id.to_owned());
}
Ok(())
}
/// Turning enrichment off or switching provider drops every stored answer
/// on the device that did it, which the settings page also does here.
fn metadata_settings_changing(&self, before: Option<&Value>, after: &Value) -> Result<()> {
let config = |value: Option<&Value>| -> MetadataConfig {
value
.and_then(|value| serde_json::from_value(value.clone()).ok())
.unwrap_or_default()
};
let (before, after) = (config(before), config(Some(after)));
if !after.enabled || before.provider != after.provider {
self.catalog.lock().clear_metadata()?;
}
Ok(())
}
}
/// Record what changed on this device since the last sync, stamped `now`.
fn stamp(registers: &mut Registers, observed: Observed, now: i64) {
for (key, register) in registers.iter_mut() {
if register.value.is_some()
&& key.starts_with(SHARE)
&& observed.shares_listed
&& !observed.values.contains_key(key)
&& !observed.unknown.contains(key)
{
*register = Register {
value: None,
updated_at: now,
};
}
}
for (key, value) in observed.values {
let unchanged = registers
.get(&key)
.is_some_and(|register| same(&key, register.value.as_ref(), Some(&value)));
if !unchanged {
registers.insert(
key,
Register {
value: Some(value),
updated_at: now,
},
);
}
}
}
/// Whether two values of one register are the same choice.
///
/// A match is compared by what it points at: its stored details move every
/// time the provider is asked again, and a register re-stamped by every
/// refresh would bounce between devices for ever.
fn same(key: &str, a: Option<&Value>, b: Option<&Value>) -> bool {
if key.starts_with(MATCH) {
let pointer = |value: Option<&Value>| {
value.map(|value| {
(
value.get("provider").cloned(),
value
.get("metadata")
.and_then(|metadata| metadata.get("remote_id"))
.cloned(),
)
})
};
return pointer(a) == pointer(b);
}
a == b
}
/// The registers from other devices that are newer than this one's.
fn merge<'r>(local: &Registers, remote: impl IntoIterator<Item = &'r Registers>) -> Registers {
let mut winners = Registers::new();
for registers in remote {
for (key, register) in registers {
let current = winners.get(key).or_else(|| local.get(key));
if current.is_none_or(|current| newer(register, current)) {
winners.insert(key.clone(), register.clone());
}
}
}
winners
}
/// Newer wins; a tie between two different values goes the same way on every
/// device, or two of them would each keep their own.
fn newer(candidate: &Register, current: &Register) -> bool {
if candidate.updated_at != current.updated_at {
return candidate.updated_at > current.updated_at;
}
let order = |register: &Register| {
register
.value
.as_ref()
.map(Value::to_string)
.unwrap_or_default()
};
order(candidate) > order(current)
}
/// `value` without the fields that stay on this device.
fn without(value: Value, local: &[&str]) -> Value {
match value {
Value::Object(mut fields) => {
for field in local {
fields.remove(*field);
}
Value::Object(fields)
}
other => other,
}
}
fn parse<T: serde::de::DeserializeOwned>(value: &Value) -> Result<T> {
serde_json::from_value(value.clone()).map_err(|e| invalid(&e.to_string()))
}
fn invalid(reason: &str) -> crate::Error {
crate::Error::Config(format!("synced setting: {reason}"))
}
#[cfg(test)]
pub(crate) mod tests {
use std::collections::HashMap;
use std::sync::Arc;
use super::*;
use crate::shares::SecretStore;
#[derive(Default)]
struct MemorySecrets(Mutex<HashMap<String, String>>);
impl SecretStore for MemorySecrets {
fn set(&self, key: &str, value: &str) -> Result<()> {
self.0.lock().insert(key.to_owned(), value.to_owned());
Ok(())
}
fn get(&self, key: &str) -> Result<Option<String>> {
Ok(self.0.lock().get(key).cloned())
}
fn delete(&self, key: &str) -> Result<()> {
self.0.lock().remove(key);
Ok(())
}
}
/// One installation's configuration and catalog.
pub(crate) struct Device {
root: PathBuf,
pub store: ShareStore,
pub catalog: Mutex<Catalog>,
}
impl Device {
pub(crate) fn new(tag: &str) -> Self {
let mut bytes = [0_u8; 8];
getrandom::fill(&mut bytes).unwrap();
let unique: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
let root = std::env::temp_dir().join(format!("pstr-profile-{tag}-{unique}"));
let dirs = AppDirs::from_paths(root.join("c"), root.join("d"), root.join("k")).unwrap();
Self {
root,
store: ShareStore::with_secret_store(dirs, Arc::new(MemorySecrets::default())),
catalog: Mutex::new(Catalog::in_memory().unwrap()),
}
}
pub(crate) fn lock(&self) -> parking_lot::MutexGuard<'_, Catalog> {
self.catalog.lock()
}
pub(crate) fn profile(&self) -> Profile<'_> {
Profile {
store: &self.store,
catalog: &self.catalog,
}
}
fn sync(&self, remote: &[&Registers], now: i64) -> (Registers, ProfileChanges) {
self.profile().sync(remote.iter().copied(), now).unwrap()
}
fn share_names(&self) -> Vec<String> {
self.store
.list()
.unwrap()
.into_iter()
.map(|share| share.name)
.collect()
}
}
impl Drop for Device {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
const LINK: &str = "https://drive.proton.me/urls/ABCDEFGHIJ#secretpart";
#[test]
fn a_share_added_on_one_device_arrives_on_another_with_its_secrets() {
let laptop = Device::new("laptop");
let phone = Device::new("phone");
laptop.sync(&[], 10);
laptop.store.add("Anime", LINK, Some("hunter2")).unwrap();
let (sent, _) = laptop.sync(&[], 20);
let (_, changes) = phone.sync(&[&sent], 30);
assert_eq!(changes.shares_added, ["share-ABCDEFGHIJ"]);
assert_eq!(phone.share_names(), ["Anime"]);
let secrets = phone.store.secrets_of("share-ABCDEFGHIJ").unwrap();
assert_eq!(secrets.url, LINK);
assert_eq!(secrets.custom_password.as_deref(), Some("hunter2"));
}
#[test]
fn a_share_removed_on_one_device_is_removed_on_the_others() {
let laptop = Device::new("laptop");
let phone = Device::new("phone");
laptop.store.add("Anime", LINK, None).unwrap();
let (sent, _) = laptop.sync(&[], 10);
let (phone_sent, _) = phone.sync(&[&sent], 20);
laptop.store.remove("share-ABCDEFGHIJ").unwrap();
let (sent, _) = laptop.sync(&[&phone_sent], 30);
let (_, changes) = phone.sync(&[&sent], 40);
assert_eq!(changes.shares_removed, ["share-ABCDEFGHIJ"]);
assert!(phone.share_names().is_empty());
}
#[test]
fn a_fresh_installation_takes_the_library_rather_than_its_defaults() {
let laptop = Device::new("laptop");
let phone = Device::new("phone");
laptop.sync(&[], 10);
let mut prefs = crate::prefs::load(&laptop.store.dirs).unwrap();
prefs.autoplay_next = false;
prefs.speed = 1.5;
crate::prefs::save(&laptop.store.dirs, &prefs).unwrap();
let (sent, _) = laptop.sync(&[], 20);
// The new phone has written its defaults before it ever synced.
crate::prefs::save(&phone.store.dirs, &crate::prefs::PlaybackPrefs::default()).unwrap();
let (_, changes) = phone.sync(&[&sent], 30);
assert!(changes.settings);
let taken = crate::prefs::load(&phone.store.dirs).unwrap();
assert!(!taken.autoplay_next);
assert_eq!(taken.speed, 1.5);
}
#[test]
fn volume_and_mute_stay_on_the_device_that_set_them() {
let laptop = Device::new("laptop");
let phone = Device::new("phone");
laptop.sync(&[], 10);
let mut prefs = crate::prefs::PlaybackPrefs {
volume: 20.0,
muted: true,
..Default::default()
};
crate::prefs::save(&laptop.store.dirs, &prefs).unwrap();
let (sent, _) = laptop.sync(&[], 20);
prefs.volume = 80.0;
prefs.muted = false;
prefs.subtitles = false;
crate::prefs::save(&phone.store.dirs, &prefs).unwrap();
phone.sync(&[], 5);
phone.sync(&[&sent], 30);
let taken = crate::prefs::load(&phone.store.dirs).unwrap();
assert_eq!(taken.volume, 80.0);
assert!(!taken.muted);
assert!(taken.subtitles, "the laptop's later choice wins");
}
#[test]
fn the_newer_change_wins_whichever_device_syncs_first() {
let laptop = Device::new("laptop");
let phone = Device::new("phone");
laptop.sync(&[], 1);
phone.sync(&[], 1);
let mut appearance = crate::appearance::load(&laptop.store.dirs).unwrap();
appearance.gradients = false;
crate::appearance::save(&laptop.store.dirs, &appearance).unwrap();
let (from_laptop, _) = laptop.sync(&[], 10);
appearance.gradients = true;
appearance.accent = crate::appearance::Accent::Peach;
crate::appearance::save(&phone.store.dirs, &appearance).unwrap();
let (from_phone, _) = phone.sync(&[&from_laptop], 20);
laptop.sync(&[&from_phone], 30);
for device in [&laptop, &phone] {
let seen = crate::appearance::load(&device.store.dirs).unwrap();
assert_eq!(seen.accent, crate::appearance::Accent::Peach);
assert!(seen.gradients);
}
}
#[test]
fn an_unchanged_device_sends_the_same_registers_again() {
let laptop = Device::new("laptop");
laptop.store.add("Anime", LINK, None).unwrap();
let (first, _) = laptop.sync(&[], 10);
let (second, changes) = laptop.sync(&[&first], 20);
assert_eq!(first, second);
assert!(changes.is_empty());
}
#[test]
fn a_share_whose_secret_cannot_be_read_is_not_removed_elsewhere() {
let laptop = Device::new("laptop");
laptop.store.add("Anime", LINK, None).unwrap();
laptop.sync(&[], 10);
laptop.store.secrets.delete("share-ABCDEFGHIJ").unwrap();
let (sent, _) = laptop.sync(&[], 20);
assert!(sent["share/share-ABCDEFGHIJ"].value.is_some());
}
#[test]
fn a_hand_picked_match_and_a_shows_track_choice_travel() {
let laptop = Device::new("laptop");
let phone = Device::new("phone");
laptop.sync(&[], 1);
let metadata = TitleMetadata {
provider: ProviderId::AniList,
remote_id: "21".to_owned(),
name: "One Piece".to_owned(),
original_name: None,
overview: None,
year: Some(1999),
kind: crate::library::TitleKind::Series,
poster_url: None,
backdrop_url: None,
rating: None,
genres: Vec::new(),
episodes: None,
url: None,
details: None,
};
laptop
.catalog
.lock()
.set_metadata(&MetadataRecord {
title_key: "one piece".to_owned(),
provider: ProviderId::AniList,
metadata: Some(metadata),
fetched_at: 5,
manual: true,
})
.unwrap();
let tracks = TitleTrackPrefs {
audio_language: Some("jpn".to_owned()),
..Default::default()
};
laptop
.catalog
.lock()
.set_title_track_prefs("one piece", &tracks)
.unwrap();
let (sent, _) = laptop.sync(&[], 10);
let (_, changes) = phone.sync(&[&sent], 20);
assert_eq!(changes.titles, 2);
let catalog = phone.catalog.lock();
let record = catalog.metadata("one piece").unwrap().unwrap();
assert!(record.manual);
assert_eq!(record.metadata.unwrap().remote_id, "21");
assert_eq!(
catalog.title_track_prefs("one piece").unwrap(),
Some(tracks)
);
}
#[test]
fn a_synced_share_that_names_another_id_is_refused() {
let phone = Device::new("phone");
let mut forged = Registers::new();
forged.insert(
"share/share-OTHER".to_owned(),
Register {
value: Some(serde_json::json!({
"name": "x", "token": "ABCDEFGHIJ", "url": LINK
})),
updated_at: 10,
},
);
let (registers, changes) = phone.sync(&[&forged], 20);
assert!(changes.shares_added.is_empty());
assert!(!registers.contains_key("share/share-OTHER"));
}
}
+488 -39
View File
@@ -14,15 +14,28 @@
//! A library is not one folder. [`SharedLibrary`] opens every configured share //! A library is not one folder. [`SharedLibrary`] opens every configured share
//! and merges them into one catalog, so the app has a single browsable view //! and merges them into one catalog, so the app has a single browsable view
//! regardless of how many links it was given. //! regardless of how many links it was given.
//!
//! ## Folders from a signed-in account
//!
//! A share can also be a folder of the viewer's own Drive — My Files, another
//! device, or something shared with them — opened through the account signed
//! in by [`crate::account`] instead of a link. It has no secrets of its own:
//! the account session is the credential, and without it such a share simply
//! fails to open, the same as a revoked link. [`ShareClient`] puts both kinds
//! behind one surface so the crawl, the stream layer and the thumbnail path
//! do not care which one they hold.
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::sync::Arc; use std::sync::Arc;
use proton_drive_rs::{Node, ProtonDrivePublicLinkClient}; use proton_drive_rs::{
Node, ProtonDriveClient, ProtonDrivePublicLinkClient, RevisionReader, ThumbnailType,
};
use proton_sdk::config::ProtonClientConfiguration; use proton_sdk::config::ProtonClientConfiguration;
use proton_sdk::ids::NodeUid; use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::account::Account;
use crate::config::{AppDirs, read_json, write_json}; use crate::config::{AppDirs, read_json, write_json};
use crate::error::{Error, Result}; use crate::error::{Error, Result};
@@ -42,19 +55,26 @@ pub trait SecretStore: Send + Sync {
} }
/// The desktop OS credential store. /// The desktop OS credential store.
///
/// A value too long for one entry is split across several; see
/// [`ENTRY_UNITS`].
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct KeyringSecretStore; pub struct KeyringSecretStore;
/// One credential-store entry per key, with no splitting.
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
impl KeyringSecretStore { struct KeyringEntries;
#[cfg(not(target_os = "android"))]
impl KeyringEntries {
fn entry(key: &str) -> Result<keyring::Entry> { fn entry(key: &str) -> Result<keyring::Entry> {
Ok(keyring::Entry::new(KEYRING_SERVICE, key)?) Ok(keyring::Entry::new(KEYRING_SERVICE, key)?)
} }
} }
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
impl SecretStore for KeyringSecretStore { impl SecretStore for KeyringEntries {
fn set(&self, key: &str, value: &str) -> Result<()> { fn set(&self, key: &str, value: &str) -> Result<()> {
Self::entry(key)?.set_password(value)?; Self::entry(key)?.set_password(value)?;
Ok(()) Ok(())
@@ -76,6 +96,205 @@ impl SecretStore for KeyringSecretStore {
} }
} }
#[cfg(not(target_os = "android"))]
impl SecretStore for KeyringSecretStore {
fn set(&self, key: &str, value: &str) -> Result<()> {
chunked::set(&KeyringEntries, key, value)
}
fn get(&self, key: &str) -> Result<Option<String>> {
chunked::get(&KeyringEntries, key)
}
fn delete(&self, key: &str) -> Result<()> {
chunked::delete(&KeyringEntries, key)
}
}
/// The most UTF-16 code units one credential-store entry is given.
///
/// Windows caps a credential's blob at 2560 bytes, and `keyring` stores it as
/// UTF-16: 1280 units. A share secret is far below that, but the account
/// session — tokens, scopes, the mailbox password and a salt per key — can
/// pass it, and failing to store a refreshed token signs the viewer out at
/// the next launch. The margin is for the header.
#[cfg(any(not(target_os = "android"), test))]
const ENTRY_UNITS: usize = 1200;
/// Splitting a long secret across several entries of an underlying store.
///
/// A short value is one entry, exactly as before splitting existed, so every
/// secret already stored still reads. A long one is written as `key#0`,
/// `key#1`, … with `key` itself holding [`HEADER`] and the count. The parts go
/// first and the header last, so a reader never finds a header naming parts
/// that are not there yet.
#[cfg(any(not(target_os = "android"), test))]
mod chunked {
use super::{ENTRY_UNITS, SecretStore};
use crate::error::Result;
/// Marks a split value. A short value that happens to start with it is
/// split too, so it cannot be mistaken for a header.
const HEADER: &str = "pstr-chunked:";
fn part(key: &str, index: usize) -> String {
format!("{key}#{index}")
}
fn parts_of(header: &str) -> Option<usize> {
header.strip_prefix(HEADER)?.parse().ok()
}
/// `value` cut into pieces of at most [`ENTRY_UNITS`] UTF-16 units, never
/// inside a character.
fn split(value: &str) -> Vec<&str> {
let mut pieces = Vec::new();
let mut start = 0;
let mut units = 0;
for (at, ch) in value.char_indices() {
if units + ch.len_utf16() > ENTRY_UNITS {
pieces.push(&value[start..at]);
start = at;
units = 0;
}
units += ch.len_utf16();
}
pieces.push(&value[start..]);
pieces
}
pub(super) fn set(store: &dyn SecretStore, key: &str, value: &str) -> Result<()> {
let before = store.get(key)?.as_deref().and_then(parts_of).unwrap_or(0);
let fits = value.encode_utf16().count() <= ENTRY_UNITS && !value.starts_with(HEADER);
let written = if fits {
store.set(key, value)?;
0
} else {
let pieces = split(value);
for (index, piece) in pieces.iter().enumerate() {
store.set(&part(key, index), piece)?;
}
store.set(key, &format!("{HEADER}{}", pieces.len()))?;
pieces.len()
};
for index in written..before {
store.delete(&part(key, index))?;
}
Ok(())
}
pub(super) fn get(store: &dyn SecretStore, key: &str) -> Result<Option<String>> {
let Some(value) = store.get(key)? else {
return Ok(None);
};
let Some(count) = parts_of(&value) else {
return Ok(Some(value));
};
let mut joined = String::new();
for index in 0..count {
// A part gone missing is a value that no longer exists whole;
// half of a token is worse than none.
let Some(piece) = store.get(&part(key, index))? else {
return Ok(None);
};
joined.push_str(&piece);
}
Ok(Some(joined))
}
pub(super) fn delete(store: &dyn SecretStore, key: &str) -> Result<()> {
let count = store.get(key)?.as_deref().and_then(parts_of).unwrap_or(0);
store.delete(key)?;
for index in 0..count {
store.delete(&part(key, index))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use parking_lot::Mutex;
use super::*;
/// An underlying store that refuses what Windows would.
#[derive(Default)]
struct Limited(Mutex<BTreeMap<String, String>>);
impl SecretStore for Limited {
fn set(&self, key: &str, value: &str) -> Result<()> {
assert!(
value.encode_utf16().count() <= 1280,
"{key} is too long for one entry"
);
self.0.lock().insert(key.to_owned(), value.to_owned());
Ok(())
}
fn get(&self, key: &str) -> Result<Option<String>> {
Ok(self.0.lock().get(key).cloned())
}
fn delete(&self, key: &str) -> Result<()> {
self.0.lock().remove(key);
Ok(())
}
}
#[test]
fn a_short_secret_is_one_entry_as_it_always_was() {
let store = Limited::default();
set(&store, "share-a", "fragment").unwrap();
assert_eq!(store.0.lock().len(), 1);
assert_eq!(get(&store, "share-a").unwrap().as_deref(), Some("fragment"));
}
#[test]
fn a_secret_longer_than_an_entry_round_trips() {
let store = Limited::default();
let long: String = "aé😀".repeat(1500);
set(&store, "proton-account", &long).unwrap();
assert!(store.0.lock().len() > 2);
assert_eq!(get(&store, "proton-account").unwrap(), Some(long));
}
#[test]
fn a_shorter_value_leaves_no_parts_of_the_longer_one_behind() {
let store = Limited::default();
set(&store, "k", &"x".repeat(5000)).unwrap();
set(&store, "k", &"y".repeat(1300)).unwrap();
assert_eq!(store.0.lock().len(), 3);
set(&store, "k", "short").unwrap();
assert_eq!(store.0.lock().len(), 1);
assert_eq!(get(&store, "k").unwrap().as_deref(), Some("short"));
}
#[test]
fn a_value_that_looks_like_a_header_is_not_taken_for_one() {
let store = Limited::default();
set(&store, "k", "pstr-chunked:3").unwrap();
assert_eq!(get(&store, "k").unwrap().as_deref(), Some("pstr-chunked:3"));
}
#[test]
fn deleting_a_split_secret_deletes_every_part() {
let store = Limited::default();
set(&store, "k", &"x".repeat(5000)).unwrap();
delete(&store, "k").unwrap();
assert!(store.0.lock().is_empty());
assert_eq!(get(&store, "k").unwrap(), None);
}
#[test]
fn a_split_secret_missing_a_part_reads_as_absent() {
let store = Limited::default();
set(&store, "k", &"x".repeat(5000)).unwrap();
store.delete("k#1").unwrap();
assert_eq!(get(&store, "k").unwrap(), None);
}
}
}
/// What the app records about a share, minus its secrets. /// What the app records about a share, minus its secrets.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Share { pub struct Share {
@@ -92,22 +311,63 @@ pub struct Share {
/// rather than after it fails. /// rather than after it fails.
#[serde(default)] #[serde(default)]
pub has_custom_password: bool, pub has_custom_password: bool,
/// Set when this share is a folder of the signed-in account rather than a
/// public link; `token` is then empty and there are no secrets.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub folder: Option<AccountFolder>,
}
/// A folder of the signed-in account, kept as a library share.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccountFolder {
pub volume_id: String,
pub link_id: String,
}
impl AccountFolder {
pub fn uid(&self) -> NodeUid {
NodeUid::new(
VolumeId::from(self.volume_id.clone()),
LinkId::from(self.link_id.clone()),
)
}
/// The share id this folder is kept under.
///
/// Derived, like a link's `share-{token}`, so the same folder added on two
/// devices is the same share — which is what lets watch history recorded
/// against it on one device land on the other. Hashed rather than spliced
/// in because Proton ids are base64 and may carry characters a catalog key
/// is better off without.
pub fn share_id(&self) -> String {
use sha2::{Digest, Sha256};
let mut hash = Sha256::new();
hash.update(self.volume_id.as_bytes());
hash.update([0]);
hash.update(self.link_id.as_bytes());
let digest = hash.finalize();
let hex: String = digest[..12]
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
format!("drive-{hex}")
}
} }
/// The secrets for one share, as held in the credential store. /// The secrets for one share, as held in the credential store.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct ShareSecrets { pub(crate) struct ShareSecrets {
/// The full share URL, fragment included. /// The full share URL, fragment included.
url: String, pub(crate) url: String,
/// The custom password, when the link has one. /// The custom password, when the link has one.
#[serde(default)] #[serde(default)]
custom_password: Option<String>, pub(crate) custom_password: Option<String>,
} }
/// The configured shares, and their secrets. /// The configured shares, and their secrets.
pub struct ShareStore { pub struct ShareStore {
dirs: AppDirs, pub(crate) dirs: AppDirs,
secrets: Arc<dyn SecretStore>, pub(crate) secrets: Arc<dyn SecretStore>,
} }
impl ShareStore { impl ShareStore {
@@ -136,9 +396,9 @@ impl ShareStore {
let mut shares = self.list()?; let mut shares = self.list()?;
if shares.iter().any(|share| share.id == id) { if shares.iter().any(|share| share.id == id) {
return Err(Error::Config(format!( return Err(Error::Config(
"a share for token {token} is already configured" "that link is already in the library".to_owned(),
))); ));
} }
let secrets = ShareSecrets { let secrets = ShareSecrets {
@@ -154,6 +414,78 @@ impl ShareStore {
name: name.to_string(), name: name.to_string(),
token, token,
has_custom_password: secrets.custom_password.is_some(), has_custom_password: secrets.custom_password.is_some(),
folder: None,
};
shares.push(share.clone());
write_json(&self.dirs.shares_file(), &shares)?;
Ok(share)
}
/// [`Self::add`], but only once the link has been opened with the
/// password given.
///
/// A link that is mistyped, expired or behind the wrong password used to
/// be stored anyway, so the first try failed on the crawl and every retry
/// failed on "already configured" — the only way out was to remove a share
/// that had never worked. Opening first means a refused link leaves
/// nothing behind, and the client it opened is handed back for the crawl.
pub async fn add_verified(
&self,
name: &str,
url: &str,
custom_password: Option<&str>,
) -> Result<(Share, ShareClient)> {
let token = token_from_url(url)?;
let id = format!("share-{token}");
if self.list()?.iter().any(|share| share.id == id) {
return Err(Error::Config(
"that link is already in the library".to_owned(),
));
}
let custom_password = custom_password.filter(|password| !password.is_empty());
let client = open_link(url, custom_password).await?;
let share = self.add(name, url, custom_password)?;
Ok((share, client))
}
/// Record a share another installation has, with the secrets it opens
/// with, replacing what this one holds under the same id. See
/// [`crate::profile`].
pub(crate) fn put_synced(&self, share: Share, secrets: Option<&ShareSecrets>) -> Result<()> {
if let Some(secrets) = secrets {
self.store_secrets(&share.id, secrets)?;
}
let mut shares = self.list()?;
match shares.iter_mut().find(|existing| existing.id == share.id) {
Some(existing) => *existing = share,
None => shares.push(share),
}
write_json(&self.dirs.shares_file(), &shares)
}
/// A share's secrets, for syncing them to another installation.
pub(crate) fn secrets_of(&self, id: &str) -> Result<ShareSecrets> {
self.load_secrets(id)
}
/// Record a folder of the signed-in account as a share.
///
/// Nothing goes in the credential store: the folder opens through the
/// account's session, which [`crate::account::AccountStore`] keeps.
pub fn add_folder(&self, name: &str, folder: AccountFolder) -> Result<Share> {
let id = folder.share_id();
let mut shares = self.list()?;
if shares.iter().any(|share| share.id == id) {
return Err(Error::Config(
"that folder is already in the library".to_owned(),
));
}
let share = Share {
id,
name: name.to_string(),
token: String::new(),
has_custom_password: false,
folder: Some(folder),
}; };
shares.push(share.clone()); shares.push(share.clone());
write_json(&self.dirs.shares_file(), &shares)?; write_json(&self.dirs.shares_file(), &shares)?;
@@ -184,6 +516,11 @@ impl ShareStore {
.iter_mut() .iter_mut()
.find(|share| share.id == id) .find(|share| share.id == id)
.ok_or_else(|| Error::NotFound(format!("no share with id {id}")))?; .ok_or_else(|| Error::NotFound(format!("no share with id {id}")))?;
if share.folder.is_some() {
return Err(Error::Config(
"that share is a folder of your account and has no link".to_owned(),
));
}
if share.token != token { if share.token != token {
return Err(Error::Config( return Err(Error::Config(
"that link is for a different share; add it as a new one".to_owned(), "that link is for a different share; add it as a new one".to_owned(),
@@ -221,20 +558,22 @@ impl ShareStore {
self.secrets.delete(id) self.secrets.delete(id)
} }
/// Open a share as a visitor. /// Open a share: a link as a visitor, a folder through `account`.
pub async fn open(&self, share: &Share) -> Result<ProtonDrivePublicLinkClient> { pub async fn open(&self, share: &Share, account: Option<&Account>) -> Result<ShareClient> {
if let Some(folder) = &share.folder {
let account = account.ok_or_else(|| {
Error::SignedOut(format!(
"sign in to Proton to open {}, a folder of your account",
share.name
))
})?;
return Ok(ShareClient::Account {
client: account.drive().clone(),
root: folder.uid(),
});
}
let secrets = self.load_secrets(&share.id)?; let secrets = self.load_secrets(&share.id)?;
let client = ProtonDrivePublicLinkClient::open( open_link(&secrets.url, secrets.custom_password.as_deref()).await
client_configuration(),
&secrets.url,
secrets.custom_password.as_deref(),
)
.await?
// The SDK default is sized for a background sync daemon. A player that
// seeks wants more blocks in flight; at 4 MiB each this is a 192 MiB
// ceiling, which is unremarkable for a desktop app.
.with_max_inflight_blocks(48);
Ok(client)
} }
fn store_secrets(&self, id: &str, secrets: &ShareSecrets) -> Result<()> { fn store_secrets(&self, id: &str, secrets: &ShareSecrets) -> Result<()> {
@@ -254,9 +593,109 @@ impl ShareStore {
} }
} }
/// Open a public link as a visitor.
async fn open_link(url: &str, custom_password: Option<&str>) -> Result<ShareClient> {
let client = ProtonDrivePublicLinkClient::open(client_configuration(), url, custom_password)
.await?
// The SDK default is sized for a background sync daemon. A player that
// seeks wants more blocks in flight; at 4 MiB each this is a 192 MiB
// ceiling, which is unremarkable for a desktop app.
.with_max_inflight_blocks(48);
Ok(ShareClient::PublicLink(Box::new(client)))
}
/// Several opened shares, presented as one library. /// Several opened shares, presented as one library.
pub struct SharedLibrary { pub struct SharedLibrary {
clients: BTreeMap<String, ProtonDrivePublicLinkClient>, clients: BTreeMap<String, ShareClient>,
}
/// One opened share, whichever kind it is.
///
/// Cheap to clone: both clients share their caches and sessions between
/// clones.
#[derive(Clone)]
pub enum ShareClient {
/// A public link, opened as a visitor. Boxed: the visitor client is
/// several times the size of the account variant, and libraries are
/// mostly links, so every map entry would otherwise pay for it.
PublicLink(Box<ProtonDrivePublicLinkClient>),
/// A folder of the signed-in account.
Account {
client: ProtonDriveClient,
root: NodeUid,
},
}
impl ShareClient {
/// The share's top folder.
pub async fn root_node(&self) -> proton_sdk::error::Result<Node> {
match self {
Self::PublicLink(client) => client.get_root_node().await,
Self::Account { client, root } => client.get_node(root).await?.ok_or_else(|| {
proton_sdk::error::ProtonError::invalid_operation(
"the folder is no longer in your Drive",
)
}),
}
}
pub async fn enumerate_folder_children_node_uids(
&self,
folder: &NodeUid,
) -> proton_sdk::error::Result<Vec<NodeUid>> {
match self {
Self::PublicLink(client) => client.enumerate_folder_children_node_uids(folder).await,
Self::Account { client, .. } => {
client.enumerate_folder_children_node_uids(folder).await
}
}
}
pub async fn enumerate_nodes(&self, uids: &[NodeUid]) -> proton_sdk::error::Result<Vec<Node>> {
match self {
Self::PublicLink(client) => client.enumerate_nodes(uids).await,
Self::Account { client, .. } => client.enumerate_nodes(uids).await,
}
}
pub async fn open_revision(&self, uid: &NodeUid) -> proton_sdk::error::Result<RevisionReader> {
match self {
Self::PublicLink(client) => client.open_revision(uid).await,
Self::Account { client, .. } => client.open_revision(uid).await,
}
}
pub async fn download_range(
&self,
uid: &NodeUid,
offset: u64,
length: u64,
) -> proton_sdk::error::Result<Vec<u8>> {
match self {
Self::PublicLink(client) => client.download_range(uid, offset, length).await,
Self::Account { client, .. } => client.download_range(uid, offset, length).await,
}
}
pub async fn download_thumbnail(
&self,
uid: &NodeUid,
kind: ThumbnailType,
) -> proton_sdk::error::Result<Option<Vec<u8>>> {
match self {
Self::PublicLink(client) => client.download_thumbnail(uid, kind).await,
Self::Account { client, .. } => client.download_thumbnail(uid, kind).await,
}
}
/// Replay a visitor handshake. An account session refreshes its own
/// tokens when one expires, so there is nothing to do for a folder.
pub async fn refresh_session(&self) -> proton_sdk::error::Result<()> {
match self {
Self::PublicLink(client) => client.refresh_session().await,
Self::Account { .. } => Ok(()),
}
}
} }
impl SharedLibrary { impl SharedLibrary {
@@ -265,8 +704,14 @@ impl SharedLibrary {
/// A share that fails to open is reported and skipped rather than failing /// A share that fails to open is reported and skipped rather than failing
/// the whole library — one revoked link should not make the other three /// the whole library — one revoked link should not make the other three
/// unwatchable. /// unwatchable.
pub async fn open_all(store: &ShareStore) -> Result<(Self, Vec<(Share, Error)>)> { ///
Self::open_all_reusing(store, BTreeMap::new()).await /// Folders of the account open through `account`; without one they are
/// reported as failures like any other share that will not open.
pub async fn open_all(
store: &ShareStore,
account: Option<&Account>,
) -> Result<(Self, Vec<(Share, Error)>)> {
Self::open_all_reusing(store, BTreeMap::new(), account).await
} }
/// Open every configured share, reusing the clients in `reusable`. /// Open every configured share, reusing the clients in `reusable`.
@@ -278,7 +723,8 @@ impl SharedLibrary {
/// link. /// link.
pub async fn open_all_reusing( pub async fn open_all_reusing(
store: &ShareStore, store: &ShareStore,
mut reusable: BTreeMap<String, ProtonDrivePublicLinkClient>, mut reusable: BTreeMap<String, ShareClient>,
account: Option<&Account>,
) -> Result<(Self, Vec<(Share, Error)>)> { ) -> Result<(Self, Vec<(Share, Error)>)> {
let mut clients = BTreeMap::new(); let mut clients = BTreeMap::new();
let mut failures = Vec::new(); let mut failures = Vec::new();
@@ -288,7 +734,7 @@ impl SharedLibrary {
clients.insert(share.id, client); clients.insert(share.id, client);
continue; continue;
} }
match store.open(&share).await { match store.open(&share, account).await {
Ok(client) => { Ok(client) => {
clients.insert(share.id.clone(), client); clients.insert(share.id.clone(), client);
} }
@@ -305,19 +751,22 @@ impl SharedLibrary {
/// The excluded share is the one whose credentials or membership just /// The excluded share is the one whose credentials or membership just
/// changed; leaving it out is what makes a removed share's live client /// changed; leaving it out is what makes a removed share's live client
/// unreachable rather than merely stale. /// unreachable rather than merely stale.
pub fn reusable_clients( ///
&self, /// Folders of the account are never handed back: they carry the session
dropping: &str, /// they were opened with, and the account may have signed out or changed
) -> BTreeMap<String, ProtonDrivePublicLinkClient> { /// since.
pub fn reusable_clients(&self, dropping: &str) -> BTreeMap<String, ShareClient> {
self.clients self.clients
.iter() .iter()
.filter(|(id, _)| id.as_str() != dropping) .filter(|(id, client)| {
id.as_str() != dropping && matches!(client, ShareClient::PublicLink(_))
})
.map(|(id, client)| (id.clone(), client.clone())) .map(|(id, client)| (id.clone(), client.clone()))
.collect() .collect()
} }
/// The client for one share. /// The client for one share.
pub fn client(&self, share_id: &str) -> Option<&ProtonDrivePublicLinkClient> { pub fn client(&self, share_id: &str) -> Option<&ShareClient> {
self.clients.get(share_id) self.clients.get(share_id)
} }
@@ -377,7 +826,7 @@ impl SharedLibrary {
.client(share_id) .client(share_id)
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?; .ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?;
let root = client.get_root_node().await?; let root = client.root_node().await?;
let mut level = vec![root.uid.clone()]; let mut level = vec![root.uid.clone()];
let mut found = vec![root]; let mut found = vec![root];
@@ -433,7 +882,7 @@ const APP_VERSION: &str = concat!("external-drive-stream@", env!("CARGO_PKG_VERS
const USER_AGENT: &str = concat!("proton-stream/", env!("CARGO_PKG_VERSION")); const USER_AGENT: &str = concat!("proton-stream/", env!("CARGO_PKG_VERSION"));
/// The API configuration every visitor client is built from. /// The API configuration every visitor client is built from.
fn client_configuration() -> ProtonClientConfiguration { pub(crate) fn client_configuration() -> ProtonClientConfiguration {
ProtonClientConfiguration::new(APP_VERSION).with_user_agent(USER_AGENT) ProtonClientConfiguration::new(APP_VERSION).with_user_agent(USER_AGENT)
} }
+261
View File
@@ -0,0 +1,261 @@
//! Rows of titles that share something, for the top of the library.
//!
//! A grid sorted one way answers one question. Shelves answer the ones a
//! viewer did not think to ask: what is still airing, what else that studio
//! made, the director whose four films happen to be in the share. They are
//! picked from what the provider said, and only where there is enough of
//! something to be worth a row — three titles from one studio is a shelf, one
//! is a fact on its page.
use std::collections::{BTreeMap, HashMap};
use crate::library::Title;
use crate::metadata::MetadataRecord;
/// How many franchises a person or studio needs before it earns a row.
///
/// Franchises, not titles: Made in Abyss and its three films are four titles
/// with one director, and a "Directed by" row holding nothing but them says
/// what the franchise's own tile already does.
const MIN_TITLES: usize = 3;
/// A genre on more than this share of the matched titles says nothing: an
/// "Action" row in an anime library is half the library again — which is what
/// it was, at 0.6, in the library this was tuned on.
const MAX_GENRE_SHARE: f32 = 0.4;
/// One row: a heading, and the keys of the titles under it, in library order.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Shelf {
pub label: String,
pub keys: Vec<String>,
}
/// Up to `limit` shelves for `titles`, most specific first: what is airing,
/// then directors, studios and genres, each by how many titles it holds.
///
/// Two shelves holding exactly the same titles are one shelf — the first,
/// which is the more specific.
pub fn pick(
titles: &[Title],
metadata: &HashMap<String, MetadataRecord>,
limit: usize,
) -> Vec<Shelf> {
let mut airing = Vec::new();
let mut directors: BTreeMap<&str, Vec<String>> = BTreeMap::new();
let mut studios: BTreeMap<&str, Vec<String>> = BTreeMap::new();
let mut genres: BTreeMap<&str, Vec<String>> = BTreeMap::new();
let mut matched = 0usize;
let refs: Vec<&Title> = titles.iter().collect();
let franchise_of: HashMap<&str, usize> = crate::franchise::group(&refs, metadata)
.into_iter()
.enumerate()
.flat_map(|(at, franchise)| {
franchise
.members
.into_iter()
.map(move |member| (titles[member].key.as_str(), at))
})
.collect();
let franchises = |keys: &[String]| {
keys.iter()
.filter_map(|key| franchise_of.get(key.as_str()))
.collect::<std::collections::HashSet<_>>()
.len()
};
for title in titles {
let Some(found) = metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
else {
continue;
};
matched += 1;
for genre in &found.genres {
genres.entry(genre).or_default().push(title.key.clone());
}
let Some(details) = &found.details else {
continue;
};
if details.is_airing() {
airing.push(title.key.clone());
}
for director in &details.directors {
directors
.entry(director)
.or_default()
.push(title.key.clone());
}
for studio in &details.studios {
studios.entry(studio).or_default().push(title.key.clone());
}
}
let ranked = |groups: BTreeMap<&str, Vec<String>>, label: &dyn Fn(&str) -> String| {
let mut rows: Vec<Shelf> = groups
.into_iter()
.filter(|(_, keys)| franchises(keys) >= MIN_TITLES)
.map(|(name, keys)| Shelf {
label: label(name),
keys,
})
.collect();
// Stable, so equal counts keep their alphabetical order.
rows.sort_by_key(|shelf| std::cmp::Reverse(shelf.keys.len()));
rows
};
let genre_ceiling = (matched as f32 * MAX_GENRE_SHARE).floor() as usize;
let genres: BTreeMap<&str, Vec<String>> = genres
.into_iter()
.filter(|(_, keys)| keys.len() <= genre_ceiling)
.collect();
let mut shelves = Vec::new();
if !airing.is_empty() {
shelves.push(Shelf {
label: "Airing now".into(),
keys: airing,
});
}
shelves.extend(ranked(directors, &|name| format!("Directed by {name}")));
shelves.extend(ranked(studios, &|name| format!("From {name}")));
shelves.extend(ranked(genres, &|name| name.to_string()));
let mut picked: Vec<Shelf> = Vec::new();
for shelf in shelves {
if picked.len() == limit {
break;
}
if !picked.iter().any(|other| other.keys == shelf.keys) {
picked.push(shelf);
}
}
picked
}
#[cfg(test)]
mod tests {
use super::*;
use crate::library::TitleKind;
use crate::metadata::{ProviderId, TitleDetails, TitleMetadata};
fn title(key: &str) -> Title {
Title {
key: key.into(),
name: key.into(),
year: None,
kind: TitleKind::Film,
seasons: Vec::new(),
share_ids: Vec::new(),
}
}
fn record(key: &str, genres: &[&str], details: TitleDetails) -> (String, MetadataRecord) {
(
key.to_string(),
MetadataRecord {
title_key: key.into(),
provider: ProviderId::AniList,
metadata: Some(TitleMetadata {
provider: ProviderId::AniList,
remote_id: key.into(),
name: key.into(),
original_name: None,
overview: None,
year: None,
kind: TitleKind::Film,
poster_url: None,
backdrop_url: None,
rating: None,
genres: genres.iter().map(|genre| genre.to_string()).collect(),
episodes: None,
url: None,
details: Some(details),
}),
fetched_at: 0,
manual: false,
},
)
}
fn kon(studio: &str) -> TitleDetails {
TitleDetails {
directors: vec!["Satoshi Kon".into()],
studios: vec![studio.into()],
..TitleDetails::default()
}
}
/// The case this exists for: four Satoshi Kon films in one share.
#[test]
fn a_director_with_enough_titles_gets_a_shelf_ahead_of_a_studio() {
let titles: Vec<Title> = [
"perfect blue",
"millennium actress",
"tokyo godfathers",
"paprika",
]
.map(title)
.to_vec();
let metadata = HashMap::from([
record("perfect blue", &["Drama"], kon("Madhouse")),
record("millennium actress", &["Drama"], kon("Madhouse")),
record("tokyo godfathers", &["Drama"], kon("Madhouse")),
record("paprika", &["Sci-Fi"], kon("Madhouse")),
]);
let shelves = pick(&titles, &metadata, 5);
assert_eq!(shelves[0].label, "Directed by Satoshi Kon");
assert_eq!(shelves[0].keys.len(), 4);
// Madhouse holds exactly the same four, so it is not a second row.
assert!(!shelves.iter().any(|shelf| shelf.label == "From Madhouse"));
// Drama is three of four: past the share a genre may cover.
assert!(!shelves.iter().any(|shelf| shelf.label == "Drama"));
}
/// Made in Abyss: four titles, one director, one story. Not a shelf.
#[test]
fn one_franchise_is_not_a_shelf_however_many_titles_it_has() {
let titles: Vec<Title> = ["abyss", "film one", "film two", "film three"]
.map(title)
.to_vec();
let kojima = |related: &[&str]| TitleDetails {
directors: vec!["Masayuki Kojima".into()],
related: related.iter().map(|id| id.to_string()).collect(),
..TitleDetails::default()
};
let metadata = HashMap::from([
record(
"abyss",
&[],
kojima(&["film one", "film two", "film three"]),
),
record("film one", &[], kojima(&["abyss"])),
record("film two", &[], kojima(&["abyss"])),
record("film three", &[], kojima(&["abyss"])),
]);
assert!(pick(&titles, &metadata, 5).is_empty());
}
#[test]
fn what_is_airing_comes_first_and_too_few_titles_make_no_shelf() {
let titles: Vec<Title> = ["a", "b"].map(title).to_vec();
let metadata = HashMap::from([
record(
"a",
&[],
TitleDetails {
status: Some("RELEASING".into()),
studios: vec!["MAPPA".into()],
..TitleDetails::default()
},
),
record("b", &[], TitleDetails::default()),
]);
let shelves = pick(&titles, &metadata, 5);
assert_eq!(shelves.len(), 1);
assert_eq!(shelves[0].label, "Airing now");
assert_eq!(shelves[0].keys, vec!["a"]);
}
}
File diff suppressed because it is too large. Load diff
+501 -166
View File
@@ -5,14 +5,24 @@
//! also cost them a signup. The trade is coverage — AniList is anime and nothing //! also cost them a signup. The trade is coverage — AniList is anime and nothing
//! else, so a library of films wants [`crate::tmdb`]. //! else, so a library of films wants [`crate::tmdb`].
//! //!
//! One query shape is used for everything: a `Page` of `media` matching the //! One query shape is used for every search: a `Page` of `media` matching the
//! search string, with every name AniList knows the show by. The alias list is //! search string, with every name AniList knows the show by. The alias list is
//! the whole reason this works — see [`crate::matching`]. //! the whole reason this works — see [`crate::matching`].
//!
//! AniList identifies a title and keeps nothing worth having about its
//! episodes, so enriching a match leans on [`crate::anizip`] for those, walking
//! AniList's own `SEQUEL` relations to find every entry the library's seasons
//! are spread across.
use pstr_core::library::TitleKind; use std::collections::HashSet;
use pstr_core::metadata::{EpisodeMetadata, ProviderId, TitleMetadata};
use pstr_core::library::{self, TitleKind};
use pstr_core::metadata::{
Enrichment, EpisodeGuide, EpisodeMetadata, ProviderId, TitleDetails, TitleMetadata,
};
use serde::Deserialize; use serde::Deserialize;
use crate::anizip::AniZip;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::limiter::RateLimiter; use crate::limiter::RateLimiter;
use crate::matching::{Candidate, Query}; use crate::matching::{Candidate, Query};
@@ -46,52 +56,116 @@ const PAGE_SIZE: u32 = 8;
/// AniList scores out of 100 and the rest of the app works out of 10. /// AniList scores out of 100 and the rest of the app works out of 10.
const SCORE_SCALE: f32 = 10.0; const SCORE_SCALE: f32 = 10.0;
const SEARCH: &str = r#" /// Everything one entry is asked for, by search and by id alike.
///
/// The relations are the franchise's — see `pstr_core::franchise` — and the
/// staff are only the most relevant few, which is where a director sits.
macro_rules! entry_fragment {
() => {
r#"
fragment entry on Media {
id
title { romaji english native }
synonyms
description(asHtml: false)
startDate { year month day }
season
coverImage { extraLarge large }
bannerImage
averageScore
popularity
genres
tags { name rank isMediaSpoiler }
episodes
format
status
siteUrl
studios(isMain: true) { nodes { name } }
staff(sort: RELEVANCE, perPage: 6) { edges { role node { name { full } } } }
nextAiringEpisode { episode airingAt }
relations { edges { relationType node { id type } } }
}
"#
};
}
const SEARCH: &str = concat!(
r#"
query ($search: String, $perPage: Int) { query ($search: String, $perPage: Int) {
Page(page: 1, perPage: $perPage) { Page(page: 1, perPage: $perPage) {
media(search: $search, type: ANIME, sort: SEARCH_MATCH) { media(search: $search, type: ANIME, sort: SEARCH_MATCH) { ...entry }
id }
title { romaji english native } }
synonyms "#,
description(asHtml: false) entry_fragment!()
startDate { year } );
coverImage { extraLarge large }
bannerImage /// One entry by the id a match already found — how a stored match gets the
averageScore /// fields it was stored without, without being searched for and decided
genres /// again.
episodes const BY_ID: &str = concat!(
format r#"
siteUrl query ($id: Int) {
} Media(id: $id, type: ANIME) { ...entry }
}
"#,
entry_fragment!()
);
/// The relations that keep to one story. `SPIN_OFF`, `CHARACTER` and `OTHER`
/// are left out: they are how one franchise reaches into another, and
/// following them merged unrelated shows into one tile.
const STORY_RELATIONS: [&str; 8] = [
"PREQUEL",
"SEQUEL",
"PARENT",
"SIDE_STORY",
"SUMMARY",
"ALTERNATIVE",
"COMPILATION",
"CONTAINS",
];
/// A tag ranked below this is AniList's community being unsure of it.
const MIN_TAG_RANK: u32 = 60;
/// How many tags to keep: the rest are increasingly specific trivia.
const MAX_TAGS: usize = 8;
/// The entries an entry leads on to.
///
/// One hop at a time rather than a nested query several levels deep: most
/// titles need no hop at all, and the ones that do stop as soon as the
/// library's files are answered.
const RELATIONS: &str = r#"
query ($id: Int) {
Media(id: $id, type: ANIME) {
relations { edges { relationType node { id type format } } }
} }
} }
"#; "#;
/// Episode titles, such as AniList has them. /// How many entries of one franchise to read before giving up on files that
/// still have no answer.
/// ///
/// `streamingEpisodes` is the only place AniList keeps them — there is no /// Attack on Titan's four seasons are seven entries and Bleach's seventeen are
/// per-episode overview in its API at all — and the entries are what the /// five, so twelve covers every long run in a real library while bounding what
/// streaming sites published: `"Episode 57 - The Immortal Legion"`, in airing /// a file no entry will ever answer — a special, a recap — can cost.
/// order, occasionally with a gap. So the number is read out of the title where const MAX_CHAIN_ENTRIES: usize = 12;
/// it says one, and only falls back to the position in the list.
const EPISODES: &str = r#"
query ($id: Int) {
Media(id: $id, type: ANIME) {
episodes
streamingEpisodes { title thumbnail }
}
}
"#;
pub struct AniList { pub struct AniList {
http: reqwest::Client, http: reqwest::Client,
/// Shared by every lookup in flight — see [`crate::limiter`]. /// Shared by every lookup in flight — see [`crate::limiter`].
limiter: RateLimiter, limiter: RateLimiter,
anizip: AniZip,
} }
impl AniList { impl AniList {
pub fn new(http: reqwest::Client) -> Self { /// `language` is the one episode names are wanted in, where ani.zip has
/// them in it.
pub fn new(http: reqwest::Client, language: String) -> Self {
Self { Self {
anizip: AniZip::new(http.clone(), language),
http, http,
limiter: RateLimiter::per_minute(REQUESTS_PER_MINUTE), limiter: RateLimiter::per_minute(REQUESTS_PER_MINUTE),
} }
@@ -103,11 +177,6 @@ impl Provider for AniList {
ProviderId::AniList ProviderId::AniList
} }
/// A sequel is its own entry here, numbering its episodes from one.
fn seasons_are_separate_entries(&self) -> bool {
true
}
async fn search(&self, query: &Query) -> Result<Vec<Candidate>> { async fn search(&self, query: &Query) -> Result<Vec<Candidate>> {
// Each term is asked only because the one before it came back with // Each term is asked only because the one before it came back with
// nothing at all — see `search_terms`. A page of candidates, however // nothing at all — see `search_terms`. A page of candidates, however
@@ -133,29 +202,113 @@ impl Provider for AniList {
Ok(Vec::new()) Ok(Vec::new())
} }
async fn episodes(&self, title: &TitleMetadata) -> Result<Vec<EpisodeMetadata>> { /// The episodes of every entry the library's files are spread across, and
/// the series' fanart.
///
/// AniList files a sequel as its own entry — Bleach's seventeenth season is
/// four of them, Mushoku Tensei's first is two — so a title's match is
/// only where its episodes *start*. From there the `SEQUEL` relations are
/// followed one entry at a time, each entry's episodes taken from ani.zip,
/// until every numbered file in `title` has an answer, the franchise ends,
/// or [`MAX_CHAIN_ENTRIES`] is reached. A one-season show is one ani.zip
/// request and no relations query at all.
///
/// Following relations rather than searching for `<title> 2nd Season` is
/// the point: that search found the base entry again for Attack on Titan,
/// whose seasons two to four were then captioned with season one's names.
async fn enrich(&self, title: &library::Title, found: &TitleMetadata) -> Result<Enrichment> {
// The id came from our own search response, so a non-numeric one is a // The id came from our own search response, so a non-numeric one is a
// record written by some other version — not worth an error, and there // record written by some other version — not worth an error, and there
// is nothing to ask about. // is nothing to ask about.
let Ok(id) = title.remote_id.parse::<i64>() else { let Ok(first) = found.remote_id.parse::<i64>() else {
return Ok(Vec::new()); return Ok(Enrichment::default());
}; };
let wanted: Vec<(Option<u32>, u32)> = title
.episodes()
.filter_map(|episode| Some((episode.node.parsed.season, episode.node.parsed.episode?)))
.collect();
let data: Option<MediaData> = self let mut enrichment = Enrichment::default();
.query(serde_json::json!({ let mut taken = HashSet::new();
"query": EPISODES, let mut visited = HashSet::new();
"variables": { "id": id }, // The same ids as `visited`, in the order they were read.
})) let mut chain = Vec::new();
.await?; let mut series_entries = 0;
let mut next = Some(Link {
id: first,
series: found.kind == TitleKind::Series,
});
while let Some(link) = next.take() {
visited.insert(link.id);
chain.push(link.id.to_string());
let entry_season = link.series.then(|| {
series_entries += 1;
series_entries
});
let mapping = self.anizip.mapping(link.id, entry_season).await?;
if enrichment.backdrop_url.is_none() {
enrichment.backdrop_url = mapping.fanart;
}
// Where two entries both list an episode, the earlier one — the
// one closer to the match — keeps it.
enrichment.episodes.extend(
mapping
.episodes
.into_iter()
.filter(|episode| taken.insert((episode.season, episode.number))),
);
Ok(data if answers_all(&enrichment.episodes, &wanted) || visited.len() >= MAX_CHAIN_ENTRIES {
.and_then(|data| data.media) break;
.map(|media| media.into_episodes()) }
.unwrap_or_default()) next = self.sequel(link.id, &visited).await?;
}
tracing::debug!(
"{}: {} episodes over {} entries",
found.name,
enrichment.episodes.len(),
visited.len()
);
enrichment.chain = chain;
Ok(enrichment)
} }
} }
impl AniList { impl AniList {
/// The entry the AniList id `id` names, as search would have found it.
/// `None` for an id AniList no longer has.
pub async fn entry(&self, id: &str) -> Result<Option<TitleMetadata>> {
let Ok(id) = id.parse::<i64>() else {
return Ok(None);
};
let data: Option<EntryData> = self
.query(serde_json::json!({
"query": BY_ID,
"variables": { "id": id },
}))
.await?;
Ok(data
.and_then(|data| data.media)
.map(|media| media.into_candidate().metadata))
}
/// The entry `id` continues into, if any.
async fn sequel(&self, id: i64, visited: &HashSet<i64>) -> Result<Option<Link>> {
let data: Option<RelationsData> = self
.query(serde_json::json!({
"query": RELATIONS,
"variables": { "id": id },
}))
.await?;
let edges = data
.and_then(|data| data.media)
.map(|media| media.relations.edges)
.unwrap_or_default();
Ok(next_in_chain(edges, visited))
}
/// One GraphQL requestimpl AniList {
/// One GraphQL request, paced, with AniList's two ways of failing — an HTTP /// One GraphQL request, paced, with AniList's two ways of failing — an HTTP
/// status and an `errors` array under a 200 — folded into one. /// status and an `errors` array under a 200 — folded into one.
/// ///
@@ -278,78 +431,78 @@ struct SearchData {
page: Page, page: Page,
} }
#[derive(Deserialize)] /// One step along a franchise.
struct MediaData { #[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename = "Media")] struct Link {
media: Option<EpisodeMedia>, id: i64,
/// Whether the entry is part of the series proper — TV, a short or a web
/// series — rather than a film or a special, which no release counts as a
/// season.
series: bool,
} }
#[derive(Deserialize)] /// Which of an entry's relations the franchise continues through.
struct EpisodeMedia {
#[serde(rename = "streamingEpisodes", default)]
streaming_episodes: Vec<StreamingEpisode>,
}
#[derive(Deserialize)]
struct StreamingEpisode {
title: Option<String>,
thumbnail: Option<String>,
}
impl EpisodeMedia {
fn into_episodes(self) -> Vec<EpisodeMetadata> {
self.streaming_episodes
.into_iter()
.enumerate()
.map(|(index, episode)| {
let raw = episode.title.unwrap_or_default();
let (number, name) = split_numbering(&raw);
EpisodeMetadata {
// AniList counts straight through, with no seasons: a
// sequel is a separate entry, not a second season.
season: None,
// The position is the fallback and not the answer: the
// lists have gaps, and an off-by-one here would caption
// every episode of a show with the next one's name.
number: number.unwrap_or(index as u32 + 1),
name: name.filter(|name| !name.is_empty()),
// AniList has no per-episode synopsis to give.
overview: None,
still_url: episode.thumbnail,
air_date: None,
}
})
.collect()
}
}
/// Split `"Episode 57 - The Immortal Legion"` into its number and its name.
/// ///
/// Both parts are optional: plenty of entries are just `"Episode 12"`, and a /// A sequel that is itself a series wins over one that is a film or a
/// few are only a name. /// special, but a special is still followed when it is the only way on:
fn split_numbering(title: &str) -> (Option<u32>, Option<String>) { /// Mushishi's first series leads to its second only through an OVA.
let trimmed = title.trim(); fn next_in_chain(edges: Vec<RelationEdge>, visited: &HashSet<i64>) -> Option<Link> {
let rest = trimmed edges
.strip_prefix("Episode ") .into_iter()
.or_else(|| trimmed.strip_prefix("episode ")) .filter(|edge| edge.relation_type.as_deref() == Some("SEQUEL"))
.or_else(|| trimmed.strip_prefix("EP")) .filter_map(|edge| edge.node)
.or_else(|| trimmed.strip_prefix("E")); // A sequel can be the manga it was adapted into; only anime has
// episodes to number.
.filter(|node| node.kind.as_deref() == Some("ANIME") && !visited.contains(&node.id))
.map(|node| Link {
id: node.id,
series: is_series_format(node.format.as_deref()),
})
.min_by_key(|link| !link.series)
}
let Some(rest) = rest else { fn is_series_format(format: Option<&str>) -> bool {
return (None, Some(trimmed.to_string()).filter(|t| !t.is_empty())); matches!(format, Some("TV" | "TV_SHORT" | "ONA"))
}; }
let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); /// Whether every numbered file has an answer in `episodes`.
if digits.is_empty() { fn answers_all(episodes: &[EpisodeMetadata], wanted: &[(Option<u32>, u32)]) -> bool {
return (None, Some(trimmed.to_string())); let guide = EpisodeGuide::new(episodes.to_vec());
} wanted
.iter()
.all(|&(season, number)| guide.get(season, number).is_some())
}
let name = rest[digits.len()..] #[derive(Deserialize)]
.trim_start() struct RelationsData {
.trim_start_matches(['-', '–', ':']) #[serde(rename = "Media")]
.trim() media: Option<RelationsMedia>,
.to_string(); }
(digits.parse().ok(), Some(name).filter(|n| !n.is_empty()))
#[derive(Deserialize)]
struct RelationsMedia {
relations: Relations,
}
#[derive(Deserialize)]
struct Relations {
#[serde(default)]
edges: Vec<RelationEdge>,
}
#[derive(Deserialize)]
struct RelationEdge {
#[serde(rename = "relationType")]
relation_type: Option<String>,
node: Option<RelationNode>,
}
#[derive(Deserialize)]
struct RelationNode {
id: i64,
#[serde(rename = "type")]
kind: Option<String>,
format: Option<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -373,12 +526,76 @@ struct Media {
banner_image: Option<String>, banner_image: Option<String>,
#[serde(rename = "averageScore")] #[serde(rename = "averageScore")]
average_score: Option<f32>, average_score: Option<f32>,
popularity: Option<u32>,
#[serde(default)] #[serde(default)]
genres: Vec<String>, genres: Vec<String>,
#[serde(default)]
tags: Vec<Tag>,
episodes: Option<u32>, episodes: Option<u32>,
format: Option<String>, format: Option<String>,
season: Option<String>,
status: Option<String>,
#[serde(rename = "siteUrl")] #[serde(rename = "siteUrl")]
site_url: Option<String>, site_url: Option<String>,
studios: Option<Studios>,
staff: Option<Staff>,
#[serde(rename = "nextAiringEpisode")]
next_airing_episode: Option<NextAiring>,
relations: Option<Relations>,
}
#[derive(Deserialize)]
struct EntryData {
#[serde(rename = "Media")]
media: Option<Media>,
}
#[derive(Deserialize)]
struct Tag {
name: String,
rank: Option<u32>,
#[serde(rename = "isMediaSpoiler", default)]
spoiler: bool,
}
#[derive(Deserialize)]
struct Studios {
#[serde(default)]
nodes: Vec<Named>,
}
#[derive(Deserialize)]
struct Named {
name: String,
}
#[derive(Deserialize)]
struct Staff {
#[serde(default)]
edges: Vec<StaffEdge>,
}
#[derive(Deserialize)]
struct StaffEdge {
role: Option<String>,
node: Option<Person>,
}
#[derive(Deserialize)]
struct Person {
name: PersonName,
}
#[derive(Deserialize)]
struct PersonName {
full: Option<String>,
}
#[derive(Deserialize)]
struct NextAiring {
episode: Option<u32>,
#[serde(rename = "airingAt")]
airing_at: Option<i64>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -391,6 +608,20 @@ struct Title {
#[derive(Deserialize)] #[derive(Deserialize)]
struct FuzzyDate { struct FuzzyDate {
year: Option<u32>, year: Option<u32>,
month: Option<u32>,
day: Option<u32>,
}
impl FuzzyDate {
/// `2013-04-07`, `2013-04` or `2013` — as much as AniList knows.
fn text(&self) -> Option<String> {
let year = self.year?;
Some(match (self.month, self.day) {
(Some(month), Some(day)) => format!("{year:04}-{month:02}-{day:02}"),
(Some(month), None) => format!("{year:04}-{month:02}"),
_ => format!("{year:04}"),
})
}
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -434,6 +665,72 @@ impl Media {
.cover_image .cover_image
.and_then(|cover| cover.extra_large.or(cover.large)); .and_then(|cover| cover.extra_large.or(cover.large));
let details = TitleDetails {
romaji: self.title.romaji.clone(),
banner_url: self.banner_image,
format: self.format.clone(),
season: self.season,
start_date: self.start_date.as_ref().and_then(FuzzyDate::text),
studios: self
.studios
.map(|studios| {
studios
.nodes
.into_iter()
.map(|studio| studio.name)
.collect()
})
.unwrap_or_default(),
directors: self
.staff
.map(|staff| {
staff
.edges
.into_iter()
// `Director`, and not `Assistant Director`,
// `Episode Director` or `Director (eps 3, 7)` — those
// are many people per show, and a shelf of one
// storyboard artist's episodes is not what a
// "directed by" row means.
.filter(|edge| edge.role.as_deref() == Some("Director"))
.filter_map(|edge| edge.node.and_then(|person| person.name.full))
.collect()
})
.unwrap_or_default(),
tags: self
.tags
.into_iter()
.filter(|tag| !tag.spoiler && tag.rank.unwrap_or(0) >= MIN_TAG_RANK)
.take(MAX_TAGS)
.map(|tag| tag.name)
.collect(),
popularity: self.popularity,
status: self.status,
next_episode: self
.next_airing_episode
.as_ref()
.and_then(|next| next.episode),
next_airing_at: self.next_airing_episode.and_then(|next| next.airing_at),
related: self
.relations
.map(|relations| {
relations
.edges
.into_iter()
.filter(|edge| {
edge.relation_type
.as_deref()
.is_some_and(|kind| STORY_RELATIONS.contains(&kind))
})
.filter_map(|edge| edge.node)
.filter(|node| node.kind.as_deref() == Some("ANIME"))
.map(|node| node.id.to_string())
.collect()
})
.unwrap_or_default(),
chain: Vec::new(),
};
Candidate { Candidate {
metadata: TitleMetadata { metadata: TitleMetadata {
provider: ProviderId::AniList, provider: ProviderId::AniList,
@@ -444,13 +741,16 @@ impl Media {
year: self.start_date.and_then(|date| date.year), year: self.start_date.and_then(|date| date.year),
kind: kind_of(self.format.as_deref()), kind: kind_of(self.format.as_deref()),
poster_url: cover, poster_url: cover,
// Usually `null`. AniList only has banners for the better-known // Not `bannerImage`: that is a 4.75:1 strip, and drawn into a
// shows, which is why a tile has to cope with a poster's shape. // 16:9 tile it is a blurred sliver of its own middle. The
backdrop_url: self.banner_image, // backdrop is ani.zip's fanart, found when the match is
// enriched.
backdrop_url: None,
rating: self.average_score.map(|score| score / SCORE_SCALE), rating: self.average_score.map(|score| score / SCORE_SCALE),
genres: self.genres, genres: self.genres,
episodes: self.episodes, episodes: self.episodes,
url: self.site_url, url: self.site_url,
details: Some(details),
}, },
aliases, aliases,
// Null on anything AniList has not seen air yet, which is not the // Null on anything AniList has not seen air yet, which is not the
@@ -594,60 +894,95 @@ mod tests {
assert_eq!(candidate.metadata.poster_url.as_deref(), Some("big.jpg")); assert_eq!(candidate.metadata.poster_url.as_deref(), Some("big.jpg"));
} }
#[test] fn edge(relation: &str, id: i64, kind: &str, format: &str) -> RelationEdge {
fn an_episode_title_gives_up_its_number_and_its_name() { RelationEdge {
assert_eq!( relation_type: Some(relation.into()),
split_numbering("Episode 57 - The Immortal Legion"), node: Some(RelationNode {
(Some(57), Some("The Immortal Legion".to_string())) id,
); kind: Some(kind.into()),
assert_eq!(split_numbering("Episode 12"), (Some(12), None)); format: Some(format.into()),
// Some entries are only a name, and some sites write it differently. }),
assert_eq!( }
split_numbering("The Day of the Beginning"),
(None, Some("The Day of the Beginning".to_string()))
);
assert_eq!(
split_numbering("E3 – Whatever"),
(Some(3), Some("Whatever".to_string()))
);
assert_eq!(split_numbering(""), (None, None));
} }
/// Attack on Titan's relations: an adaptation, two compilation films, a
/// prequel OVA and a spin-off around the one sequel that is the next season.
#[test] #[test]
fn episode_numbers_come_from_the_titles_and_not_from_the_order() { fn the_chain_continues_through_the_sequel_and_nothing_else() {
// AniList lists what streaming sites published, and those lists have let edges = vec![
// gaps. Numbering by position would caption episode 4 as episode 3. edge("ADAPTATION", 53390, "MANGA", "MANGA"),
let media: EpisodeMedia = serde_json::from_str( edge("ALTERNATIVE", 20691, "ANIME", "MOVIE"),
r#"{"streamingEpisodes": [ edge("PREQUEL", 20811, "ANIME", "OVA"),
{"title": "Episode 1 - Fullmetal Alchemist", "thumbnail": "one.jpg"}, edge("SPIN_OFF", 21281, "ANIME", "TV"),
{"title": "Episode 3 - City of Heresy", "thumbnail": null} edge("SEQUEL", 20958, "ANIME", "TV"),
]}"#, ];
) assert_eq!(
.expect("parse"); next_in_chain(edges, &HashSet::new()),
Some(Link {
let episodes = media.into_episodes(); id: 20958,
assert_eq!(episodes[0].number, 1); series: true
assert_eq!(episodes[0].name.as_deref(), Some("Fullmetal Alchemist")); })
assert_eq!(episodes[0].still_url.as_deref(), Some("one.jpg")); );
assert_eq!(episodes[1].number, 3);
// AniList counts straight through; nothing here is a season.
assert!(episodes.iter().all(|episode| episode.season.is_none()));
} }
/// Mushishi's first series leads on only through an OVA, which is followed
/// — but not counted as a season of the series.
#[test] #[test]
fn an_untitled_list_still_numbers_its_episodes() { fn a_special_is_followed_when_it_is_the_only_way_on() {
let media: EpisodeMedia = serde_json::from_str( let edges = vec![edge("SEQUEL", 20526, "ANIME", "SPECIAL")];
r#"{"streamingEpisodes": [{"title": null, "thumbnail": null}, assert_eq!(
{"title": "", "thumbnail": null}]}"#, next_in_chain(edges, &HashSet::new()),
) Some(Link {
.expect("parse"); id: 20526,
let episodes = media.into_episodes(); series: false
assert_eq!(episodes[0].number, 1); })
assert_eq!(episodes[1].number, 2); );
assert!(episodes.iter().all(|episode| episode.name.is_none())); // With a series beside it, the series wins.
let edges = vec![
edge("SEQUEL", 20752, "ANIME", "SPECIAL"),
edge("SEQUEL", 20751, "ANIME", "TV"),
];
assert_eq!(
next_in_chain(edges, &HashSet::new()).map(|link| link.id),
Some(20751)
);
} }
/// Missing fields are the normal case for a recently added entry, and must /// A franchise whose relations loop must end rather than walk forever.
#[test]
fn an_entry_already_read_is_not_read_again() {
let edges = vec![edge("SEQUEL", 1, "ANIME", "TV")];
assert_eq!(next_in_chain(edges, &HashSet::from([1])), None);
let manga = vec![edge("SEQUEL", 2, "MANGA", "MANGA")];
assert_eq!(next_in_chain(manga, &HashSet::new()), None);
}
/// The walk stops once every file is answered and not before: a season
/// folder of absolute numbers counts as answered by its own season.
#[test]
fn the_walk_stops_when_every_numbered_file_has_an_answer() {
let episode = |season, number, absolute| EpisodeMetadata {
season: Some(season),
number,
absolute: Some(absolute),
entry_season: None,
entry_number: None,
name: None,
overview: None,
still_url: None,
air_date: None,
};
let season_one = vec![episode(1, 1, 1), episode(1, 2, 2)];
assert!(answers_all(&season_one, &[(Some(1), 1), (None, 2)]));
assert!(!answers_all(&season_one, &[(Some(1), 1), (Some(2), 26)]));
let both = vec![episode(1, 1, 1), episode(2, 1, 26)];
assert!(answers_all(&both, &[(Some(1), 1), (Some(2), 26)]));
// A film: nothing numbered, nothing to walk for.
assert!(answers_all(&[], &[]));
}
/// Missing fields are the normal case /// Missing fields are the normal case for a recently added entry, and must
/// not fail the whole page of results. /// not fail the whole page of results.
#[test] #[test]
fn an_entry_with_almost_nothing_in_it_still_parses() { fn an_entry_with_almost_nothing_in_it_still_parses() {
+296
View File
@@ -0,0 +1,296 @@
//! ani.zip: what AniList does not say about one of its own entries.
//!
//! AniList is where a title is *identified* — its search and its aliases are
//! what make a romaji folder name match — but it keeps no episode list worth
//! the name and no landscape art. ani.zip answers both, keyed by the AniList id
//! the match already produced, so asking it sends nothing about the library the
//! match did not already send. No key and no account, like AniList.
//!
//! The episode list is TVDB's, and that is the point of it: release groups
//! number their files the way TVDB does — `Bleach - S17E01` is TVDB's season
//! 17, not AniList's — and every episode here carries TVDB's season, number and
//! absolute number together. See `docs/METADATA.md`.
//!
//! It is a community service rather than an institution, so everything it says
//! is stored, and an entry it does not know (`404`) is an empty answer rather
//! than a failure.
use pstr_core::metadata::EpisodeMetadata;
use serde::Deserialize;
use crate::error::{Error, Result};
use crate::limiter::RateLimiter;
const ENDPOINT: &str = "https://api.ani.zip/mappings";
/// ani.zip publishes no limit. One a second is far below anything a CDN-cached
/// JSON endpoint notices, and a library's enrichment is a one-off burst.
const REQUESTS_PER_MINUTE: u32 = 60;
/// TVDB's own placeholder for an episode it has not named yet.
const UNNAMED: &str = "TBA";
/// What ani.zip knows about one AniList entry.
#[derive(Debug, Default)]
pub struct Mapping {
pub episodes: Vec<EpisodeMetadata>,
/// TVDB's fanart for the whole series: 16:9, usually 1920×1080.
pub fanart: Option<String>,
}
pub struct AniZip {
http: reqwest::Client,
/// The episode-title language tried first; English, then romaji, then
/// Japanese after it.
language: String,
limiter: RateLimiter,
}
impl AniZip {
pub fn new(http: reqwest::Client, language: String) -> Self {
Self {
http,
language,
limiter: RateLimiter::per_minute(REQUESTS_PER_MINUTE),
}
}
/// Everything ani.zip has for the AniList entry `anilist_id`.
///
/// `entry_season` is where this entry sits among the franchise's series
/// entries — see [`EpisodeMetadata::entry_season`] — and `None` for a film
/// or a special, whose episodes no release numbers that way.
pub async fn mapping(&self, anilist_id: i64, entry_season: Option<u32>) -> Result<Mapping> {
self.limiter.acquire().await;
let response = self
.http
.get(ENDPOINT)
.query(&[("anilist_id", anilist_id)])
.send()
.await?;
let status = response.status();
if status.as_u16() == 404 {
return Ok(Mapping::default());
}
if status.as_u16() == 429 {
self.limiter.throttled(response.headers()).await;
return Err(Error::RateLimited);
}
if !status.is_success() {
return Err(Error::Http(format!("ani.zip answered {status}")));
}
let payload: Payload = response.json().await?;
Ok(payload.into_mapping(&self.language, entry_season))
}
}
#[derive(Deserialize, Default)]
struct Payload {
#[serde(default)]
episodes: std::collections::BTreeMap<String, Episode>,
#[serde(default)]
images: Vec<Image>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Episode {
#[serde(default, deserialize_with = "lenient_number")]
season_number: Option<u32>,
#[serde(default, deserialize_with = "lenient_number")]
episode_number: Option<u32>,
#[serde(default, deserialize_with = "lenient_number")]
absolute_episode_number: Option<u32>,
title: Option<std::collections::HashMap<String, Option<String>>>,
/// TVDB's synopsis.
overview: Option<String>,
/// AniDB's, which is there when TVDB's is not.
summary: Option<String>,
image: Option<String>,
air_date: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Image {
cover_type: Option<String>,
url: Option<String>,
}
impl Payload {
fn into_mapping(self, language: &str, entry_season: Option<u32>) -> Mapping {
let fanart = self
.images
.into_iter()
.find(|image| image.cover_type.as_deref() == Some("Fanart"))
.and_then(|image| image.url);
let episodes = self
.episodes
.into_iter()
.filter_map(|(key, episode)| {
// An episode TVDB has not placed has nothing a filename could
// be matched against — a film's own entry is the usual case.
let season = episode.season_number?;
let number = episode.episode_number?;
// Keys are the entry's own numbering: `"1"`, `"2"` for its
// episodes and `"S1"` for its specials, which no release counts
// as part of the season.
let entry_number = entry_season.and(key.parse::<u32>().ok());
Some(EpisodeMetadata {
season: Some(season),
number,
absolute: episode.absolute_episode_number,
entry_season: entry_number.and(entry_season),
entry_number,
name: episode
.title
.as_ref()
.and_then(|titles| title_in(titles, language)),
overview: text(episode.overview).or_else(|| text(episode.summary)),
still_url: text(episode.image),
air_date: text(episode.air_date),
})
})
.collect();
Mapping { episodes, fanart }
}
}
/// The episode's name in `language`, else English, romaji or Japanese.
fn title_in(
titles: &std::collections::HashMap<String, Option<String>>,
language: &str,
) -> Option<String> {
[language, "en", "x-jat", "ja"]
.into_iter()
.filter_map(|language| titles.get(language).cloned().flatten())
.map(|title| title.trim().to_string())
.find(|title| !title.is_empty() && title != UNNAMED)
}
fn text(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
/// A number, whether it arrives as one or as a string of digits.
///
/// ani.zip writes its regular episodes' numbers as JSON numbers and its
/// specials' as strings; one strict field would fail the whole entry over the
/// specials.
fn lenient_number<'de, D>(deserializer: D) -> std::result::Result<Option<u32>, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(match serde_json::Value::deserialize(deserializer)? {
serde_json::Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()),
serde_json::Value::String(text) => text.trim().parse().ok(),
_ => None,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// Attack on Titan Season 2 as ani.zip answers it, trimmed: a regular
/// episode numbered in numbers, a special numbered in strings, a film-like
/// entry TVDB never placed, and the series' art.
const SEASON_TWO: &str = r#"{
"episodes": {
"1": {"seasonNumber": 2, "episodeNumber": 1, "absoluteEpisodeNumber": 26,
"episode": "1",
"title": {"en": "Beast Titan", "ja": "獣の巨人", "x-jat": "Kemono no Kyojin"},
"overview": "Titans appear inside Wall Rose.",
"image": "https://artworks.thetvdb.com/still.jpg",
"airDate": "2017-04-01"},
"S1": {"seasonNumber": "0", "episodeNumber": "7", "episode": "S1",
"title": {"en": "TBA", "x-jat": "Ilse no Techou"},
"summary": "An OVA."},
"2": {"episode": "2", "title": {"en": "Unplaced"}}
},
"images": [
{"coverType": "Banner", "url": "banner.jpg"},
{"coverType": "Fanart", "url": "fanart.jpg"}
]
}"#;
fn mapping(entry_season: Option<u32>) -> Mapping {
serde_json::from_str::<Payload>(SEASON_TWO)
.expect("parse")
.into_mapping("en", entry_season)
}
#[test]
fn an_episode_carries_tvdbs_numbering_and_its_entrys() {
let mapping = mapping(Some(2));
let first = mapping
.episodes
.iter()
.find(|episode| episode.season == Some(2))
.expect("the regular episode");
assert_eq!(first.number, 1);
assert_eq!(first.absolute, Some(26));
assert_eq!((first.entry_season, first.entry_number), (Some(2), Some(1)));
assert_eq!(first.name.as_deref(), Some("Beast Titan"));
assert_eq!(
first.overview.as_deref(),
Some("Titans appear inside Wall Rose.")
);
assert_eq!(first.air_date.as_deref(), Some("2017-04-01"));
}
/// A special is numbered in strings and filed under season zero, and no
/// release counts it as an episode of the entry it came with.
#[test]
fn a_special_keeps_its_tvdb_place_and_takes_no_entry_number() {
let mapping = mapping(Some(2));
let special = mapping
.episodes
.iter()
.find(|episode| episode.season == Some(0))
.expect("the special");
assert_eq!(special.number, 7);
assert_eq!(special.entry_number, None);
assert_eq!(special.entry_season, None);
// `TBA` is TVDB saying it has no name yet, so the next language answers.
assert_eq!(special.name.as_deref(), Some("Ilse no Techou"));
// AniDB's summary stands in for TVDB's missing overview.
assert_eq!(special.overview.as_deref(), Some("An OVA."));
}
#[test]
fn an_episode_tvdb_never_placed_is_left_out() {
assert_eq!(mapping(Some(2)).episodes.len(), 2);
}
/// A film or a special as an entry has no place among the series' seasons.
#[test]
fn an_entry_outside_the_series_numbers_nothing_by_entry() {
assert!(
mapping(None)
.episodes
.iter()
.all(|episode| episode.entry_season.is_none() && episode.entry_number.is_none())
);
}
/// The 16:9 picture, not the banner — the banner is the 4.75:1 strip that
/// looked wrong on every desktop tile.
#[test]
fn the_fanart_is_taken_and_the_banner_is_not() {
assert_eq!(mapping(None).fanart.as_deref(), Some("fanart.jpg"));
}
#[test]
fn an_entry_ani_zip_has_nothing_for_parses_to_nothing() {
let mapping = serde_json::from_str::<Payload>("{}")
.expect("parse")
.into_mapping("en", Some(1));
assert!(mapping.episodes.is_empty());
assert_eq!(mapping.fanart, None);
}
}
+3 -1
View File
@@ -10,7 +10,8 @@
//! Title ──▶ Query ──▶ Provider::search ──▶ [Candidate] ──▶ matching::best //! Title ──▶ Query ──▶ Provider::search ──▶ [Candidate] ──▶ matching::best
//! (anilist | tmdb) │ //! (anilist | tmdb) │
//! ▼ //! ▼
//! TitleMetadata //! Enrichment ◀── Provider::enrich ◀──────────────────── TitleMetadata
//! (episodes, fanart) (anilist relations + ani.zip | tmdb)
//! ``` //! ```
//! //!
//! Three things are worth knowing before touching any of it: //! Three things are worth knowing before touching any of it:
@@ -28,6 +29,7 @@
//! without either depending on this crate. //! without either depending on this crate.
pub mod anilist; pub mod anilist;
pub mod anizip;
pub mod error; pub mod error;
pub mod limiter; pub mod limiter;
pub mod matching; pub mod matching;
+1
View File
@@ -208,6 +208,7 @@ mod tests {
genres: Vec::new(), genres: Vec::new(),
episodes: None, episodes: None,
url: None, url: None,
details: None,
}, },
aliases: std::iter::once(name) aliases: std::iter::once(name)
.chain(aliases.iter().copied()) .chain(aliases.iter().copied())
+17 -25
View File
@@ -1,35 +1,24 @@
//! What every metadata source has to be able to do. //! What every metadata source has to be able to do.
use pstr_core::metadata::{EpisodeMetadata, ProviderId, TitleMetadata}; use pstr_core::library::Title;
use pstr_core::metadata::{Enrichment, ProviderId, TitleMetadata};
use crate::error::Result; use crate::error::Result;
use crate::matching::{Candidate, Query}; use crate::matching::{Candidate, Query};
/// A source of titles. /// A source of titles.
/// ///
/// Deliberately narrow: search, and the episode list of something search /// Deliberately narrow: search, and enriching something search already
/// already matched. Both are requests to a third party, and neither happens /// matched. Both are requests to a third party, and neither happens until the
/// until the viewer has turned enrichment on — but note what the second one /// viewer has turned enrichment on — but note what the second one does *not*
/// does *not* need: it takes the id search returned, so it sends the provider /// need: it takes the id search returned, so it sends nothing about the library
/// nothing about the library it did not already learn from the match. /// it did not already learn from the match.
/// ///
/// Not `#[async_trait]`: this is only ever used through a concrete type or an /// Not `#[async_trait]`: this is only ever used through a concrete type or an
/// enum, never as `dyn Provider`, so the native `async fn` costs nothing here. /// enum, never as `dyn Provider`, so the native `async fn` costs nothing here.
pub trait Provider: Send + Sync { pub trait Provider: Send + Sync {
fn id(&self) -> ProviderId; fn id(&self) -> ProviderId;
/// Whether a sequel is a *separate entry* rather than a second season of
/// the same one.
///
/// AniList works that way — `Oshi no Ko`, `Oshi no Ko 2nd Season` and
/// `Oshi no Ko 3rd Season` are three ids, each numbering its episodes from
/// one — so a library that files all three under one title has to search
/// again per season or seasons two upwards get no episode names at all (and,
/// worse, would be answered with season one's). TMDB files them as seasons
/// of one show and its episode list already carries season numbers, so
/// searching per season there would only find the wrong entry.
fn seasons_are_separate_entries(&self) -> bool;
/// Everything the provider thinks `query` might be, in its own order. /// Everything the provider thinks `query` might be, in its own order.
/// ///
/// Scoring is not the provider's job — it returns candidates and /// Scoring is not the provider's job — it returns candidates and
@@ -40,13 +29,16 @@ pub trait Provider: Send + Sync {
query: &Query, query: &Query,
) -> impl std::future::Future<Output = Result<Vec<Candidate>>> + Send; ) -> impl std::future::Future<Output = Result<Vec<Candidate>>> + Send;
/// Every episode the provider lists for a title it already matched. /// The episodes, and any art the search answer lacked, of `found` — the
/// entry `title` matched.
/// ///
/// An empty list is a real answer — a film has no episodes, and plenty of /// `title` is there for its files: a provider that files each sequel as a
/// series entries have none listed — and the caller caches it rather than /// separate entry (AniList) has to know which seasons the library holds to
/// asking again on the next render. /// know how far along the franchise to look. An empty episode list is a
fn episodes( /// real answer — a film has none — and the caller records it.
fn enrich(
&self, &self,
title: &TitleMetadata, title: &Title,
) -> impl std::future::Future<Output = Result<Vec<EpisodeMetadata>>> + Send; found: &TitleMetadata,
) -> impl std::future::Future<Output = Result<Enrichment>> + Send;
} }
+356 -165
View File
@@ -20,11 +20,12 @@
//! Nothing here is requested unless [`MetadataConfig::enabled`] is set. See the //! Nothing here is requested unless [`MetadataConfig::enabled`] is set. See the
//! note in `pstr_core::metadata` on why that is off by default. //! note in `pstr_core::metadata` on why that is off by default.
use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use pstr_core::library::{Title, TitleKind, title_key}; use pstr_core::library::{Title, TitleKind, title_key};
use pstr_core::metadata::{ use pstr_core::metadata::{
EpisodeMetadata, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata, Enrichment, MATCH_TTL_SECS, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata,
}; };
use crate::anilist::AniList; use crate::anilist::AniList;
@@ -63,17 +64,24 @@ impl Source {
} }
} }
async fn episodes(&self, title: &TitleMetadata) -> Result<Vec<EpisodeMetadata>> { /// The entry a stored match names, fetched again by its id. `None` from a
/// provider that has no details to add.
async fn entry(&self, id: &str) -> Result<Option<TitleMetadata>> {
match self { match self {
Self::AniList(provider) => provider.episodes(title).await, Self::AniList(provider) => provider.entry(id).await,
Self::Tmdb(provider) => provider.episodes(title).await, Self::Tmdb(_) => Ok(None),
} }
} }
fn seasons_are_separate_entries(&self) -> bool { /// Whether [`Source::entry`] has anything to give.
fn has_details(&self) -> bool {
matches!(self, Self::AniList(_))
}
async fn enrich(&self, title: &Title, found: &TitleMetadata) -> Result<Enrichment> {
match self { match self {
Self::AniList(provider) => provider.seasons_are_separate_entries(), Self::AniList(provider) => provider.enrich(title, found).await,
Self::Tmdb(provider) => provider.seasons_are_separate_entries(), Self::Tmdb(provider) => provider.enrich(title, found).await,
} }
} }
} }
@@ -101,7 +109,9 @@ impl MetadataService {
.map_err(Error::Network)?; .map_err(Error::Network)?;
let source = match config.provider { let source = match config.provider {
ProviderId::AniList => Source::AniList(AniList::new(http.clone())), ProviderId::AniList => {
Source::AniList(AniList::new(http.clone(), config.language.clone()))
}
ProviderId::Tmdb => Source::Tmdb(Tmdb::new( ProviderId::Tmdb => Source::Tmdb(Tmdb::new(
http.clone(), http.clone(),
api_key.ok_or(Error::MissingApiKey(ProviderId::Tmdb))?, api_key.ok_or(Error::MissingApiKey(ProviderId::Tmdb))?,
@@ -204,136 +214,29 @@ impl MetadataService {
} }
} }
/// What the provider lists as the episodes of a title it matched. /// The episodes of a matched title, and any art its match lacked.
/// ///
/// Only ever called for a title that already has a match, because the /// Only ever called for a title that already has a match, because the
/// provider's own id is what it takes — there is no second search here, and /// provider's own id is what it takes — there is no second search here, and
/// nothing about the library goes out that the match did not already send. /// nothing about the library goes out that the match did not already send.
/// An empty list is a real answer and belongs in the catalog: a film has no ///
/// episodes, and asking again per render is the trap `MetadataRecord` /// An `Err` is a failure to ask and must not be stored: the title stays
/// exists to avoid. /// un-enriched and the next match run asks again. An empty answer is an
pub async fn episodes(&self, title: &TitleMetadata) -> Result<Vec<EpisodeMetadata>> { /// answer, and is — see `Catalog::set_enrichment`.
let episodes = self.source.episodes(title).await?; pub async fn enrich(&self, title: &Title, found: &TitleMetadata) -> Result<Enrichment> {
let enrichment = self.source.enrich(title, found).await?;
tracing::debug!( tracing::debug!(
"{}: {} episodes from {}", "{}: {} episodes from {}{}",
title.name, title.name,
episodes.len(), enrichment.episodes.len(),
self.provider().label() self.provider().label(),
); if enrichment.backdrop_url.is_some() {
Ok(episodes) ", with a backdrop"
} } else {
""
/// The episode list for a whole title, with a failure treated as an empty
/// one.
///
/// Deliberately not fatal to the title it belongs to: a poster and a
/// synopsis that arrived are worth keeping even when the episode request
/// was the one that hit the rate limit. The empty result is not cached as
/// an answer — the next match run asks again — because the only thing that
/// triggers one is a viewer pressing the button.
///
/// The match itself only ever answers for *one* entry, and on a provider
/// that files each sequel separately — AniList — that entry is season one.
/// So each further season of the title is searched for by name and its
/// episodes are tagged with the season they came from; without that,
/// seasons two and three have no episode names at all. See
/// [`MetadataService::season_episodes`].
pub async fn title_episodes(
&self,
title: &Title,
found: &TitleMetadata,
) -> Vec<EpisodeMetadata> {
let mut episodes = self
.episodes(found)
.await
.inspect_err(|error| tracing::warn!("episodes for {}: {error}", found.name))
.unwrap_or_default();
if !self.splits_seasons() {
return episodes;
}
// Season one is what the title's own match already answered for.
let later: Vec<u32> = title
.seasons
.iter()
.filter_map(|season| season.number)
.filter(|number| *number > 1)
.collect();
for season in later {
match self.season_episodes(title, season).await {
Ok(found) => episodes.extend(found),
Err(error) => {
tracing::warn!("episodes for {} season {season}: {error}", title.name);
}
} }
}
episodes
}
/// Whether this provider files a sequel as its own entry, so that a title
/// with several seasons has to be searched for once per season.
pub fn splits_seasons(&self) -> bool {
self.source.seasons_are_separate_entries()
}
/// The episodes of one season of `title`, for a provider that files each
/// season separately.
///
/// A second search, not a second guess: the title's own match is season
/// one's entry, and asking it about episode one of season two would answer
/// with episode one of season one. So the season is searched for by the
/// name the provider itself uses — `Oshi no Ko 2nd Season`, and `Oshi no Ko
/// Season 2` when that finds nothing, which are the two conventions between
/// them covering everything AniList carries.
///
/// A miss is an empty list rather than an error: a season the provider has
/// never heard of is ordinary, and it leaves those rows named by their
/// filenames exactly as they were before.
pub async fn season_episodes(
&self,
title: &Title,
season: u32,
) -> Result<Vec<EpisodeMetadata>> {
if !self.splits_seasons() {
return Ok(Vec::new());
}
let mut found = None;
for name in season_names(&title.name, season) {
// No year: a sequel airs years after the series the library named
// the folder for, so carrying the title's year over would penalise
// the very entry being looked for.
let query = Query::new(name, None, title.kind);
let candidates = self.source.search(&query).await?;
if let Some(matched) = matching::best(&query, candidates) {
found = Some(matched);
break;
}
}
let Some(found) = found else {
tracing::debug!("{}: no entry for season {season}", title.name);
return Ok(Vec::new());
};
let episodes = self.source.episodes(&found).await?;
tracing::debug!(
"{} season {season}: {} episodes from {:?}",
title.name,
episodes.len(),
found.name
); );
// The entry numbers its own episodes from one; what makes them season Ok(enrichment)
// two's is which entry they came from, and that has to be recorded here
// or nothing can look them up again.
Ok(episodes
.into_iter()
.map(|episode| EpisodeMetadata {
season: Some(season),
..episode
})
.collect())
} }
/// Download one piece of artwork. /// Download one piece of artwork.
@@ -351,29 +254,195 @@ impl MetadataService {
} }
} }
/// What to search for when looking for one season of a series, best first. /// What one match run has to do about one title.
/// #[derive(Debug, Clone)]
/// `2nd Season` is AniList's own convention and matches its romaji titles pub enum Work {
/// outright; `Season 2` is what its English titles and most western libraries /// Search for it, and enrich it if it matches.
/// use. Season one is never searched for this way — that is the title's own Match(Title),
/// match. /// It is already matched; only its enrichment is missing or old.
fn season_names(title: &str, season: u32) -> Vec<String> { Enrich(Title, Box<TitleMetadata>),
vec![ /// It is matched, but stored before [`TitleMetadata::details`] existed:
format!("{title} {} Season", ordinal(season)), /// fetch the same entry again by id — never searched for, so a match the
format!("{title} Season {season}"), /// viewer picked by hand stays theirs — and enrich it too if `enrich`.
] Refresh {
title: Title,
record: Box<MetadataRecord>,
enrich: bool,
},
} }
/// `2` → `2nd`. English ordinals, because that is what the provider writes. impl Work {
fn ordinal(number: u32) -> String { pub fn title(&self) -> &Title {
let suffix = match (number % 10, number % 100) { match self {
(_, 11..=13) => "th", Self::Match(title) | Self::Enrich(title, _) | Self::Refresh { title, .. } => title,
(1, _) => "st", }
(2, _) => "nd", }
(3, _) => "rd", }
_ => "th",
}; /// What a piece of [`Work`] found, ready to store.
format!("{number}{suffix}") #[derive(Debug)]
pub struct Outcome {
pub title_key: String,
/// The new answer, for [`Work::Match`] and [`Work::Refresh`]. Store it —
/// misses included.
pub record: Option<MetadataRecord>,
/// What enriching the match found, when it was asked and answered. `None`
/// after a failure, which must leave the title un-enriched so the next run
/// asks again.
pub enrichment: Option<Enrichment>,
}
/// Decide what a match run over `titles` has to do.
///
/// Three kinds of work, and the difference is requests saved: a title that
/// already has a good match but has not been enriched — or was enriched longer
/// ago than a match is trusted — needs enriching only, and searching for it
/// again would ask the provider something it has already answered. One whose
/// match predates its details is fetched again by id, which is one request
/// and decides nothing.
///
/// A hand-picked entry is never searched for again, not even when `force`d —
/// that means "the automatic answers are wrong", and re-deciding the one title
/// the viewer already fixed by hand is the opposite of what they asked for. It
/// is still enriched and refreshed.
///
/// `details` is whether the provider has [`TitleMetadata::details`] to give
/// — see [`MetadataService::has_details`].
pub fn plan(
titles: Vec<Title>,
stored: &HashMap<String, MetadataRecord>,
enriched: &HashMap<String, (ProviderId, i64)>,
provider: ProviderId,
details: bool,
force: bool,
) -> Vec<Work> {
let now = now();
let mut work = Vec::new();
for title in titles {
let record = stored.get(&title.key);
let pinned = record.is_some_and(|record| record.manual && record.provider == provider);
if !pinned && (force || !is_usable(record, provider)) {
work.push(Work::Match(title));
continue;
}
let Some(record) = record.filter(|record| record.metadata.is_some()) else {
continue;
};
let fresh = enriched.get(&title.key).is_some_and(|(asked, at)| {
*asked == provider && now.saturating_sub(*at) < MATCH_TTL_SECS
});
let stale_details = details
&& record
.metadata
.as_ref()
.is_some_and(|found| found.details.is_none());
if stale_details {
work.push(Work::Refresh {
title,
record: Box::new(record.clone()),
enrich: !fresh,
});
} else if !fresh && let Some(found) = record.metadata.clone() {
work.push(Work::Enrich(title, Box::new(found)));
}
}
work
}
impl MetadataService {
/// Whether this provider fills in [`TitleMetadata::details`], and so
/// whether a match stored without them is worth fetching again.
pub fn has_details(&self) -> bool {
self.source.has_details()
}
/// Do one piece of [`Work`].
///
/// A failed search is an `Err` and nothing is stored. A failed enrichment
/// after a successful search is not: the match is worth keeping, and the
/// title is simply left un-enriched — a poster and a synopsis that arrived
/// are worth showing even when the episode request was the one that hit the
/// rate limit.
pub async fn work(&self, work: &Work) -> Result<Outcome> {
match work {
Work::Match(title) => {
let record = self.record(title).await?;
let enrichment = match &record.metadata {
Some(found) => self.enrich_or_log(title, found).await,
None => None,
};
Ok(Outcome {
title_key: record.title_key.clone(),
record: Some(record),
enrichment,
})
}
Work::Enrich(title, found) => Ok(Outcome {
title_key: title.key.clone(),
record: None,
enrichment: Some(self.enrich(title, found).await?),
}),
Work::Refresh {
title,
record,
enrich,
} => {
let Some(stored) = &record.metadata else {
return Ok(Outcome {
title_key: title.key.clone(),
record: None,
enrichment: None,
});
};
// An id the provider no longer has keeps what was stored, with
// empty details so it is not asked about on every run.
let found = self
.source
.entry(&stored.remote_id)
.await?
.unwrap_or_else(|| TitleMetadata {
details: Some(Default::default()),
..stored.clone()
});
let enrichment = match enrich {
true => self.enrich_or_log(title, &found).await,
false => None,
};
Ok(Outcome {
title_key: title.key.clone(),
record: Some(MetadataRecord {
metadata: Some(found),
fetched_at: now(),
..(**record).clone()
}),
enrichment,
})
}
}
}
/// 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
.inspect_err(|error| tracing::warn!("enrich {}: {error}", title.name))
.ok()
}
} }
/// Whether a stored answer can be used as-is. /// Whether a stored answer can be used as-is.
@@ -412,22 +481,144 @@ mod tests {
assert!(MetadataService::new(&tmdb, Some("key".into())).is_ok()); assert!(MetadataService::new(&tmdb, Some("key".into())).is_ok());
} }
/// The names a season is searched for by, in the order they are tried. fn title(key: &str) -> Title {
Title {
key: key.into(),
name: key.into(),
year: None,
kind: TitleKind::Series,
seasons: Vec::new(),
share_ids: Vec::new(),
}
}
fn matched(key: &str, manual: bool) -> MetadataRecord {
MetadataRecord {
title_key: key.into(),
provider: ProviderId::AniList,
metadata: Some(TitleMetadata {
provider: ProviderId::AniList,
remote_id: "1".into(),
name: key.into(),
original_name: None,
overview: None,
year: None,
kind: TitleKind::Series,
poster_url: None,
backdrop_url: None,
rating: None,
genres: Vec::new(),
episodes: None,
url: None,
details: None,
}),
fetched_at: now(),
manual,
}
}
fn kinds(work: &[Work]) -> Vec<(&str, &str)> {
work.iter()
.map(|work| match work {
Work::Match(title) => (title.key.as_str(), "match"),
Work::Enrich(title, _) => (title.key.as_str(), "enrich"),
Work::Refresh { title, .. } => (title.key.as_str(), "refresh"),
})
.collect()
}
/// A matched title is enriched once and then left alone until its
/// enrichment is as old as a match is trusted; an unasked title is matched.
#[test] #[test]
fn a_season_is_searched_for_the_way_the_provider_names_it() { fn a_run_matches_the_unasked_and_enriches_the_unenriched() {
assert_eq!( let stored = HashMap::from([
season_names("Oshi no Ko", 2), ("enriched".to_string(), matched("enriched", false)),
vec!["Oshi no Ko 2nd Season", "Oshi no Ko Season 2"] ("stale".to_string(), matched("stale", false)),
("bare".to_string(), matched("bare", false)),
]);
let enriched = HashMap::from([
("enriched".to_string(), (ProviderId::AniList, now())),
(
"stale".to_string(),
(ProviderId::AniList, now() - MATCH_TTL_SECS - 1),
),
]);
let titles = ["enriched", "stale", "bare", "new"].map(title).to_vec();
let work = plan(
titles,
&stored,
&enriched,
ProviderId::AniList,
false,
false,
); );
assert_eq!(ordinal(1), "1st"); assert_eq!(
assert_eq!(ordinal(3), "3rd"); kinds(&work),
assert_eq!(ordinal(4), "4th"); vec![("stale", "enrich"), ("bare", "enrich"), ("new", "match")]
// The teens are the ones a naive rule gets wrong, and a show does reach );
// them: `11th` is not `11st`. }
assert_eq!(ordinal(11), "11th");
assert_eq!(ordinal(12), "12th"); /// "Match again" re-searches everything except what the viewer pinned —
assert_eq!(ordinal(13), "13th"); /// and a pinned title that was never enriched is still enriched.
assert_eq!(ordinal(21), "21st"); #[test]
fn a_forced_run_leaves_a_pinned_match_alone_but_still_enriches_it() {
let stored = HashMap::from([
("pinned".to_string(), matched("pinned", true)),
("automatic".to_string(), matched("automatic", false)),
]);
let titles = ["pinned", "automatic"].map(title).to_vec();
let work = plan(
titles,
&stored,
&HashMap::new(),
ProviderId::AniList,
false,
true,
);
assert_eq!(
kinds(&work),
vec![("pinned", "enrich"), ("automatic", "match")]
);
}
/// A match stored before details existed is fetched again by id — a
/// pinned one included, and without being searched for — and only by a
/// provider that has details to give.
#[test]
fn a_match_without_details_is_refreshed_rather_than_searched_for() {
let stored = HashMap::from([
("pinned".to_string(), matched("pinned", true)),
("automatic".to_string(), matched("automatic", false)),
]);
let enriched = HashMap::from([("pinned".to_string(), (ProviderId::AniList, now()))]);
let titles = ["pinned", "automatic"].map(title).to_vec();
let work = plan(
titles.clone(),
&stored,
&enriched,
ProviderId::AniList,
true,
false,
);
assert_eq!(
kinds(&work),
vec![("pinned", "refresh"), ("automatic", "refresh")]
);
assert!(matches!(&work[0], Work::Refresh { enrich: false, .. }));
assert!(matches!(&work[1], Work::Refresh { enrich: true, .. }));
let work = plan(
titles,
&stored,
&enriched,
ProviderId::AniList,
false,
false,
);
assert_eq!(kinds(&work), vec![("automatic", "enrich")]);
} }
/// A record from the other provider is never usable — switching provider has /// A record from the other provider is never usable — switching provider has
+21 -10
View File
@@ -10,8 +10,8 @@
//! better evidence than the guess, and [`crate::matching`] only nudges on kind //! better evidence than the guess, and [`crate::matching`] only nudges on kind
//! anyway. //! anyway.
use pstr_core::library::TitleKind; use pstr_core::library::{Title, TitleKind};
use pstr_core::metadata::{EpisodeMetadata, ProviderId, TitleMetadata}; use pstr_core::metadata::{Enrichment, EpisodeMetadata, ProviderId, TitleMetadata};
use serde::Deserialize; use serde::Deserialize;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
@@ -73,12 +73,6 @@ impl Provider for Tmdb {
ProviderId::Tmdb ProviderId::Tmdb
} }
/// One show, every season under it — and [`Self::episodes`] already returns
/// them with their season numbers.
fn seasons_are_separate_entries(&self) -> bool {
false
}
async fn search(&self, query: &Query) -> Result<Vec<Candidate>> { async fn search(&self, query: &Query) -> Result<Vec<Candidate>> {
if self.api_key.trim().is_empty() { if self.api_key.trim().is_empty() {
return Err(Error::MissingApiKey(ProviderId::Tmdb)); return Err(Error::MissingApiKey(ProviderId::Tmdb));
@@ -101,6 +95,19 @@ impl Provider for Tmdb {
.collect()) .collect())
} }
/// The episode list, and nothing else: the match already carried a proper
/// backdrop, and TMDB files every season under one show, so the library's
/// seasons need no walking.
async fn enrich(&self, _title: &Title, found: &TitleMetadata) -> Result<Enrichment> {
Ok(Enrichment {
episodes: self.episodes(found).await?,
backdrop_url: None,
chain: Vec::new(),
})
}
}
impl Tmdb {
/// Two requests: the show, to learn which seasons exist, and then those /// Two requests: the show, to learn which seasons exist, and then those
/// seasons appended onto one more. /// seasons appended onto one more.
/// ///
@@ -153,6 +160,9 @@ impl Provider for Tmdb {
EpisodeMetadata { EpisodeMetadata {
season: Some(number), season: Some(number),
number: episode.episode_number, number: episode.episode_number,
absolute: None,
entry_season: None,
entry_number: None,
name: episode.name.clone().filter(|name| !name.is_empty()), name: episode.name.clone().filter(|name| !name.is_empty()),
overview: episode.overview.clone().filter(|text| !text.is_empty()), overview: episode.overview.clone().filter(|text| !text.is_empty()),
still_url: episode still_url: episode
@@ -165,9 +175,7 @@ impl Provider for Tmdb {
} }
Ok(episodes) Ok(episodes)
} }
}
impl Tmdb {
/// One authenticated GET, paced, with TMDB's failures named. /// One authenticated GET, paced, with TMDB's failures named.
/// ///
/// TMDB is far more generous than AniList and a library-sized run rarely /// TMDB is far more generous than AniList and a library-sized run rarely
@@ -343,6 +351,9 @@ impl Result_ {
}, },
self.id self.id
)), )),
// Nothing here yet: TMDB's would come from a detail request
// per title, and the search answer has none of it.
details: None,
}, },
aliases, aliases,
// Always: `search/multi` results are filtered on `media_type` // Always: `search/multi` results are filtered on `media_type`
+1 -1
View File
@@ -42,4 +42,4 @@ pub use proton_sdk::ids::NodeUid;
pub use reader::{FileBlocks, LibraryOpener, RevisionBlocks}; pub use reader::{FileBlocks, LibraryOpener, RevisionBlocks};
pub use ring::{DEFAULT_RING_BYTES, RingStats}; pub use ring::{DEFAULT_RING_BYTES, RingStats};
pub use source::{RevisionOpener, StreamConfig, StreamSource}; 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 std::time::Duration;
use async_trait::async_trait; use async_trait::async_trait;
use proton_drive_rs::ProtonDrivePublicLinkClient;
use proton_sdk::error::ProtonError; use proton_sdk::error::ProtonError;
use proton_sdk::ids::NodeUid; use proton_sdk::ids::NodeUid;
@@ -159,7 +158,7 @@ impl LibraryOpener {
Self { library } Self { library }
} }
fn client(&self, share_id: &str) -> Result<&ProtonDrivePublicLinkClient> { fn client(&self, share_id: &str) -> Result<&pstr_core::ShareClient> {
self.library self.library
.client(share_id) .client(share_id)
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open"))) .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. /// bandwidth on cancelled prefetches. Well past a 1080p bitrate either way.
pub const DEFAULT_READAHEAD_BLOCKS: usize = 12; 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 /// How far a read may land from where the previous one ended and still count as
/// a continuation rather than a seek. /// a continuation rather than a seek.
/// ///
@@ -299,6 +305,42 @@ impl VideoStream {
Ok(buf) 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 { pub fn stats(&self) -> StreamStats {
StreamStats { StreamStats {
ring: self.inner.ring.stats(), ring: self.inner.ring.stats(),
+6 -6
View File
@@ -10,7 +10,7 @@ reimplementing it in Kotlin.
## Toolchain ## Toolchain
Install JDK 17, Android SDK Platform/Build Tools 36, NDK Install JDK 17, Android SDK Platform/Build Tools 36, NDK
`29.0.14206865`, Rust 1.96, Gradle 8.13, and the native helpers: `30.0.16248370`, Rust 1.96, Gradle 8.13, and the native helpers:
```bash ```bash
rustup target add aarch64-linux-android x86_64-linux-android rustup target add aarch64-linux-android x86_64-linux-android
@@ -55,15 +55,15 @@ installs the signed minified APK and runs instrumentation against it, which is
the only path that exercises R8 — the step that has broken this app five distinct the only path that exercises R8 — the step that has broken this app five distinct
ways that a debug build cannot reproduce. Without the `ANDROID_RELEASE_*` ways that a debug build cannot reproduce. Without the `ANDROID_RELEASE_*`
variables below, drop `--release` and the debug APK is used instead. The matrix — phone and tablet layouts, rotation, variables below, drop `--release` and the debug APK is used instead. The matrix — phone and tablet layouts, rotation,
process recreation, playback and background controls, Picture-in-Picture, process recreation, playback and background controls,
download cancellation/resume, and an offline launch with networking disabled — download cancellation/resume, and an offline launch with networking disabled —
is `scripts/android-acceptance.sh`; `--list` prints every case and what it is `scripts/android-acceptance.sh`; `--list` prints every case and what it
needs, and `PSTR_ACCEPTANCE_ONLY=name` runs one. Five cases need a real share, needs, and `PSTR_ACCEPTANCE_ONLY=name` runs one. Five cases need a real share,
given as `PSTR_ACCEPTANCE_SHARE_URL` and `PSTR_ACCEPTANCE_SHARE_PASSWORD`; they given as `PSTR_ACCEPTANCE_SHARE_URL` and `PSTR_ACCEPTANCE_SHARE_PASSWORD`; they
reach the device as instrumentation arguments and are stored nowhere. Without reach the device as instrumentation arguments and are stored nowhere. Without
them those cases report `skip`. `picture-in-picture` is still `pending`, so the them those cases report `skip`. There is no Picture-in-Picture: it was removed
run tells you the matrix is incomplete — but no longer because of B40, which is after a PiP window that never sized its video right proved less use than
fixed; the case now just needs writing. `docs/TESTING.md` has the layer-by-layer background audio and the lock-screen transport. `docs/TESTING.md` has the layer-by-layer
detail, including why lint runs against a baseline. detail, including why lint runs against a baseline.
Requires JDK 17. `build-android.sh` locates it, because a newer JVM does not Requires JDK 17. `build-android.sh` locates it, because a newer JVM does not
@@ -110,6 +110,6 @@ ABIs, plus a source archive and revision record, under
packages the staged libraries, and compiles `pstr_mpv`, the JNI/EGL adapter. It packages the staged libraries, and compiles `pstr_mpv`, the JNI/EGL adapter. It
feeds libmpv through the Rust stream C ABI; decrypted bytes remain in native feeds libmpv through the Rust stream C ABI; decrypted bytes remain in native
memory. `PlaybackService` owns the mpv core for background audio and media memory. `PlaybackService` owns the mpv core for background audio and media
controls, while the activity supplies the current `Surface` and enters PiP. controls, while the activity supplies the current `Surface`.
Do not publish an Android binary until this exact pipeline is verified on real Do not publish an Android binary until this exact pipeline is verified on real
arm64 hardware and an x86_64 emulator. arm64 hardware and an x86_64 emulator.
+35 -1
View File
@@ -104,6 +104,36 @@ an edge case.
holds only the id, display name and token. holds only the id, display name and token.
- Nothing this app holds can write to the share: a viewer link grants - 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. `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 - 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 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 proves the bytes were encrypted to the node key. Nodes come back with nothing
@@ -118,7 +148,11 @@ an edge case.
| Share list | config dir, `shares.json` | **No** — atomic writes, never overwritten when unparseable | | Share list | config dir, `shares.json` | **No** — atomic writes, never overwritten when unparseable |
| Share secrets | OS credential store | **No** | | Share secrets | OS credential store | **No** |
| Catalog | data dir, `catalog.db` | Yes — recrawl | | Catalog | data dir, `catalog.db` | Yes — recrawl |
| Watch state | same DB, own table | **No** — deliberately survives a 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 | | 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 | | 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 | | Provider artwork | cache dir, `posters/` | Yes — refetched by URL; stored scaled to 640 px |
+119
View File
@@ -146,6 +146,125 @@ and [B44](#b44--r8-broke-the-app-under-instrumentation-in-five-places).
## Fixed ## Fixed
### B64 — Android offline downloads looked stuck
**Symptom.** A queued episode, and more so a queued season, sat at a progress
bar that did not move for long stretches, then jumped.
**Cause.** Three, compounding. WorkManager starts every queued worker whose
constraints are met, so "Download season" was a dozen transfers at once, each
with a share of the link. Each read through the *playback* stream, so behind
every block it read sat twelve blocks of read-ahead competing for the same
bandwidth — and filling the shared memory ring and the disk block cache, a
second copy of every byte. And progress was reported once per completed
in-order block, so the first report waited on all of that. The desktop had none
of these: three downloads at most, a copy stream with no read-ahead, four
blocks in flight.
**Fix.** The Android engine allows two downloads to transfer at once; the rest
wait for a slot, reported as queued (the worker no longer marks itself running
before its first progress report, and its notification says "Waiting to
download"). Downloads open through `StreamSource::open_for_copy` and read with
`VideoStream::blocks_in_order` at `COPY_BLOCKS_IN_FLIGHT`, now shared with the
desktop engine. The rows show a smoothed transfer rate beside the bytes, so a
slow download reads as slow rather than stuck.
**Verified.** Workspace gate and the Android host check pass, with
`TransferRateTest` covering the rate. Not yet observed on a device.
### B63 — a taskbar pin on Windows lost its icon
**Symptom.** Proton Stream pinned to the Windows taskbar showed its icon at
first, then after a while — reliably after an update — the generic program
icon.
**Cause.** Two, each enough on its own. `proton-stream.exe` carried no icon
resource: the window sets one at runtime, which is what a pin shows while the
icon cache still holds it, but a pin made from the running window points at
the exe, and once the cache is rebuilt the exe has nothing to give. A pin made
from the Start Menu shortcut instead took the shortcut's `Icon`, which Windows
Installer keeps in `C:\Windows\Installer\{ProductCode}`. Every upgrade has a
new ProductCode and deletes the old product's folder, icon included.
**Fix.** `crates/pstr-app/build.rs` compiles `proton-stream.rc`, which embeds
`packaging/windows/proton-stream.ico`, into the GUI exe: `rc.exe` on MSVC,
mingw's `windres` on the cross build. Both installer definitions drop the
shortcut's `Icon` (and the WiX one `Advertise`), so the shortcut shows the
exe's icon too. A pin that already lost its icon keeps the dead reference;
unpin and pin again once.
**Verified.** `objdump -p` on the cross-built exe lists the `RT_ICON` entries,
all seven sizes. `msiinfo export … Shortcut` on a wixl build has an empty
`Icon_` column. Not yet checked on a Windows taskbar across an upgrade.
### B62 — an MSI upgrade left the app without libmpv
**Symptom.** Installing a new `.msi` over an old one ended with an app that
would not start for want of mpv. Installing the same `.msi` a second time
fixed it.
**Cause.** Both installers remove the old product early — the wixl one at
`RemoveExistingProducts` after `InstallInitialize`, WiX's `MajorUpgrade` by
default after `InstallValidate` — but costing runs before either, at
`CostFinalize`. Costing found the old `libmpv-2.dll` on disk with the same
version as the new one, since mpv is pinned, and decided not to copy it. Then
removing the old product deleted it. The second install found no DLL on disk,
so it copied one.
**Fix.** `REINSTALLMODE=amus` in both installer definitions, which copies every
file whatever is on disk. Every file is private to the install directory, so
the forced overwrite can downgrade nothing shared.
**Verified.** `msiinfo export … InstallExecuteSequence` on a wixl build shows
`FileCost` 900 and `CostFinalize` 1000 ahead of `RemoveExistingProducts` 1501,
and `msiinfo export … Property` has `REINSTALLMODE amus`. Not yet checked as an
upgrade on Windows.
### B61 — later seasons were captioned with season one's episode names
**Symptom.** On a 42-title AniList library, Attack on Titan seasons 2–4 and
Jigokuraku season 2 showed season one's episode names. Dan Da Dan, Kaiju No. 8
and The Apothecary Diaries had a handful of names numbered past the end of the
season. Bleach's had 20 rows called `Untitled`. Bleach `S17` (the Thousand-Year
Blood War) and Mushishi `S02`/`S03` had none at all. Jujutsu Kaisen 0 kept the
TV series' names after it was re-pinned to the film. Every desktop tile drew a
blurred strip of AniList banner.
**Cause.** Four, independent.
- `season_episodes` searched for `<title> 2nd Season`, and the scorer, seeing
the base entry's alias inside the query, picked the base entry again. That
search was [B11](#b11--seasons-past-the-first-had-no-episode-names-and-were-one-query-from-having-the-wrong-ones)'s
fix, and it only worked where the sequel's name scored higher than the base's.
- `streamingEpisodes` is what a streaming site published: partial, and
numbered however that site counted.
- A release's season is not an AniList entry. `S17` is four entries, and
Mushoku Tensei's `S01` is two.
- An empty episode list was never written, so a re-match kept the old one.
`bannerImage`, a 4.75:1 strip, was stored as the 16:9 backdrop.
**Fix.** AniList walks `SEQUEL` relations from the match. Each entry's episodes
come from ani.zip with TVDB's season, number and absolute number, plus the
entry's own numbering. `EpisodeGuide::get` meets whichever of those a filename
used without ever crossing seasons. Enrichment is stored as one unit,
replaced even when empty and recorded as asked. A changed match drops the old
entry's rows at once. The backdrop is ani.zip's 1920×1080 fanart, and the
banner is no longer stored. Schema v9 deletes the bad rows. See
[METADATA.md](METADATA.md).
**Verified.** `EpisodeGuide` tests in `pstr_core::metadata` (absolute numbers
in a season folder, by-entry seasons, no season crossing), and the catalog's
`an_empty_enrichment_clears_the_episodes_the_last_match_left`,
`pinning_a_different_entry_drops_the_old_entrys_episodes_at_once` and
`upgrading_drops_anilists_episode_rows_and_banners_but_keeps_its_matches`.
Live, against a copy of the same library through `pstr metadata match`:
- 0 lookups failed.
- Every numbered file is answered except 2 Attack on Titan files, 2 Bleach
files and one Mushoku Tensei special, which TVDB does not list.
- 29 of 44 matched titles have fanart, and none has a banner.
- A second run asks nothing.
### B60 — a failed "Add and crawl" left the crawl spinner running forever ### B60 — a failed "Add and crawl" left the crawl spinner running forever
**Symptom.** Pasting a link without its `#` fragment, or one already added, **Symptom.** Pasting a link without its `#` fragment, or one already added,
+24 -25
View File
@@ -106,26 +106,22 @@ card (`App::tick_up_next`, `ui::player::up_next_card`) with *Play now* and
chapters, autoplay on, a next episode, and playback actually running — a paused chapters, autoplay on, a next episode, and playback actually running — a paused
episode is one somebody walked away from. episode is one somebody walked away from.
Done: **per-episode metadata.** `Provider::episodes` fetches an episode list for Done: **per-episode metadata.** `Provider::enrich` fetches the episode list —
a title that already matched (AniList `streamingEpisodes`, which is titles and and, for AniList, the series' fanart — of a title that already matched, and
thumbnails only and no synopsis; TMDB `/tv/{id}` plus every season appended onto `Catalog::set_enrichment` stores both and records that it asked (schema **v9**),
one more request), stored in `episode_metadata` — schema **v4**. The reconciling so an empty answer is not re-asked every run. TMDB is `/tv/{id}` plus every
between a release's numbering and a provider's lives in one tested place, season appended onto one more request. AniList walks the match's `SEQUEL`
`EpisodeGuide::get`: the season and number the filename states, then the relations and takes each entry's episodes from ani.zip, which carries TVDB's
provider's absolute numbering, then season one for a file that states no season season, number and absolute number — the numbering release groups use. The
at all. That last fallback is what makes `#057` line up with TMDB filing a reconciling between a release's numbering and a provider's lives in one tested
64-episode anime under one season. Episode names show on the title page's rows, place, `EpisodeGuide::get`. [METADATA.md](METADATA.md) has the audit that drove
in the player's title strip and in the "next:" line autoplay prints. this, the order `get` tries things in, and what comes next.
**A season is a separate lookup on AniList.** `Provider::seasons_are_separate_entries` The first version searched AniList for `<title> 2nd Season` per season and took
is what splits the two providers: TMDB files every season under one show and episode names from `streamingEpisodes`; both were wrong often enough to show —
returns them numbered, while AniList makes each sequel its own entry numbering the search found the base entry again and captioned Attack on Titan's later
from one (`[Oshi no Ko] 2nd Season`, id 166531, is not `[Oshi no Ko]`, id seasons with season one's names, and `streamingEpisodes` is whatever a streaming
150672). So for AniList each season past the first is searched for by name — site published. v9 deletes everything they stored.
`<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.
Done: **matching a title by hand.** `MATCH_FLOOR` is set where a wrong poster Done: **matching a title by hand.** `MATCH_FLOOR` is set where a wrong poster
costs more than none, and the price of that is a few titles it refuses to decide costs more than none, and the price of that is a few titles it refuses to decide
@@ -145,11 +141,11 @@ rather than storing a miss, so the next run treats the title as one it has never
asked about. Switching provider still clears everything, hand-picked rows asked about. Switching provider still clears everything, hand-picked rows
included: a `remote_id` means nothing to the other provider. included: a `remote_id` means nothing to the other provider.
Worth knowing before chasing a missing name: AniList's episode titles come from Worth knowing before chasing a missing name: ani.zip only lists what TVDB has
`streamingEpisodes`, which is what streaming sites published, and for some shows placed. A file it cannot answer is usually a special (`S02E00`), an episode TVDB
it is simply **empty** — all three Oshi no Ko entries have none, while Fullmetal skipped, or one too new to be listed — Attack on Titan's last two files and two
Alchemist Brotherhood has 64. There is nothing to fix on our side for those; of Bleach's forty-four are the standing examples. `pstr metadata match` prints
TMDB is the provider that has them. how many episodes each title got.
Note the glyph trap: egui's bundled fonts cover the transport icons Note the glyph trap: egui's bundled fonts cover the transport icons
(`⏮ ⏭ ⏸ ▶ 🔊 🔇 ⛶`) and *not* `← 💬 ▣`, which draw as an empty box. Anything new (`⏮ ⏭ ⏸ ▶ 🔊 🔇 ⛶`) and *not* `← 💬 ▣`, which draw as an empty box. Anything new
@@ -165,7 +161,10 @@ reads an external subtitle through the same protocol); the work is in
## Design decisions already taken ## Design decisions already taken
- **Fat client, no server.** Each viewer's app talks to Proton directly. - **Fat client, no server.** Each viewer's app talks to Proton directly.
- **Public links only**, but several, merged into one catalog. No account login. - **Public links first**, several, merged into one catalog. A Proton account
is optional: it adds folders of the viewer's own Drive as shares, and watch
history synced through a `proton-stream` device in that Drive — one file per
installation, so no two devices ever write the same file.
- **Embedded libmpv**, not a bundled decoder stack. HEVC 10-bit, HDR and ASS - **Embedded libmpv**, not a bundled decoder stack. HEVC 10-bit, HDR and ASS
subtitles with hardware decode, for free. subtitles with hardware decode, for free.
- **No transcoding.** mpv demuxes and decodes natively; there is no remux or - **No transcoding.** mpv demuxes and decodes natively; there is no remux or
+90
View File
@@ -0,0 +1,90 @@
# iOS Development
The iOS client lives in `ios/`. It is a SwiftUI port of the Android client, screen
for screen and string for string, over the same Rust engine: `crates/pstr-android`
builds unchanged for `aarch64-apple-ios` as a static library, and UniFFI writes its
Swift bindings (module `PstrBridge`). It targets iOS 17 and newer on iPhone and
iPad. Keep reusable behaviour in the Rust crates; when Android changes, the iOS
file of the same name is the one to change with it.
| Android | iOS |
|---|---|
| `ui/AppViewModel.kt` | `App/AppModel.swift` |
| `ui/ProtonStreamApp.kt` | `App/ProtonStreamApp.swift` (`RootView`) |
| `native/NativeRuntime.kt`, `native/KeystoreSecretStore.kt` | `App/NativeRuntime.swift`, `App/KeychainSecretStore.swift` |
| `settings/SettingsStore.kt` | `App/SettingsStore.swift` (same keys, in `UserDefaults`) |
| `ui/*Screen.kt`, `ui/AccountUi.kt`, `ui/LanguagePicker.kt` | `UI/*View.swift`, `UI/AccountUI.swift`, `UI/LanguagePicker.swift` |
| `ui/RemoteArtwork.kt`, `ui/theme/*` | `UI/RemoteArtwork.swift`, `UI/Theme/*` |
| `download/*` | `Download/*` |
| `playback/PlayerScreen.kt`, `NativeMpvHost.kt`, `PlaybackService.kt`, `StreamSession.kt` | `Player/PlayerView.swift`, `Player/PlayerHost.swift` |
| `cpp/pstr_mpv.cpp` | `Player/MpvPlayer.swift`, `Player/StreamProtocol.swift` |
Playback is libmpv from [MPVKit](https://github.com/mpvkit/MPVKit)'s GPL build,
rendering through MoltenVK into a `CAMetalLayer`, with the `pstr://` stream protocol
reading decrypted blocks straight out of Rust as on Android.
## Toolchain
macOS with Xcode 16 or newer, XcodeGen, and Rust 1.96 with the iOS targets:
```bash
brew install xcodegen
rustup target add aarch64-apple-ios aarch64-apple-ios-sim
```
The Xcode project and `Info.plist` are generated from `ios/project.yml` and are not
committed, nor are the bindings under `ios/build/generated`.
```bash
scripts/build-ios.sh test # simulator static library, bindings, unit tests
scripts/build-ios.sh all # device static library, bindings, dist/proton-stream-<version>-ios.ipa
```
`all` is `rust`, `bindings` and `ipa` in order; each can be run on its own. After
`bindings` and an `xcodegen generate` in `ios/`, the project opens in Xcode as usual,
where a run on a device needs a development team set on the target.
The `.ipa` is unsigned. SideStore and AltStore re-sign what they install with the
viewer's own Apple ID, which is also why the app has no extensions: a free Apple ID
gets few app IDs, and each extension costs one.
`.github/workflows/ios.yml` runs the tests and builds the `.ipa` on `macos-15` for
every push to `main` and every pull request that touches the iOS sources or the Rust
crates under them. `release.yml` calls it too, and publishes the `.ipa` next to the
other artifacts.
`scripts/render-ios-icon.sh` renders `ios/ProtonStream/Resources/AppIcon.svg`, which
is Android's adaptive launcher icon with both layers on one canvas, into the asset
catalog.
## Where iOS differs
Each of these is the platform's doing, not a choice to diverge.
- **No Picture-in-Picture.** libmpv draws into a Metal layer, and AVKit's
Picture-in-Picture only takes frames from `AVPlayerLayer` or
`AVSampleBufferDisplayLayer`. Background audio and the lock-screen transport work
as on Android.
- **No share sheet.** A share extension would cost a second app ID. A link reaches
the app through its URL scheme — `protonstream://add?url=<link>`, or the link with
`https` swapped for `protonstream` — or through **Paste link** in the Add share
form, which finds a link anywhere in the clipboard's text.
- **Downloads run while the app does.** A transfer is a Rust task, not a
`URLSession` task, so iOS has nothing to hand it to in the background. A download
keeps going for the grace iOS gives a backgrounded app, then stops at a block
boundary and is queued again; it resumes from its `.part` file when the app next
comes forward. "Download on Wi-Fi only" stops transfers on a cellular or
otherwise expensive network rather than holding them back.
- **Sync runs when the app leaves the screen**, inside the same grace, in
place of Android's `WatchSyncWorker`, and every five minutes while it is open.
- **Background audio off pauses** the episode when the app leaves the screen, where
Android stops it.
- **Presses are drawn by hand.** Compose's ripple shows every press; SwiftUI's
`.plain` button style and `onTapGesture` show none, which on a slow iPad reads as
a tap that missed. `PressableStyle` and `tapAndHold` (`UI/Theme/Accent.swift`)
stand in for the ripple, and a settings page goes back by an edge strip of its
own, as a title page does, where Android has the system back.
- **Nothing is backed up**, as on Android (`allowBackup="false"`). The share
secrets are this-device-only Keychain items, so a restored share list would
arrive without them, and a restored `sync.json` would give two devices one sync
identity.
+174
View File
@@ -0,0 +1,174 @@
# Metadata
How a library of filenames becomes posters, synopses and episode names, what
was wrong with the first version of it, and where it is going. The privacy
stance from `pstr_core::metadata` holds throughout: nothing here is requested
until the viewer turns enrichment on, and every new source named below sits
behind that same switch.
## What a real library showed
Audited against a 42-title, 758-file anime library matched on AniList
(schema v8, October 2026). The match itself — title to AniList entry — was
good. What hung off it was not:
1. **Later seasons captioned with season one's names.** Attack on Titan
seasons 2–4 and Jigokuraku season 2 all stored season one's episode list.
The per-season lookup searched for `Attack on Titan 2nd Season`, and the
similarity scorer, seeing the base entry's alias inside the query, picked the
base entry again. That is the failure `EpisodeGuide::get` already warned
about: not a missing caption but a wrong one.
2. **`streamingEpisodes` is not an episode list.** It is what a streaming site
published, partial and numbered however that site numbered it. Dan Da Dan
had 5 rows numbered 13–17, Kaiju No. 8 had 4–15, The Apothecary Diaries had
25–48 under two seasons at once, Bleach had 20 rows called `Untitled`, Akira
had a trailer. No row anywhere had a synopsis — AniList keeps none.
3. **A release's season is not an AniList entry.** Bleach `S17` is the
Thousand-Year Blood War, four separate AniList entries. Mushoku Tensei `S01`
is two (11 + 12 episodes). Attack on Titan's season folders hold
absolutely-numbered files — `S2` is episodes 26–37. Mushishi's `S03` is
*Zoku Shou 2*, which TVDB files as the second half of season two.
4. **Answers outlived their match.** `jujutsu kaisen 0 movie` kept the TV
series' 24 episode names after it was re-pinned to the film: an empty episode
list was never written, so the old one stayed.
5. **Desktop art was the wrong shape.** The 16:9 tile and the library's hero
prefer `backdrop_url`, which on AniList is `bannerImage` — a 1900×400 strip,
4.75:1, cached at 640×135. Cropped to 16:9 and stretched, that is a blurred
sliver from the middle of a banner. Android looks right because it draws the
2:3 cover.
6. **"Newest" sorts on nothing.** It reads the year from the filename, which 2
of 42 titles state.
## Sources
| Source | Key | What it is good for |
|---|---|---|
| **AniList** GraphQL | none | Identity. Search, every alias, cover art, relations (`SEQUEL`, `PREQUEL`, `SIDE_STORY`…), studios, staff, tags, format, season, popularity, next airing episode, the MAL id. |
| **ani.zip** `api.ani.zip/mappings?anilist_id=` | none | Everything AniList lacks, per AniList id: TVDB season, episode and absolute numbers; episode titles in several languages; TVDB synopses; 16:9 stills; air dates; runtime. Series art: TVDB **fanart at 1920×1080**, poster, banner, logo. Cross-ids for MAL, AniDB, TVDB, TMDB, Kitsu, IMDb. A community service — cache what it says. |
| **TMDB** | free key | Film backdrops and logos; live-action TV. Already implemented. |
| **TVmaze** | none | Live-action TV episodes, keyless. |
| **AniSkip** | none | Opening and ending timestamps by MAL id and episode. |
| **Jikan** (MAL) | none | Filler and recap flags. Timed out when tried; optional. |
| **manami-project/anime-offline-database** | none | Every anime with its synonyms, ids and relations, as one 6 MB `.zst`. Matching against it locally sends no title anywhere. |
| AniDB, TVDB directly | registration / paid | Covered by ani.zip, which republishes their data. |
The fact the whole design turns on: **release groups number files the way TVDB
does.** `Bleach - S17E01` is TVDB season 17; Attack on Titan's 26–96 are TVDB
absolute numbers. ani.zip gives both for every AniList entry, so the join
between a filename and an episode is a lookup, not a guess.
## Phase 1 — correct data (done)
**Seasons by relation, not by name.** `AniList::enrich` starts at the matched
entry and follows its `SEQUEL` edges — TV formats first, a special or film only
when it is the sole link onward (Mushishi's chain runs through an OVA). Each
entry's episodes come from ani.zip. The walk stops as soon as every numbered file
in the title has an answer, at the end of the chain, or after
`MAX_CHAIN_ENTRIES`, so a one-season show costs one ani.zip request and no
relations query at all.
**One episode, three numberings.** `EpisodeMetadata` carries TVDB's
`(season, number)`, TVDB's `absolute` number, and its `(entry_season,
entry_number)` — the position of its entry among the franchise's TV entries,
and its number within that entry, which is how a release that follows AniList
rather than TVDB numbers its seasons. `EpisodeGuide::get` tries, in order:
1. the season and number the file states;
2. for a file in no season or season one, the provider's own absolute list,
then season one;
3. the absolute number — but only into the season the file states, so an
absolutely-numbered season folder (`Attack on Titan/S2/… - 26`) finds
S2E1, and `S03E13` can never be answered with season one's 13th;
4. the entry numbering, only for a season TVDB does not have at all — which is
what makes Mushishi `S03E01` *Zoku Shou 2* episode 1 while Mushoku Tensei
`S02E01`, a season TVDB does have, stays TVDB's.
`streamingEpisodes` is gone.
**Enrichment is recorded, empty or not.** A title's episode list and its
fanart are one *enrichment*, stored together by `Catalog::set_enrichment` in one
transaction that always replaces the old episode rows — so an empty answer
clears what a previous match left behind. `title_metadata.enriched_at` records
that it was asked, which is what keeps a film with no episodes from being
re-asked on every match run. A new match clears it; a failed enrichment leaves it
clear, so the title is asked again.
**No banner on a 16:9 tile.** AniList's `bannerImage` is no longer stored as a
backdrop. The backdrop is ani.zip's fanart where there is one; otherwise the tile
letterboxes the poster.
**Schema v9** adds the three numbering columns and `enriched_at`, deletes every
stored AniList episode row and every AniList banner-as-backdrop — all of it
`streamingEpisodes` data and wrong-shaped art — and leaves every title
un-enriched, so the next match run (which the app starts on its own) refills
them. Matches, hand-picked ones included, are kept.
## Phase 2 — art and order (done)
**More of the answer kept.** The AniList query now also asks for the full
start date, broadcast season, banner, popularity, status, the next airing
episode, spoiler-free tags ranked 60 or more, main studios, the `Director`
among the most relevant staff, and the relations that keep to one story
(`PREQUEL`, `SEQUEL`, `PARENT`, `SIDE_STORY`, `SUMMARY`, `ALTERNATIVE`,
`COMPILATION`, `CONTAINS` — not `SPIN_OFF`, `CHARACTER` or `OTHER`, which reach
into other franchises). All of it lives in `TitleDetails`, stored as one JSON
column (**schema v10**). A match stored before that is fetched again *by id*
(`Work::Refresh`) — never searched for, so hand-picked matches stay — and a
refresh of the same entry keeps its episodes, fanart and enrichment record.
Enrichment also stores the chain of entries it walked.
**Art.** Three textures per title where there was one: the tile's (`Art::of`),
the 2:3 cover (`Art::poster`), and wide art (`Art::wide` — fanart, else the
AniList banner) decoded at up to 1920 px for the library's hero and the title
page's band. The banner, wrong in a 16:9 tile, fits those strips as drawn.
**Poster grid.** Settings → Appearance → *Library tiles*: covers (the default)
or 16:9 stills. Covers fill their tile; a cover in a still-shaped tile is still
fitted whole.
**Names.** Settings → Appearance → *Title names*: English (the default, and
what Android always showed), romaji, or the share's own. Search matches all
of them whichever is shown.
**Sorts.** `pstr_core::browse`, shared by both clients. A–Z by the shown name, recently watched, recently added (from
`node_first_seen`, which survives recrawls; files present before v10 count as
added at 0), newest by air date, highest rated, most popular.
**Franchises.** `pstr_core::franchise::group` joins titles that share a
provider id — their own, a related one, or one their episode walk passed
through. The grid folds each franchise into its lead (the first series to air,
else the first film) with a `+N` badge; the stack button beside the sort turns
that off. The title page shows the whole franchise in release order. On the
library this was built against: Evangelion ×4, Made in Abyss ×4, Heaven's Feel
×3, Mushishi with its OVA, Jujutsu Kaisen with *0*, Chainsaw Man with *Reze*.
**Shelves.** `pstr_core::shelves::pick`: airing now, then directors, studios
and genres, each only when it spans three *franchises* — Made in Abyss's four
titles are one director's, and that is not a shelf — and a genre only under 40%
of the library. Four at most, over the unfiltered library. Here: *Directed by
Satoshi Kon*, *From MADHOUSE*, *From MAPPA*, *From Production I.G*.
**The title page** adds the season, studios, director and next airing episode
to its line of facts, and the tags beside the genres.
**Android.** The same, through the bridge: `AndroidEngine::arrangement`
returns the grid's tiles and the shelves as keys into `library`'s titles, and
each `TitleRecord` carries its display name, franchise and details. The
library's bar has a sort menu with the franchise toggle in it; the grid shows
`+N` on a folded tile and up to three shelves above it; the title page has the
same facts, tags and franchise row as the desktop's; *Title names* is in
Settings → Appearance. Android's grid is always covers, so it has no
*Library tiles* choice.
Not done: TMDB fills no details.
## Phase 3 — reach
- **Local matching** against the offline database, so only ids leave the
machine.
- **Routing by title**: AniList first, then TMDB (with a key) or TVmaze for
whatever is not anime — Game of Thrones in a mostly-anime share.
- **Folders of films** matched per file: four Rebuild films in one folder are
four entries, not one.
- **Skip intro** from AniSkip for files without chapters, and filler flags from
Jikan.
+3 -4
View File
@@ -255,10 +255,9 @@ three dispositions, and the third is the one to read:
| `pending` | specified but not implemented. Never passes, and the summary reports the matrix as incomplete until it is gone | | `pending` | specified but not implemented. Never passes, and the summary reports the matrix as incomplete until it is gone |
| `skip` | needs a share that was not configured | | `skip` | needs a share that was not configured |
Today `process-recreation` is `xfail` against B35, and `picture-in-picture` is Today `process-recreation` is `xfail` against B35 — asserting today's behaviour
`pending` against B40 — asserting today's behaviour would encode the bug rather would encode the bug rather than catch it. The `picture-in-picture` case went
than catch it. **The matrix is not complete, and the runner says so on every with Picture-in-Picture itself, so nothing is `pending`.
run.**
The five live cases need a share, given as environment variables that reach the The five live cases need a share, given as environment variables that reach the
device as instrumentation arguments and are written nowhere: device as instrumentation arguments and are written nowhere:
+20
View File
@@ -0,0 +1,20 @@
# proton-stream for iOS license
SPDX-License-Identifier: GPL-3.0-or-later
Copyright (c) proton-stream contributors.
The iOS application under this directory is free software: you may
redistribute it and/or modify it under the terms of the GNU General Public
License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE. See the [GNU GPL version 3](https://www.gnu.org/licenses/gpl-3.0.html)
for the complete license text.
This licensing choice applies to the iOS application and its distribution,
including the combined native player. Rust crates outside `ios/` retain the
licenses declared in their manifests; the repository root `LICENSE` remains the
MIT license for those components.
+779 -94
View File
@@ -1,140 +1,825 @@
import Foundation import Foundation
import Observation import Observation
import UIKit
/// The app's one Rust engine and the screen state read from it. /// The Proton account: where a sign-in stands, and how sync is doing.
struct AccountUiState: Equatable {
/// Nil until the bridge has said.
var state: AccountState?
/// A sign-in step is out and not answered yet.
var busy = false
/// Why the last sign-in step was refused.
var error: String?
/// The CAPTCHA page Proton wants solved before it accepts the sign-in.
var verificationUrl: String?
/// When watch history last synced.
var syncedAt: Date?
/// Positions the last sync took from other devices.
var applied = 0
/// Why the last sync failed, until one succeeds.
var syncError: String?
}
/// One episode's watch state as it was before a change the viewer can undo.
/// Nil `state` means it had never been played.
struct WatchSnapshot: Equatable {
let shareId: String
let linkId: String
let state: WatchStateRecord?
}
/// The app's screen state and every action on it: Android's `AppViewModel`,
/// field for field and message for message.
/// ///
/// Rust owns the Proton sessions, SQLite and every decrypted byte; this only /// Rust owns the Proton sessions, SQLite and every decrypted byte; this asks it
/// asks it questions off the main thread and keeps the answers for SwiftUI. /// questions off the main thread and keeps the answers for SwiftUI.
@MainActor @MainActor
@Observable @Observable
final class AppModel { final class AppModel {
let engine: AndroidEngine? var loading = true
private(set) var startupError: String? var refreshing = false
private(set) var query = ""
var titles: [TitleRecord] = [] var titles: [TitleRecord] = []
/// The grid: `titles` ordered, each franchise folded into one tile when
/// `grouped`, and the shelves above it. Nil until the bridge has answered,
/// when the grid falls back to `titles` as they are.
var arrangement: ArrangementRecord?
var sort: LibrarySort = .name
var grouped = true
var shares: [ShareRecord] = [] var shares: [ShareRecord] = []
var search = "" var offline: [OfflineRecord] = []
var crawling = false var metadataSettings = MetadataSettingsRecord(enabled: false, provider: .aniList, language: "en", ready: true)
var error: String? var storage = StorageUsageRecord(offlineBytes: 0, offlineCount: 0, partialBytes: 0, cacheBytes: 0)
/// What the snackbar says.
var message: String?
/// What is running in the background right now, said in a banner for as
/// long as it runs: an add, a crawl, a match, a sync. Without it a
/// minute-long crawl looked like a tap that did nothing.
private(set) var activity: String?
/// What Undo on `message` puts back, when the message offers one.
var undo: [WatchSnapshot]?
var account = AccountUiState()
init() { @ObservationIgnored private var searchTask: Task<Void, Never>?
do { @ObservationIgnored private var started = false
engine = try AndroidEngine(paths: Self.paths(), secrets: KeychainSecretStore()) /// Launch and the first `.active` both ask; one crawl answers.
} catch { @ObservationIgnored private var catchingUp = false
engine = nil /// Every banner line whose work is still running, oldest first.
startupError = String(describing: error) @ObservationIgnored private var activities: [(token: Int, line: String)] = []
@ObservationIgnored private var nextActivity = 0
/// Five minutes, as on Android: cheap when nothing changed — one folder
/// listing, and no upload.
private static let syncInterval: Duration = .seconds(5 * 60)
func start() {
if started { return }
started = true
reload()
enteredForeground()
DownloadCoordinator.shared.onFinished = { [weak self] in self?.reload() }
DownloadCoordinator.shared.resumeQueued()
Task {
let state = (try? await run { $0.accountState() }) ?? .signedOut
account.state = state
// While the app is alive, on top of after sign-in and whenever the
// player closes.
while !Task.isCancelled {
await syncWatchHistory()
try? await Task.sleep(for: Self.syncInterval)
}
} }
} }
/// Application Support survives updates and is backed up, which is what
/// the watch history wants. The block cache goes under Caches, which iOS
/// may purge and never backs up.
private static func paths() throws -> AndroidPaths {
let files = FileManager.default
let support = try files.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let caches = try files.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let config = support.appendingPathComponent("config", isDirectory: true)
let data = support.appendingPathComponent("data", isDirectory: true)
let cache = caches.appendingPathComponent("proton-stream", isDirectory: true)
for directory in [config, data, cache] {
try files.createDirectory(at: directory, withIntermediateDirectories: true)
}
return AndroidPaths(config: config.path, data: data.path, cache: cache.path)
}
struct NoEngine: Error, CustomStringConvertible {
var description: String { "the Rust engine did not start" }
}
/// Run a blocking bridge call off the main thread. The synchronous exports /// Run a blocking bridge call off the main thread. The synchronous exports
/// take the catalog lock and touch SQLite, which a scroll must not wait on. /// take the catalog lock and touch SQLite, which a scroll must not wait on.
func run<T>(_ work: @escaping (AndroidEngine) throws -> T) async throws -> T { nonisolated func run<T: Sendable>(_ work: @escaping @Sendable (AndroidEngine) throws -> T) async throws -> T {
guard let engine else { throw NoEngine() } try await Task.detached(priority: .userInitiated) {
return try await Task.detached(priority: .userInitiated) { try work(engine) }.value try work(try NativeRuntime.blockingEngine())
}.value
} }
func report(_ error: Error) { // MARK: - Library
self.error = String(describing: error)
/// Reorder the grid, or fold or unfold its franchises.
func arrange(_ sort: LibrarySort, _ grouped: Bool) {
self.sort = sort
self.grouped = grouped
Task { await reloadLibrary(query) }
} }
func reload() async { func search(_ query: String) {
let needle = search.trimmingCharacters(in: .whitespaces) self.query = query
do { searchTask?.cancel()
let (titles, shares) = try await run { engine in searchTask = Task {
(try engine.library(search: needle.isEmpty ? nil : needle), try engine.shares()) try? await Task.sleep(for: .milliseconds(300))
if Task.isCancelled { return }
await reloadLibrary(query)
}
}
func refresh() {
crawl(nil)
}
/// Recrawl one share rather than the whole library: a share whose link has
/// expired makes a whole-library refresh fail, and must not leave no way to
/// refresh the shares that still work.
func refreshShare(_ id: String) {
crawl(id)
}
private func crawl(_ shareId: String?) {
Task { await crawlNow(shareId) }
}
/// The crawl itself, for pull to refresh, which holds its spinner until
/// this returns.
func crawlNow(_ shareId: String?) async {
let name = shareId.flatMap { id in shares.first { $0.id == id }?.name }
await busy(name.map { "Reading \($0)…" } ?? "Refreshing the library…") {
message = nil
do {
try await NativeRuntime.engine().crawl(shareId: shareId)
} catch {
message = errorMessage(error)
} }
self.titles = titles }
self.shares = shares await reloadNow()
} catch { // Not awaited: pull to refresh lets go once the crawl is in, and the
report(error) // banner carries the lookup.
Task { await matchAfterCrawl() }
}
/// Look up whatever a crawl brought that has no answer yet: a new show, or
/// a new episode its stored guide cannot name. When everything already
/// has one the bridge asks nothing, so this is cheap after every crawl.
private func matchAfterCrawl() async {
if metadataSettings.enabled, metadataSettings.ready {
await match(force: false, quiet: true)
} }
} }
func crawl(shareId: String? = nil) async { /// Run enrichment again. With `force` every title is looked up afresh,
guard let engine, !crawling else { return } /// including ones that already matched.
crawling = true func matchTitles(force: Bool) {
defer { crawling = false } Task { await match(force: force) }
do {
try await engine.crawl(shareId: shareId)
} catch {
report(error)
}
await reload()
} }
func addShare(name: String, url: String, password: String?) async { /// With `quiet`, a run nobody asked for says nothing when it found
/// nothing to do.
private func match(force: Bool, quiet: Bool = false) async {
await busy("Looking up titles…") {
do {
let summary = try await NativeRuntime.engine().matchTitles(force: force)
let found = summary.matched + summary.unmatched + summary.failed + summary.episodes > 0
if !quiet || found {
message = Self.describe(summary)
}
} catch {
message = errorMessage(error)
}
}
reload()
}
/// Say `what` in the activity banner while `work` runs.
///
/// The banner shows the newest line still running. Work overlaps — a sync
/// starts during a crawl and ends after it — so each line is removed by
/// itself rather than by putting back whatever was showing before, which
/// once brought back a finished crawl's line and spun it forever.
private func busy(_ what: String, _ work: () async -> Void) async {
let token = nextActivity
nextActivity += 1
activities.append((token, what))
showActivity()
await work()
activities.removeAll { $0.token == token }
showActivity()
}
private func showActivity() {
activity = activities.last?.line
refreshing = activity != nil
}
// MARK: - Shares
/// Add a link, and return why it was refused, if it was.
///
/// The link is opened before anything is stored, so the form can stay up
/// with the reason under it and a retry is a retry — not "already in the
/// library" for a share that never opened. Only the new share is crawled:
/// another share that fails to open must not make this one look broken.
func addShare(name: String, url: String, password: String?) async -> String? {
let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 }
let share: ShareRecord
do { do {
let share = try await run { try $0.addShare(name: name, url: url, customPassword: password) } share = try await NativeRuntime.engine().addShare(name: name, url: url, customPassword: password)
await crawl(shareId: share.id)
} catch { } catch {
report(error) return errorMessage(error)
}
reload()
Task { await crawlAdded([share.id], announce: true) }
return nil
}
/// Crawl shares that are new here — added on this device or by sync —
/// then match them when enrichment is on.
private func crawlAdded(_ ids: [String], announce: Bool) async {
guard !ids.isEmpty else { return }
let before = Set(titles.map(\.key))
var failed: [String] = []
for id in ids {
let name = shares.first { $0.id == id }?.name ?? "the new share"
await busy("Reading \(name)…") {
do {
try await NativeRuntime.engine().crawl(shareId: id)
} catch {
failed.append("\(name): \(errorMessage(error))")
}
}
}
await reloadNow()
if !failed.isEmpty {
message = failed.joined(separator: "\n")
return
}
await matchAfterCrawl()
if announce {
let added = Set(titles.map(\.key)).subtracting(before).count
message = added == 1 ? "1 title added" : "\(added) titles added"
} }
} }
func repairShare(id: String, url: String, password: String?) async { /// Re-supply the link behind a share whose stored secret cannot be read.
do { /// Re-entering the link rewrites the secret and leaves the catalog and the
_ = try await run { try $0.repairShare(shareId: id, url: url, customPassword: password) } /// offline files alone.
await crawl(shareId: id) func repairShare(id: String, url: String, password: String?) {
} catch { Task {
report(error) let name = shares.first { $0.id == id }?.name ?? "the share"
await busy("Reading \(name) again…") {
do {
let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 }
_ = try await run { try $0.repairShare(shareId: id, url: url, customPassword: password) }
try await NativeRuntime.engine().crawl(shareId: id)
} catch {
reportError(error)
}
await reloadNow()
}
await matchAfterCrawl()
} }
} }
func removeShare(id: String) async { func removeShare(_ id: String) {
do { Task {
try await run { try $0.removeShare(shareId: id) } let name = shares.first { $0.id == id }?.name ?? "the share"
} catch { await busy("Removing \(name)…") {
report(error) do {
// Wait for the transfers to stop before Rust removes catalog
// and files. Rust also rejects any late publication.
await DownloadCoordinator.shared.cancelShare(id)
let store = DownloadStateStore.shared
let engine = try await NativeRuntime.engine()
for download in store.records() where download.shareId == id {
try await engine.removeOfflineEpisode(shareId: download.shareId, linkId: download.linkId)
}
store.removeShare(id)
try await run { try $0.removeShare(shareId: id) }
} catch {
message = errorMessage(error)
}
await reloadNow()
}
} }
await reload()
} }
/// Marking seen puts the position at the duration where one is known, and func dismissMessage() {
/// unwatching rewinds, as on desktop and Android. message = nil
func setWatched(_ episode: EpisodeRecord, _ watched: Bool) async { undo = nil
}
// MARK: - Account
/// Start signing in, or start again with `verificationToken` once the
/// CAPTCHA from a first try is solved.
func signIn(_ username: String, _ password: String, verificationToken: String? = nil) {
signInStep { try await $0.signIn(username: username, password: password, verificationToken: verificationToken) }
}
func submitSecondFactor(_ code: String) {
signInStep { try await $0.submitSecondFactor(code: code) }
}
func submitMailboxPassword(_ password: String) {
signInStep(keepStepOnFailure: true) { try await $0.submitMailboxPassword(password: password) }
}
func cancelSignIn() {
account = AccountUiState(state: .signedOut)
Task { try? await run { $0.cancelSignIn() } }
}
/// The CAPTCHA page closed without a token.
func dismissVerification() {
account.verificationUrl = nil
account.busy = false
}
private func signInStep(keepStepOnFailure: Bool = false, _ step: @escaping (AndroidEngine) async throws -> SignInOutcome) {
account.busy = true
account.error = nil
account.verificationUrl = nil
Task {
do {
let outcome = try await step(NativeRuntime.engine())
switch outcome {
case let .done(username):
account = AccountUiState(state: .signedIn(username: username))
message = "Signed in as \(username)"
await syncWatchHistory()
reload()
case .secondFactor:
account.state = .secondFactor
account.busy = false
case .mailboxPassword:
account.state = .mailboxPassword
account.busy = false
case let .humanVerification(url):
account.verificationUrl = url
}
} catch {
account.busy = false
account.error = errorMessage(error)
// A refused code spends the half-made session.
if !keepStepOnFailure { account.state = .signedOut }
}
}
}
func signOut() {
Task {
await busy("Signing out…") {
do {
try await NativeRuntime.engine().signOut()
} catch {
reportError(error)
}
account = AccountUiState(state: .signedOut)
await reloadNow()
}
}
}
/// Sync now, from a tap: says so when it is done.
func syncNow() {
Task {
var synced = false
await busy("Syncing with your other devices…") { synced = await syncWatchHistory() }
guard synced else { return }
let applied = account.applied
message = applied > 0 ? "Synced, \(applied) positions from other devices" : "Everything is up to date"
}
}
/// Pull other devices' positions, shares and settings in and push this
/// one's out. Quiet: it runs on a timer, and a failure is shown on the
/// account card rather than as a snackbar every five minutes of an offline
/// evening. Returns whether it synced.
@discardableResult
func syncWatchHistory() async -> Bool {
guard case .signedIn = account.state else { return false }
do { do {
try await run { engine in guard let report = try await NativeRuntime.engine().syncWatchHistory() else { return false }
let duration = try engine.watchState(shareId: episode.shareId, linkId: episode.linkId)?.durationSecs account.syncedAt = Date()
try engine.saveWatchState( account.applied = Int(report.applied)
shareId: episode.shareId, linkId: episode.linkId, account.syncError = nil
positionSecs: watched ? duration ?? 0 : 0, await takeSynced(report)
durationSecs: duration, watched: watched return true
} catch {
let state = try? await run { $0.accountState() }
if let state { account.state = state }
account.syncError = errorMessage(error)
// Signed out by the engine: the session ended, and that is worth
// interrupting for, unlike a dropped connection.
if state == .signedOut { reportError(error) }
return false
}
}
/// What another device changed: its shares crawled or dropped here, its
/// settings repainted.
private func takeSynced(_ report: SyncRecord) async {
for id in report.sharesRemoved {
await DownloadCoordinator.shared.cancelShare(id)
DownloadStateStore.shared.removeShare(id)
}
if report.settingsChanged {
await AppearanceState.shared.reload()
}
if report.applied > 0 || report.titles > 0 || report.settingsChanged || !report.sharesRemoved.isEmpty {
await reloadNow()
}
// A crawl takes longer than the grace iOS gives a backgrounded app.
// What is left uncrawled is picked up on the way back.
if !report.sharesAdded.isEmpty, UIApplication.shared.applicationState != .background {
await crawlAdded(report.sharesAdded, announce: false)
message = report.sharesAdded.count == 1 ? "A share arrived from another device" : "\(report.sharesAdded.count) shares arrived from other devices"
}
}
/// On launch and back on screen: crawl shares the catalog has nothing of —
/// brought by a sync that ran in the background, or an add whose crawl
/// never finished.
func enteredForeground() {
guard !catchingUp else { return }
catchingUp = true
Task {
defer { catchingUp = false }
guard let ids = try? await run({ try $0.uncrawledShares() }), !ids.isEmpty else { return }
await crawlAdded(ids, announce: false)
}
}
/// Leaving the app: push where playback stopped, inside the grace iOS
/// gives a backgrounded app — what `WatchSyncWorker` does on Android.
func syncInBackground() {
guard case .signedIn = account.state else { return }
let task = BackgroundTask(name: "watch-history-sync")
Task {
await syncWatchHistory()
task.end()
}
}
/// After the player closes: where it stopped is what another device wants next.
func playerClosed() {
reload()
Task { await syncWatchHistory() }
}
func addAccountFolder(name: String, volumeId: String, linkId: String) {
Task {
do {
let share = try await run { try $0.addAccountFolder(name: name, volumeId: volumeId, linkId: linkId) }
reload()
await crawlAdded([share.id], announce: true)
} catch {
reportError(error)
}
}
}
// MARK: - Watch state
/// Every episode of a title watched or unwatched, with an Undo: a whole
/// title's positions are what this throws away, and a mis-tap on a tile's
/// menu should not cost them.
func setTitleWatched(_ title: TitleRecord, _ watched: Bool) {
showWatched(title.playlist, watched)
changeWatch(title.playlist, "\(title.displayName) marked \(watched ? "watched" : "unwatched")") { engine, episode, before in
let duration = before?.durationSecs
try engine.saveWatchState(
shareId: episode.shareId,
linkId: episode.linkId,
positionSecs: watched ? duration ?? 0 : 0,
durationSecs: duration,
watched: watched
)
}
}
/// Off Continue watching by forgetting where the episode stopped — what the
/// desktop does — with an Undo that puts the position back.
func forgetPosition(_ title: TitleRecord, _ episode: EpisodeRecord) {
changeWatch([episode], "\(title.displayName) removed from Continue watching") { engine, target, before in
try engine.saveWatchState(shareId: target.shareId, linkId: target.linkId, positionSecs: 0, durationSecs: before?.durationSecs, watched: false)
}
}
/// Off the history page: the position and the watched mark both go, which
/// is "never played", with an Undo.
func removeFromHistory(_ title: TitleRecord, _ episode: EpisodeRecord) {
changeWatch([episode], "\(episode.label) of \(title.displayName) removed from history") { engine, target, before in
try engine.saveWatchState(shareId: target.shareId, linkId: target.linkId, positionSecs: 0, durationSecs: before?.durationSecs, watched: false)
}
}
/// Put back what the last undoable change replaced.
func performUndo() {
guard let snapshots = undo else { return }
message = nil
undo = nil
Task {
do {
try await run { engine in
for snapshot in snapshots {
try engine.saveWatchState(
shareId: snapshot.shareId,
linkId: snapshot.linkId,
positionSecs: snapshot.state?.positionSecs ?? 0,
durationSecs: snapshot.state?.durationSecs,
watched: snapshot.state?.watched ?? false
)
}
}
} catch {
reportError(error)
}
reload()
}
}
private func changeWatch(
_ episodes: [EpisodeRecord],
_ text: String,
_ change: @escaping @Sendable (AndroidEngine, EpisodeRecord, WatchStateRecord?) throws -> Void
) {
Task {
do {
let snapshots = try await run { engine in
try episodes.map { episode in
let before = try engine.watchState(shareId: episode.shareId, linkId: episode.linkId)
try change(engine, episode, before)
return WatchSnapshot(shareId: episode.shareId, linkId: episode.linkId, state: before)
}
}
message = text
undo = snapshots
} catch {
reportError(error)
}
reload()
}
}
func reportError(_ error: Error) {
message = errorMessage(error)
}
func report(_ text: String) {
message = text
}
// MARK: - Metadata
/// Store the enrichment settings, and return why not, if they were not.
///
/// The page shows the new settings as soon as they are stored; the match
/// run that follows can take minutes and says so in the activity banner.
/// Before, the page only changed once that run had finished, so a save
/// looked like it had done nothing.
func saveMetadataSettings(enabled: Bool, provider: MetadataProvider, language: String, apiKey: String) async -> String? {
do {
metadataSettings = try await run { engine in
if provider == .tmdb, !apiKey.trimmingCharacters(in: .whitespaces).isEmpty {
try engine.setMetadataApiKey(provider: provider, key: apiKey)
}
try engine.setMetadataSettings(settings: MetadataSettingsRecord(enabled: enabled, provider: provider, language: language, ready: true))
return try engine.metadataSettings()
}
} catch {
return errorMessage(error)
}
await reloadNow()
if enabled {
Task { await match(force: false) }
}
return nil
}
func reloadAfterMetadataChange() { reload() }
/// What an enrichment pass did, in one line. Failures are reported even
/// when most titles matched.
static func describe(_ summary: MatchSummary) -> String {
if summary.matched == 0, summary.unmatched == 0, summary.failed == 0 {
return "Everything is already matched"
}
var parts = ["\(summary.matched) matched"]
if summary.unmatched > 0 { parts.append("\(summary.unmatched) not found") }
if summary.episodes > 0 { parts.append("\(summary.episodes) episodes named") }
if summary.failed > 0 { parts.append("\(summary.failed) failed") }
return parts.joined(separator: ", ")
}
// MARK: - Offline
func removeOffline(_ file: OfflineRecord) {
Task {
await busy("Deleting the download…") {
do {
try await NativeRuntime.engine().removeOfflineEpisode(shareId: file.shareId, linkId: file.linkId)
} catch {
message = errorMessage(error)
}
await reloadNow()
}
}
}
func pauseDownload(_ download: RetainedDownload) { DownloadCoordinator.shared.pause(download) }
func resumeDownload(_ download: RetainedDownload) { DownloadCoordinator.shared.resume(download) }
func deletePartial(_ download: RetainedDownload) {
Task {
await DownloadCoordinator.shared.cancelAndWait(download)
do {
try await NativeRuntime.engine().removeOfflineEpisode(shareId: download.shareId, linkId: download.linkId)
DownloadStateStore.shared.remove(download.shareId, download.linkId)
} catch {
reportError(error)
}
reload()
}
}
func removeAllOffline() {
Task {
await busy("Deleting every download…") {
await DownloadCoordinator.shared.cancelAll()
do {
try await NativeRuntime.engine().removeAllOffline()
DownloadStateStore.shared.clear()
} catch {
reportError(error)
}
await reloadNow()
}
}
}
func clearBlockCache() {
Task {
do {
let reclaimed = try await run { try $0.clearBlockCache() }
message = "Reclaimed \(formatBytes(reclaimed)) of cache"
} catch {
reportError(error)
}
reload()
}
}
// MARK: - Progress
/// Persist where an episode was left. Runs on its own task rather than the
/// player's, so a save issued on the way out is not cancelled by the
/// player going away.
func saveProgress(_ episode: EpisodeRecord, position: Double, duration: Double, watched: Bool) {
Task {
do {
try await run { engine in
try engine.saveWatchState(
shareId: episode.shareId,
linkId: episode.linkId,
positionSecs: min(max(position, 0), duration),
durationSecs: duration,
watched: watched
)
}
} catch {
reportError(error)
}
}
}
/// Mark an episode seen, or unseen, without playing it.
///
/// Unwatching rewinds, matching the desktop client. Marking one seen puts
/// the position at its duration where one is known, so the progress bar
/// agrees with the tick.
func setWatched(_ episode: EpisodeRecord, _ watched: Bool) {
showWatched([episode], watched)
Task {
do {
try await run { engine in
let duration = try engine.watchState(shareId: episode.shareId, linkId: episode.linkId)?.durationSecs
try engine.saveWatchState(
shareId: episode.shareId,
linkId: episode.linkId,
positionSecs: watched ? duration ?? 0 : 0,
durationSecs: duration,
watched: watched
)
}
} catch {
reportError(error)
}
reload()
}
}
/// Draw a watched mark the moment it is tapped, before the bridge has
/// stored it. The reload that shows the stored state is a second away on
/// an old iPad, and a tick that does not appear gets tapped again. That
/// reload still follows, and puts back whatever was really stored.
private func showWatched(_ targets: [EpisodeRecord], _ watched: Bool) {
let keys = Set(targets.map { "\($0.shareId)/\($0.linkId)" })
titles = titles.map { title in
guard title.playlist.contains(where: { keys.contains("\($0.shareId)/\($0.linkId)") }) else { return title }
var title = title
title.seasons = title.seasons.map { season in
var season = season
season.episodes = season.episodes.map { episode in
guard keys.contains("\(episode.shareId)/\(episode.linkId)") else { return episode }
var episode = episode
episode.watched = watched
if !watched {
episode.progress = nil
episode.resumeAt = nil
}
return episode
}
return season
}
title.watchedCount = UInt64(title.playlist.filter(\.watched).count)
return title
}
}
// MARK: - Reload
func reload() {
Task { await reloadNow() }
}
/// `reload`, for a caller that goes on to read what it loaded.
func reloadNow() async {
let query = query.trimmingCharacters(in: .whitespaces).isEmpty ? nil : self.query
let sort = sort
let grouped = grouped
do {
let result = try await run { engine in
// Here rather than inside `library()`, which is read-only
// and cached: a full reload is the one place that has
// already paid for a walk of the offline files.
_ = try engine.pruneOfflineFiles()
return Reloaded(
shares: try engine.shares(),
titles: try engine.library(search: query),
arrangement: try engine.arrangement(search: query, sort: sort, grouped: grouped),
offline: try engine.offlineFiles(),
metadataSettings: try engine.metadataSettings(),
storage: try engine.storageUsage()
) )
} }
loading = false
shares = result.shares
titles = result.titles
arrangement = result.arrangement
offline = result.offline
metadataSettings = result.metadataSettings
storage = result.storage
} catch { } catch {
report(error) loading = false
message = errorMessage(error)
} }
await reload()
} }
/// Episodes started but not finished, most recent first. private func reloadLibrary(_ query: String) async {
var continueWatching: [(title: TitleRecord, episode: EpisodeRecord)] { let search = query.trimmingCharacters(in: .whitespaces).isEmpty ? nil : query
titles let sort = sort
.flatMap { title in let grouped = grouped
title.seasons.flatMap(\.episodes) do {
.filter { $0.resumeAt != nil && !$0.watched } let (titles, arrangement) = try await run { engine in
.map { (title: title, episode: $0) } (try engine.library(search: search), try engine.arrangement(search: search, sort: sort, grouped: grouped))
} }
.sorted { $0.episode.lastPlayed > $1.episode.lastPlayed } self.titles = titles
self.arrangement = arrangement
} catch {
message = errorMessage(error)
}
} }
} }
/// The grace iOS gives an app leaving the screen, ended exactly once —
/// whether by the work finishing or by the time running out.
@MainActor
final class BackgroundTask {
private var identifier = UIBackgroundTaskIdentifier.invalid
init(name: String) {
identifier = UIApplication.shared.beginBackgroundTask(withName: name) { [weak self] in
MainActor.assumeIsolated { self?.end() }
}
}
func end() {
guard identifier != .invalid else { return }
UIApplication.shared.endBackgroundTask(identifier)
identifier = .invalid
}
}
private struct Reloaded: @unchecked Sendable {
let shares: [ShareRecord]
let titles: [TitleRecord]
let arrangement: ArrangementRecord
let offline: [OfflineRecord]
let metadataSettings: MetadataSettingsRecord
let storage: StorageUsageRecord
}
@@ -0,0 +1,40 @@
import Foundation
/// A Proton Drive public link, wherever in a piece of text it sits.
///
/// The fragment is kept: it is the share's secret, and a link without it cannot
/// be opened. Matching stops at whitespace and at the quotes and brackets a
/// message app wraps a link in, which never occur in a link itself.
private let shareLinkPattern = #"https://drive\.proton\.me/urls/[^\s"'<>()\[\]]+"#
/// The first share link in `text`, if any — for the clipboard.
func shareLink(in text: String) -> String? {
text.range(of: shareLinkPattern, options: .regularExpression).map { String(text[$0]) }
}
/// The share link a URL opened in this app carries, if any.
///
/// iOS hands an app no `https://drive.proton.me` link — Universal Links belong
/// to the domain's owner — so a link arrives through the app's own scheme:
/// `protonstream://add?url=<link>`, or the link itself with its scheme swapped,
/// `protonstream://drive.proton.me/urls/…#…`.
func shareLink(from url: URL) -> String? {
guard url.scheme?.lowercased() == "protonstream" else { return nil }
if url.host == "add" {
let value = URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?.first { $0.name == "url" }?.value
return value.flatMap(wholeShareLink)
}
let text = url.absoluteString
let rest = text[text.index(text.startIndex, offsetBy: "protonstream://".count)...]
return wholeShareLink("https://\(rest)")
}
/// `value` when the whole of it is a share link — what `ACTION_VIEW` accepts on
/// Android — rather than a link somewhere inside it.
private func wholeShareLink(_ value: String) -> String? {
guard let range = value.range(of: shareLinkPattern, options: .regularExpression),
range == value.startIndex ..< value.endIndex
else { return nil }
return value
}
+73
View File
@@ -0,0 +1,73 @@
import Foundation
/// Process-wide owner of the Rust engine; records and decrypted media remain
/// app-private. The counterpart of Android's `NativeRuntime`.
enum NativeRuntime {
private static let lock = NSLock()
private static var instance: AndroidEngine?
/// The engine, built on first use off the main thread: construction opens
/// SQLite, spins a Tokio runtime and reads the Keychain.
static func engine() async throws -> AndroidEngine {
try await Task.detached(priority: .userInitiated) { try blockingEngine() }.value
}
/// The engine from a thread that may block — a bridge callback, a
/// background task. Never call this on the main thread.
static func blockingEngine() throws -> AndroidEngine {
try lock.withLock {
if let instance { return instance }
let engine = try AndroidEngine(paths: try paths(), secrets: KeychainSecretStore())
// The budget is this app's setting, so the engine starts on the
// default and is told the chosen one before anything streams.
engine.setStreamCacheBudget(bytes: SettingsStore.gibToBytes(SettingsStore().cacheBudgetGib))
instance = engine
return engine
}
}
/// The stored palette, without building the engine.
///
/// Callable from the main thread, and meant to be: it is the only palette
/// read early enough to decide what the first frame is painted in, and
/// `engine()` is not. This reads one small JSON file and resolves it.
static func initialPalette() -> PaletteRecord? {
guard let paths = try? paths() else { return nil }
return storedPalette(paths: paths)
}
/// Application Support for the catalog and the configuration, Caches for
/// the block cache, which iOS may purge.
///
/// Neither the catalog nor the configuration is backed up, as on Android
/// (`allowBackup="false"`): the share secrets live in the Keychain as
/// this-device-only items, so a restored share list would arrive without
/// them, and a restored `sync.json` would give two phones one sync identity
/// writing one file — the case `pstr-core::sync` exists to prevent.
private static func paths() throws -> AndroidPaths {
let files = FileManager.default
let support = try files.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let caches = try files.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
var config = support.appendingPathComponent("config", isDirectory: true)
var data = support.appendingPathComponent("data", isDirectory: true)
let cache = caches.appendingPathComponent("stream", isDirectory: true)
for directory in [config, data, cache] {
try files.createDirectory(at: directory, withIntermediateDirectories: true)
}
var excluded = URLResourceValues()
excluded.isExcludedFromBackup = true
try config.setResourceValues(excluded)
try data.setResourceValues(excluded)
return AndroidPaths(config: config.path, data: data.path, cache: cache.path)
}
}
/// What a bridge failure says, without Swift's type name around it.
///
/// `BridgeError`'s own description is `String(reflecting:)`, which reads
/// `PstrBridge.BridgeError.Failure(reason: "…")` in a snackbar.
func errorMessage(_ error: Error) -> String {
if case let BridgeError.Failure(reason) = error { return reason }
let text = error.localizedDescription
return text.isEmpty ? "Unexpected error" : text
}
+341 -19
View File
@@ -1,48 +1,370 @@
import AVFoundation import AVFoundation
import SwiftUI import SwiftUI
import UIKit
@main @main
struct ProtonStreamApp: App { struct ProtonStreamApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var delegate
@State private var model = AppModel() @State private var model = AppModel()
@State private var appearance = AppearanceState.shared
init() { init() {
// `.playback` keeps the audio going with the screen locked or the app // `.playback` keeps the audio going with the screen locked or the app
// in the background, which `UIBackgroundModes: audio` then permits. // in the background, which `UIBackgroundModes: audio` then permits.
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback) try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
// The palette read before the first frame, so the first frame is
// already painted in it.
AppearanceState.shared.seed(NativeRuntime.initialPalette())
} }
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
let scheme = appearance.scheme
RootView() RootView()
.environment(model) .environment(model)
.preferredColorScheme(.dark) .environment(\.scheme, scheme)
.preferredColorScheme(scheme.colorScheme)
.tint(scheme.primary)
.font(.inter(.bodyLarge))
.background(scheme.background.ignoresSafeArea())
.task { await appearance.load() }
}
}
}
final class AppDelegate: NSObject, UIApplicationDelegate {
func application(_: UIApplication, supportedInterfaceOrientationsFor _: UIWindow?) -> UIInterfaceOrientationMask {
MainActor.assumeIsolated { OrientationLock.mask }
}
}
/// From this width the library and an open title share the window.
private let twoPaneWidth: CGFloat = 840
/// From this width the destinations are a rail down the side, not a bar.
private let railWidth: CGFloat = 600
/// How much room the floating mini transport needs at the foot of a page.
private let miniTransportInset: CGFloat = 88
enum Destination: String, CaseIterable {
case library, history, shares, downloads, settings
var label: String {
switch self {
case .library: "Library"
case .history: "History"
case .shares: "Shares"
case .downloads: "Downloads"
case .settings: "Settings"
}
}
var icon: String {
switch self {
case .library: "play.rectangle.on.rectangle.fill"
case .history: "clock.arrow.circlepath"
case .shares: "square.and.arrow.up"
case .downloads: "arrow.down.circle"
case .settings: "gearshape"
} }
} }
} }
struct RootView: View { struct RootView: View {
@Environment(AppModel.self) private var model @Environment(AppModel.self) private var model
@Environment(\.scheme) private var scheme
@Environment(\.scenePhase) private var phase
// Keys, not records: what has to survive is *which* title, and a record is
// no longer current after the library reloads. Everything else is derived,
// so a reload that renames a season updates the open screen.
@SceneStorage("destination") private var destination: Destination = .library
@SceneStorage("selectedTitle") private var selectedTitleKey: String?
@State private var incomingLink: String?
@State private var host = PlayerHost.shared
var body: some View { var body: some View {
if let failure = model.startupError { GeometryReader { geometry in
ContentUnavailableView("Could not start", systemImage: "exclamationmark.triangle", description: Text(failure)) let wide = geometry.size.width >= railWidth
} else { ZStack(alignment: .bottom) {
TabView { HStack(spacing: 0) {
NavigationStack { LibraryView() } if wide { NavigationRail(destination: $destination, onSelect: select) }
.tabItem { Label("Library", systemImage: "play.rectangle.on.rectangle") } page(width: geometry.size.width)
NavigationStack { SharesView() } .safeAreaInset(edge: .bottom, spacing: 0) {
.tabItem { Label("Shares", systemImage: "link") } VStack(spacing: 8) {
NavigationStack { SettingsView() } if host.isOpen && host.minimized {
.tabItem { Label("Settings", systemImage: "gearshape") } MiniTransport(host: host)
}
if !wide { NavigationBar(destination: $destination, onSelect: select) }
}
}
}
VStack(spacing: 8) {
ActivityBanner()
Snackbar()
}
.padding(.bottom, (wide ? 16 : 72) + (host.isOpen && host.minimized ? miniTransportInset : 0))
.animation(.easeInOut(duration: 0.2), value: model.activity)
} }
.task { await model.reload() } .background(scheme.background.ignoresSafeArea())
.alert("Something went wrong", isPresented: .init( }
get: { model.error != nil }, .overlay {
set: { if !$0 { model.error = nil } } // Over the shell rather than instead of it: the page behind stays
)) { // where it was, so leaving the player finds the title page as it was.
Button("OK", role: .cancel) {} if host.isOpen && !host.minimized {
} message: { Group {
Text(model.error ?? "") if let player = host.player {
PlayerView(host: host, player: player)
} else {
ZStack(alignment: .topTrailing) {
Color.black.ignoresSafeArea()
Text(host.playbackError ?? "Playback is unavailable: libmpv did not start.")
.foregroundStyle(.white)
.frame(maxWidth: .infinity, maxHeight: .infinity)
IconButton("xmark", "Close", tint: .white, action: host.close)
}
}
}
.transition(.move(edge: .bottom))
}
}
.animation(.easeInOut(duration: 0.25), value: host.isOpen && !host.minimized)
.onAppear {
model.start()
host.onSaveProgress = { [model] episode, position, duration, watched in
model.saveProgress(episode, position: position, duration: duration, watched: watched)
}
host.onClosed = { [model] in model.playerClosed() }
}
.onChange(of: model.titles) { _, titles in host.update(titles: titles) }
.onChange(of: phase) { _, phase in
switch phase {
case .background:
host.enteredBackground()
model.syncInBackground()
case .active:
host.enteredForeground()
model.enteredForeground()
DownloadCoordinator.shared.resumeQueued()
default:
break
}
}
// A link opened in this app: show the Add form with it filled in. A
// playing episode keeps going in the mini transport rather than
// covering the form.
.onOpenURL { url in
guard let link = shareLink(from: url) else { return }
incomingLink = link
destination = .shares
selectedTitleKey = nil
if host.isOpen { host.minimized = true }
}
}
private func select(_ target: Destination) {
destination = target
selectedTitleKey = nil
}
private func play(_ title: TitleRecord, _ index: Int) {
host.play(title, at: index)
}
@ViewBuilder private func page(width: CGFloat) -> some View {
switch destination {
case .library:
libraryPage(width: width)
case .history:
HistoryView(onPlay: play, onTitle: { title in
destination = .library
selectedTitleKey = title.key
})
case .shares:
SharesView(incomingLink: $incomingLink)
case .downloads:
DownloadsView()
case .settings:
SettingsView()
}
}
/// Side by side where there is room for both: on a tablet the library
/// stays in view while a title is open, so moving between titles is one
/// tap rather than back and in again.
@ViewBuilder private func libraryPage(width: CGFloat) -> some View {
let selected = selectedTitleKey.flatMap { key in model.titles.first { $0.key == key } }
let library = LibraryView(onResume: play, onTitle: { title in withAnimation(.easeOut(duration: 0.25)) { selectedTitleKey = title.key } })
if width >= twoPaneWidth {
HStack(spacing: 0) {
library.frame(width: width * 0.45)
Rectangle().fill(scheme.outlineVariant).frame(width: 1).ignoresSafeArea()
Group {
if let selected { titlePane(selected) } else { EmptyState("Choose a title", "Its seasons and episodes open here.") }
}
.frame(maxWidth: .infinity)
}
} else {
// The library stays underneath the title page rather than being
// rebuilt, so going back finds the grid scrolled where it was.
ZStack {
library
.opacity(selected == nil ? 1 : 0)
.allowsHitTesting(selected == nil)
.accessibilityHidden(selected != nil)
if let selected {
titlePane(selected)
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .opacity.combined(with: .scale(scale: 0.9))))
.zIndex(1)
}
}
}
}
private func titlePane(_ selected: TitleRecord) -> some View {
TitleView(
title: selected,
onPlay: { _, index in host.play(selected, at: index) },
onBack: { withAnimation(.easeOut(duration: 0.25)) { selectedTitleKey = nil } },
franchise: selected.franchise.compactMap { key in model.titles.first { $0.key == key } },
onOpenTitle: { selectedTitleKey = $0.key }
)
.id(selected.key)
}
}
/// The destinations along the foot of a phone, the selected one in an accent
/// pill — the desktop's rule is that the accent marks the thing you are on.
private struct NavigationBar: View {
@Binding var destination: Destination
let onSelect: (Destination) -> Void
@Environment(\.scheme) private var scheme
var body: some View {
HStack(spacing: 0) {
ForEach(Destination.allCases, id: \.self) { item in
let selected = item == destination
Button { onSelect(item) } label: {
VStack(spacing: 4) {
Image(systemName: item.icon)
.font(.system(size: 18))
.foregroundStyle(selected ? scheme.onPrimary : scheme.onSurfaceVariant)
.frame(width: 56, height: 30)
.background(selected ? scheme.primary : .clear, in: Capsule())
Text(item.label)
.textStyle(.labelMedium, semibold: selected)
.foregroundStyle(selected ? scheme.onSurface : scheme.onSurfaceVariant)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity)
.padding(.top, 10)
.padding(.bottom, 6)
.contentShape(Rectangle())
}
.buttonStyle(.pressable)
.accessibilityLabel(item.label)
.accessibilityAddTraits(selected ? .isSelected : [])
}
}
.background(scheme.surfaceContainer.ignoresSafeArea(edges: .bottom))
}
}
/// The same destinations down the side of a wide window.
private struct NavigationRail: View {
@Binding var destination: Destination
let onSelect: (Destination) -> Void
@Environment(\.scheme) private var scheme
var body: some View {
VStack(spacing: 12) {
ForEach(Destination.allCases, id: \.self) { item in
let selected = item == destination
Button { onSelect(item) } label: {
VStack(spacing: 4) {
Image(systemName: item.icon)
.font(.system(size: 18))
.foregroundStyle(selected ? scheme.onPrimary : scheme.onSurfaceVariant)
.frame(width: 56, height: 32)
.background(selected ? scheme.primary : .clear, in: Capsule())
Text(item.label)
.textStyle(.labelMedium, semibold: selected)
.foregroundStyle(selected ? scheme.onSurface : scheme.onSurfaceVariant)
}
.frame(width: 80)
.contentShape(Rectangle())
}
.buttonStyle(.pressable)
.accessibilityLabel(item.label)
.accessibilityAddTraits(selected ? .isSelected : [])
}
Spacer()
}
.padding(.top, 24)
.background(scheme.surface.ignoresSafeArea())
}
}
/// What the app is busy with, for as long as it is: a spinner and one line.
/// Not dismissible and not timed — it goes when the work does.
private struct ActivityBanner: View {
@Environment(AppModel.self) private var model
@Environment(\.scheme) private var scheme
var body: some View {
if let activity = model.activity {
HStack(spacing: 12) {
ProgressView().controlSize(.small).tint(scheme.primary)
Text(activity)
.textStyle(.bodyMedium)
.foregroundStyle(scheme.onSurface)
.lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.tight))
.padding(.horizontal, 12)
.transition(.move(edge: .bottom).combined(with: .opacity))
.accessibilityElement(children: .combine)
.accessibilityAddTraits(.updatesFrequently)
}
}
}
/// The app's one line of feedback: what just happened, with Undo where it can
/// be taken back.
private struct Snackbar: View {
@Environment(AppModel.self) private var model
@Environment(\.scheme) private var scheme
var body: some View {
if let message = model.message {
HStack(spacing: 8) {
Text(message)
.textStyle(.bodyMedium)
.foregroundStyle(scheme.inverseOnSurface)
.frame(maxWidth: .infinity, alignment: .leading)
if model.undo != nil {
Button("Undo", action: model.performUndo)
.textStyle(.labelLarge)
.foregroundStyle(scheme.inversePrimary)
.buttonStyle(.pressable)
.padding(.horizontal, 8)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(scheme.inverseSurface, in: RoundedRectangle(cornerRadius: Corner.tight))
.padding(.horizontal, 12)
.transition(.move(edge: .bottom).combined(with: .opacity))
.onTapGesture(perform: model.dismissMessage)
// Long with an Undo to reach for, short without — Material's two
// snackbar durations.
.task(id: message) {
try? await Task.sleep(for: .seconds(model.undo != nil ? 10 : 4))
if !Task.isCancelled, model.message == message { model.dismissMessage() }
} }
} }
} }
+55
View File
@@ -0,0 +1,55 @@
import Foundation
/// The settings that are this front end's alone.
///
/// Playback preferences — volume, languages, autoplay, auto-skip — deliberately
/// do *not* live here: they are `pstr_core::prefs`, reached over the bridge, so
/// there is one definition of them rather than one per front end. Keys and
/// defaults are Android's `SettingsStore`.
struct SettingsStore {
private let defaults = UserDefaults.standard
var wifiOnly: Bool {
get { bool(Key.wifiOnly, default: true) }
nonmutating set { defaults.set(newValue, forKey: Key.wifiOnly) }
}
var backgroundAudio: Bool {
get { bool(Key.backgroundAudio, default: true) }
nonmutating set { defaults.set(newValue, forKey: Key.backgroundAudio) }
}
/// Whether mpv may decode in hardware. Off is the way out for a file
/// VideoToolbox shows green or torn frames for; it costs battery, so it is
/// on unless the viewer turns it off. Read at each load, so a change applies
/// from the next file.
var hardwareDecoding: Bool {
get { bool(Key.hardwareDecoding, default: true) }
nonmutating set { defaults.set(newValue, forKey: Key.hardwareDecoding) }
}
/// The streaming cache's budget, in GiB. See `cacheBudgetChoices`.
var cacheBudgetGib: Int {
get { defaults.object(forKey: Key.cacheBudgetGib) as? Int ?? Self.defaultCacheBudgetGib }
nonmutating set { defaults.set(newValue, forKey: Key.cacheBudgetGib) }
}
/// The desktop's range, in the steps a phone's storage is bought in.
static let cacheBudgetChoices = [1, 2, 4, 8, 16]
/// `DiskCacheConfig::DEFAULT_BUDGET_BYTES`, so an unset choice changes nothing.
static let defaultCacheBudgetGib = 4
static func gibToBytes(_ gib: Int) -> UInt64 { UInt64(gib) * 1024 * 1024 * 1024 }
private func bool(_ key: String, default value: Bool) -> Bool {
defaults.object(forKey: key) as? Bool ?? value
}
private enum Key {
static let wifiOnly = "wifi_only"
static let backgroundAudio = "background_audio"
static let hardwareDecoding = "hardware_decoding"
static let cacheBudgetGib = "cache_budget_gib"
}
}
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff. Show more