Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce02bbc35a | ||
|
|
0b9a9dab50 | ||
|
|
73973841c2 | ||
|
|
2f3be1d193 | ||
|
|
8e42906a7f | ||
|
|
dc70cafbb7 | ||
|
|
a28135cd51 | ||
|
|
4b9c01d52d | ||
|
|
3356602951 | ||
|
|
537217e70a | ||
|
|
ed05a1e723 | ||
|
|
da16ab0575 | ||
|
|
d9ca8f8d60 | ||
|
|
26cccd5c63 | ||
|
|
0b2813cd42 | ||
|
|
6cdab2fde1 | ||
|
|
6239780978 | ||
|
|
67cf3044f1 | ||
|
|
66be35c3d4 | ||
|
|
046cfe62b8 | ||
|
|
e3dcbec1f2 | ||
|
|
99abc8f205 | ||
|
|
3638e0e5df | ||
|
|
aef182e7e8 | ||
|
|
6853725ca0 | ||
|
|
4c80076762 |
No files matched your search
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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: |
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 814 KiB After Width: | Height: | Size: 813 KiB |
|
Before Width: | Height: | Size: 781 KiB After Width: | Height: | Size: 768 KiB |
|
Before Width: | Height: | Size: 821 KiB After Width: | Height: | Size: 806 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 628 KiB After Width: | Height: | Size: 631 KiB |
|
Before Width: | Height: | Size: 649 KiB After Width: | Height: | Size: 669 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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| {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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", ¤t.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));
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"))]
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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![
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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};
|
||||||
@@ -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")))
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||