Compare commits

20 Commits
Author SHA1 Message Date
nvrl e3dcbec1f2 chore(version): bump to 1.1.1
Patch, not minor. Nothing a viewer uses changes: the Android build moves to
NDK r30 and mpv-android 687c7fd, which brings newer harfbuzz, fribidi,
unibreak, fontconfig, libxml2 and curl into the bundled libmpv. The catalog
schema and on-disk data are unchanged from 1.1.0.
2026-10-04 02:54:21 +02:00
nvrl 99abc8f205 build(android): move to NDK r30 and mpv-android 687c7fd
Upstream mpv-android moved its build to NDK r30 (30.0.16248370), and each
mpv-android revision pins its own NDK, so the two move together. The new
revision also updates harfbuzz to 14.5.0, unibreak to 8.0, fribidi to
1.0.17, libxml2 to 2.15.4, fontconfig to 2.18.3 and curl to 8.22.0, and
builds libass with assembly on x86_64 (nasm, already installed in CI).

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

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

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

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

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

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

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

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

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

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

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

Schema v9 adds the columns and deletes the AniList episode rows and
banners. Matches are kept. Desktop, Android and the CLI now share
service::plan and MetadataService::work. The settings text names
ani.zip. Audit and roadmap in docs/METADATA.md; bug entry B61.
2026-10-04 00:47:41 +02:00
nvrl 52d27ec996 chore(version): bump to 1.0.1 2026-10-03 23:00:34 +02:00
nvrl 66eb1b49a1 ci(android): keep the cached libmpv and Rust target trees
Gradle deletes the declared outputs of a task it has no history for, so
on every fresh checkout buildLibmpvAndroid threw away the libmpv tree just
restored from the cache and rebuilt it from source (~14 min per job). The
task no longer declares outputs; the script's STAMP decides.

rust-cache treats target/android/<triple> as a profile directory and
deletes its release tree before saving, so both cargo-ndk ABIs were
rebuilt from scratch on every run (~6 min per job). It now caches only the
registry and helpers; the target directory goes through actions/cache,
keyed on toolchain and lockfile and restoring from the last lockfile.
2026-10-03 23:00:19 +02:00
nvrl 22425e0bad fix(android): centre the launcher glyph optically in its mask 2026-10-03 23:00:19 +02:00
nvrl 50481e6aed fix(android): lint on the predictive-back opt-in and the shared-art modifier
enableOnBackInvokedCallback only exists from API 33 and the app runs from
31, which lint reports as UnusedAttribute; tools:targetApi says that is
intended, since older releases ignore it. sharedArt becomes an extension
on Modifier, as Compose lint asks of modifier factories.
2026-10-03 14:53:09 +02:00
nvrl bedbe0ef52 fix(android): pin the version in the settings screenshot
The Settings index shows the installed version, read from the package,
so the committed image held 0.5.1 and the 1.0.0 bump failed the
screenshot check. SettingsScreen takes the version as a parameter that
defaults to the package's, and the test passes a fixed one.
2026-10-03 14:24:32 +02:00
nvrl 7914cf4dc1 chore(version): bump to 1.0.0 2026-10-03 13:35:36 +02:00
nvrl 39a7f3cc38 docs: PLAN.md status and the screenshot list after the second Android pass 2026-10-03 13:34:28 +02:00
nvrl 630f07a462 feat(android): library and title side by side on tablets, a poster that becomes the page, and predictive back
From 840 dp wide, the library and the open title share the window, so
moving between titles is one tap and the grid stays where it was; with
nothing open, the right pane says to choose one. On narrower windows, a
poster tapped in the grid grows into the title page's backdrop and
shrinks back on the way out.

The app opts into predictive back. Dragging back on a title page shrinks
the page as the gesture goes and restores it if the gesture is
abandoned.
2026-10-03 13:34:04 +02:00
nvrl 9cba54df14 feat(android): light-palette and tablet screenshots, and a title page that fits a tablet
The screenshot tests now also capture the library and a title in
Catppuccin Latte, and a title and the history page on a tablet. The
tablet capture showed the title page opening on nothing but its
backdrop: a 16:9 picture the width of a landscape tablet is taller than
the window. The backdrop is now no taller than about half the window,
and the rest of the page keeps a phone's reading width, centred.
2026-10-03 13:27:19 +02:00
nvrl 4ff8ae303c feat(android): Change match from a library tile's long press
The tile menu now offers the same provider search as the title page, so
a title wearing the wrong poster can be fixed from the grid that shows
it, without opening the title first.
2026-10-03 13:15:59 +02:00
nvrl 6c2ec83cb1 feat(android): a downloads page of rows under each show
Saved episodes are list rows with their still, the episode's name rather
than its file, and its size, under a heading per show; the page opens
with how much is on the device. Downloads still arriving come first, with
pause or resume and cancel as icons beside the progress. Deleting a saved
episode now asks first, since getting it back is a download.
2026-10-03 13:13:41 +02:00
nvrl 677ab2d928 feat(android): settings as a page per area, and a language picker
The first Settings page lists Playback, Downloads and storage,
Appearance, Metadata and About, each with a line saying where it stands,
and each opens its own page with a way back. On one long page, every
setting added pushed the rest further from reach.

Audio and subtitle language are chosen from a list of languages by name
instead of typed as "jpn or eng". The list stores the three-letter tag a
Matroska file carries, reads a stored tag in either ISO form, and still
takes any other tag typed in.
2026-10-03 13:07:11 +02:00
nvrl fb32d6c850 feat(android): per-page app bars, edge to edge, and search behind an icon
The single "proton-stream" bar over every page is gone. The library has a
large title that folds away as the grid scrolls, with search and refresh as
icons; search opens into a field in the bar that takes the keyboard at once,
and back closes and clears it. History, Shares, Downloads and Settings each
name themselves in a bar of their own, drawn in the page's background until
content scrolls under it. The title page now draws its backdrop under the
status bar, with the back button below it.

A search with no results says so instead of claiming the library is empty.
2026-10-03 12:56:03 +02:00
nvrl 110f97caf9 feat(android): generated placeholder art and a skeleton library
A title without artwork shows the accent as a gradient with its initials
instead of a black card with a play triangle, so a library of unmatched
titles is no longer a grid of identical cards. Art with the name already
over it, and episode stills, get the gradient without initials. While
artwork loads, and while the catalog first loads, the page shows pulsing
shapes where the posters will be rather than a spinner.

The screenshot tests turn artwork loading off, so whether a failed fetch
lands before the capture no longer decides what the image shows.
2026-10-03 12:45:34 +02:00
75 changed files with 6377 additions and 1446 deletions

No files matched your search

+54 -12
View File
@@ -39,8 +39,8 @@ env:
CARGO_TERM_COLOR: always
ANDROID_API: '36'
ANDROID_BUILD_TOOLS: '37.0.0'
ANDROID_NDK: '29.0.14206865'
ANDROID_NDK_HOME: /usr/local/lib/android/sdk/ndk/29.0.14206865
ANDROID_NDK: '30.0.16248370'
ANDROID_NDK_HOME: /usr/local/lib/android/sdk/ndk/30.0.16248370
jobs:
build:
@@ -107,19 +107,34 @@ jobs:
"build-tools;${ANDROID_BUILD_TOOLS}"
"ndk;${ANDROID_NDK}"
- uses: dtolnay/rust-toolchain@stable
- id: toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: '1.96'
targets: aarch64-linux-android,x86_64-linux-android
# One cache for both jobs: the signed build compiles the same crates for
# the same targets. Saved on failure too, so a run that breaks late does
# not throw away everything it compiled before the break.
# The registry and the installed helpers only. rust-cache reads
# `target/android/<triple>` as a profile directory and deletes the
# `release` tree under it before saving, so with its target caching on
# every run rebuilt both ABIs from scratch.
- uses: Swatinem/rust-cache@v2.9.1
with:
shared-key: android
shared-key: android-registry
cache-targets: false
cache-on-failure: true
# The target directory as is: the host build the UniFFI bindings come
# from and both cargo-ndk ABIs. One entry for both jobs, since the signed
# build compiles the same crates for the same targets. A new lockfile
# starts from the last one's tree, so a version bump rebuilds only the
# workspace crates.
- id: target
uses: actions/cache/restore@v6
with:
path: target
key: rust-target-android-${{ steps.toolchain.outputs.cachekey }}-${{ hashFiles('Cargo.lock') }}
restore-keys: rust-target-android-${{ steps.toolchain.outputs.cachekey }}-
# libmpv and its dependency tree are the slowest part of the build, and
# depend only on the pinned revision and the build script, which stamps
# its output with both and skips the build when the stamp matches. Built
@@ -167,6 +182,12 @@ jobs:
- name: Android checks and artifacts
run: bash scripts/build-android.sh all
- if: steps.target.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
with:
path: target
key: ${{ steps.target.outputs.cache-primary-key }}
signed-release:
name: Signed release
if: startsWith(github.ref, 'refs/tags/v') || inputs.signed_release
@@ -209,19 +230,34 @@ jobs:
"build-tools;${ANDROID_BUILD_TOOLS}"
"ndk;${ANDROID_NDK}"
- uses: dtolnay/rust-toolchain@stable
- id: toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: '1.96'
targets: aarch64-linux-android,x86_64-linux-android
# One cache for both jobs: the signed build compiles the same crates for
# the same targets. Saved on failure too, so a run that breaks late does
# not throw away everything it compiled before the break.
# The registry and the installed helpers only. rust-cache reads
# `target/android/<triple>` as a profile directory and deletes the
# `release` tree under it before saving, so with its target caching on
# every run rebuilt both ABIs from scratch.
- uses: Swatinem/rust-cache@v2.9.1
with:
shared-key: android
shared-key: android-registry
cache-targets: false
cache-on-failure: true
# The target directory as is: the host build the UniFFI bindings come
# from and both cargo-ndk ABIs. One entry for both jobs, since the signed
# build compiles the same crates for the same targets. A new lockfile
# starts from the last one's tree, so a version bump rebuilds only the
# workspace crates.
- id: target
uses: actions/cache/restore@v6
with:
path: target
key: rust-target-android-${{ steps.toolchain.outputs.cachekey }}-${{ hashFiles('Cargo.lock') }}
restore-keys: rust-target-android-${{ steps.toolchain.outputs.cachekey }}-
# libmpv and its dependency tree are the slowest part of the build, and
# depend only on the pinned revision and the build script, which stamps
# its output with both and skips the build when the stamp matches. Built
@@ -266,6 +302,12 @@ jobs:
- name: Build signed APK and AAB
run: bash scripts/build-android.sh signed
- if: steps.target.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
with:
path: target
key: ${{ steps.target.outputs.cache-primary-key }}
# The APK bundles a GPL libmpv, so its corresponding source ships next to
# it. The AAB is for Play only and stays out of the GitHub release.
- name: Stage release files
Generated
+7 -7
View File
@@ -4359,7 +4359,7 @@ dependencies = [
[[package]]
name = "pstr-android"
version = "0.5.1"
version = "1.1.1"
dependencies = [
"android_logger",
"jni 0.21.1",
@@ -4378,7 +4378,7 @@ dependencies = [
[[package]]
name = "pstr-app"
version = "0.5.1"
version = "1.1.1"
dependencies = [
"anyhow",
"chrono",
@@ -4409,7 +4409,7 @@ dependencies = [
[[package]]
name = "pstr-cli"
version = "0.5.1"
version = "1.1.1"
dependencies = [
"anyhow",
"clap",
@@ -4427,7 +4427,7 @@ dependencies = [
[[package]]
name = "pstr-core"
version = "0.5.1"
version = "1.1.1"
dependencies = [
"directories",
"futures",
@@ -4445,7 +4445,7 @@ dependencies = [
[[package]]
name = "pstr-meta"
version = "0.5.1"
version = "1.1.1"
dependencies = [
"pstr-core",
"reqwest",
@@ -4458,7 +4458,7 @@ dependencies = [
[[package]]
name = "pstr-player"
version = "0.5.1"
version = "1.1.1"
dependencies = [
"async-trait",
"libloading",
@@ -4473,7 +4473,7 @@ dependencies = [
[[package]]
name = "pstr-stream"
version = "0.5.1"
version = "1.1.1"
dependencies = [
"async-trait",
"futures",
+1 -1
View File
@@ -11,7 +11,7 @@ members = [
]
[workspace.package]
version = "0.5.1"
version = "1.1.1"
edition = "2024"
license = "MIT"
repository = "https://github.com/narrrl/proton-stream"
+14 -9
View File
@@ -10,16 +10,21 @@ gained about twenty features on 2026-10-02 that Android does not have.
## Status (2026-10-03)
Done: section 0, every parity item, the Library, Title, Shares and Settings
redesigns, Inter, and screenshot tests. Not yet verified on a device; the
host gate (ktlint, lint, unit and screenshot tests) and CI's build job pass.
Done: section 0, every parity item, and every recommendation in section 2 —
the Library, Title, Shares, Settings and Downloads redesigns, per-page app
bars drawn edge to edge, search behind an icon, skeleton loading and
generated placeholder art, Settings sub-pages and a language picker, a
long-press Change match, Inter, light and tablet screenshots, the library
and a title side by side from 840 dp, a poster-to-backdrop shared element
transition, and predictive back on the title page.
Open, from section 2: per-screen app bars and edge-to-edge under the status
bar, skeleton loading and generated placeholder art, a search icon that
expands, a language picker, Settings sub-pages, the Downloads page, light
theme and tablet screenshots, `ListDetailPaneScaffold` on tablets, shared
element transitions and predictive back, and a long-press Change match (the
title page has it).
Two departures from the text below. The tablet layout is a two-pane `Row`
rather than `ListDetailPaneScaffold`, which would have added the
`adaptive-layout` artifact for one split. Pull to refresh shows the
indicator while a crawl runs, but not the crawl's progress.
Not yet verified on a device; the host gate (ktlint, unit and screenshot
tests) passes, and Android lint runs in CI.
## 0. CI (done)
+1 -1
View File
@@ -72,7 +72,7 @@ against the SDK's own range download.
| Transcoding | None. mpv demuxes and decodes natively. |
| 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. |
| 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. |
## Building
+1 -1
View File
@@ -7,7 +7,7 @@ distributions.
| Component | License |
|---|---|
| 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 |
| Kotlin | Apache-2.0 |
| JNA | Apache-2.0 or LGPL-2.1-or-later |
+5 -9
View File
@@ -47,7 +47,7 @@ android {
namespace = "io.narl.protonstream"
compileSdk = 36
buildToolsVersion = "37.0.0"
ndkVersion = "29.0.14206865"
ndkVersion = "30.0.16248370"
defaultConfig {
applicationId = "io.narl.protonstream"
@@ -180,20 +180,16 @@ val uniffiSourceDir = layout.buildDirectory.dir("generated/source/uniffi")
val hostRustLibrary = repositoryRoot.resolve(
"target/debug/${System.mapLibraryName("pstr_android")}",
)
val stagedMpvDir = layout.buildDirectory.dir("generated/mpv")
val buildLibmpvAndroid by tasks.registering(Exec::class) {
group = "native"
description = "Builds the pinned GPL libmpv and stages both Android ABIs."
workingDir(repositoryRoot)
commandLine("bash", repositoryRoot.resolve("scripts/build-libmpv-android.sh").absolutePath)
inputs.file(repositoryRoot.resolve("scripts/build-libmpv-android.sh"))
outputs.files(
stagedMpvDir.map { it.file("jniLibs/arm64-v8a/libmpv.so") },
stagedMpvDir.map { it.file("jniLibs/x86_64/libmpv.so") },
stagedMpvDir.map { it.file("include/mpv/client.h") },
stagedMpvDir.map { it.file("REVISION") },
)
// No declared outputs: the script's STAMP is the up-to-date check. Gradle
// deletes the declared outputs of a task it has no history for, which on a
// fresh CI checkout threw away the tree just restored from the cache and
// rebuilt libmpv from source on every run.
}
val buildRustHost by tasks.registering(Exec::class) {
+2
View File
@@ -11,6 +11,8 @@
android:name=".ProtonStreamApplication"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true"
tools:targetApi="33"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
@@ -6,7 +6,7 @@ complete GNU GPL version 3 text is packaged beside this file as `GPL-3.0.txt`.
| Component | License |
|---|---|
| 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 |
| Kotlin | Apache-2.0 |
| JNA | Apache-2.0 or LGPL-2.1-or-later |
@@ -302,7 +302,7 @@ fun PlayerScreen(
NowPlaying(
media = key,
title = episode.label,
show = title.canonicalName ?: title.name,
show = title.displayName,
detail = episode.detail,
artworkUrl = title.posterUrl ?: title.backdropUrl,
artworkFile = episode.thumbnailSource,
@@ -425,7 +425,7 @@ fun PlayerScreen(
style = MaterialTheme.typography.titleMedium,
)
Text(
listOfNotNull(title.canonicalName ?: title.name, episode.detail.takeIf(String::isNotBlank))
listOfNotNull(title.displayName, episode.detail.takeIf(String::isNotBlank))
.joinToString(" • "),
color = Color.White.copy(alpha = 0.7f),
maxLines = 1,
@@ -954,6 +954,7 @@ private fun EpisodeChooser(
episode.label,
Modifier.fillMaxSize(),
fallback = episode.thumbnailSource,
labelled = false,
)
episode.progress?.takeIf { !episode.watched && !current }?.let { progress ->
AccentProgress(
@@ -1048,6 +1049,7 @@ private fun UpNextCard(
upNext.label,
Modifier.fillMaxSize(),
fallback = upNext.thumbnailSource,
labelled = false,
)
Box(Modifier.size(40.dp).clip(CircleShape).background(Color.Black.copy(alpha = 0.5f)))
CircularProgressIndicator(
@@ -25,6 +25,8 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.withContext
import io.narl.protonstream.native.NativeRuntime
import uniffi.pstr_android.ArrangementRecord
import uniffi.pstr_android.LibrarySort
import uniffi.pstr_android.ShareRecord
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.OfflineRecord
@@ -40,6 +42,14 @@ data class AppUiState(
val refreshing: Boolean = false,
val query: String = "",
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 offline: List<OfflineRecord> = emptyList(),
val metadataSettings: MetadataSettingsRecord = MetadataSettingsRecord(
@@ -86,6 +96,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) {
mutableState.update { it.copy(query = query) }
searchQuery.value = query
@@ -205,7 +221,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
val episodes = title.seasons.flatMap { it.episodes }
changeWatch(
episodes,
"${title.canonicalName ?: title.name} marked ${if (watched) "watched" else "unwatched"}",
"${title.displayName} marked ${if (watched) "watched" else "unwatched"}",
) { engine, episode, before ->
val duration = before?.durationSecs
engine.saveWatchState(
@@ -225,7 +241,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
fun forgetPosition(title: TitleRecord, episode: EpisodeRecord) {
changeWatch(
listOf(episode),
"${title.canonicalName ?: title.name} removed from Continue watching",
"${title.displayName} removed from Continue watching",
) { engine, target, before ->
engine.saveWatchState(target.shareId, target.linkId, 0.0, before?.durationSecs, false)
}
@@ -238,7 +254,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
fun removeFromHistory(title: TitleRecord, episode: EpisodeRecord) {
changeWatch(
listOf(episode),
"${episode.label} of ${title.canonicalName ?: title.name} removed from history",
"${episode.label} of ${title.displayName} removed from history",
) { engine, target, before ->
engine.saveWatchState(target.shareId, target.linkId, 0.0, before?.durationSecs, false)
}
@@ -444,9 +460,12 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
// and cached: a full reload is the one place that has
// already paid for a walk of the offline files.
engine.pruneOfflineFiles()
val current = mutableState.value
val query = current.query.takeIf(String::isNotBlank)
Reloaded(
engine.shares(),
engine.library(mutableState.value.query.takeIf(String::isNotBlank)),
engine.library(query),
engine.arrangement(query, current.sort, current.grouped),
engine.offlineFiles(),
engine.metadataSettings(),
engine.storageUsage(),
@@ -457,6 +476,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
loading = false,
shares = result.shares,
titles = result.titles,
arrangement = result.arrangement,
offline = result.offline,
metadataSettings = result.metadataSettings,
storage = result.storage,
@@ -469,10 +489,16 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
}
private suspend fun reloadLibrary(query: String) {
val current = mutableState.value
runCatching {
withContext(Dispatchers.IO) { NativeRuntime.engine().library(query.takeIf(String::isNotBlank)) }
}.onSuccess { titles -> mutableState.update { it.copy(titles = titles) } }
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
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) } }
}
class Factory(private val context: Context, private val workManager: WorkManager) : ViewModelProvider.Factory {
@@ -484,6 +510,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
private data class Reloaded(
val shares: List<ShareRecord>,
val titles: List<TitleRecord>,
val arrangement: ArrangementRecord,
val offline: List<OfflineRecord>,
val metadataSettings: MetadataSettingsRecord,
val storage: StorageUsageRecord,
@@ -1,15 +1,96 @@
package io.narl.protonstream.ui
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import uniffi.pstr_android.EpisodeRecord
/**
* A tab's page: its own name in an app bar, over whatever the page holds.
*
* One bar for the whole app said "proton-stream" on every page, which is a band
* of height spent naming the app the viewer is already in. The bar draws under
* the status bar itself, which is why the shell hands pages no top padding;
* [content] gets the rest of [padding] (the navigation bar and the mini
* transport) to apply as it scrolls.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun TabPage(
title: String,
padding: PaddingValues,
navigationIcon: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
content: @Composable (PaddingValues) -> Unit,
) {
// Pinned: the bar stays, and takes the container tint once the page has
// scrolled under it, so the edge between the two is still visible.
val scroll = TopAppBarDefaults.pinnedScrollBehavior()
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
TopAppBar(
title = { Text(title) },
navigationIcon = navigationIcon,
actions = actions,
colors = pageBarColors(),
scrollBehavior = scroll,
)
Box(Modifier.weight(1f)) { content(padding) }
}
}
/**
* The page's own background until something scrolls under the bar, then the
* container tint. A bar in `surface` from the start read as a band across the
* top of a page drawn in `background`.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun pageBarColors(): TopAppBarColors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
)
/**
* The shared-element scopes the library-to-title switch runs in, or null where
* there is none — the two-pane layout, a screenshot, a preview. Locals rather
* than parameters, because the poster that animates is three calls deep in the
* grid and nothing between needs to know.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
internal val LocalSharedTransition = staticCompositionLocalOf<SharedTransitionScope?> { null }
internal val LocalPaneVisibility = staticCompositionLocalOf<AnimatedVisibilityScope?> { null }
/**
* A title's art as one element across the switch: the poster tapped in the
* grid grows into the backdrop of the page it opens, and shrinks back on the
* way out, so the page is visibly *that* title's rather than a new screen.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
internal fun Modifier.sharedArt(key: String): Modifier {
val transition = LocalSharedTransition.current ?: return this
val visibility = LocalPaneVisibility.current ?: return this
return with(transition) {
this@sharedArt.sharedBounds(rememberSharedContentState("art/$key"), visibility)
}
}
@Composable
internal fun EmptyState(title: String, body: String) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -38,3 +119,21 @@ internal fun formatBytes(bytes: ULong): String {
else -> "$bytes bytes"
}
}
/**
* What an episode row is headed with: "1. Mother and Children".
*
* The provider's name for the episode, else the name the filename gave it,
* else "Episode 1". The bare filename only when none of those exist — a column
* of `[Group] Show - S01E01.mkv` is forty rows that differ in two characters.
*/
internal fun EpisodeRecord.heading(position: Int): String {
val named = providerName ?: detail.takeIf { it != name }
val number = number?.toInt()
return when {
named != null && number != null -> "$number. $named"
named != null -> named
number != null -> "Episode $number"
else -> name.substringBeforeLast('.').ifBlank { "Episode ${position + 1}" }
}
}
@@ -1,47 +1,59 @@
package io.narl.protonstream.ui
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.Spacer
import androidx.compose.foundation.layout.aspectRatio
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.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.Card
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.AlertDialog
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.Text
import androidx.compose.material3.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.livedata.observeAsState
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.work.WorkManager
import io.narl.protonstream.download.DownloadCoordinator
import io.narl.protonstream.download.DownloadStateStore
import io.narl.protonstream.download.RetainedDownload
import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.AccentProgress
import io.narl.protonstream.ui.theme.QuietButton
import io.narl.protonstream.ui.theme.TonalButton
import uniffi.pstr_android.OfflineRecord
import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.TitleRecord
/** One show's saved episodes, and the show itself where the library has it. */
private data class OfflineGroup(val name: String, val title: TitleRecord?, val files: List<OfflineRecord>)
@Composable
internal fun DownloadsScreen(
state: AppUiState,
onRemove: (uniffi.pstr_android.OfflineRecord) -> Unit,
onRemove: (OfflineRecord) -> Unit,
onPause: (RetainedDownload) -> Unit,
onResume: (RetainedDownload) -> Unit,
onDeletePartial: (RetainedDownload) -> Unit,
@@ -57,83 +69,175 @@ internal fun DownloadsScreen(
// but not its show, and the library is the only thing that does.
val groups = remember(state.offline, state.titles) {
val shows = state.titles.flatMap { title ->
title.seasons.flatMap(SeasonRecord::episodes).map {
"${it.shareId}/${it.linkId}" to (title.canonicalName ?: title.name)
}
title.seasons.flatMap(SeasonRecord::episodes).map { "${it.shareId}/${it.linkId}" to title }
}.toMap()
state.offline
.groupBy { shows["${it.shareId}/${it.linkId}"] ?: "Not in the library" }
.toSortedMap()
.groupBy { shows["${it.shareId}/${it.linkId}"] }
.map { (title, files) ->
OfflineGroup(title?.displayName ?: "Not in the library", title, files)
}
.sortedBy { it.name }
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp),
contentPadding = PaddingValues(bottom = 16.dp),
) {
item {
Text("Offline downloads", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.height(16.dp))
}
// Deleting a saved episode asks first: it is the viewer's own choice to
// keep it, and getting it back is a download, not a tap.
var deleting by remember { mutableStateOf<OfflineRecord?>(null) }
TabPage("Downloads", padding) { body ->
if (retained.isEmpty() && state.offline.isEmpty()) {
item { EmptyState("No downloads", "Episodes, seasons, and shows saved offline appear here.") }
Box(Modifier.fillMaxSize().padding(body)) {
EmptyState("No downloads", "Episodes, seasons and shows saved offline appear here.")
}
return@TabPage
}
// Under the show they belong to, as on desktop: a saved season is
// fourteen rows, and fourteen rows of "Episode 3" name nothing.
groups.forEach { (show, files) ->
item(key = "group/$show") {
Text(show, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 4.dp))
LazyColumn(
modifier = Modifier.fillMaxSize().padding(body),
contentPadding = PaddingValues(bottom = 24.dp),
) {
if (state.offline.isNotEmpty()) {
item(key = "summary") {
Text(
"${state.offline.size} ${if (state.offline.size == 1) "episode" else "episodes"} · " +
"${formatBytes(state.offline.sumOf { it.size })} on this device",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
}
// What is still arriving comes first: it is the part of the page
// that changes, and the part with something to do.
if (retained.isNotEmpty()) {
item(key = "in-progress") { SectionHeading("In progress") }
items(retained, key = { "partial/${it.shareId}/${it.linkId}" }) { download ->
PartialRow(download, onPause, onResume, onDeletePartial)
}
}
// Under the show they belong to, as on desktop: a saved season is
// fourteen rows, and fourteen rows of "Episode 3" name nothing.
groups.forEach { group ->
item(key = "group/${group.name}") {
SectionHeading(
group.name,
"${group.files.size} ${if (group.files.size == 1) "episode" else "episodes"} · " +
formatBytes(group.files.sumOf { it.size }),
)
}
items(group.files, key = { "${it.shareId}/${it.linkId}" }) { file ->
SavedRow(file, group.title, onDelete = { deleting = file })
}
}
}
}
deleting?.let { file ->
AlertDialog(
onDismissRequest = { deleting = null },
title = { Text("Delete this download?") },
text = {
Text(
"${files.size} ${if (files.size == 1) "episode" else "episodes"} · " +
formatBytes(files.sumOf { it.size }),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 8.dp),
"${file.episode?.label ?: "The episode"} (${formatBytes(file.size)}) leaves this device. " +
"It still streams, and can be downloaded again.",
)
}
items(files, key = { "${it.shareId}/${it.linkId}" }) { file ->
val episode = file.episode
Card(Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(episode?.label ?: file.linkId, fontWeight = FontWeight.SemiBold)
Text(formatBytes(file.size))
}
TonalButton(onClick = { onRemove(file) }) {
Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Delete")
}
}
}
}
}
items(retained, key = { "${it.shareId}/${it.linkId}" }) { download ->
val progress = if (download.total > 0L) download.downloaded.toFloat() / download.total else 0f
Card(Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
Column(Modifier.padding(16.dp)) {
Text(download.label, fontWeight = FontWeight.SemiBold)
Text(download.status.replaceFirstChar { it.uppercase() })
if (download.total > 0L) {
AccentProgress(
progress = { progress },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
Text("${formatBytes(download.downloaded.toULong())} of ${formatBytes(download.total.toULong())}", style = MaterialTheme.typography.bodySmall)
}
download.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(top = 8.dp),
) {
if (download.status == RetainedDownload.STATUS_RUNNING ||
download.status == RetainedDownload.STATUS_QUEUED
) {
TonalButton(onClick = { onPause(download) }) { Text("Pause") }
} else {
TonalButton(onClick = { onResume(download) }) { Text("Resume") }
}
QuietButton(onClick = { onDeletePartial(download) }) { Text("Delete partial") }
}
}
}
},
confirmButton = {
AccentButton(onClick = {
deleting = null
onRemove(file)
}) { Text("Delete") }
},
dismissButton = { TonalButton(onClick = { deleting = null }) { Text("Cancel") } },
)
}
}
@Composable
private fun SectionHeading(title: String, caption: String? = null) {
Column(Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 4.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
caption?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
/** A saved episode: its still, what it is, its size, and a way to remove it. */
@Composable
private fun SavedRow(file: OfflineRecord, title: TitleRecord?, onDelete: () -> Unit) {
val episode = file.episode
ListItem(
leadingContent = {
RemoteArtwork(
episode?.stillUrl ?: title?.backdropUrl,
title?.name ?: file.linkId,
Modifier.width(96.dp).aspectRatio(16f / 9f).clip(MaterialTheme.shapes.small),
fallback = episode?.thumbnailSource,
labelled = episode == null,
)
},
headlineContent = {
Text(episode?.heading(0) ?: file.linkId, maxLines = 2, overflow = TextOverflow.Ellipsis)
},
supportingContent = {
Text(listOfNotNull(episode?.label, formatBytes(file.size)).joinToString(" · "))
},
trailingContent = {
IconButton(onClick = onDelete) {
Icon(Icons.Outlined.Delete, contentDescription = "Delete download")
}
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
/**
* A download not yet finished: how far it has got, why it stopped if it did,
* and pause or resume beside cancel.
*/
@Composable
private fun PartialRow(
download: RetainedDownload,
onPause: (RetainedDownload) -> Unit,
onResume: (RetainedDownload) -> Unit,
onDeletePartial: (RetainedDownload) -> Unit,
) {
val active = download.status == RetainedDownload.STATUS_RUNNING ||
download.status == RetainedDownload.STATUS_QUEUED
ListItem(
headlineContent = { Text(download.label, maxLines = 2, overflow = TextOverflow.Ellipsis) },
supportingContent = {
Column {
Text(
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) {
AccentProgress(
progress = { (download.downloaded.toFloat() / download.total).coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
}
download.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
},
trailingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
if (active) {
IconButton(onClick = { onPause(download) }) {
Icon(Icons.Default.Pause, contentDescription = "Pause download")
}
} else {
IconButton(onClick = { onResume(download) }) {
Icon(Icons.Default.PlayArrow, contentDescription = "Resume download")
}
}
// What has arrived so far is discarded; nothing finished is.
IconButton(onClick = { onDeletePartial(download) }) {
Icon(Icons.Default.Close, contentDescription = "Cancel and delete what has arrived")
}
}
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
@@ -80,28 +80,32 @@ internal fun HistoryScreen(
history(state.titles).groupBy { Instant.ofEpochSecond(it.episode.lastPlayed).atZone(zone).toLocalDate() }
}
if (days.isEmpty()) {
Box(Modifier.fillMaxSize().padding(padding)) {
EmptyState("Nothing watched yet", "Episodes you play show up here, newest first.")
TabPage("History", padding) { body ->
Box(Modifier.fillMaxSize().padding(body)) {
EmptyState("Nothing watched yet", "Episodes you play show up here, newest first.")
}
}
return
}
LazyColumn(Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(bottom = 24.dp)) {
days.forEach { (day, played) ->
item(key = "day/$day") {
Text(
dayHeading(day, today),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 4.dp),
)
}
items(played, key = { "${it.episode.shareId}/${it.episode.linkId}" }) { entry ->
HistoryRow(
entry,
onPlay = { onPlay(entry.title, entry.index) },
onTitle = { onTitle(entry.title) },
onRemove = { onRemove(entry.title, entry.episode) },
)
TabPage("History", padding) { body ->
LazyColumn(Modifier.fillMaxSize().padding(body), contentPadding = PaddingValues(bottom = 24.dp)) {
days.forEach { (day, played) ->
item(key = "day/$day") {
Text(
dayHeading(day, today),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 4.dp),
)
}
items(played, key = { "${it.episode.shareId}/${it.episode.linkId}" }) { entry ->
HistoryRow(
entry,
onPlay = { onPlay(entry.title, entry.index) },
onTitle = { onTitle(entry.title) },
onRemove = { onRemove(entry.title, entry.episode) },
)
}
}
}
}
@@ -130,6 +134,7 @@ private fun HistoryRow(entry: Played, onPlay: () -> Unit, onTitle: () -> Unit, o
episode.label,
Modifier.fillMaxSize(),
fallback = episode.thumbnailSource,
labelled = false,
)
episode.progress?.takeIf { !episode.watched }?.let { progress ->
AccentProgress(
@@ -140,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)) {
Text(
entry.title.canonicalName ?: entry.title.name,
entry.title.displayName,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -0,0 +1,183 @@
package io.narl.protonstream.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.TonalButton
/**
* The languages offered by name, as the three-letter tag a Matroska file
* carries.
*
* The same short list the desktop names tracks from (`pstr-player`'s
* `language_name`): what the shares this app is pointed at actually contain.
* mpv treats the two- and three-letter forms of a language as one, so the tag
* chosen here also matches a file that says "ja". Anything else is still one
* typed tag away.
*/
internal val LANGUAGES: List<Pair<String, String>> = listOf(
"jpn" to "Japanese",
"eng" to "English",
"ger" to "German",
"fre" to "French",
"spa" to "Spanish",
"ita" to "Italian",
"por" to "Portuguese",
"dut" to "Dutch",
"rus" to "Russian",
"pol" to "Polish",
"swe" to "Swedish",
"dan" to "Danish",
"nor" to "Norwegian",
"fin" to "Finnish",
"cze" to "Czech",
"hun" to "Hungarian",
"tur" to "Turkish",
"ara" to "Arabic",
"heb" to "Hebrew",
"hin" to "Hindi",
"kor" to "Korean",
"chi" to "Chinese",
"tha" to "Thai",
"vie" to "Vietnamese",
"ukr" to "Ukrainian",
"rum" to "Romanian",
"gre" to "Greek",
"ind" to "Indonesian",
"may" to "Malay",
)
/**
* "Japanese", for a tag in any of the forms a file or the desktop may have
* stored — "jpn", "ja", "JA-jp" — and the tag itself for one not on the list.
* Null is no preference.
*/
internal fun languageLabel(tag: String?): String {
val value = tag?.trim()?.takeIf(String::isNotEmpty) ?: return "No preference"
return languageOf(value)?.second ?: value
}
/** The listed language a tag names, matching either ISO 639 form. */
private fun languageOf(tag: String): Pair<String, String>? {
val base = tag.split('-', '_').first().lowercase()
return LANGUAGES.firstOrNull { (code, _) -> code == base || TWO_LETTER[code] == base || terminology(code) == base }
}
/** ISO 639-2/T, for the handful whose bibliographic code differs. */
private fun terminology(code: String): String? = when (code) {
"ger" -> "deu"
"fre" -> "fra"
"dut" -> "nld"
"cze" -> "ces"
"chi" -> "zho"
"rum" -> "ron"
"gre" -> "ell"
"may" -> "msa"
else -> null
}
/** ISO 639-1, the form a file or the desktop may also have stored. */
private val TWO_LETTER = mapOf(
"jpn" to "ja", "eng" to "en", "ger" to "de", "fre" to "fr", "spa" to "es", "ita" to "it",
"por" to "pt", "dut" to "nl", "rus" to "ru", "pol" to "pl", "swe" to "sv", "dan" to "da",
"nor" to "no", "fin" to "fi", "cze" to "cs", "hun" to "hu", "tur" to "tr", "ara" to "ar",
"heb" to "he", "hin" to "hi", "kor" to "ko", "chi" to "zh", "tha" to "th", "vie" to "vi",
"ukr" to "uk", "rum" to "ro", "gre" to "el", "ind" to "id", "may" to "ms",
)
/**
* A language preference as a row, opening a list to choose from.
*
* It was a free-text field asking for "jpn or eng", which is a question only
* someone who has read a Matroska header can answer.
*/
@Composable
internal fun LanguageSetting(headline: String, value: String?, onChange: (String?) -> Unit) {
var choosing by remember { mutableStateOf(false) }
ListItem(
headlineContent = { Text(headline) },
supportingContent = { Text(languageLabel(value)) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.clickable(role = Role.Button) { choosing = true },
)
if (choosing) {
LanguageDialog(
headline,
value,
onDismiss = { choosing = false },
onChoose = {
choosing = false
onChange(it)
},
)
}
}
@Composable
private fun LanguageDialog(headline: String, value: String?, onDismiss: () -> Unit, onChoose: (String?) -> Unit) {
val current = value?.let(::languageOf)?.first ?: value
// A tag that is not on the list starts in the field, so it is shown and
// can be edited rather than silently replaced.
var typed by remember { mutableStateOf(current?.takeIf { tag -> LANGUAGES.none { it.first == tag } }.orEmpty()) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(headline) },
text = {
Column {
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 360.dp)) {
item(key = "none") { LanguageOption("No preference", current == null) { onChoose(null) } }
items(LANGUAGES, key = { it.first }) { (code, name) ->
LanguageOption(name, current == code) { onChoose(code) }
}
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
OutlinedTextField(
value = typed,
onValueChange = { typed = it },
label = { Text("Another tag") },
supportingText = { Text("As it appears in the file, such as \"tgl\"") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
AccentButton(enabled = typed.isNotBlank(), onClick = { onChoose(typed.trim().lowercase()) }) {
Text("Use tag")
}
},
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@Composable
private fun LanguageOption(name: String, selected: Boolean, onClick: () -> Unit) {
ListItem(
headlineContent = { Text(name, style = MaterialTheme.typography.bodyLarge) },
leadingContent = { RadioButton(selected = selected, onClick = null) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.clickable(role = Role.RadioButton, onClick = onClick),
)
}
@@ -1,6 +1,19 @@
package io.narl.protonstream.ui
import androidx.compose.foundation.background
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.ui.graphics.Brush
@@ -10,6 +23,7 @@ import java.time.LocalDate
import androidx.compose.foundation.combinedClickable
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.RemoveCircleOutline
@@ -44,7 +58,6 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -63,7 +76,13 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
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.LibrarySort
import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType
@@ -79,6 +98,12 @@ private const val CONTINUE_WATCHING_MAX = 12
/** 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)
/** 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)
@Composable
internal fun LibraryScreen(
@@ -90,11 +115,15 @@ internal fun LibraryScreen(
onSetTitleWatched: (TitleRecord, Boolean) -> Unit,
onForgetPosition: (TitleRecord, EpisodeRecord) -> Unit,
padding: PaddingValues,
onMatchChanged: () -> Unit = {},
onError: (Throwable) -> Unit = {},
onArrange: (LibrarySort, Boolean) -> Unit = { _, _ -> },
) {
val context = LocalContext.current
// What a long press opened a menu for: a title, or a Continue watching card.
var menuFor by remember { mutableStateOf<TitleRecord?>(null) }
var menuResume by remember { mutableStateOf<Resumable?>(null) }
var matching by remember { mutableStateOf<TitleRecord?>(null) }
// Most recently played first, one episode per show: a shelf that lists four
// episodes of the same series is a shelf with room for nothing else.
val resumable = remember(state.titles) {
@@ -106,41 +135,50 @@ internal fun LibraryScreen(
?.let { Resumable(title, it.value, it.index) }
}.sortedByDescending { it.episode.lastPlayed }.take(CONTINUE_WATCHING_MAX)
}
Column(Modifier.fillMaxSize().padding(padding)) {
// A filled pill rather than an outlined form field: search is how the
// page is navigated, not a value being entered.
TextField(
value = state.query,
onValueChange = onSearch,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
singleLine = true,
shape = CircleShape,
placeholder = { Text("Search library") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
trailingIcon = {
if (state.query.isNotEmpty()) {
IconButton(onClick = { onSearch("") }) {
Icon(Icons.Default.Close, contentDescription = "Clear search")
// Search is an icon until it is wanted: a permanent field above the
// library spent a band of every visit on something used in few of them.
var searching by rememberSaveable { mutableStateOf(state.query.isNotEmpty()) }
val closeSearch = {
searching = false
onSearch("")
}
BackHandler(enabled = searching, onBack = closeSearch)
// The large title folds into the bar as the grid scrolls, and comes back
// only at the top: the page says what it is on arrival, then gets out of
// the way.
val scroll = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
if (searching) {
SearchBar(state.query, onSearch, closeSearch)
} else {
LargeTopAppBar(
title = { Text("Library") },
actions = {
IconButton(onClick = { searching = true }) {
Icon(Icons.Default.Search, contentDescription = "Search library")
}
}
},
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
),
)
// Pull to refresh, the gesture every list on a phone answers to; the
// icon in the bar stays for a viewer who does not know it.
ArrangeMenu(state.sort, state.grouped, onArrange)
// Pull to refresh is the gesture; the icon stays for a
// viewer who does not know it.
IconButton(onClick = onRefresh, enabled = !state.refreshing) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
},
colors = pageBarColors(),
scrollBehavior = scroll,
)
}
PullToRefreshBox(
isRefreshing = state.refreshing,
onRefresh = onRefresh,
modifier = Modifier.fillMaxSize(),
modifier = Modifier.weight(1f).padding(padding),
) {
when {
state.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
state.loading -> LibrarySkeleton()
state.titles.isEmpty() && state.query.isNotBlank() -> EmptyState(
"Nothing matches",
"No title in the library is called “${state.query.trim()}”.",
)
state.titles.isEmpty() -> EmptyState(
"Your library is empty",
"Add a Proton Drive public link under Shares, then refresh.",
@@ -159,7 +197,7 @@ internal fun LibraryScreen(
menuFor?.let { title ->
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
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") {
onResume(title, nextUpIndex(playlist))
}
@@ -169,11 +207,25 @@ internal fun LibraryScreen(
if (allWatched) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle,
if (allWatched) "Mark unwatched" else "Mark watched",
) { onSetTitleWatched(title, !allWatched) }
// The same search the title page offers, for the tile that is
// plainly wrong — the wrong poster is what the grid shows.
MenuRow(Icons.Default.Edit, "Change match") { matching = title }
}
}
matching?.let { title ->
ChangeMatchDialog(
title = title,
onDismiss = { matching = null },
onChanged = {
matching = null
onMatchChanged()
},
onError = onError,
)
}
menuResume?.let { entry ->
TileMenu(
entry.title.canonicalName ?: entry.title.name,
entry.title.displayName,
entry.episode.label,
onDismiss = { menuResume = null },
) {
@@ -186,6 +238,100 @@ 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
* takes the keyboard as soon as it opens, since opening it is asking to type.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SearchBar(query: String, onSearch: (String) -> Unit, onClose: () -> Unit) {
val focus = remember { FocusRequester() }
LaunchedEffect(Unit) { focus.requestFocus() }
TopAppBar(
navigationIcon = {
IconButton(onClick = onClose) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close search")
}
},
title = {
// A filled pill rather than an outlined form field: search is how
// the page is navigated, not a value being entered.
TextField(
value = query,
onValueChange = onSearch,
modifier = Modifier.fillMaxWidth().padding(end = 8.dp).focusRequester(focus),
singleLine = true,
shape = CircleShape,
placeholder = { Text("Search library") },
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(onClick = { onSearch("") }) {
Icon(Icons.Default.Close, contentDescription = "Clear search")
}
}
},
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
),
)
},
colors = pageBarColors(),
)
}
/**
* A long press's menu, from the bottom of the screen where the thumb already
* is. Each row closes it before doing its thing.
@@ -239,6 +385,18 @@ private fun LibraryGrid(
onTitleMenu: (TitleRecord) -> 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
// two titles to a screen, which is a library read one row at a time.
LazyVerticalGrid(
@@ -281,16 +439,78 @@ private fun LibraryGrid(
}
}
}
item(key = "library-heading", span = { GridItemSpan(maxLineSpan) }) {
Text("Library", 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 ->
PosterTile(title, onClick = { onTitle(title) }, onLongClick = { onTitleMenu(title) })
items(tiles, key = { it.title.key }) { tile ->
PosterTile(
tile.title,
onClick = { onTitle(tile.title) },
onLongClick = { onTitleMenu(tile.title) },
folded = tile.folded,
)
}
}
}
/**
* The grid's shape before the catalog has answered: posters and their two
* lines of text as blocks, so the page does not jump when the titles land.
*/
@Composable
private fun LibrarySkeleton() {
LazyVerticalGrid(
columns = GridCells.Adaptive(108.dp),
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
userScrollEnabled = false,
) {
items(SKELETON_TILES) {
Column {
Skeleton(Modifier.fillMaxWidth().aspectRatio(2f / 3f).clip(MaterialTheme.shapes.medium))
Spacer(Modifier.height(8.dp))
Skeleton(Modifier.fillMaxWidth(0.8f).height(12.dp).clip(MaterialTheme.shapes.small))
Spacer(Modifier.height(6.dp))
Skeleton(Modifier.fillMaxWidth(0.5f).height(10.dp).clip(MaterialTheme.shapes.small))
}
}
}
}
/** 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. */
private const val SKELETON_TILES = 18
/**
* The title the banner shows: the one watched last, or with nothing part
* watched, one of the titles the provider has a backdrop for, turning over
@@ -310,9 +530,10 @@ private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int)
Box(Modifier.fillMaxWidth().aspectRatio(4f / 3f).clip(MaterialTheme.shapes.large)) {
RemoteArtwork(
title.backdropUrl ?: title.posterUrl,
title.canonicalName ?: title.name,
title.displayName,
Modifier.fillMaxSize(),
fallback = title.thumbnailSource,
labelled = false,
)
Box(
Modifier.fillMaxSize().background(
@@ -321,7 +542,7 @@ private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int)
)
Column(Modifier.align(Alignment.BottomStart).padding(16.dp)) {
Text(
title.canonicalName ?: title.name,
title.displayName,
style = MaterialTheme.typography.headlineMedium,
color = Color.White,
maxLines = 2,
@@ -369,7 +590,7 @@ private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: ()
Box(Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(MaterialTheme.shapes.medium)) {
RemoteArtwork(
entry.episode.stillUrl ?: entry.title.backdropUrl,
entry.title.canonicalName ?: entry.title.name,
entry.title.displayName,
Modifier.fillMaxSize(),
fallback = entry.episode.thumbnailSource,
)
@@ -381,7 +602,7 @@ private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: ()
}
}
Text(
entry.title.canonicalName ?: entry.title.name,
entry.title.displayName,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -397,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
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(
Modifier
modifier
.clip(MaterialTheme.shapes.medium)
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
) {
RemoteArtwork(
title.posterUrl ?: title.backdropUrl,
title.canonicalName ?: title.name,
Modifier.fillMaxWidth().aspectRatio(2f / 3f).clip(MaterialTheme.shapes.medium),
fallback = title.thumbnailSource,
)
Box {
RemoteArtwork(
title.posterUrl ?: title.backdropUrl,
title.displayName,
(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))
Text(
title.canonicalName ?: title.name,
title.displayName,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
@@ -2,12 +2,16 @@ package io.narl.protonstream.ui
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.material3.VerticalDivider
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -17,16 +21,18 @@ import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.plus
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material.icons.Icons
@@ -35,7 +41,6 @@ import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
@@ -68,6 +73,9 @@ import io.narl.protonstream.playback.LibmpvHost
import io.narl.protonstream.playback.NativeMpvHost
import io.narl.protonstream.playback.PlayerScreen
/** From this width the library and an open title share the window. */
private val TWO_PANE_WIDTH = 840.dp
/** How much room the floating mini transport needs at the foot of a page. */
private val MINI_TRANSPORT_INSET = 88.dp
@@ -86,7 +94,7 @@ private enum class Destination(val label: String, val icon: ImageVector) {
Settings("Settings", Icons.Default.Settings),
}
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
@Composable
fun ProtonStreamApp(
playerHost: LibmpvHost? = null,
@@ -162,23 +170,10 @@ fun ProtonStreamApp(
) {
Scaffold(
snackbarHost = { SnackbarHost(snackbars) },
topBar = {
TopAppBar(
title = { Text(selectedTitle?.name ?: "proton-stream") },
actions = {
if (destination == Destination.Library) {
if (state.refreshing) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
Spacer(Modifier.width(16.dp))
} else {
IconButton(onClick = model::refresh) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
}
}
},
)
},
// Each page draws its own bar under the status bar, and the title
// page draws its backdrop there, so the top inset is theirs.
contentWindowInsets = ScaffoldDefaults.contentWindowInsets
.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom),
) { padding ->
// Back from a secondary tab returns to the library rather than
// leaving the app; the library's own back is the system's, which is
@@ -200,36 +195,71 @@ fun ProtonStreamApp(
Box(Modifier.fillMaxSize()) {
AnimatedContent(destination, label = "primary navigation") { target ->
when (target) {
Destination.Library -> if (selectedTitle == null) {
LibraryScreen(
state,
model::search,
{ title, index ->
playingTitleKey = title.key
playingIndex = index
playerMinimized = false
},
{ selectedTitleKey = it.key },
model::refresh,
model::setTitleWatched,
model::forgetPosition,
body,
)
} else {
TitleScreen(
selectedTitle,
playerHost != null,
{ _, index ->
playingTitleKey = selectedTitle.key
playingIndex = index
playerMinimized = false
},
{ selectedTitleKey = null },
model::reportError,
model::reloadAfterMetadataChange,
model::setWatched,
body,
)
// 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.
Destination.Library -> BoxWithConstraints(Modifier.fillMaxSize()) {
val libraryPane = @Composable {
LibraryScreen(
state,
model::search,
{ title, index ->
playingTitleKey = title.key
playingIndex = index
playerMinimized = false
},
{ selectedTitleKey = it.key },
model::refresh,
model::setTitleWatched,
model::forgetPosition,
body,
onMatchChanged = model::reloadAfterMetadataChange,
onError = model::reportError,
onArrange = model::arrange,
)
}
val titlePane = @Composable { selected: TitleRecord ->
TitleScreen(
selected,
playerHost != null,
{ _, index ->
playingTitleKey = selected.key
playingIndex = index
playerMinimized = false
},
{ selectedTitleKey = null },
model::reportError,
model::reloadAfterMetadataChange,
model::setWatched,
body,
franchise = selected.franchise.mapNotNull { key ->
state.titles.firstOrNull { it.key == key }
},
onOpenTitle = { selectedTitleKey = it.key },
)
}
if (maxWidth >= TWO_PANE_WIDTH) {
Row(Modifier.fillMaxSize()) {
Box(Modifier.weight(0.45f)) { libraryPane() }
VerticalDivider()
Box(Modifier.weight(0.55f)) {
selectedTitle?.let { titlePane(it) } ?: Box(Modifier.padding(body)) {
EmptyState("Choose a title", "Its seasons and episodes open here.")
}
}
}
} else {
SharedTransitionLayout {
AnimatedContent(selectedTitle, contentKey = { it?.key }, label = "title") { open ->
CompositionLocalProvider(
LocalSharedTransition provides this@SharedTransitionLayout,
LocalPaneVisibility provides this,
) {
open?.let { titlePane(it) } ?: libraryPane()
}
}
}
}
}
Destination.Shares -> SharesScreen(
state.shares,
@@ -267,6 +297,7 @@ fun ProtonStreamApp(
model::clearBlockCache,
model::removeAllOffline,
body,
onNamesChanged = model::reloadAfterMetadataChange,
)
}
}
@@ -6,8 +6,13 @@ import android.graphics.BitmapFactory
import android.util.LruCache
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -16,12 +21,19 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import io.narl.protonstream.native.NativeRuntime
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -64,7 +76,9 @@ internal val EpisodeRecord.thumbnailSource: ThumbnailSource
*
* [fallback] is used only when there is no provider artwork or it fails to
* load, which — with metadata off — is the common case rather than the rare
* one.
* one. Until either answers, the box pulses as a skeleton; when neither has
* anything, it shows [ArtworkPlaceholder] rather than an empty card — without
* initials when [labelled] is false, for art with the name already over it.
*/
@Composable
internal fun RemoteArtwork(
@@ -72,19 +86,98 @@ internal fun RemoteArtwork(
description: String,
modifier: Modifier = Modifier,
fallback: ThumbnailSource? = null,
labelled: Boolean = true,
) {
val context = LocalContext.current
var image by remember(url, fallback) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(url, fallback) {
image = (loadArtwork(context, url) ?: loadThumbnail(fallback))?.asImageBitmap()
val enabled = LocalArtworkLoading.current
var image by remember(url, fallback) { mutableStateOf<ArtworkLoad>(ArtworkLoad.Pending) }
LaunchedEffect(url, fallback, enabled) {
if (!enabled) {
image = ArtworkLoad.Missing
return@LaunchedEffect
}
image = (loadArtwork(context, url) ?: loadThumbnail(fallback))
?.let { ArtworkLoad.Loaded(it.asImageBitmap()) }
?: ArtworkLoad.Missing
}
Box(modifier.background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center) {
image?.let {
Image(it, description, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
} ?: Text("▶", style = MaterialTheme.typography.displaySmall)
when (val loaded = image) {
ArtworkLoad.Pending -> Skeleton(modifier)
ArtworkLoad.Missing -> ArtworkPlaceholder(description, modifier, labelled)
is ArtworkLoad.Loaded -> Image(loaded.image, description, modifier, contentScale = ContentScale.Crop)
}
}
private sealed interface ArtworkLoad {
data object Pending : ArtworkLoad
data object Missing : ArtworkLoad
data class Loaded(val image: ImageBitmap) : ArtworkLoad
}
/**
* Whether [RemoteArtwork] fetches anything at all.
*
* Off in the screenshot tests: there is no bridge on the host, and whether a
* failed fetch lands before or after the capture would decide between a
* skeleton and a placeholder in the committed image.
*/
internal val LocalArtworkLoading = staticCompositionLocalOf { true }
/**
* Something being loaded: the surface tint, breathing.
*
* A spinner per tile is a grid of spinners; a shape where the poster will be
* says what is coming and where, and settles the layout before it arrives.
*/
@Composable
internal fun Skeleton(modifier: Modifier = Modifier) {
val pulse by rememberInfiniteTransition(label = "skeleton").animateFloat(
initialValue = 0.55f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse),
label = "skeleton alpha",
)
Box(modifier.graphicsLayer { alpha = pulse }.background(MaterialTheme.colorScheme.surfaceVariant))
}
/**
* Art for a title that has none: the accent as a gradient, and its initials.
*
* A black card with a play triangle on it said nothing about which title it
* was, so a grid of unmatched titles was a grid of identical cards. Each name
* sets where on the accent ramp its gradient starts, so neighbours differ
* while every one stays in the theme's colours.
*/
@Composable
internal fun ArtworkPlaceholder(name: String, modifier: Modifier = Modifier, labelled: Boolean = true) {
val scheme = MaterialTheme.colorScheme
val brush = remember(name, scheme.primary, scheme.secondary) {
val start = lerp(scheme.primary, scheme.secondary, (name.hashCode().mod(5)) / 4f)
Brush.linearGradient(listOf(start, lerp(start, Color.Black, 0.55f)))
}
BoxWithConstraints(modifier.background(brush), contentAlignment = Alignment.Center) {
if (!labelled) return@BoxWithConstraints
val size = with(LocalDensity.current) { (minOf(maxWidth, maxHeight) * 0.3f).toSp() }
Text(
initials(name),
color = Color.White.copy(alpha = 0.9f),
fontSize = size,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
)
}
}
/**
* "Oshi no Ko" is "OK", "Akira" is "A": the first letters of up to two
* capitalised words, so particles and articles in a romanised name do not
* take a slot. A name with no capitals uses its first two words.
*/
internal fun initials(name: String): String {
val words = name.split(' ', '-', '_', '.', ':').filter { word -> word.firstOrNull()?.isLetterOrDigit() == true }
val picked = words.filter { it.first().isUpperCase() || it.first().isDigit() }.ifEmpty { words }
return picked.take(2).joinToString("") { it.first().uppercase() }
}
/**
* The same HTTPS-only fetch behind the library grid, reusable off the composition.
*
@@ -1,6 +1,19 @@
package io.narl.protonstream.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ManageSearch
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.PlayCircle
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.foundation.border
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -58,6 +71,7 @@ import io.narl.protonstream.ui.theme.TonalButton
import uniffi.pstr_android.MetadataProvider
import uniffi.pstr_android.FlavorChoice
import uniffi.pstr_android.AppearanceRecord
import uniffi.pstr_android.TitleNamesChoice
import uniffi.pstr_android.AccentChoice
import uniffi.pstr_android.PlaybackPrefsRecord
import androidx.compose.material3.ListItem
@@ -77,6 +91,12 @@ internal fun SettingsScreen(
onClearCache: () -> Unit,
onRemoveAllOffline: () -> Unit,
padding: PaddingValues,
initialPage: SettingsPage? = null,
// A parameter so the screenshot tests can pin it: read from the package,
// every version bump changed the committed image of this page.
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 settings = remember { SettingsStore(context) }
@@ -103,156 +123,185 @@ internal fun SettingsScreen(
var confirmDelete by remember { mutableStateOf(false) }
var showMetadata by remember { mutableStateOf(false) }
var legalDocument by remember { mutableStateOf<LegalDocument?>(null) }
// Rows with one line of explanation each, in groups, rather than a toggle
// followed by a paragraph: the page read as a document to get through
// before reaching the switch that was wanted.
Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState())) {
SettingsGroup("Playback")
prefs?.let { current ->
SettingSwitch(
"Autoplay next episode",
"Starts the next episode when the credits begin",
current.autoplayNext,
) { on -> update { it.copy(autoplayNext = on) } }
// Off is the safer default: a mis-named chapter then costs a tap on
// Skip rather than a scene.
SettingSwitch(
"Skip openings and endings",
"Uses the release's chapters; off shows a Skip button instead",
current.autoSkip,
) { on -> update { it.copy(autoSkip = on) } }
SettingSwitch("Subtitles", "Shown by default where a file has them", current.subtitles) { on ->
update { it.copy(subtitles = on) }
}
// Language tags, not a picker: which languages exist is a property
// of each file, and a list built from one episode is wrong for the
// next. A show that has been given its own choice keeps it.
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
LanguageField("Audio language", current.audioLanguage, Modifier.weight(1f)) { tag ->
update { it.copy(audioLanguage = tag) }
}
LanguageField("Subtitle language", current.subtitleLanguage, Modifier.weight(1f)) { tag ->
update { it.copy(subtitleLanguage = tag) }
}
}
Text(
"Tags as they appear in the file, such as jpn or eng",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
// One row per area on the first page, each opening its own page: grouped
// on one page, the storage controls sat a long scroll below playback, and
// every setting added made the rest harder to find.
var page by rememberSaveable { mutableStateOf(initialPage) }
BackHandler(enabled = page != null) { page = null }
val back: @Composable () -> Unit = {
IconButton(onClick = { page = null }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to settings")
}
SettingSwitch(
"Hardware decoding",
"Turn off if video shows green or torn frames",
hardwareDecoding,
) {
hardwareDecoding = it
settings.hardwareDecoding = it
}
SettingSwitch(
"Background audio",
"Keeps playing with the app closed, from the notification",
backgroundAudio,
) {
backgroundAudio = it
settings.backgroundAudio = it
}
SettingsGroup("Downloads and storage")
SettingSwitch("Download on Wi-Fi only", "Mobile data is never used for downloads", wifiOnly) {
wifiOnly = it
settings.wifiOnly = it
// Constraints are baked in at enqueue time, so a queue that already
// exists keeps the policy it was queued under until it is re-issued.
DownloadCoordinator.applyNetworkPolicy(context)
}
Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
Text("Streaming cache")
Text(
"${formatBytes(state.storage.cacheBytes)} of $cacheBudgetGib GiB used",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
AccentProgress(
progress = {
(state.storage.cacheBytes.toDouble() / SettingsStore.gibToBytes(cacheBudgetGib).toDouble())
.toFloat().coerceIn(0f, 1f)
},
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
)
// What has been watched is kept up to this much, so going back over
// a scene costs no download. A lower choice frees the difference at
// once. Wrapping, so five chips survive a narrow phone at a large
// font scale.
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SettingsStore.CACHE_BUDGET_CHOICES.forEach { gib ->
FilterChip(
selected = gib == cacheBudgetGib,
onClick = {
cacheBudgetGib = gib
settings.cacheBudgetGib = gib
scope.launch(Dispatchers.IO) {
runCatching {
NativeRuntime.engine().setStreamCacheBudget(SettingsStore.gibToBytes(gib))
}
}
}
when (page) {
null -> TabPage("Settings", padding) { body ->
Column(Modifier.fillMaxSize().padding(body).verticalScroll(rememberScrollState())) {
SettingsPage.entries.forEach { entry ->
ListItem(
headlineContent = { Text(entry.title) },
supportingContent = {
Text(
when (entry) {
SettingsPage.Playback -> "Autoplay, languages and decoding"
SettingsPage.Storage -> "${formatBytes(state.storage.cacheBytes)} cached · " +
"${state.storage.offlineCount} offline"
SettingsPage.Appearance -> "Palette, accent and gradient"
SettingsPage.Metadata -> if (state.metadataSettings.enabled) {
"On · ${state.metadataSettings.provider.displayName()}"
} else {
"Off"
}
SettingsPage.About -> "Version $version · licences"
},
)
},
label = { Text("$gib GiB") },
leadingContent = { Icon(entry.icon, contentDescription = null) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.clickable { page = entry },
)
}
}
}
// The cache is rebuildable, so it goes without asking. Offline episodes
// are a choice the viewer made, so that one asks.
SettingAction("Clear streaming cache", "Frees ${formatBytes(state.storage.cacheBytes)}", onClick = onClearCache)
SettingAction(
"Delete all offline episodes",
"${state.storage.offlineCount} " + (if (state.storage.offlineCount == 1uL) "episode" else "episodes") +
" · ${formatBytes(state.storage.offlineBytes)}" +
(
state.storage.partialBytes.takeIf { it > 0uL }
?.let { " · ${formatBytes(it)} unfinished" }
.orEmpty()
),
enabled = state.storage.offlineCount > 0uL || state.storage.partialBytes > 0uL,
onClick = { confirmDelete = true },
)
SettingsGroup("Appearance")
Column(Modifier.padding(horizontal = 16.dp)) { AppearancePicker() }
SettingsGroup("Metadata")
SettingAction(
"Metadata enrichment",
if (state.metadataSettings.enabled) {
"On · ${state.metadataSettings.provider.displayName()} · sends title names to it"
} else {
"Off, the privacy default"
},
onClick = { showMetadata = true },
)
// Every title looked up again, matched ones included: the way out of a
// library the provider answered wrong, which otherwise stays wrong for
// as long as the match is remembered.
SettingAction(
"Match everything again",
"Looks every title up again, hand-picked matches excepted",
enabled = state.metadataSettings.enabled && !state.refreshing,
onClick = onMatchAgain,
)
SettingsGroup("About")
SettingAction("Licence", "GPL-3.0-or-later. Comes with absolutely no warranty.") {
legalDocument = LegalDocument("GNU GPL v3", "licenses/GPL-3.0.txt")
SettingsPage.Playback -> SettingsSubPage(SettingsPage.Playback, padding, back) {
prefs?.let { current ->
SettingSwitch(
"Autoplay next episode",
"Starts the next episode when the credits begin",
current.autoplayNext,
) { on -> update { it.copy(autoplayNext = on) } }
// Off is the safer default: a mis-named chapter then costs a tap on
// Skip rather than a scene.
SettingSwitch(
"Skip openings and endings",
"Uses the release's chapters; off shows a Skip button instead",
current.autoSkip,
) { on -> update { it.copy(autoSkip = on) } }
SettingSwitch("Subtitles", "Shown by default where a file has them", current.subtitles) { on ->
update { it.copy(subtitles = on) }
}
// Preferences, not filters: a file with nothing in the language
// plays its own default track. A show that has been given its
// own choice keeps it.
LanguageSetting("Audio language", current.audioLanguage) { tag ->
update { it.copy(audioLanguage = tag) }
}
LanguageSetting("Subtitle language", current.subtitleLanguage) { tag ->
update { it.copy(subtitleLanguage = tag) }
}
}
SettingSwitch(
"Hardware decoding",
"Turn off if video shows green or torn frames",
hardwareDecoding,
) {
hardwareDecoding = it
settings.hardwareDecoding = it
}
SettingSwitch(
"Background audio",
"Keeps playing with the app closed, from the notification",
backgroundAudio,
) {
backgroundAudio = it
settings.backgroundAudio = it
}
}
SettingAction("Third-party notices", "libmpv, Inter and the other components bundled") {
legalDocument = LegalDocument("Third-party notices", "licenses/THIRD_PARTY_NOTICES.md")
SettingsPage.Storage -> SettingsSubPage(SettingsPage.Storage, padding, back) {
SettingSwitch("Download on Wi-Fi only", "Mobile data is never used for downloads", wifiOnly) {
wifiOnly = it
settings.wifiOnly = it
// Constraints are baked in at enqueue time, so a queue that already
// exists keeps the policy it was queued under until it is re-issued.
DownloadCoordinator.applyNetworkPolicy(context)
}
Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
Text("Streaming cache")
Text(
"${formatBytes(state.storage.cacheBytes)} of $cacheBudgetGib GiB used",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
AccentProgress(
progress = {
(state.storage.cacheBytes.toDouble() / SettingsStore.gibToBytes(cacheBudgetGib).toDouble())
.toFloat().coerceIn(0f, 1f)
},
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
)
// What has been watched is kept up to this much, so going back over
// a scene costs no download. A lower choice frees the difference at
// once. Wrapping, so five chips survive a narrow phone at a large
// font scale.
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SettingsStore.CACHE_BUDGET_CHOICES.forEach { gib ->
FilterChip(
selected = gib == cacheBudgetGib,
onClick = {
cacheBudgetGib = gib
settings.cacheBudgetGib = gib
scope.launch(Dispatchers.IO) {
runCatching {
NativeRuntime.engine().setStreamCacheBudget(SettingsStore.gibToBytes(gib))
}
}
},
label = { Text("$gib GiB") },
)
}
}
}
// The cache is rebuildable, so it goes without asking. Offline episodes
// are a choice the viewer made, so that one asks.
SettingAction("Clear streaming cache", "Frees ${formatBytes(state.storage.cacheBytes)}", onClick = onClearCache)
SettingAction(
"Delete all offline episodes",
"${state.storage.offlineCount} " + (if (state.storage.offlineCount == 1uL) "episode" else "episodes") +
" · ${formatBytes(state.storage.offlineBytes)}" +
(
state.storage.partialBytes.takeIf { it > 0uL }
?.let { " · ${formatBytes(it)} unfinished" }
.orEmpty()
),
enabled = state.storage.offlineCount > 0uL || state.storage.partialBytes > 0uL,
onClick = { confirmDelete = true },
)
}
SettingsPage.Appearance -> SettingsSubPage(SettingsPage.Appearance, padding, back) {
Column(Modifier.padding(horizontal = 16.dp)) { AppearancePicker(onNamesChanged) }
}
SettingsPage.Metadata -> SettingsSubPage(SettingsPage.Metadata, padding, back) {
SettingAction(
"Metadata enrichment",
if (state.metadataSettings.enabled) {
"On · ${state.metadataSettings.provider.displayName()} · sends title names to it"
} else {
"Off, the privacy default"
},
onClick = { showMetadata = true },
)
// Every title looked up again, matched ones included: the way out of a
// library the provider answered wrong, which otherwise stays wrong for
// as long as the match is remembered.
SettingAction(
"Match everything again",
"Looks every title up again, hand-picked matches excepted",
enabled = state.metadataSettings.enabled && !state.refreshing,
onClick = onMatchAgain,
)
}
SettingsPage.About -> SettingsSubPage(SettingsPage.About, padding, back) {
ListItem(
headlineContent = { Text("Proton Stream") },
supportingContent = { Text("Version $version") },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
SettingAction("Licence", "GPL-3.0-or-later. Comes with absolutely no warranty.") {
legalDocument = LegalDocument("GNU GPL v3", "licenses/GPL-3.0.txt")
}
SettingAction("Third-party notices", "libmpv, Inter and the other components bundled") {
legalDocument = LegalDocument("Third-party notices", "licenses/THIRD_PARTY_NOTICES.md")
}
}
Spacer(Modifier.height(24.dp))
}
if (confirmDelete) {
AlertDialog(
@@ -286,15 +335,39 @@ internal fun SettingsScreen(
}
}
/** A group's heading, in the accent, the way Android's own settings mark them. */
/** The areas the first page lists, each a page of its own. */
internal enum class SettingsPage(val title: String, val icon: ImageVector) {
Playback("Playback", Icons.Default.PlayCircle),
Storage("Downloads and storage", Icons.Default.Storage),
Appearance("Appearance", Icons.Default.Palette),
Metadata("Metadata", Icons.AutoMirrored.Filled.ManageSearch),
About("About", Icons.Default.Info),
}
/** One area's page: its name, a way back, and its rows. */
@Composable
private fun SettingsGroup(label: String) {
Text(
label,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 4.dp),
)
private fun SettingsSubPage(
page: SettingsPage,
padding: PaddingValues,
back: @Composable () -> Unit,
rows: @Composable ColumnScope.() -> Unit,
) {
TabPage(page.title, padding, navigationIcon = back) { body ->
Column(Modifier.fillMaxSize().padding(body).verticalScroll(rememberScrollState())) {
rows()
Spacer(Modifier.height(24.dp))
}
}
}
/** The installed version, as the launcher and the store show it. */
@Composable
private fun appVersion(): String {
val context = LocalContext.current
return remember {
runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }
.getOrNull() ?: "unknown"
}
}
/** A switch with its one line of explanation; the whole row toggles it. */
@@ -350,7 +423,7 @@ private fun MetadataSettingsDialog(
title = { Text("Metadata enrichment") },
text = {
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 }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MetadataProvider.entries.forEach { option ->
@@ -432,7 +505,7 @@ internal fun SettingToggle(label: String, checked: Boolean, onChecked: (Boolean)
* the file both clients read.
*/
@Composable
private fun AppearancePicker() {
private fun AppearancePicker(onNamesChanged: () -> Unit) {
val scope = rememberCoroutineScope()
var choice by remember { mutableStateOf<AppearanceRecord?>(null) }
var swatches by remember { mutableStateOf<Map<AccentChoice, Color>>(emptyMap()) }
@@ -501,6 +574,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 ->
scope.launch { repaint(current.copy(gradients = on), store = true) }
}
@@ -512,6 +605,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. */
private fun FlavorChoice.label() = when (this) {
FlavorChoice.PROTON -> "Proton"
@@ -521,20 +621,3 @@ private fun FlavorChoice.label() = when (this) {
FlavorChoice.MOCHA -> "Catppuccin Mocha"
FlavorChoice.PERSONA5 -> "Persona 5"
}
/**
* One language tag, committed as it is typed.
*
* Blank is a real answer and means "no preference" — the bridge stores it as
* absent, which is what leaves the choice to the container's own default track.
*/
@Composable
private fun LanguageField(label: String, value: String?, modifier: Modifier = Modifier, onChange: (String?) -> Unit) {
OutlinedTextField(
value = value.orEmpty(),
onValueChange = { onChange(it.trim().takeIf(String::isNotEmpty)) },
label = { Text(label) },
singleLine = true,
modifier = modifier,
)
}
@@ -60,38 +60,33 @@ internal fun SharesScreen(
var showAdd by remember { mutableStateOf(false) }
var repairing by remember { mutableStateOf<ShareRecord?>(null) }
var removing by remember { mutableStateOf<ShareRecord?>(null) }
Box(Modifier.fillMaxSize().padding(padding)) {
if (shares.isEmpty()) {
EmptyState("No shares yet", "Add a Proton Drive public link to build your library.")
} else {
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
item(key = "heading") {
Text(
"Proton Drive public links",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
items(shares, key = { it.id }) { share ->
ShareRow(
share = share,
onRefresh = { onRefresh(share.id) },
onRepair = { repairing = share },
onRemove = { removing = share },
)
TabPage("Shares", padding) { body ->
Box(Modifier.fillMaxSize().padding(body)) {
if (shares.isEmpty()) {
EmptyState("No shares yet", "Add a Proton Drive public link to build your library.")
} else {
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
items(shares, key = { it.id }) { share ->
ShareRow(
share = share,
onRefresh = { onRefresh(share.id) },
onRepair = { repairing = share },
onRemove = { removing = share },
)
}
}
}
// Where a thumb is, and the one thing this page is for. The share
// sheet and a tapped link land in the same form.
ExtendedFloatingActionButton(
onClick = { showAdd = true },
icon = { Icon(Icons.Default.Add, contentDescription = null) },
text = { Text("Add share") },
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
)
}
// Where a thumb is, and the one thing this page is for. The share
// sheet and a tapped link land in the same form.
ExtendedFloatingActionButton(
onClick = { showAdd = true },
icon = { Icon(Icons.Default.Add, contentDescription = null) },
text = { Text("Add share") },
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
)
}
if (showAdd || incomingLink != null) {
AddShareDialog(
@@ -1,7 +1,11 @@
package io.narl.protonstream.ui
import android.content.Intent
import androidx.activity.compose.BackHandler
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.ui.graphics.graphicsLayer
import kotlin.coroutines.cancellation.CancellationException
import androidx.compose.foundation.clickable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -14,6 +18,9 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.aspectRatio
@@ -66,7 +73,9 @@ import io.narl.protonstream.ui.theme.EdgedButton
import io.narl.protonstream.ui.theme.QuietButton
import io.narl.protonstream.ui.theme.TonalButton
import io.narl.protonstream.ui.theme.solid
import androidx.compose.foundation.border
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.MatchRecord
@@ -88,12 +97,27 @@ internal fun TitleScreen(
onMetadataChanged: () -> Unit,
onSetWatched: (EpisodeRecord, Boolean) -> Unit,
padding: PaddingValues,
/** Every title of its franchise, in release order, this one included. */
franchise: List<TitleRecord> = emptyList(),
onOpenTitle: (TitleRecord) -> Unit = {},
) {
val context = LocalContext.current
var showMatch by remember(title.key) { mutableStateOf(false) }
var overviewOpen by remember(title.key) { mutableStateOf(false) }
BackHandler(onBack = onBack)
// Predictive back: the page shrinks towards the library as the gesture
// is dragged, and springs back if it is let go of, so the viewer sees
// where back goes before committing to it.
var backProgress by remember { mutableFloatStateOf(0f) }
PredictiveBackHandler { gesture ->
try {
gesture.collect { backProgress = it.progress }
onBack()
} catch (cancelled: CancellationException) {
backProgress = 0f
throw cancelled
}
}
// Display order across seasons: what previous/next and autoplay walk, and
// the same order the desktop client uses.
@@ -116,171 +140,243 @@ internal fun TitleScreen(
.getWorkInfosByTagLiveData(DownloadCoordinator.TAG).observeAsState(emptyList())
val downloads = remember(work) { DownloadStateStore(context).records() }
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
// On a tablet the page keeps a phone's reading width, centred: a Play
// button and synopsis lines two thousand pixels wide are read by turning
// the head. The backdrop still spans the window, but no taller than about
// half of it, or a landscape tablet opened on a picture and nothing else.
BoxWithConstraints(
Modifier
.fillMaxSize()
.graphicsLayer {
val scale = 1f - 0.1f * backProgress
scaleX = scale
scaleY = scale
shape = RoundedCornerShape((32 * backProgress).dp)
clip = backProgress > 0f
}
.padding(padding),
) {
item(key = "backdrop") {
// Edge to edge, fading into the page: the art is the first thing
// the page says, and a framed thumbnail with a margin round it read
// as one card among the many below it.
Box(Modifier.fillMaxWidth().aspectRatio(16f / 9f)) {
RemoteArtwork(
title.backdropUrl ?: title.posterUrl,
title.canonicalName ?: title.name,
Modifier.fillMaxSize(),
fallback = title.thumbnailSource,
)
Box(
Modifier.fillMaxSize().background(
Brush.verticalGradient(
0.45f to Color.Transparent,
1f to MaterialTheme.colorScheme.background,
val side = ((maxWidth - READABLE_WIDTH) / 2).coerceAtLeast(0.dp)
val backdropHeight = minOf(maxWidth * 9f / 16f, maxHeight * 0.55f)
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item(key = "backdrop") {
// Edge to edge, fading into the page: the art is the first thing
// the page says, and a framed thumbnail with a margin round it read
// as one card among the many below it.
Box(Modifier.sharedArt(title.key).fillMaxWidth().height(backdropHeight)) {
RemoteArtwork(
title.backdropUrl ?: title.posterUrl,
title.displayName,
Modifier.fillMaxSize(),
fallback = title.thumbnailSource,
labelled = false,
)
Box(
Modifier.fillMaxSize().background(
Brush.verticalGradient(
0.45f to Color.Transparent,
1f to MaterialTheme.colorScheme.background,
),
),
),
)
IconButton(
onClick = onBack,
modifier = Modifier
.padding(8.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.4f)),
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library", tint = Color.White)
}
}
}
item(key = "heading") {
Column(Modifier.padding(horizontal = 16.dp)) {
Text(title.canonicalName ?: title.name, style = MaterialTheme.typography.headlineMedium)
title.originalName?.takeIf { it != title.canonicalName }?.let {
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Text(
listOfNotNull(
(title.metadataYear ?: title.year)?.toString(),
title.rating?.let { "★ %.1f".format(it) },
title.genres.takeIf { it.isNotEmpty() }?.take(3)?.joinToString(", "),
"${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL },
).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
// The one thing the page is for, full width and naming what it
// will play: "Resume" alone left the viewer guessing which
// episode of forty it meant.
AccentButton(
onClick = { onPlay(playlist, nextUp) },
enabled = playerReady && playlist.isNotEmpty(),
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
) {
Icon(Icons.Default.PlayArrow, contentDescription = null, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text(
when {
!playerReady -> "Player loading…"
upNext == null -> "Play"
upNext.resumeAt != null -> "Resume ${upNext.label}"
title.watchedCount > 0uL -> "Continue with ${upNext.label}"
playlist.size == 1 -> "Play"
else -> "Play ${upNext.label}"
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
// Everything else as labelled icons in one row: four actions of
// three button styles used to wrap over three lines and bury
// the episodes under them.
Row(Modifier.fillMaxWidth().padding(top = 12.dp)) {
// Only where it would do something different: an unstarted
// show already starts at the beginning.
if (upNext?.resumeAt != null || title.watchedCount > 0uL) {
TitleAction(Icons.Default.Replay, "Start over", Modifier.weight(1f), enabled = playerReady) {
onPlay(playlist, 0)
}
}
TitleAction(Icons.Default.Download, "Download", Modifier.weight(1f)) {
DownloadCoordinator.enqueue(context, playlist)
}
TitleAction(Icons.Default.Edit, "Match", Modifier.weight(1f)) { showMatch = true }
// The provider's own page for this title: where a viewer goes
// to check that the thing the app matched is what they have.
title.externalUrl?.let { url ->
TitleAction(
Icons.AutoMirrored.Filled.OpenInNew,
title.metadataProvider?.displayName() ?: "Provider",
Modifier.weight(1f),
) {
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
}.onFailure(onPreferenceError)
}
}
}
title.overview?.let { overview ->
// Three lines, then a tap for the rest: a synopsis with its
// source notes ran to a screen and a half on a phone.
Text(
overview.trim(),
maxLines = if (overviewOpen) Int.MAX_VALUE else 3,
overflow = TextOverflow.Ellipsis,
IconButton(
onClick = onBack,
modifier = Modifier
.padding(top = 12.dp)
.clickable { overviewOpen = !overviewOpen },
)
}
// Said plainly, because it changes what a re-match will do: a
// hand-picked match is not overwritten by an automatic pass.
if (title.manualMatch) {
Text(
"Matched by hand",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
.statusBarsPadding()
.padding(8.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.4f)),
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library", tint = Color.White)
}
}
}
}
item(key = "seasons") {
Row(
Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
itemsIndexed(title.seasons, key = { _, it -> it.label }) { index, entry ->
FilterChip(
selected = index == seasonIndex,
onClick = { seasonIndex = index },
label = { Text(entry.label) },
item(key = "heading") {
Column(Modifier.padding(horizontal = side + 16.dp)) {
Text(title.displayName, style = MaterialTheme.typography.headlineMedium)
// 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(
listOfNotNull(
title.formatLabel,
title.seasonLabel ?: (title.metadataYear ?: title.year)?.toString(),
title.rating?.let { "★ %.1f".format(it) },
"${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL },
).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
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
// will play: "Resume" alone left the viewer guessing which
// episode of forty it meant.
AccentButton(
onClick = { onPlay(playlist, nextUp) },
enabled = playerReady && playlist.isNotEmpty(),
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
) {
Icon(Icons.Default.PlayArrow, contentDescription = null, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text(
when {
!playerReady -> "Player loading…"
upNext == null -> "Play"
upNext.resumeAt != null -> "Resume ${upNext.label}"
title.watchedCount > 0uL -> "Continue with ${upNext.label}"
playlist.size == 1 -> "Play"
else -> "Play ${upNext.label}"
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
// Everything else as labelled icons in one row: four actions of
// three button styles used to wrap over three lines and bury
// the episodes under them.
Row(Modifier.fillMaxWidth().padding(top = 12.dp)) {
// Only where it would do something different: an unstarted
// show already starts at the beginning.
if (upNext?.resumeAt != null || title.watchedCount > 0uL) {
TitleAction(Icons.Default.Replay, "Start over", Modifier.weight(1f), enabled = playerReady) {
onPlay(playlist, 0)
}
}
TitleAction(Icons.Default.Download, "Download", Modifier.weight(1f)) {
DownloadCoordinator.enqueue(context, playlist)
}
TitleAction(Icons.Default.Edit, "Match", Modifier.weight(1f)) { showMatch = true }
// The provider's own page for this title: where a viewer goes
// to check that the thing the app matched is what they have.
title.externalUrl?.let { url ->
TitleAction(
Icons.AutoMirrored.Filled.OpenInNew,
title.metadataProvider?.displayName() ?: "Provider",
Modifier.weight(1f),
) {
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
}.onFailure(onPreferenceError)
}
}
}
title.overview?.let { overview ->
// Three lines, then a tap for the rest: a synopsis with its
// source notes ran to a screen and a half on a phone.
Text(
overview.trim(),
maxLines = if (overviewOpen) Int.MAX_VALUE else 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.padding(top = 12.dp)
.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
// hand-picked match is not overwritten by an automatic pass.
if (title.manualMatch) {
Text(
"Matched by hand",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
}
season?.let {
IconButton(onClick = { DownloadCoordinator.enqueue(context, it.episodes) }) {
Icon(Icons.Default.Download, contentDescription = "Download ${it.label}")
}
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) }
}
}
}
}
}
}
season?.let { shown ->
itemsIndexed(shown.episodes, key = { _, it -> it.linkId }) { position, episode ->
EpisodeRow(
episode = episode,
numbering = episode.numbering(shown.number, position),
position = position,
download = downloads.firstOrNull {
it.shareId == episode.shareId && it.linkId == episode.linkId
},
playerReady = playerReady,
onPlay = { onPlay(playlist, playlist.indexOfFirst { it.linkId == episode.linkId }) },
onDownload = { DownloadCoordinator.enqueue(context, episode) },
onPause = { DownloadCoordinator.pause(context, it) },
onResume = { DownloadCoordinator.resume(context, it) },
onSetWatched = { onSetWatched(episode, it) },
)
item(key = "seasons") {
Row(
Modifier.fillMaxWidth().padding(start = side + 16.dp, end = side + 4.dp, top = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
itemsIndexed(title.seasons, key = { _, it -> it.label }) { index, entry ->
FilterChip(
selected = index == seasonIndex,
onClick = { seasonIndex = index },
label = { Text(entry.label) },
)
}
}
season?.let {
IconButton(onClick = { DownloadCoordinator.enqueue(context, it.episodes) }) {
Icon(Icons.Default.Download, contentDescription = "Download ${it.label}")
}
}
}
}
season?.let { shown ->
itemsIndexed(shown.episodes, key = { _, it -> it.linkId }) { position, episode ->
Box(Modifier.padding(horizontal = side)) {
EpisodeRow(
episode = episode,
numbering = episode.numbering(shown.number, position),
position = position,
download = downloads.firstOrNull {
it.shareId == episode.shareId && it.linkId == episode.linkId
},
playerReady = playerReady,
onPlay = { onPlay(playlist, playlist.indexOfFirst { it.linkId == episode.linkId }) },
onDownload = { DownloadCoordinator.enqueue(context, episode) },
onPause = { DownloadCoordinator.pause(context, it) },
onResume = { DownloadCoordinator.resume(context, it) },
onSetWatched = { onSetWatched(episode, it) },
)
}
}
}
}
}
@@ -294,6 +390,71 @@ 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. */
private val READABLE_WIDTH = 720.dp
/** One of the title's secondary actions: an icon with its name under it. */
@Composable
private fun TitleAction(
@@ -386,6 +547,7 @@ private fun EpisodeRow(
heading,
Modifier.fillMaxSize(),
fallback = episode.thumbnailSource,
labelled = false,
)
episode.progress?.takeIf { !episode.watched }?.let { progress ->
AccentProgress(
@@ -483,26 +645,8 @@ private fun EpisodeRow(
}
}
/**
* What an episode row is headed with: "1. Mother and Children".
*
* The provider's name for the episode, else the name the filename gave it,
* else "Episode 1". The bare filename only when none of those exist — a column
* of `[Group] Show - S01E01.mkv` is forty rows that differ in two characters.
*/
private fun EpisodeRecord.heading(position: Int): String {
val named = providerName ?: detail.takeIf { it != name }
val number = number?.toInt()
return when {
named != null && number != null -> "$number. $named"
named != null -> named
number != null -> "Episode $number"
else -> name.substringBeforeLast('.').ifBlank { "Episode ${position + 1}" }
}
}
@Composable
private fun ChangeMatchDialog(
internal fun ChangeMatchDialog(
title: TitleRecord,
onDismiss: () -> Unit,
onChanged: () -> Unit,
@@ -2,15 +2,17 @@
The play mark from packaging/io.narl.proton-stream.svg, cut into the blocks a
revision is fetched in. The paths are in the SVG's 128-unit space; the group
maps its 104-unit tile onto the 72dp a launcher shows of a 108dp layer.
It centres x=70, between the glyph's centroid (66) and its box (73): the
SVG's own centre, 64, leaves it sitting visibly right in a round mask.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportHeight="108">
<group
android:pivotX="64" android:pivotY="62"
android:pivotX="70" android:pivotY="62"
android:scaleX="0.6923" android:scaleY="0.6923"
android:translateX="-10" android:translateY="-8">
android:translateX="-16" android:translateY="-8">
<group android:translateY="3">
<clip-path android:pathData="M0,0h63v128h-63zM68.5,0h10v128h-10zM84,0h44v128h-44z" />
<path android:fillColor="#4D2E1596" android:pathData="M50,41.5v41a6,6 0,0 0,9.1 5.1l34.2,-20.5a6,6 0,0 0,0 -10.3L59.1,36.4A6,6 0,0 0,50 41.5z" />
@@ -3,9 +3,9 @@
android:width="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportHeight="108">
<group
android:pivotX="64" android:pivotY="62"
android:pivotX="70" android:pivotY="62"
android:scaleX="0.6923" android:scaleY="0.6923"
android:translateX="-10" android:translateY="-8">
android:translateX="-16" android:translateY="-8">
<clip-path android:pathData="M0,0h63v128h-63zM68.5,0h10v128h-10zM84,0h44v128h-44z" />
<path android:fillColor="#FFFFFFFF" android:pathData="M50,41.5v41a6,6 0,0 0,9.1 5.1l34.2,-20.5a6,6 0,0 0,0 -10.3L59.1,36.4A6,6 0,0 0,50 41.5z" />
</group>
@@ -0,0 +1,28 @@
package io.narl.protonstream.ui
import org.junit.Assert.assertEquals
import org.junit.Test
class ArtworkTest {
@Test
fun `initials skip the lower-case particles of a romanised name`() {
assertEquals("OK", initials("Oshi no Ko"))
assertEquals("AH", initials("Ano Hi Mita Hana no Namae o Bokutachi wa Mada Shiranai."))
}
@Test
fun `a one-word name has one initial`() {
assertEquals("A", initials("Akira"))
}
@Test
fun `a name with no capitals falls back to its first two words`() {
assertEquals("BW", initials("blue world order"))
}
@Test
fun `punctuation and separators are not words`() {
assertEquals("S2", initials("Steins;Gate - 2"))
assertEquals("", initials(" - "))
}
}
@@ -23,6 +23,9 @@ class HistoryTest {
metadataProvider = null, metadataId = null, metadataYear = null, metadataKind = null, posterUrl = null,
backdropUrl = null, rating = null, genres = emptyList(), providerEpisodeCount = null, externalUrl = null,
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
@@ -0,0 +1,31 @@
package io.narl.protonstream.ui
import org.junit.Assert.assertEquals
import org.junit.Test
class LanguagePickerTest {
@Test
fun `a stored tag reads as its language in either ISO form`() {
assertEquals("Japanese", languageLabel("jpn"))
assertEquals("Japanese", languageLabel("ja"))
assertEquals("German", languageLabel("deu"))
assertEquals("German", languageLabel("ger"))
}
@Test
fun `a region suffix and capitals do not hide the language`() {
assertEquals("Portuguese", languageLabel("PT-br"))
}
@Test
fun `a tag not on the list is shown as typed, and blank is no preference`() {
assertEquals("tgl", languageLabel("tgl"))
assertEquals("No preference", languageLabel(null))
assertEquals("No preference", languageLabel(" "))
}
@Test
fun `every listed language has a two-letter form`() {
LANGUAGES.forEach { (code, name) -> assertEquals(code, name, languageLabel(code)) }
}
}
@@ -13,6 +13,8 @@ import androidx.test.core.app.ApplicationProvider
import androidx.work.testing.WorkManagerTestInitHelper
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
import com.github.takahirom.roborazzi.captureRoboImage
import io.narl.protonstream.ui.theme.AppearanceState
import io.narl.protonstream.ui.theme.ProtonFallback
import io.narl.protonstream.ui.theme.ProtonStreamTheme
import java.time.LocalDate
import java.time.ZoneOffset
@@ -23,10 +25,15 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import uniffi.pstr_android.ArrangementRecord
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.OfflineRecord
import uniffi.pstr_android.PaletteRecord
import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.ShareRecord
import uniffi.pstr_android.ShelfRecord
import uniffi.pstr_android.StorageUsageRecord
import uniffi.pstr_android.TileRecord
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType
@@ -36,8 +43,9 @@ import uniffi.pstr_android.TitleType
* The screens are about to be redesigned one by one, and a layout regression —
* a row that wraps, a button pushed off the edge — compiles and passes every
* other test. Here it is a changed PNG in review. No artwork is loaded: the
* bridge is not available on the host, so every tile shows its placeholder,
* which is also what a title without metadata looks like on a phone.
* bridge is not available on the host, so every tile shows its generated
* placeholder, which is also what a title without metadata looks like on a
* phone.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@@ -48,12 +56,22 @@ class ScreenshotTest {
@Before
fun setUp() {
WorkManagerTestInitHelper.initializeTestWorkManager(ApplicationProvider.getApplicationContext())
// The palette is process-wide and the sandbox outlives a test, so a
// light capture would otherwise repaint every one after it.
AppearanceState.apply(ProtonFallback)
}
private fun captureLight(name: String, content: @Composable () -> Unit) {
AppearanceState.apply(LATTE)
capture(name, content)
}
private fun capture(name: String, content: @Composable () -> Unit) {
compose.setContent {
ProtonStreamTheme {
Surface(color = MaterialTheme.colorScheme.background) { content() }
CompositionLocalProvider(LocalArtworkLoading provides false) {
Surface(color = MaterialTheme.colorScheme.background) { content() }
}
}
}
compose.onRoot().captureRoboImage("src/test/screenshots/$name.png")
@@ -70,6 +88,28 @@ class ScreenshotTest {
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
}
@Test
fun `the library in the light palette`() = captureLight("library-phone-light") {
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
}
@Test
fun `a title in the light palette`() = captureLight("title-phone-light") {
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues())
}
@Test
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
fun `a title on a tablet`() = capture("title-tablet") {
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues())
}
@Test
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
fun `the history page on a tablet`() = capture("history-tablet") {
HistoryScreen(LIBRARY, { _, _ -> }, {}, { _, _ -> }, PaddingValues(), ZoneOffset.UTC, LocalDate.of(2026, 9, 21))
}
@Test
fun `the library at a large font scale`() = capture("library-phone-large-font") {
// Robolectric has no font-scale qualifier; the density is what Compose
@@ -85,9 +125,14 @@ class ScreenshotTest {
LibraryScreen(AppUiState(loading = false), {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
}
@Test
fun `the library while the catalog loads`() = capture("library-loading") {
LibraryScreen(AppUiState(), {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
}
@Test
fun `a title with seasons`() = capture("title-phone") {
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues())
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues(), franchise = listOf(SERIES, SEQUEL))
}
@Test
@@ -97,7 +142,17 @@ class ScreenshotTest {
@Test
fun `the settings page`() = capture("settings-phone") {
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues())
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), version = "1.0.0")
}
@Test
fun `the playback settings`() = capture("settings-playback") {
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), SettingsPage.Playback)
}
@Test
fun `the storage settings`() = capture("settings-storage") {
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), SettingsPage.Storage)
}
@Test
@@ -107,12 +162,42 @@ class ScreenshotTest {
HistoryScreen(LIBRARY, { _, _ -> }, {}, { _, _ -> }, PaddingValues(), ZoneOffset.UTC, LocalDate.of(2026, 9, 21))
}
@Test
fun `the downloads page with a season saved`() = capture("downloads-phone") {
val saved = SERIES.seasons.first().episodes.take(2).map {
OfflineRecord(it.shareId, it.linkId, "revision", 1_300_000_000uL, it)
}
// One title: the fixtures reuse link ids across titles, so with the
// whole library the episodes would be filed under whichever came last.
DownloadsScreen(LIBRARY.copy(titles = listOf(SERIES), offline = saved), {}, {}, {}, {}, PaddingValues())
}
@Test
fun `the downloads page with nothing offline`() = capture("downloads-empty") {
DownloadsScreen(LIBRARY, {}, {}, {}, {}, PaddingValues())
}
private companion object {
/** Catppuccin Latte with the default accent, as `Palette::resolve` gives it. */
val LATTE = PaletteRecord(
background = 0xFFEFF1F5u,
surface = 0xFFE6E9EFu,
sunken = 0xFFDCE0E8u,
card = 0xFFCCD0DAu,
cardHover = 0xFFBCC0CCu,
border = 0xFFBCC0CCu,
text = 0xFF4C4F69u,
muted = 0xFF6C6F85u,
accent = 0xFF732FCCu,
accentAlt = 0xFF1856D1u,
accentDim = 0xFFC4BBE4u,
onAccent = 0xFFEFF1F5u,
danger = 0xFFD20F39u,
elevated = 0xFFAAAEBDu,
dangerDim = 0xFFE7D1D5u,
light = true,
)
fun episode(season: UInt, number: UInt, watched: Boolean = false, progress: Double? = null): EpisodeRecord {
// `detail` is the filename unless the filename named the episode,
// which is what the bridge sends.
@@ -168,6 +253,17 @@ class ScreenshotTest {
externalUrl = null,
manualMatch = false,
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(
@@ -178,8 +274,19 @@ class ScreenshotTest {
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.",
).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)))))
.copy(kind = TitleType.FILM, year = 1988u)
@@ -191,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("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(
ShareRecord("anime", "anime", true),
ShareRecord("films", "films", false),
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 813 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 769 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 807 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 678 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+388 -92
View File
@@ -11,12 +11,13 @@ use std::sync::{Arc, OnceLock};
use parking_lot::Mutex;
use pstr_core::appearance::{Accent, Appearance, Flavor, Palette};
use pstr_core::browse::Sort;
use pstr_core::catalog::{Catalog, OfflineFile, TitleTrackPrefs, WatchState, build_rows};
use pstr_core::chapters::{Chapter, ChapterRole};
use pstr_core::config::AppDirs;
use pstr_core::library::{Episode, Library, Title, TitleKind};
use pstr_core::metadata::{
EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata,
EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata, TitleNames,
};
use pstr_core::prefs::PlaybackPrefs;
use pstr_core::proton_drive_rs::{ProtonDrivePublicLinkClient, ThumbnailType};
@@ -175,6 +176,55 @@ pub struct AppearanceRecord {
pub flavor: FlavorChoice,
pub accent: AccentChoice,
pub gradients: bool,
/// Which name a title goes by. Shared with the desktop.
pub names: TitleNamesChoice,
}
/// The shared `pstr_core::metadata::TitleNames`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
pub enum TitleNamesChoice {
/// The share's folder name.
Library,
English,
Romaji,
}
/// The shared `pstr_core::browse::Sort`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
pub enum LibrarySort {
Name,
Recent,
Added,
Release,
Rating,
Popularity,
}
/// One tile of the library grid.
#[derive(Debug, Clone, uniffi::Record)]
pub struct TileRecord {
/// The title drawn.
pub key: String,
/// How many other titles of its franchise it stands for.
pub folded: u32,
}
/// One row of titles that share a director, studio or genre.
#[derive(Debug, Clone, uniffi::Record)]
pub struct ShelfRecord {
pub label: String,
pub keys: Vec<String>,
}
/// The library grid, ordered and folded, and the shelves above it — what
/// `pstr_core::browse` and `pstr_core::shelves` make of the library, as keys
/// into [`AndroidEngine::library`]'s titles.
#[derive(Debug, Clone, uniffi::Record)]
pub struct ArrangementRecord {
pub tiles: Vec<TileRecord>,
/// Empty while searching: a shelf of titles the search did not find reads
/// as the search not working.
pub shelves: Vec<ShelfRecord>,
}
/// One flavour and accent resolved into colours, packed as `0xAARRGGBB`.
@@ -264,6 +314,10 @@ pub struct MatchRecord {
pub genres: Vec<String>,
pub episode_count: Option<u32>,
pub external_url: Option<String>,
/// `TitleDetails` as JSON, opaque to Kotlin: carried so that a match
/// picked by hand keeps the studios, relations and dates search found,
/// rather than losing them on the way back across the bridge.
pub details: Option<String>,
}
#[derive(Debug, Clone, uniffi::Record)]
@@ -323,6 +377,26 @@ pub struct TitleRecord {
pub external_url: Option<String>,
pub manual_match: bool,
pub seasons: Vec<SeasonRecord>,
/// The name to show, as the viewer's name setting has it.
pub display_name: String,
/// The widest art there is: fanart, else the provider's banner.
pub wide_url: Option<String>,
/// `TV`, `Film`, `OVA` — the provider's format, said for a person.
pub format_label: Option<String>,
/// `Spring 2013`.
pub season_label: Option<String>,
pub studios: Vec<String>,
pub directors: Vec<String>,
pub tags: Vec<String>,
/// Still airing.
pub airing: bool,
/// The next episode to air and when, Unix seconds — only while that is
/// still ahead.
pub next_episode: Option<u32>,
pub next_airing_at: Option<i64>,
/// Every title of its franchise, in release order, its own key included;
/// empty for a title that stands alone.
pub franchise: Vec<String>,
}
/// Playback preferences that outlive one file, one title and one launch.
@@ -948,23 +1022,70 @@ impl AndroidEngine {
/// table scans, a `Library::build` and a full record conversion per
/// keystroke.
pub fn library(&self, search: Option<String>) -> Result<Vec<TitleRecord>, BridgeError> {
let titles = self.all_title_records()?;
let needle = search.unwrap_or_default().trim().to_lowercase();
if needle.is_empty() {
return Ok(titles);
}
Ok(titles
.into_iter()
.filter(|title| {
title.name.to_lowercase().contains(&needle)
|| title.seasons.iter().any(|season| {
season
.episodes
.iter()
.any(|episode| episode.name.to_lowercase().contains(&needle))
let query = search.unwrap_or_default();
self.with_library(|cached| {
if query.trim().is_empty() {
return cached.titles.clone();
}
let found: std::collections::HashSet<&str> =
pstr_core::browse::search(&cached.library, &cached.metadata, &query)
.into_iter()
.map(|title| title.key.as_str())
.collect();
cached
.titles
.iter()
.filter(|title| found.contains(title.key.as_str()))
.cloned()
.collect()
})
}
/// The library grid for `search`, in `sort` order, each franchise folded
/// into one tile when `grouped` — and the shelves above it. Keys into
/// [`AndroidEngine::library`]'s titles, so the records cross the bridge
/// once.
pub fn arrangement(
&self,
search: Option<String>,
sort: LibrarySort,
grouped: bool,
) -> Result<ArrangementRecord, BridgeError> {
let query = search.unwrap_or_default();
self.with_library(|cached| {
let titles = pstr_core::browse::search(&cached.library, &cached.metadata, &query);
let tiles = pstr_core::browse::arrange(
&titles,
pstr_core::browse::Order {
sort: sort_choice(sort),
grouped,
names: cached.names,
metadata: &cached.metadata,
added: &cached.added,
},
);
let shelves = if query.trim().is_empty() {
pstr_core::shelves::pick(&cached.library.titles, &cached.metadata, SHELVES)
.into_iter()
.map(|shelf| ShelfRecord {
label: shelf.label,
keys: shelf.keys,
})
})
.collect())
.collect()
} else {
Vec::new()
};
ArrangementRecord {
tiles: tiles
.iter()
.map(|tile| TileRecord {
key: titles[tile.lead].key.clone(),
folded: (tile.members.len() - 1) as u32,
})
.collect(),
shelves,
}
})
}
pub fn metadata_settings(&self) -> Result<MetadataSettingsRecord, BridgeError> {
@@ -1180,8 +1301,14 @@ impl AndroidEngine {
}
pub fn set_appearance(&self, appearance: AppearanceRecord) -> Result<(), BridgeError> {
pstr_core::appearance::save(&self.dirs, &appearance_choice(appearance))
.map_err(BridgeError::from_display)
// The record has no grid shape — Android's is always covers — so the
// desktop's is kept as it was stored.
let stored = pstr_core::appearance::load(&self.dirs).unwrap_or_default();
let appearance = Appearance {
tiles: stored.tiles,
..appearance_choice(appearance)
};
pstr_core::appearance::save(&self.dirs, &appearance).map_err(BridgeError::from_display)
}
/// The colours the stored choice resolves to.
@@ -1420,44 +1547,29 @@ impl AndroidEngine {
impl AndroidEngine {
/// One enrichment pass over the whole library.
///
/// Two kinds of work, and the difference is a request saved: a title that
/// already has a good match but no episode list needs the episode request
/// only. Both run fanned out under a semaphore, and a provider error counts
/// against the run rather than ending it — one rate-limited title must not
/// leave the other thirty unenriched. This mirrors the desktop engine's
/// `run_match`, which is the reference for the behaviour.
/// What to do per title is decided by `pstr_meta::service::plan`, the same
/// as on the desktop. The work runs fanned out under a semaphore, and a
/// provider error counts against the run rather than ending it — one
/// rate-limited title must not leave the other thirty unenriched. This
/// mirrors the desktop engine's `run_match`, which is the reference for the
/// behaviour.
async fn match_titles_inner(&self, force: bool) -> Result<MatchSummary, BridgeError> {
let service = Arc::new(self.metadata_service()?);
let provider = service.provider();
let (titles, stored) = self.titles_and_metadata()?;
let listed = self
let enriched = self
.catalog
.lock()
.episode_metadata_ages()
.enrichment_ages()
.map_err(BridgeError::from_display)?;
let mut pending: Vec<Work> = Vec::new();
for title in titles {
let record = stored.get(&title.key);
// 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)));
}
}
let pending = pstr_meta::service::plan(
titles,
&stored,
&enriched,
provider,
service.has_details(),
force,
);
let permits = Arc::new(tokio::sync::Semaphore::new(LOOKUP_CONCURRENCY));
let mut lookups = tokio::task::JoinSet::new();
@@ -1466,32 +1578,15 @@ impl AndroidEngine {
let permits = Arc::clone(&permits);
lookups.spawn(async move {
let _permit = permits.acquire().await.ok()?;
Some(match work {
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.to_string()),
},
Work::Episodes(title, found) => {
let episodes = service.title_episodes(&title, &found).await;
Ok((title.key, None, episodes))
}
})
Some(service.work(&work).await.map_err(|error| error.to_string()))
});
}
let mut summary = MatchSummary::default();
while let Some(joined) = lookups.join_next().await {
match joined.map_err(BridgeError::from_display)? {
Some(Ok((title_key, record, episodes))) => {
if let Some(record) = &record {
Some(Ok(outcome)) => {
if let Some(record) = &outcome.record {
if record.metadata.is_some() {
summary.matched += 1;
} else {
@@ -1502,16 +1597,16 @@ impl AndroidEngine {
log::warn!("store metadata for {}: {error}", record.title_key);
}
}
if !episodes.is_empty() {
summary.episodes += episodes.len() as u32;
let stored = self.catalog.lock().set_episode_metadata(
&title_key,
if let Some(enrichment) = &outcome.enrichment {
summary.episodes += enrichment.episodes.len() as u32;
let stored = self.catalog.lock().set_enrichment(
&outcome.title_key,
provider,
now(),
&episodes,
enrichment,
);
if let Err(error) = stored {
log::warn!("store episodes for {title_key}: {error}");
log::warn!("store enrichment for {}: {error}", outcome.title_key);
}
}
}
@@ -1539,13 +1634,19 @@ impl AndroidEngine {
}
/// Every title as a bridge record, rebuilt only when the catalog moved.
fn all_title_records(&self) -> Result<Vec<TitleRecord>, BridgeError> {
/// Run `read` over the library, its metadata and its records, rebuilt
/// only when the catalog or the name setting has changed since.
fn with_library<T>(&self, read: impl FnOnce(&CachedLibrary) -> T) -> Result<T, BridgeError> {
let names = pstr_core::appearance::load(&self.dirs)
.unwrap_or_default()
.names;
let catalog = self.catalog.lock();
let writes = catalog.writes();
if let Some(cached) = self.library_cache.lock().as_ref()
&& cached.writes == writes
&& cached.names == names
{
return Ok(cached.titles.clone());
return Ok(read(cached));
}
let files = catalog.all_files().map_err(BridgeError::from_display)?;
@@ -1559,26 +1660,61 @@ impl AndroidEngine {
let offline = catalog
.all_offline_files()
.map_err(BridgeError::from_display)?;
let first_seen = catalog.first_seen().map_err(BridgeError::from_display)?;
drop(catalog);
let library = Library::build(files, &watch);
let all: Vec<&Title> = library.titles.iter().collect();
let franchises: HashMap<String, Vec<String>> = 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 now = now();
let titles: Vec<TitleRecord> = library
.titles
.iter()
.map(|title| {
title_record(
let mut record = title_record(
title,
&offline,
metadata.get(&title.key),
guides.get(&title.key),
)
);
record.display_name =
pstr_core::browse::display_name(title, &metadata, names).to_owned();
record.franchise = franchises.get(&title.key).cloned().unwrap_or_default();
if let Some(details) = metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
.and_then(|found| found.details.as_ref())
&& let Some((episode, at)) = details.next_airing(now)
{
record.next_episode = Some(episode);
record.next_airing_at = Some(at);
}
record
})
.collect();
*self.library_cache.lock() = Some(CachedLibrary {
let added = library.added_at(&first_seen);
let cached = CachedLibrary {
writes,
titles: titles.clone(),
});
Ok(titles)
names,
library,
metadata,
added,
titles,
};
let answer = read(&cached);
*self.library_cache.lock() = Some(cached);
Ok(answer)
}
fn titles_and_metadata(
@@ -2337,17 +2473,14 @@ fn normalized_language(language: Option<String>) -> Option<String> {
/// The library conversion, valid while the catalog has not been written to.
struct CachedLibrary {
writes: u64,
names: TitleNames,
library: Library,
metadata: HashMap<String, MetadataRecord>,
/// When each title last gained a file.
added: HashMap<String, i64>,
titles: Vec<TitleRecord>,
}
/// One title's share of an enrichment pass.
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<TitleMetadata>),
}
/// How many provider lookups may be in flight at once.
///
/// Deliberately small. Both providers rate-limit by client, and a phone
@@ -2399,9 +2532,29 @@ fn appearance_record(appearance: Appearance) -> AppearanceRecord {
Accent::Red => AccentChoice::Red,
},
gradients: appearance.gradients,
names: match appearance.names {
TitleNames::Library => TitleNamesChoice::Library,
TitleNames::English => TitleNamesChoice::English,
TitleNames::Romaji => TitleNamesChoice::Romaji,
},
}
}
fn sort_choice(sort: LibrarySort) -> Sort {
match sort {
LibrarySort::Name => Sort::Name,
LibrarySort::Recent => Sort::Recent,
LibrarySort::Added => Sort::Added,
LibrarySort::Release => Sort::Release,
LibrarySort::Rating => Sort::Rating,
LibrarySort::Popularity => Sort::Popularity,
}
}
/// How many shelves the library shows above the grid — fewer than the
/// desktop's four, because on a phone each is a screen's height.
const SHELVES: usize = 3;
fn appearance_choice(record: AppearanceRecord) -> Appearance {
Appearance {
flavor: match record.flavor {
@@ -2424,6 +2577,12 @@ fn appearance_choice(record: AppearanceRecord) -> Appearance {
AccentChoice::Red => Accent::Red,
},
gradients: record.gradients,
names: match record.names {
TitleNamesChoice::Library => TitleNames::Library,
TitleNamesChoice::English => TitleNames::English,
TitleNamesChoice::Romaji => TitleNames::Romaji,
},
..Appearance::default()
}
}
@@ -2497,6 +2656,7 @@ fn title_record(
guide: Option<&EpisodeGuide>,
) -> TitleRecord {
let metadata = record.and_then(|record| record.metadata.as_ref());
let details = metadata.and_then(|found| found.details.as_ref());
TitleRecord {
key: title.key.clone(),
name: title.name.clone(),
@@ -2521,6 +2681,17 @@ fn title_record(
provider_episode_count: metadata.and_then(|metadata| metadata.episodes),
external_url: metadata.and_then(|metadata| metadata.url.clone()),
manual_match: record.is_some_and(|record| record.manual),
display_name: metadata.map_or_else(|| title.name.clone(), |found| found.name.clone()),
wide_url: metadata.and_then(|found| found.wide_art().map(str::to_owned)),
format_label: details.and_then(|details| details.format_label().map(str::to_owned)),
season_label: details.and_then(|details| details.season_label()),
studios: details.map_or_else(Vec::new, |details| details.studios.clone()),
directors: details.map_or_else(Vec::new, |details| details.directors.clone()),
tags: details.map_or_else(Vec::new, |details| details.tags.clone()),
airing: details.is_some_and(|details| details.is_airing()),
next_episode: None,
next_airing_at: None,
franchise: Vec::new(),
seasons: title
.seasons
.iter()
@@ -2580,6 +2751,10 @@ fn match_record(metadata: TitleMetadata) -> MatchRecord {
genres: metadata.genres,
episode_count: metadata.episodes,
external_url: metadata.url,
details: metadata
.details
.as_ref()
.and_then(|details| serde_json::to_string(details).ok()),
}
}
@@ -2598,6 +2773,10 @@ fn title_metadata(record: MatchRecord) -> TitleMetadata {
genres: record.genres,
episodes: record.episode_count,
url: record.external_url,
details: record
.details
.as_deref()
.and_then(|details| serde_json::from_str(details).ok()),
}
}
@@ -2661,6 +2840,7 @@ mod tests {
flavor,
accent: AccentChoice::Mauve,
gradients: true,
names: super::TitleNamesChoice::English,
}
}
@@ -2973,6 +3153,11 @@ mod tests {
genres: vec!["Action".to_owned(), "Sci-Fi".to_owned()],
episodes: Some(26),
url: Some("https://anilist.co/anime/1".to_owned()),
details: Some(pstr_core::metadata::TitleDetails {
studios: vec!["Sunrise".to_owned()],
related: vec!["5".to_owned()],
..Default::default()
}),
};
assert_eq!(title_metadata(match_record(metadata.clone())), metadata);
@@ -3010,6 +3195,117 @@ mod tests {
assert_eq!(pstr_android_stream_size(handle), -1);
}
/// The arrangement folds a franchise into its series, and the name
/// setting — stored in the file the desktop reads — renames the tile
/// without anything in the catalog changing.
#[test]
fn the_library_folds_a_franchise_and_follows_the_name_setting() {
use pstr_core::catalog::CatalogNode;
use pstr_core::metadata::{MetadataRecord, TitleDetails};
let root = std::env::temp_dir().join(format!(
"pstr-android-arrangement-{}-{}",
std::process::id(),
super::now()
));
let engine = AndroidEngine::new(
AndroidPaths {
config: root.join("config").to_string_lossy().into_owned(),
data: root.join("data").to_string_lossy().into_owned(),
cache: root.join("cache").to_string_lossy().into_owned(),
},
Box::<MemorySecrets>::default(),
)
.expect("engine");
let file = |link: &str, name: &str| CatalogNode {
share_id: "s".into(),
link_id: link.into(),
volume_id: "v".into(),
parent_link_id: None,
name: name.into(),
is_folder: false,
media_type: None,
size: None,
active_revision_id: None,
parsed: pstr_core::naming::parse(name),
};
let matched = |key: &str, id: &str, name: &str, kind, related: &str| MetadataRecord {
title_key: key.into(),
provider: ProviderId::AniList,
metadata: Some(TitleMetadata {
provider: ProviderId::AniList,
remote_id: id.into(),
name: name.into(),
original_name: None,
overview: None,
year: None,
kind,
poster_url: None,
backdrop_url: None,
rating: None,
genres: Vec::new(),
episodes: None,
url: None,
details: Some(TitleDetails {
related: vec![related.into()],
..TitleDetails::default()
}),
}),
fetched_at: 0,
manual: false,
};
{
let mut catalog = engine.catalog.lock();
catalog
.replace_share(
"s",
&[
file("a", "Sousou no Frieren S01E01.mkv"),
file("b", "Made in Abyss Dawn of the Deep Soul.mkv"),
],
)
.expect("crawl");
catalog
.set_metadata(&matched(
"sousou no frieren",
"1",
"Frieren",
TitleKind::Series,
"2",
))
.expect("match");
catalog
.set_metadata(&matched(
"made in abyss dawn of the deep soul",
"2",
"Dawn of the Deep Soul",
TitleKind::Film,
"1",
))
.expect("match");
}
let arranged = engine
.arrangement(None, super::LibrarySort::Name, true)
.expect("arrange");
assert_eq!(arranged.tiles.len(), 1);
assert_eq!(arranged.tiles[0].key, "sousou no frieren");
assert_eq!(arranged.tiles[0].folded, 1);
let titles = engine.library(Some("frieren".into())).expect("library");
assert_eq!(titles.len(), 1);
assert_eq!(titles[0].display_name, "Frieren");
assert_eq!(titles[0].franchise.len(), 2);
let mut appearance = engine.appearance().expect("appearance");
appearance.names = super::TitleNamesChoice::Library;
engine.set_appearance(appearance).expect("store");
let titles = engine.library(Some("frieren".into())).expect("library");
assert_eq!(titles[0].display_name, "Sousou no Frieren");
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn share_mutations_cleanup_even_when_secret_deletion_fails() {
let unique = format!(
+174 -48
View File
@@ -12,7 +12,7 @@ use pstr_core::Share;
use pstr_core::appearance::Appearance;
use pstr_core::config::AppDirs;
use pstr_core::library::Library;
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord};
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, TitleNames};
use crate::engine::{
DownloadItem, DownloadKey, Engine, Event, ImageCache, describe_failures, watch_state,
@@ -213,6 +213,8 @@ pub enum Action {
FocusSearch,
/// Show a different slice of the library, or in a different order.
SetShelf(Filter, Sort),
/// Fold each franchise into one tile, or show every title.
SetGrouped(bool),
/// Open or close the list of keyboard shortcuts.
ToggleShortcuts,
/// One page back: a title to the library, the library to nowhere.
@@ -302,25 +304,55 @@ impl Matcher {
/// 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
/// that is the film's frame rate.
#[derive(Default)]
pub struct LibraryView {
/// The query `matches` answers, as typed.
query: String,
/// Which titles the grid shows, and in what order.
pub filter: Filter,
pub sort: Sort,
/// The filter and order `matches` was built with.
built_with: (Filter, Sort),
/// One tile per franchise rather than one per title. See
/// `pstr_core::franchise`.
pub grouped: bool,
/// What `matches` was built with.
built_with: Option<(Filter, Sort, bool, TitleNames)>,
/// Indices into [`Library::titles`].
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.
pub history: Vec<ui::history::Entry>,
/// The "Continue watching" shelf, most recent first, as indices.
pub resumable: Vec<usize>,
/// The library changed under the lists.
/// The library or what is known about it changed under the lists.
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.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Filter {
@@ -350,43 +382,95 @@ impl Filter {
}
}
/// What order the grid is in.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Sort {
/// The library's own order: by name, ignoring a leading article.
#[default]
Name,
/// Most recently played first.
Recent,
/// Newest first, undated last.
Year,
pub use pstr_core::browse::Sort;
/// What the library view is built from, besides the library itself.
pub struct ViewInputs<'a> {
pub query: &'a str,
pub metadata: &'a HashMap<String, MetadataRecord>,
pub names: TitleNames,
/// When each title last gained a file, by key.
pub added: &'a HashMap<String, i64>,
}
/// How many shelves the library shows above the grid.
const SHELVES: usize = 4;
impl LibraryView {
fn refresh(&mut self, library: &Library, query: &str) {
if !self.stale && self.query == query && self.built_with == (self.filter, self.sort) {
fn refresh(&mut self, library: &Library, inputs: ViewInputs<'_>) {
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;
}
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
.titles
.iter()
.position(|candidate| std::ptr::eq(candidate, title))
};
let mut matches: Vec<&pstr_core::library::Title> = library
.search(query)
.into_iter()
.filter(|title| self.filter.admits(title))
// Indices into what was arranged, made indices into the library.
let entries: Vec<(usize, usize)> = tiles
.iter()
.filter_map(|tile| Some((index_of(titles[tile.lead])?, tile.members.len())))
.collect();
match self.sort {
Sort::Name => {}
// Stable, so titles never played keep their alphabetical order
// after the ones that have been.
Sort::Recent => matches.sort_by_key(|title| std::cmp::Reverse(title.last_played())),
Sort::Year => matches.sort_by_key(|title| std::cmp::Reverse(title.year.unwrap_or(0))),
}
self.matches = matches.into_iter().filter_map(index_of).collect();
self.built_with = (self.filter, self.sort);
self.folded = entries
.iter()
.filter(|(_, members)| *members > 1)
.map(|(lead, members)| (*lead, members - 1))
.collect();
self.matches = entries.into_iter().map(|(lead, _)| lead).collect();
self.built_with = Some(built_with);
if self.stale {
self.history = ui::history::entries(library);
self.resumable = library
@@ -395,7 +479,7 @@ impl LibraryView {
.filter_map(index_of)
.collect();
}
self.query = query.to_owned();
self.query = inputs.query.to_owned();
self.stale = false;
}
}
@@ -428,6 +512,8 @@ pub struct App {
pub posters: ImageCache,
/// What the providers have said, keyed by title key.
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.
pub episodes: HashMap<String, EpisodeGuide>,
pub settings: MetadataConfig,
@@ -531,6 +617,7 @@ impl App {
thumbs: ImageCache::default(),
posters: ImageCache::default(),
metadata: HashMap::new(),
added: HashMap::new(),
episodes: HashMap::new(),
settings,
api_key: String::new(),
@@ -612,8 +699,9 @@ impl App {
self.connecting = false;
self.note(ctx, format!("could not open the shares: {error}"), true);
}
Event::LibraryLoaded(library) => {
Event::LibraryLoaded(library, added) => {
self.library = library;
self.added = added;
self.loaded = true;
self.view.stale = true;
}
@@ -678,6 +766,8 @@ impl App {
self.posters.forget(&key);
}
self.metadata = records;
// Names, franchises and shelves all come from it.
self.view.stale = true;
}
Event::EpisodeMetadata(episodes) => self.episodes = episodes,
Event::MetadataConfig(config) => {
@@ -975,6 +1065,7 @@ impl App {
posters: &mut self.posters,
metadata: &self.metadata,
episodes: &self.episodes,
names: self.engine.appearance().names,
}
.still(&title.key, episode)
});
@@ -1220,6 +1311,7 @@ impl App {
self.view.filter = filter;
self.view.sort = sort;
}
Action::SetGrouped(grouped) => self.view.grouped = grouped,
Action::Back => match self.page {
Page::Title(_) => self.page = Page::Library,
// Back out of a search before out of anything else: it is the
@@ -1652,6 +1744,7 @@ impl eframe::App for App {
posters,
metadata,
episodes,
names: engine.appearance().names,
},
open: *episode_drawer,
});
@@ -1750,7 +1843,15 @@ impl eframe::App for App {
});
self.search = search;
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 {
engine,
@@ -1836,6 +1937,7 @@ impl eframe::App for App {
posters: &mut *posters,
metadata,
episodes,
names: engine.appearance().names,
};
match page {
Page::Library => ui::library::show(
@@ -1846,6 +1948,7 @@ impl eframe::App for App {
view,
loaded: *loaded,
search,
tiles: engine.appearance().tiles,
},
&mut actions,
),
@@ -1854,6 +1957,7 @@ impl eframe::App for App {
&mut art,
library,
key,
view.franchises.get(key.as_str()).map(Vec::as_slice),
ui::title::OfflineView {
downloads,
files: offline_files,
@@ -1883,6 +1987,7 @@ impl eframe::App for App {
posters,
metadata,
episodes,
names: engine.appearance().names,
};
ui::matcher::show(ctx, open, &mut art, settings.provider, &mut actions);
}
@@ -2036,7 +2141,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 —
/// a different one, a new one, or none any more — has to be fetched or
@@ -2045,19 +2151,27 @@ fn changed_art(
before: &HashMap<String, MetadataRecord>,
after: &HashMap<String, MetadataRecord>,
) -> Vec<String> {
let url = |records: &HashMap<String, MetadataRecord>, key: &str| {
records
.get(key)
.and_then(|record| record.metadata.as_ref())
.and_then(|metadata| metadata.tile_art())
.map(|(url, _)| url.to_owned())
// Every picture a title is drawn with, in the order of the keys below.
let urls = |records: &HashMap<String, MetadataRecord>, key: &str| {
let found = records.get(key).and_then(|record| record.metadata.as_ref());
[
found
.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
.keys()
.chain(after.keys())
.filter(|key| url(before, key) != url(after, key))
.cloned()
.collect();
let mut keys: Vec<String> = Vec::new();
for key in before.keys().chain(after.keys()) {
let (old, new) = (urls(before, key), urls(after, key));
let texture_keys = [key.clone(), ui::poster_key(key), ui::wide_key(key)];
for ((old, new), texture) in old.iter().zip(&new).zip(texture_keys) {
if old != new {
keys.push(texture);
}
}
}
keys.sort_unstable();
keys.dedup();
keys
@@ -2306,6 +2420,7 @@ mod tests {
genres: Vec::new(),
episodes: None,
url: None,
details: None,
});
(
key.into(),
@@ -2395,9 +2510,20 @@ mod tests {
record("added", Some("w")),
]
.into();
// The tile and the cover are both drawn from the poster here; the
// wide art has nothing to be.
assert_eq!(
changed_art(&before, &after),
vec!["added", "dropped", "found", "moved"]
vec![
"added",
"dropped",
"found",
"moved",
"poster:added",
"poster:dropped",
"poster:found",
"poster:moved"
]
);
}
}
+71 -90
View File
@@ -160,6 +160,11 @@ struct PartialMarker {
/// difference is VRAM held for every title in the library.
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.
pub enum Event {
/// The share list changed on disk.
@@ -175,8 +180,9 @@ pub enum Event {
},
/// Shares could not be opened at all.
ConnectFailed(String),
/// The catalog was re-read.
LibraryLoaded(Library),
/// The catalog was re-read: the library, and when each title last
/// gained a file.
LibraryLoaded(Library, HashMap<String, i64>),
/// A crawl finished for one share.
Crawled {
share_id: String,
@@ -739,15 +745,16 @@ impl Engine {
self.runtime.spawn_blocking(move || {
let result = {
let catalog = engine.catalog.lock();
catalog
.all_files()
.and_then(|files| Ok((files, catalog.all_watch_states()?)))
catalog.all_files().and_then(|files| {
Ok((files, catalog.all_watch_states()?, catalog.first_seen()?))
})
};
match result {
Ok((files, watch)) => {
Ok((files, watch, first_seen)) => {
let library = Library::build(files, &watch);
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));
}
Err(error) => engine.fail("read the catalog", error),
@@ -1903,44 +1910,25 @@ impl Engine {
};
let provider = service.provider();
let (stored, listed) = {
let (stored, enriched) = {
let catalog = self.catalog.lock();
match catalog
.all_metadata()
.and_then(|stored| Ok((stored, catalog.episode_metadata_ages()?)))
.and_then(|stored| Ok((stored, catalog.enrichment_ages()?)))
{
Ok(pair) => pair,
Err(error) => return self.fail("read stored metadata", error),
}
};
// Two kinds of work, and the difference is a request saved: a title
// that already has a good match but no episode list needs the episode
// request only — searching for it again would ask the provider
// something it has already answered.
let mut pending: Vec<Work> = Vec::new();
for title in titles {
let record = stored.get(&title.key);
// 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)));
}
}
let pending = pstr_meta::service::plan(
titles,
&stored,
&enriched,
provider,
service.has_details(),
force,
);
if pending.is_empty() {
return self.emit(Event::Status("everything is already matched".into()));
}
@@ -1959,25 +1947,7 @@ impl Engine {
let Ok(_permit) = permits.acquire().await else {
return None;
};
Some(match work {
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,
)),
})
Some(service.work(work).await)
});
}
@@ -1985,8 +1955,8 @@ impl Engine {
let mut episodes_found = 0usize;
while let Some(result) = lookups.next().await {
match result {
Some(Ok((title_key, record, episodes))) => {
if let Some(record) = &record {
Some(Ok(outcome)) => {
if let Some(record) = &outcome.record {
if record.metadata.is_some() {
matched += 1;
} else {
@@ -1998,16 +1968,16 @@ impl Engine {
tracing::warn!("store metadata for {}: {error}", record.title_key);
}
}
if !episodes.is_empty() {
episodes_found += episodes.len();
let stored = self.catalog.lock().set_episode_metadata(
&title_key,
if let Some(enrichment) = &outcome.enrichment {
episodes_found += enrichment.episodes.len();
let stored = self.catalog.lock().set_enrichment(
&outcome.title_key,
provider,
now(),
&episodes,
enrichment,
);
if let Err(error) = stored {
tracing::warn!("store episodes for {title_key}: {error}");
tracing::warn!("store enrichment for {}: {error}", outcome.title_key);
}
}
}
@@ -2078,17 +2048,21 @@ impl Engine {
return engine.fail("store the match", error);
}
let episodes = service.title_episodes(&title, &found).await;
if !episodes.is_empty() {
let stored = engine.catalog.lock().set_episode_metadata(
&title.key,
service.provider(),
now(),
&episodes,
);
if let Err(error) = stored {
tracing::warn!("store episodes for {}: {error}", title.key);
// A failure leaves the title un-enriched, and the next match run
// asks again.
match service.enrich(&title, &found).await {
Ok(enrichment) => {
let stored = engine.catalog.lock().set_enrichment(
&title.key,
service.provider(),
now(),
&enrichment,
);
if let Err(error) = stored {
tracing::warn!("store enrichment for {}: {error}", title.key);
}
}
Err(error) => tracing::warn!("enrich {}: {error}", title.key),
}
engine.emit(Event::Status(format!("{} is now {name}", title.name)));
@@ -2128,15 +2102,26 @@ impl Engine {
/// paths invalidates by itself and the same picture shared by two titles is
/// fetched once.
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 path = self
.dirs
.poster_cache()
.join(format!("{}.img", digest(&url)));
// A size of its own on disk: the tile-sized copy of the same URL is
// already there under the plain name, scaled down.
let name = match max_edge {
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 {
if let Ok(bytes) = tokio::fs::read(&path).await {
match decode_thumbnail(&bytes) {
match decode_scaled(&bytes, max_edge) {
Ok(decoded) => {
if let Some(smaller) = &decoded.smaller {
store_image(&path, smaller).await;
@@ -2163,7 +2148,7 @@ impl Engine {
}
};
match decode_thumbnail(&bytes) {
match decode_scaled(&bytes, max_edge) {
Ok(decoded) => {
store_image(&path, decoded.smaller.as_deref().unwrap_or(&bytes)).await;
engine.emit(Event::Poster {
@@ -2202,15 +2187,6 @@ fn digest(url: &str) -> String {
.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.
fn now() -> i64 {
std::time::SystemTime::now()
@@ -2348,9 +2324,14 @@ struct Decoded {
/// copy is handed back so the cache holds that instead, and the next launch
/// decodes a picture already the size it is drawn at.
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, smaller) = if decoded.width().max(decoded.height()) > THUMBNAIL_MAX_EDGE {
let scaled = decoded.thumbnail(THUMBNAIL_MAX_EDGE, THUMBNAIL_MAX_EDGE);
let (decoded, smaller) = if decoded.width().max(decoded.height()) > max_edge {
let scaled = decoded.thumbnail(max_edge, max_edge);
let encoded = encode_for_cache(&scaled);
(scaled, encoded)
} else {
+6
View File
@@ -35,6 +35,12 @@ pub const CARD_WIDTH: f32 = 232.0;
/// Video stills are 16:9. A poster shape would letterbox every one of them.
pub const CARD_ASPECT: f32 = 9.0 / 16.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.
///
+129 -30
View File
@@ -1,5 +1,6 @@
//! The library page: what to watch, as a wall of stills.
use pstr_core::appearance::Tiles;
use pstr_core::library::{Library, Title, TitleKind};
use crate::app::{Action, Filter, LibraryView, Page, Sort};
@@ -16,6 +17,8 @@ pub struct Shelves<'a> {
/// Whether the catalog has been read yet.
pub loaded: bool,
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>) {
@@ -24,6 +27,7 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
view,
loaded,
search,
tiles,
} = shelves;
if !loaded {
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);
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| {
@@ -86,7 +100,7 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
return;
}
grid(ui, art, &matches, actions);
grid(ui, art, &matches, view, tiles, actions);
});
}
@@ -121,7 +135,7 @@ fn featured<'a>(
metadata
.get(&title.key)
.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();
if pictured.is_empty() {
@@ -144,7 +158,7 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
return;
}
let radius = egui::CornerRadius::same(theme::radius::LG);
let picture = art.of(title);
let picture = art.wide(title);
let found = art
.metadata
.get(&title.key)
@@ -252,7 +266,7 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
ui.add(
egui::Label::new(
theme::Role::Display
.rich(&title.name)
.rich(art.name(title))
.strong()
.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>) {
// Right to left: the order first, so it ends up last.
let sort = egui::ComboBox::from_id_salt("library-sort")
.selected_text(match view.sort {
Sort::Name => "A – Z",
Sort::Recent => "Recently watched",
Sort::Year => "Newest",
})
.selected_text(view.sort.label())
.width(150.0)
.show_ui(ui, |ui| {
let mut picked = None;
for (sort, label) in [
(Sort::Name, "A – Z"),
(Sort::Recent, "Recently watched"),
(Sort::Year, "Newest"),
] {
if ui.selectable_label(view.sort == sort, label).clicked() {
for sort in Sort::ALL {
if ui
.selectable_label(view.sort == sort, sort.label())
.clicked()
{
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 {
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);
if let Some(filter) = ui::widgets::segmented(
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.
fn continue_row(
ui: &mut egui::Ui,
@@ -337,17 +411,20 @@ fn continue_row(
})
.unwrap_or_else(|| episode.label());
let picture = art.of(title);
let response = ui::card(
ui,
Card {
art: art.of(title),
name: &title.name,
art: picture,
name: art.name(title),
subtitle: remaining,
progress: episode.progress().map(|value| value as f32),
badge: episode.numbering(),
// 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,
aspect: theme::CARD_ASPECT,
},
);
tile_menu(&response, title, true, actions);
@@ -361,10 +438,18 @@ fn continue_row(
}
/// Every title, wrapped to the window.
fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut Vec<Action>) {
let grid = ui::columns(ui.available_width());
let row_height = ui::card_height(grid.width);
for row in titles.chunks(grid.columns) {
fn grid(
ui: &mut egui::Ui,
art: &mut Art<'_>,
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
// 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.
@@ -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 left edge.
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(
ui,
Card {
art: art.of(title),
name: &title.name,
subtitle: subtitle(title),
art: picture,
name: art.name(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),
// What a film is says itself in the subtitle below the
// card; a second `Film` over the poster is noise.
badge: None,
// card; a second `Film` over the poster is noise. A
// folded franchise says how much is under it.
badge: folded.map(|more| format!("+{more}")),
width: grid.width,
aspect,
},
);
tile_menu(&response, title, false, actions);
@@ -517,7 +616,7 @@ fn subtitle(title: &Title) -> String {
/// fraction is a flash of something false.
fn placeholder(ui: &mut egui::Ui) {
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");
for _ in 0..2 {
ui.horizontal(|ui| {
+96 -11
View File
@@ -20,9 +20,9 @@ use std::collections::HashMap;
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Vec2};
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;
/// Everything the pages need to draw one title's picture.
@@ -40,8 +40,12 @@ pub struct Art<'a> {
pub metadata: &'a HashMap<String, MetadataRecord>,
/// What they said about the episodes under those titles.
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<'_> {
/// What the provider says about one file of a title, matched on the
/// numbering its name states.
@@ -74,6 +78,58 @@ impl Art<'_> {
.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.
///
/// Provider artwork first, then Proton's still, then nothing. That order is
@@ -104,6 +160,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.
pub fn format_time(seconds: f64) -> String {
if !seconds.is_finite() || seconds < 0.0 {
@@ -492,6 +559,9 @@ pub struct Card<'a> {
/// sideways-scrolling shelf has no edge to reach and passes
/// [`theme::CARD_WIDTH`].
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
@@ -499,10 +569,10 @@ pub struct Card<'a> {
/// different height by title length reads as broken.
const CARD_TEXT_HEIGHT: f32 = 54.0;
/// How tall [`card`] draws a tile of this width, so a grid can step over rows
/// it does not draw.
pub fn card_height(width: f32) -> f32 {
(width * theme::CARD_ASPECT).round() + CARD_TEXT_HEIGHT
/// How tall [`card`] draws a tile of this width and shape, so a grid can step
/// over rows it does not draw.
pub fn card_height(width: f32, aspect: f32) -> f32 {
(width * aspect).round() + CARD_TEXT_HEIGHT
}
/// Draw one tile and report whether it was clicked.
@@ -515,7 +585,10 @@ pub fn card_height(width: f32) -> f32 {
/// reads as deliberate in a way a stretched poster does not.
pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
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 radius = CornerRadius::same(theme::radius::MD);
@@ -574,7 +647,7 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
.with_texture(texture.id(), cover_uv(size, image_rect.size())),
);
}
Some((texture, ArtShape::Portrait)) => {
Some((texture, ArtShape::Portrait)) if fit => {
painter.add(
egui::epaint::RectShape::filled(
contain_rect(texture.size_vec2(), image_rect),
@@ -587,6 +660,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 => {}
}
@@ -777,9 +858,13 @@ pub struct Grid {
/// 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.
pub fn columns(available: f32) -> Grid {
let columns = (((available + theme::CARD_GAP) / (theme::CARD_WIDTH + theme::CARD_GAP)).floor()
as usize)
.max(1);
columns_of(available, theme::CARD_WIDTH)
}
/// [`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
// 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
+40 -3
View File
@@ -5,8 +5,8 @@
//! that manages Proton Drive links, and a page that is half link management and
//! half preferences is two pages.
use pstr_core::appearance::{Accent, Appearance, Flavor};
use pstr_core::metadata::{MetadataConfig, ProviderId};
use pstr_core::appearance::{Accent, Appearance, Flavor, Tiles};
use pstr_core::metadata::{MetadataConfig, ProviderId, TitleNames};
use crate::app::Action;
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;
ui::widgets::settings_row(
ui,
@@ -369,7 +404,9 @@ fn metadata_form(
"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 \
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);
+125 -15
View File
@@ -20,6 +20,7 @@ pub fn show(
art: &mut Art<'_>,
library: &Library,
key: &str,
franchise: Option<&[String]>,
offline: OfflineView<'_>,
actions: &mut Vec<Action>,
) {
@@ -41,6 +42,15 @@ pub fn show(
header(ui, art, title, &offline, actions);
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
// every season stacked as a collapsing header: a four-season show
// was a page of headers to scroll past to reach the one being
@@ -100,6 +110,60 @@ pub fn show(
});
}
/// Every title of the franchise, in release order, as covers — this one
/// marked rather than left out, so the row says where it sits in the story.
fn franchise_row(
ui: &mut egui::Ui,
art: &mut Art<'_>,
current: &Title,
members: &[&Title],
actions: &mut Vec<Action>,
) {
egui::ScrollArea::horizontal()
.id_salt(("franchise", &current.key))
.auto_shrink([false, true])
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
for member in members {
let here = member.key == current.key;
let picture = art.poster(member);
let found = art
.metadata
.get(&member.key)
.and_then(|record| record.metadata.as_ref());
let what = found
.and_then(|found| found.details.as_ref())
.and_then(|details| details.format_label())
.unwrap_or(match member.kind {
TitleKind::Series => "Series",
TitleKind::Film => "Film",
});
let year = found
.and_then(|found| found.year)
.or(member.year)
.map(|year| format!(" · {year}"))
.unwrap_or_default();
let response = ui::card(
ui,
Card {
art: picture,
name: art.name(member),
subtitle: format!("{what}{year}"),
progress: member.resume().and_then(|e| e.progress()).map(|v| v as f32),
badge: here.then(|| "Here".to_owned()),
width: 128.0,
aspect: theme::POSTER_ASPECT,
},
);
if !here && response.clicked() {
actions.push(Action::Goto(Page::Title(member.key.clone())));
}
}
});
});
}
/// A title's poster, at the poster's own shape, with how far into it the
/// viewer is along its foot.
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 found = record.and_then(|record| record.metadata.clone());
let by_hand = record.is_some_and(|record| record.manual);
// Asked for once and used twice: the same picture is the poster beside the
// text and the banner behind all of it.
let picture = art.of(title);
// The cover beside the text, and the widest, largest picture there is
// behind all of it — two textures, because the band is drawn across the
// 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
// 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.
let backdrop = picture
let backdrop = wide
.as_ref()
.map(|(texture, _)| (texture.clone(), ui.painter().add(egui::Shape::Noop)));
let shown = art.name(title).to_owned();
ui.add_space(theme::space::S);
ui.horizontal_top(|ui| {
@@ -214,11 +281,12 @@ fn header(
ui,
Card {
art: picture,
name: &title.name,
name: &shown,
subtitle: String::new(),
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
badge: None,
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
// right of it rather than under it.
ui.set_max_width(ui.available_width().min(820.0));
ui.label(theme::Role::Display.rich(&title.name).strong());
// The provider's name for it, when it is not the one the files use.
// Worth showing rather than replacing the filename's: a viewer
// should be able to tell what the match actually matched.
if let Some(found) = &found
&& found.name != title.name
{
ui.label(theme::Role::Display.rich(&shown).strong());
// Whichever of the share's name and the provider's is not the one
// shown. Worth showing rather than only replacing: a viewer should
// be able to tell what the match actually matched, and find the
// folder it came from.
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!(
"also known as {}{}",
found.name,
"also known as {other}{}",
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.
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.spacing_mut().item_spacing.x = 6.0;
for genre in &found.genres {
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);
}
@@ -530,6 +616,30 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
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(rating) = found.rating {
parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
+78 -31
View File
@@ -483,18 +483,19 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
)
.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 stored = catalog.all_metadata()?;
let enriched = catalog.enrichment_ages()?;
let mut pending: Vec<_> = library
.titles
.iter()
.filter(|title| {
force
|| !pstr_meta::service::is_usable(stored.get(&title.key), settings.provider)
})
.collect();
let mut pending = pstr_meta::service::plan(
library.titles,
&stored,
&enriched,
settings.provider,
service.has_details(),
force,
);
if let Some(limit) = limit {
pending.truncate(limit);
}
@@ -504,7 +505,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
return Ok(());
}
println!(
"matching {} titles against {}…",
"looking up {} titles against {}…",
pending.len(),
settings.provider.label()
);
@@ -513,32 +514,71 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
// and the GUI already has the parallel path. What this command is
// for is reading the output.
let (mut matched, mut failed) = (0usize, 0usize);
for title in pending {
match service.record(title).await {
Ok(record) => {
match &record.metadata {
Some(found) => {
matched += 1;
println!(
" {:<40} → {} ({})",
title.name,
found.name,
found
.year
.map(|year| year.to_string())
.unwrap_or_else(|| "-".into())
);
}
None => println!(" {:<40} → no match", title.name),
}
catalog.set_metadata(&record)?;
}
for work in pending {
use pstr_meta::service::Work;
let name = &work.title().name;
// Whether this work asks for episodes at all — a refresh of an
// already-enriched title does not, and saying they were "not
// fetched" there reads as a failure.
let enriches = matches!(work, Work::Match(_) | Work::Refresh { enrich: true, .. });
let outcome = match service.work(&work).await {
Ok(outcome) => outcome,
Err(error) => {
failed += 1;
// Not stored: a failure to ask is not an answer, and the
// 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");
@@ -926,3 +966,10 @@ async fn play(
drop(opened.library);
Ok(())
}
/// Unix seconds, for the "when was this asked" columns.
fn unix_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_secs() as i64)
}
+34
View File
@@ -123,6 +123,30 @@ impl Accent {
}
}
/// What shape the library's grid is.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Tiles {
/// 2:3 covers. The default, because the art a provider has for every
/// title is a cover; a 16:9 still exists for fewer of them, and a grid of
/// letterboxed covers was the desktop's weakest page.
#[default]
Posters,
/// 16:9 stills: the provider's fanart, else a frame from the file.
Stills,
}
impl Tiles {
pub const ALL: [Self; 2] = [Self::Posters, Self::Stills];
pub fn label(self) -> &'static str {
match self {
Self::Posters => "Posters",
Self::Stills => "Stills",
}
}
}
/// The whole of the choice.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
// 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
/// striped.
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 {
@@ -146,6 +174,8 @@ impl Default for Appearance {
flavor: Flavor::default(),
accent: Accent::default(),
gradients: true,
tiles: Tiles::default(),
names: crate::metadata::TitleNames::default(),
}
}
}
@@ -615,6 +645,7 @@ mod tests {
flavor: Flavor::Mocha,
accent: Accent::PinkSky,
gradients: false,
..Appearance::default()
};
save(&dirs, &appearance).unwrap();
assert_eq!(load(&dirs).unwrap(), appearance);
@@ -652,6 +683,7 @@ mod tests {
flavor,
accent,
gradients: true,
..Appearance::default()
});
let ratio = worst_contrast(palette.accent, palette.accent_alt, palette.on_accent);
assert!(
@@ -681,6 +713,7 @@ mod tests {
flavor: Flavor::Persona5,
accent: Accent::Red,
gradients: false,
..Appearance::default()
});
assert_eq!(palette.accent, PERSONA5.red);
assert!(!palette.light);
@@ -697,6 +730,7 @@ mod tests {
flavor,
accent: Accent::Red,
gradients: true,
..Appearance::default()
});
// The accent is the flavour's red — deepened on the light flavour,
// where it would otherwise be too bright to put a label on.
+395
View File
@@ -0,0 +1,395 @@
//! Finding and ordering titles: what both library grids show, and in what
//! order.
//!
//! Here rather than in a front end so the desktop and Android agree: the same
//! search finds the same titles, a franchise folds into the same tile, and
//! "Newest" means the same thing on both.
use std::collections::{HashMap, HashSet};
use crate::library::{Library, Title, sort_name};
use crate::metadata::{MetadataRecord, TitleNames};
/// What order a grid is in.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Sort {
/// By the name shown, ignoring a leading article.
#[default]
Name,
/// Most recently played first.
Recent,
/// Most recently given a new file first.
Added,
/// Newest first by when it aired, undated last.
Release,
/// Best rated first, unrated last.
Rating,
/// Most followed at the provider first.
Popularity,
}
impl Sort {
pub const ALL: [Self; 6] = [
Self::Name,
Self::Recent,
Self::Added,
Self::Release,
Self::Rating,
Self::Popularity,
];
pub fn label(self) -> &'static str {
match self {
Self::Name => "A – Z",
Self::Recent => "Recently watched",
Self::Added => "Recently added",
Self::Release => "Newest",
Self::Rating => "Highest rated",
Self::Popularity => "Most popular",
}
}
}
/// The name to show for `title`: the provider's, in the form the viewer
/// picked, else the share's.
pub fn display_name<'a>(
title: &'a Title,
metadata: &'a HashMap<String, MetadataRecord>,
names: TitleNames,
) -> &'a str {
metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
.and_then(|found| found.display_name(names))
.unwrap_or(&title.name)
}
/// The titles `query` finds, in library order.
///
/// By the share's name and every filename under it — a release group, an
/// episode name — and by every name the provider gave: a folder called
/// `Sousou no Frieren` is found by typing "Frieren: Beyond".
pub fn search<'a>(
library: &'a Library,
metadata: &HashMap<String, MetadataRecord>,
query: &str,
) -> Vec<&'a Title> {
let needle = query.trim().to_lowercase();
let by_files: HashSet<*const Title> = library
.search(query)
.into_iter()
.map(|title| title as *const Title)
.collect();
library
.titles
.iter()
.filter(|title| {
by_files.contains(&(*title as *const Title))
|| metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
.is_some_and(|found| {
std::iter::once(found.name.as_str())
.chain(found.original_name.as_deref())
.chain(
found
.details
.as_ref()
.and_then(|details| details.romaji.as_deref()),
)
.any(|name| name.to_lowercase().contains(&needle))
})
})
.collect()
}
/// How to order a grid.
#[derive(Debug, Clone, Copy)]
pub struct Order<'a> {
pub sort: Sort,
/// One tile per franchise rather than one per title.
pub grouped: bool,
pub names: TitleNames,
pub metadata: &'a HashMap<String, MetadataRecord>,
/// When each title last gained a file, by key — see
/// [`Library::added_at`].
pub added: &'a HashMap<String, i64>,
}
/// One tile: the title drawn, and every title it stands for — itself
/// included, in release order — as indices into what was arranged.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tile {
pub lead: usize,
pub members: Vec<usize>,
}
/// `titles` as tiles, in `order`.
///
/// Every sort is stable over the name order, so ties — and the undated,
/// unrated and unplayed — keep their alphabetical order.
pub fn arrange<'t>(titles: &[&'t Title], order: Order<'_>) -> Vec<Tile> {
let metadata = order.metadata;
let found = |title: &Title| {
metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
};
let mut by_name: Vec<usize> = (0..titles.len()).collect();
by_name
.sort_by_cached_key(|&index| sort_name(display_name(titles[index], metadata, order.names)));
let sorted: Vec<&Title> = by_name.iter().map(|&index| titles[index]).collect();
let mut tiles: Vec<Tile> = if order.grouped {
crate::franchise::group(&sorted, metadata)
.into_iter()
.map(|franchise| Tile {
lead: by_name[franchise.lead(&sorted)],
members: franchise
.members
.iter()
.map(|&member| by_name[member])
.collect(),
})
.collect()
} else {
by_name
.iter()
.map(|&index| Tile {
lead: index,
members: vec![index],
})
.collect()
};
// A folded franchise files under the name on its tile — the lead's — and
// not under whichever member sorted first: "Made in Abyss" belongs at M
// even when its film is called "Dawn of the Deep Soul".
tiles.sort_by_cached_key(|tile| {
sort_name(display_name(titles[tile.lead], metadata, order.names))
});
let members = |tile: &Tile| -> Vec<&'t Title> {
tile.members.iter().map(|&index| titles[index]).collect()
};
match order.sort {
Sort::Name => {}
Sort::Recent => tiles.sort_by_key(|tile| {
std::cmp::Reverse(members(tile).iter().map(|title| title.last_played()).max())
}),
Sort::Added => tiles.sort_by_key(|tile| {
std::cmp::Reverse(
members(tile)
.iter()
.filter_map(|title| order.added.get(&title.key))
.max()
.copied(),
)
}),
Sort::Release => tiles.sort_by_cached_key(|tile| {
std::cmp::Reverse(
members(tile)
.iter()
.map(|title| crate::franchise::release_key(title, metadata))
// `~` is how the key says "undated"; it sorts last here
// rather than first.
.filter(|key| key != "~")
.max(),
)
}),
Sort::Rating => tiles.sort_by_key(|tile| {
std::cmp::Reverse(
found(titles[tile.lead])
.and_then(|found| found.rating)
.map(|rating| (rating * 100.0) as i64),
)
}),
Sort::Popularity => tiles.sort_by_key(|tile| {
std::cmp::Reverse(
found(titles[tile.lead])
.and_then(|found| found.details.as_ref())
.and_then(|details| details.popularity),
)
}),
}
tiles
}
#[cfg(test)]
mod tests {
use super::*;
use crate::library::TitleKind;
use crate::metadata::{ProviderId, TitleDetails, TitleMetadata};
fn title(key: &str, name: &str, kind: TitleKind) -> Title {
Title {
key: key.into(),
name: name.into(),
year: None,
kind,
seasons: Vec::new(),
share_ids: Vec::new(),
}
}
fn matched(
key: &str,
id: &str,
english: &str,
start: &str,
rating: f32,
related: &[&str],
) -> (String, MetadataRecord) {
(
key.into(),
MetadataRecord {
title_key: key.into(),
provider: ProviderId::AniList,
metadata: Some(TitleMetadata {
provider: ProviderId::AniList,
remote_id: id.into(),
name: english.into(),
original_name: None,
overview: None,
year: None,
kind: TitleKind::Series,
poster_url: None,
backdrop_url: None,
rating: Some(rating),
genres: Vec::new(),
episodes: None,
url: None,
details: Some(TitleDetails {
romaji: Some(key.into()),
start_date: Some(start.into()),
related: related.iter().map(|id| id.to_string()).collect(),
..TitleDetails::default()
}),
}),
fetched_at: 0,
manual: false,
},
)
}
fn fixture() -> (Vec<Title>, HashMap<String, MetadataRecord>) {
let titles = vec![
title("abyss film", "Made in Abyss Dawn", TitleKind::Film),
title("made in abyss", "Made in Abyss", TitleKind::Series),
title("sousou no frieren", "Sousou no Frieren", TitleKind::Series),
];
let metadata = HashMap::from([
matched(
"abyss film",
"2",
"Dawn of the Deep Soul",
"2020-01-17",
8.5,
&["1"],
),
matched(
"made in abyss",
"1",
"Made in Abyss",
"2017-07-07",
8.4,
&["2"],
),
matched(
"sousou no frieren",
"3",
"Frieren: Beyond Journey's End",
"2023-09-29",
9.1,
&[],
),
]);
(titles, metadata)
}
fn keys(tiles: &[Tile], titles: &[&Title]) -> Vec<String> {
tiles
.iter()
.map(|tile| titles[tile.lead].key.clone())
.collect()
}
#[test]
fn a_franchise_folds_into_its_series_and_sorts_by_the_name_shown() {
let (owned, metadata) = fixture();
let titles: Vec<&Title> = owned.iter().collect();
let added = HashMap::new();
let order = Order {
sort: Sort::Name,
grouped: true,
names: TitleNames::English,
metadata: &metadata,
added: &added,
};
let tiles = arrange(&titles, order);
// "Frieren" before "Made in Abyss" in English; the film is folded.
assert_eq!(
keys(&tiles, &titles),
vec!["sousou no frieren", "made in abyss"]
);
assert_eq!(tiles[1].members, vec![1, 0]);
let tiles = arrange(
&titles,
Order {
names: TitleNames::Library,
grouped: false,
..order
},
);
assert_eq!(
keys(&tiles, &titles),
vec!["made in abyss", "abyss film", "sousou no frieren"]
);
}
#[test]
fn newest_and_best_rated_read_the_provider_and_put_the_unknown_last() {
let (mut owned, metadata) = fixture();
owned.push(title(
"game of thrones",
"Game of Thrones",
TitleKind::Series,
));
let titles: Vec<&Title> = owned.iter().collect();
let added = HashMap::new();
let order = Order {
sort: Sort::Release,
grouped: true,
names: TitleNames::Library,
metadata: &metadata,
added: &added,
};
// The Made in Abyss franchise dates from its newest part, 2020.
assert_eq!(
keys(&arrange(&titles, order), &titles),
vec!["sousou no frieren", "made in abyss", "game of thrones"]
);
let order = Order {
sort: Sort::Rating,
..order
};
assert_eq!(
keys(&arrange(&titles, order), &titles)
.last()
.map(String::as_str),
Some("game of thrones")
);
}
#[test]
fn search_finds_a_title_by_any_name_the_provider_gave_it() {
let (owned, metadata) = fixture();
let library = Library { titles: owned };
let found = search(&library, &metadata, "beyond journey");
assert_eq!(found.len(), 1);
assert_eq!(found[0].key, "sousou no frieren");
assert_eq!(search(&library, &metadata, "").len(), 3);
}
}
+602 -49
View File
@@ -17,11 +17,14 @@ use rusqlite::{Connection, OptionalExtension, params};
use crate::error::Result;
use crate::library::TitleKind;
use crate::metadata::{EpisodeGuide, EpisodeMetadata, MetadataRecord, ProviderId, TitleMetadata};
use crate::metadata::{
Enrichment, EpisodeGuide, EpisodeMetadata, MetadataRecord, ProviderId, TitleDetails,
TitleMetadata,
};
use crate::naming::{self, ParsedName};
/// Bump when adding a migration.
pub const SCHEMA_VERSION: i64 = 8;
pub const SCHEMA_VERSION: i64 = 10;
const MIGRATION_V1: &str = r#"
CREATE TABLE nodes (
@@ -153,6 +156,57 @@ ALTER TABLE title_track_prefs ADD COLUMN audio_title TEXT;
ALTER TABLE title_track_prefs ADD COLUMN subtitle_title TEXT;
"#;
/// Episodes numbered three ways, and a record of which titles were enriched.
///
/// A release numbers its files by TVDB season, by absolute number inside a
/// season folder, or by AniList entry; an episode row now carries all three so
/// `EpisodeGuide::get` can meet whichever the filename used. `enriched_at` is
/// set once a matched title's episodes and art were asked for — empty answers
/// included, which is what stops a film being re-asked on every run.
///
/// The deletes are a repair, not a reset. Every AniList episode row came from
/// `streamingEpisodes` — partial, numbered however a streaming site numbered
/// it, and in places a later season captioned with season one's names — and
/// every AniList backdrop was a 4.75:1 banner drawn into a 16:9 tile. Both are
/// wrong rather than stale, so they go; the matches stay, hand-picked ones
/// included, and the next match run enriches them again. See
/// `docs/METADATA.md`.
const MIGRATION_V9: &str = r#"
ALTER TABLE episode_metadata ADD COLUMN absolute INTEGER;
ALTER TABLE episode_metadata ADD COLUMN entry_season INTEGER;
ALTER TABLE episode_metadata ADD COLUMN entry_number INTEGER;
ALTER TABLE title_metadata ADD COLUMN enriched_at INTEGER;
DELETE FROM episode_metadata WHERE provider = 'anilist';
UPDATE title_metadata SET backdrop_url = NULL WHERE provider = 'anilist';
"#;
/// What the library is sorted, grouped and shelved by, and when each file
/// first appeared.
///
/// `details` is [`TitleDetails`] as JSON — see there for why one column.
/// `NULL` means it was never fetched, which a match run reads as "fetch it"
/// rather than as "there is none"; every existing match starts that way.
/// `chain` is the provider ids enrichment walked, newline-separated; every
/// title is marked un-enriched so the next run records it.
///
/// `node_first_seen` is kept apart from `nodes` because a recrawl replaces
/// every node row and "recently added" has to survive that. Files already in
/// the catalog are recorded as seen at 0 — when they arrived is not known, and
/// pretending it was today would put the whole library at the top.
const MIGRATION_V10: &str = r#"
ALTER TABLE title_metadata ADD COLUMN details TEXT;
ALTER TABLE title_metadata ADD COLUMN chain TEXT;
CREATE TABLE node_first_seen (
share_id TEXT NOT NULL,
link_id TEXT NOT NULL,
seen_at INTEGER NOT NULL,
PRIMARY KEY (share_id, link_id)
);
INSERT OR IGNORE INTO node_first_seen (share_id, link_id, seen_at)
SELECT share_id, link_id, 0 FROM nodes WHERE is_folder = 0;
UPDATE title_metadata SET enriched_at = NULL;
"#;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OfflineFile {
pub revision_id: String,
@@ -332,6 +386,18 @@ impl Catalog {
tx.pragma_update(None, "user_version", 8)?;
tx.commit()?;
}
if version < 9 {
let tx = self.conn.transaction()?;
tx.execute_batch(MIGRATION_V9)?;
tx.pragma_update(None, "user_version", 9)?;
tx.commit()?;
}
if version < 10 {
let tx = self.conn.transaction()?;
tx.execute_batch(MIGRATION_V10)?;
tx.pragma_update(None, "user_version", 10)?;
tx.commit()?;
}
Ok(())
}
@@ -392,6 +458,13 @@ impl Catalog {
}
}
// Only files: a folder appearing is not something anyone watches.
tx.execute(
"INSERT OR IGNORE INTO node_first_seen (share_id, link_id, seen_at)
SELECT share_id, link_id, ?2 FROM nodes WHERE share_id = ?1 AND is_folder = 0",
params![share_id, unix_now()],
)?;
if !retain_offline {
tx.execute("DELETE FROM offline_files WHERE share_id = ?1 AND NOT EXISTS (SELECT 1 FROM nodes WHERE nodes.share_id = offline_files.share_id AND nodes.link_id = offline_files.link_id AND nodes.active_revision_id = offline_files.revision_id)", params![share_id])?;
}
@@ -428,6 +501,18 @@ impl Catalog {
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
}
/// When each file first appeared in a crawl, keyed `(share_id, link_id)`.
/// `0` for a file that was already there before this was recorded.
pub fn first_seen(&self) -> Result<HashMap<(String, String), i64>> {
let mut statement = self
.conn
.prepare("SELECT share_id, link_id, seen_at FROM node_first_seen")?;
let rows = statement.query_map([], |row| {
Ok(((row.get(0)?, row.get(1)?), row.get::<_, i64>(2)?))
})?;
Ok(rows.collect::<std::result::Result<_, _>>()?)
}
/// Drop everything a share contributed, watch positions included.
///
/// Unlike a recrawl — which replaces rows and deliberately leaves watch
@@ -445,6 +530,10 @@ impl Catalog {
"DELETE FROM offline_files WHERE share_id = ?1",
params![share_id],
)?;
tx.execute(
"DELETE FROM node_first_seen WHERE share_id = ?1",
params![share_id],
)?;
tx.commit()?;
Ok(())
}
@@ -665,7 +754,7 @@ impl Catalog {
let mut statement = self.conn.prepare(
"SELECT title_key, provider, matched, fetched_at, remote_id, name, original_name,
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url,
manual
manual, details, chain
FROM title_metadata",
)?;
let rows = statement.query_map([], row_to_metadata)?;
@@ -686,7 +775,7 @@ impl Catalog {
.query_row(
"SELECT title_key, provider, matched, fetched_at, remote_id, name, original_name,
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes,
url, manual
url, manual, details, chain
FROM title_metadata WHERE title_key = ?1",
params![title_key],
row_to_metadata,
@@ -699,14 +788,41 @@ impl Catalog {
///
/// A miss is stored too — see [`MetadataRecord::metadata`]. Storing only
/// matches would leave every unmatched title asking again on every render.
///
/// Clears the title's enrichment record, so the answer is enriched again;
/// see [`Catalog::set_enrichment`]. And when the answer is a different
/// entry from the one stored — or none — the old entry's episode rows go
/// at once: they are not about this one, and a front end that pins a match
/// without enriching it must not show them in the meantime.
pub fn set_metadata(&self, record: &MetadataRecord) -> Result<()> {
let data = record.metadata.as_ref();
self.conn.execute(
"INSERT INTO title_metadata (
"DELETE FROM episode_metadata WHERE title_key = ?1 AND NOT EXISTS (
SELECT 1 FROM title_metadata
WHERE title_key = ?1 AND provider = ?2 AND remote_id IS ?3
)",
params![
record.title_key,
record.provider.as_str(),
data.map(|data| data.remote_id.as_str()),
],
)?;
let details = data
.and_then(|data| data.details.as_ref())
.map(serde_json::to_string)
.transpose()
.map_err(|error| crate::error::Error::Config(error.to_string()))?;
// `same` below is whether the row already describes this entry. When
// it does, what enrichment added — the fanart, the chain, the record
// that it was asked — still describes it and is kept: refreshing a
// match's details must not cost the library its episode lists.
self.conn.execute(
&"INSERT INTO title_metadata (
title_key, provider, matched, fetched_at, remote_id, name, original_name,
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url,
manual
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)
manual, details
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16,
?17, ?18)
ON CONFLICT (title_key) DO UPDATE SET
provider = excluded.provider,
matched = excluded.matched,
@@ -718,12 +834,23 @@ impl Catalog {
year = excluded.year,
kind = excluded.kind,
poster_url = excluded.poster_url,
backdrop_url = excluded.backdrop_url,
backdrop_url = CASE WHEN same THEN COALESCE(excluded.backdrop_url,
title_metadata.backdrop_url)
ELSE excluded.backdrop_url END,
rating = excluded.rating,
genres = excluded.genres,
episodes = excluded.episodes,
url = excluded.url,
manual = excluded.manual",
manual = excluded.manual,
details = excluded.details,
chain = CASE WHEN same THEN title_metadata.chain END,
enriched_at = CASE WHEN same THEN title_metadata.enriched_at END"
.replace(
"same",
"(title_metadata.provider = excluded.provider
AND title_metadata.remote_id IS excluded.remote_id
AND excluded.matched = 1)",
),
params![
record.title_key,
record.provider.as_str(),
@@ -742,6 +869,7 @@ impl Catalog {
data.and_then(|data| data.episodes),
data.and_then(|data| data.url.as_deref()),
record.manual as i64,
details,
],
)?;
Ok(())
@@ -776,12 +904,7 @@ impl Catalog {
Ok(())
}
/// Replace everything stored about one title's episodes.
///
/// A replace rather than an upsert per row: a provider that has *dropped*
/// an episode — a special that was recounted, a season renumbered — should
/// leave nothing behind, and an episode list is small enough that the
/// difference is a few hundred microseconds.
/// Replace everything stored about one title's episodes, and nothing else.
pub fn set_episode_metadata(
&mut self,
title_key: &str,
@@ -790,31 +913,40 @@ impl Catalog {
episodes: &[EpisodeMetadata],
) -> Result<()> {
let tx = self.conn.transaction()?;
replace_episodes(&tx, title_key, provider, fetched_at, episodes)?;
tx.commit()?;
Ok(())
}
/// Store what enriching a matched title found, and that it was asked.
///
/// The episode list is replaced even when the new one is empty — a title
/// re-pinned from a series to its film must not keep the series' episode
/// names. The backdrop only changes when one was found. And the title is
/// marked enriched either way, so a film with no episodes is not asked
/// about again on every run; [`Catalog::set_metadata`] clears the mark when
/// the match itself changes.
pub fn set_enrichment(
&mut self,
title_key: &str,
provider: ProviderId,
fetched_at: i64,
enrichment: &Enrichment,
) -> Result<()> {
let tx = self.conn.transaction()?;
replace_episodes(&tx, title_key, provider, fetched_at, &enrichment.episodes)?;
tx.execute(
"DELETE FROM episode_metadata WHERE title_key = ?1",
params![title_key],
"UPDATE title_metadata
SET enriched_at = ?3, backdrop_url = COALESCE(?4, backdrop_url), chain = ?5
WHERE title_key = ?1 AND provider = ?2",
params![
title_key,
provider.as_str(),
fetched_at,
enrichment.backdrop_url.as_deref(),
enrichment.chain.join("\n"),
],
)?;
{
let mut insert = tx.prepare(
"INSERT INTO episode_metadata (
title_key, season, number, provider, fetched_at,
name, overview, still_url, air_date
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
)?;
for episode in episodes {
insert.execute(params![
title_key,
episode.season.map_or(ABSOLUTE_SEASON, i64::from),
episode.number,
provider.as_str(),
fetched_at,
episode.name.as_deref(),
episode.overview.as_deref(),
episode.still_url.as_deref(),
episode.air_date.as_deref(),
])?;
}
}
tx.commit()?;
Ok(())
}
@@ -825,7 +957,8 @@ impl Catalog {
/// season drawn a row at a time must not be a SQLite round-trip per row.
pub fn all_episode_metadata(&self) -> Result<HashMap<String, EpisodeGuide>> {
let mut statement = self.conn.prepare(
"SELECT title_key, season, number, name, overview, still_url, air_date
"SELECT title_key, season, number, name, overview, still_url, air_date,
absolute, entry_season, entry_number
FROM episode_metadata",
)?;
let rows = statement.query_map([], |row| {
@@ -836,6 +969,9 @@ impl Catalog {
EpisodeMetadata {
season: (season != ABSOLUTE_SEASON).then_some(season as u32),
number: row.get::<_, i64>(2)? as u32,
absolute: row.get(7)?,
entry_season: row.get(8)?,
entry_number: row.get(9)?,
name: row.get(3)?,
overview: row.get(4)?,
still_url: row.get(5)?,
@@ -855,13 +991,13 @@ impl Catalog {
.collect())
}
/// Which titles already have an episode list stored, and how fresh.
///
/// Enough to decide whether to ask again without reading every row: the
/// answer is only ever compared against a TTL.
pub fn episode_metadata_ages(&self) -> Result<HashMap<String, (ProviderId, i64)>> {
/// When each matched title was last enriched, for the provider it is
/// matched against. A title missing here has never been, or its match has
/// changed since.
pub fn enrichment_ages(&self) -> Result<HashMap<String, (ProviderId, i64)>> {
let mut statement = self.conn.prepare(
"SELECT title_key, provider, MIN(fetched_at) FROM episode_metadata GROUP BY title_key",
"SELECT title_key, provider, enriched_at FROM title_metadata
WHERE enriched_at IS NOT NULL",
)?;
let rows = statement.query_map([], |row| {
Ok((
@@ -873,15 +1009,80 @@ impl Catalog {
let mut ages = HashMap::new();
for row in rows {
let (title_key, provider, fetched_at) = row?;
let (title_key, provider, enriched_at) = row?;
if let Some(provider) = ProviderId::parse(&provider) {
ages.insert(title_key, (provider, fetched_at));
ages.insert(title_key, (provider, enriched_at));
}
}
Ok(ages)
}
}
/// Replace every episode row of one title with `episodes`, inside `tx`.
///
/// A replace rather than an upsert per row: a provider that has *dropped* an
/// episode — a special that was recounted, a season renumbered — should leave
/// nothing behind, and an episode list is small enough that the difference is a
/// few hundred microseconds.
fn replace_episodes(
tx: &rusqlite::Transaction<'_>,
title_key: &str,
provider: ProviderId,
fetched_at: i64,
episodes: &[EpisodeMetadata],
) -> Result<()> {
tx.execute(
"DELETE FROM episode_metadata WHERE title_key = ?1",
params![title_key],
)?;
let mut insert = tx.prepare(
"INSERT OR REPLACE INTO episode_metadata (
title_key, season, number, provider, fetched_at,
name, overview, still_url, air_date,
absolute, entry_season, entry_number
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
)?;
for episode in episodes {
insert.execute(params![
title_key,
episode.season.map_or(ABSOLUTE_SEASON, i64::from),
episode.number,
provider.as_str(),
fetched_at,
episode.name.as_deref(),
episode.overview.as_deref(),
episode.still_url.as_deref(),
episode.air_date.as_deref(),
episode.absolute,
episode.entry_season,
episode.entry_number,
])?;
}
Ok(())
}
/// The `details` and `chain` columns, read back.
///
/// JSON that will not parse is treated as never fetched rather than failing
/// the read: the next match run fetches it again, where an error here would
/// take the whole grid down over one row.
fn details_column(details: Option<String>, chain: Option<String>) -> Option<TitleDetails> {
let mut details: TitleDetails = serde_json::from_str(&details?).ok()?;
details.chain = chain
.unwrap_or_default()
.lines()
.filter(|id| !id.is_empty())
.map(str::to_string)
.collect();
Some(details)
}
fn unix_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_secs() as i64)
}
/// `None` for a row whose provider column this build does not recognise.
fn row_to_metadata(row: &rusqlite::Row<'_>) -> rusqlite::Result<Option<MetadataRecord>> {
let title_key: String = row.get(0)?;
@@ -914,6 +1115,7 @@ fn row_to_metadata(row: &rusqlite::Row<'_>) -> rusqlite::Result<Option<MetadataR
.collect(),
episodes: row.get(14)?,
url: row.get(15)?,
details: details_column(row.get(17)?, row.get(18)?),
})
};
@@ -1198,6 +1400,7 @@ mod tests {
genres: vec!["Action".into(), "Drama".into()],
episodes: Some(25),
url: Some("https://anilist.co/anime/16498".into()),
details: None,
}
}
@@ -1281,6 +1484,9 @@ mod tests {
&[EpisodeMetadata {
season: None,
number: 1,
absolute: None,
entry_season: None,
entry_number: None,
name: Some("To You, in 2000 Years".into()),
overview: None,
still_url: None,
@@ -1370,6 +1576,9 @@ mod tests {
EpisodeMetadata {
season: None,
number: 57,
absolute: None,
entry_season: None,
entry_number: None,
name: Some("The Immortal Legion".into()),
overview: Some("Ed and Al…".into()),
still_url: None,
@@ -1378,6 +1587,9 @@ mod tests {
EpisodeMetadata {
season: Some(1),
number: 1,
absolute: None,
entry_season: None,
entry_number: None,
name: Some("Fullmetal Alchemist".into()),
overview: None,
still_url: Some("still.jpg".into()),
@@ -1407,9 +1619,347 @@ mod tests {
.expect("store again");
let stored = catalog.all_episode_metadata().expect("read");
assert_eq!(stored["fma"].len(), 1);
}
let ages = catalog.episode_metadata_ages().expect("ages");
assert_eq!(ages["fma"], (ProviderId::AniList, 200));
/// The three numberings a release might use have to survive a restart, or
/// an absolutely-numbered season folder stops finding its names.
#[test]
fn every_numbering_of_an_episode_round_trips() {
let mut catalog = Catalog::in_memory().expect("open");
let episode = EpisodeMetadata {
season: Some(2),
number: 11,
absolute: Some(39),
entry_season: Some(3),
entry_number: Some(1),
name: Some("Lightning's End".into()),
overview: None,
still_url: None,
air_date: None,
};
catalog
.set_episode_metadata(
"mushishi",
ProviderId::AniList,
1,
std::slice::from_ref(&episode),
)
.expect("store");
let stored = catalog.all_episode_metadata().expect("read");
assert_eq!(stored["mushishi"].get(Some(2), 11), Some(&episode));
assert_eq!(stored["mushishi"].get(Some(3), 1), Some(&episode));
}
/// **What left Jujutsu Kaisen 0 with the TV series' episode names.** The
/// title was re-pinned to the film, the film had no episodes, and an empty
/// list was never written — so the series' list stayed.
#[test]
fn an_empty_enrichment_clears_the_episodes_the_last_match_left() {
let mut catalog = Catalog::in_memory().expect("open");
let record = MetadataRecord {
title_key: "jujutsu kaisen 0 movie".into(),
provider: ProviderId::AniList,
metadata: Some(found()),
fetched_at: 1,
manual: true,
};
catalog.set_metadata(&record).expect("store");
catalog
.set_episode_metadata(
&record.title_key,
ProviderId::AniList,
1,
&[EpisodeMetadata {
season: Some(1),
number: 1,
absolute: None,
entry_season: None,
entry_number: None,
name: Some("Ryomen Sukuna".into()),
overview: None,
still_url: None,
air_date: None,
}],
)
.expect("store episodes");
// Storing the same entry again keeps them; it is the same entry.
catalog.set_metadata(&record).expect("store again");
assert_eq!(catalog.all_episode_metadata().expect("read").len(), 1);
catalog
.set_enrichment(
&record.title_key,
ProviderId::AniList,
5,
&Enrichment::default(),
)
.expect("enrich");
assert!(catalog.all_episode_metadata().expect("read").is_empty());
// Asked, and the answer was nothing: that is recorded, or a film is
// asked again on every run.
assert_eq!(
catalog
.enrichment_ages()
.expect("ages")
.get(&record.title_key),
Some(&(ProviderId::AniList, 5))
);
}
/// Android pins a match without enriching it; until the next match run,
/// the title must show no episode names rather than the old entry's.
#[test]
fn pinning_a_different_entry_drops_the_old_entrys_episodes_at_once() {
let mut catalog = Catalog::in_memory().expect("open");
let series = MetadataRecord {
title_key: "jujutsu kaisen 0 movie".into(),
provider: ProviderId::AniList,
metadata: Some(found()),
fetched_at: 1,
manual: false,
};
catalog.set_metadata(&series).expect("store");
catalog
.set_episode_metadata(
&series.title_key,
ProviderId::AniList,
1,
&[EpisodeMetadata {
season: Some(1),
number: 1,
absolute: None,
entry_season: None,
entry_number: None,
name: Some("Ryomen Sukuna".into()),
overview: None,
still_url: None,
air_date: None,
}],
)
.expect("store episodes");
let film = MetadataRecord {
metadata: Some(TitleMetadata {
remote_id: "131573".into(),
..found()
}),
manual: true,
..series
};
catalog.set_metadata(&film).expect("pin");
assert!(catalog.all_episode_metadata().expect("read").is_empty());
}
/// Enrichment adds a backdrop when it found one and otherwise leaves the
/// match's own alone; a new match makes the title un-enriched again.
#[test]
fn enrichment_keeps_the_backdrop_it_did_not_replace_and_a_different_match_resets_it() {
let mut catalog = Catalog::in_memory().expect("open");
let record = MetadataRecord {
title_key: "attack on titan".into(),
provider: ProviderId::AniList,
metadata: Some(TitleMetadata {
backdrop_url: Some("old.jpg".into()),
..found()
}),
fetched_at: 1,
manual: false,
};
catalog.set_metadata(&record).expect("store");
let backdrop = |catalog: &Catalog| {
catalog
.metadata("attack on titan")
.expect("read")
.and_then(|record| record.metadata)
.and_then(|found| found.backdrop_url)
};
catalog
.set_enrichment(
"attack on titan",
ProviderId::AniList,
2,
&Enrichment::default(),
)
.expect("enrich");
assert_eq!(backdrop(&catalog).as_deref(), Some("old.jpg"));
let enrichment = Enrichment {
backdrop_url: Some("fanart.jpg".into()),
..Enrichment::default()
};
catalog
.set_enrichment("attack on titan", ProviderId::AniList, 3, &enrichment)
.expect("enrich again");
assert_eq!(backdrop(&catalog).as_deref(), Some("fanart.jpg"));
catalog
.set_metadata(&MetadataRecord {
metadata: Some(TitleMetadata {
remote_id: "99147".into(),
..found()
}),
..record
})
.expect("match something else");
assert!(catalog.enrichment_ages().expect("ages").is_empty());
}
/// Details round-trip, and refreshing a match's details — the same entry
/// stored again — keeps what enrichment found for it.
#[test]
fn refreshing_the_same_match_keeps_its_enrichment_and_a_new_one_does_not() {
let mut catalog = Catalog::in_memory().expect("open");
let record = MetadataRecord {
title_key: "mushishi".into(),
provider: ProviderId::AniList,
metadata: Some(found()),
fetched_at: 1,
manual: true,
};
catalog.set_metadata(&record).expect("store");
let enrichment = Enrichment {
episodes: vec![EpisodeMetadata {
season: Some(1),
number: 1,
absolute: Some(1),
entry_season: Some(1),
entry_number: Some(1),
name: Some("The Green Seat".into()),
overview: None,
still_url: None,
air_date: None,
}],
backdrop_url: Some("fanart.jpg".into()),
chain: vec!["457".into(), "20595".into()],
};
catalog
.set_enrichment("mushishi", ProviderId::AniList, 2, &enrichment)
.expect("enrich");
let refreshed = MetadataRecord {
metadata: Some(TitleMetadata {
details: Some(TitleDetails {
studios: vec!["Artland".into()],
..TitleDetails::default()
}),
..found()
}),
fetched_at: 3,
..record.clone()
};
catalog.set_metadata(&refreshed).expect("refresh");
let read = catalog.metadata("mushishi").expect("read").expect("a row");
let data = read.metadata.expect("matched");
assert_eq!(data.backdrop_url.as_deref(), Some("fanart.jpg"));
let details = data.details.expect("details");
assert_eq!(details.studios, vec!["Artland"]);
assert_eq!(details.chain, vec!["457", "20595"]);
assert!(
catalog
.enrichment_ages()
.expect("ages")
.contains_key("mushishi")
);
assert_eq!(
catalog.all_episode_metadata().expect("read")["mushishi"].len(),
1
);
// A different entry is a different answer: nothing carries over.
catalog
.set_metadata(&MetadataRecord {
metadata: Some(TitleMetadata {
remote_id: "20526".into(),
..found()
}),
..record
})
.expect("repin");
let read = catalog.metadata("mushishi").expect("read").expect("a row");
assert_eq!(read.metadata.and_then(|data| data.backdrop_url), None);
assert!(catalog.enrichment_ages().expect("ages").is_empty());
}
/// A file keeps the moment it first appeared across recrawls, and one
/// that was there before this was recorded reads as 0.
#[test]
fn a_file_remembers_when_it_first_appeared() {
let mut catalog = Catalog::in_memory().expect("open");
catalog
.replace_share("s", &[file("s", "a", "Show S01E01.mkv")])
.expect("crawl");
let first = catalog.first_seen().expect("read")[&("s".to_string(), "a".to_string())];
assert!(first > 0);
catalog
.conn
.execute("UPDATE node_first_seen SET seen_at = 5", [])
.expect("age it");
catalog
.replace_share(
"s",
&[
file("s", "a", "Show S01E01.mkv"),
file("s", "b", "Show S01E02.mkv"),
],
)
.expect("recrawl");
let seen = catalog.first_seen().expect("read");
assert_eq!(seen[&("s".to_string(), "a".to_string())], 5);
assert!(seen[&("s".to_string(), "b".to_string())] > 5);
}
/// Schema v9 repairs what AniList's first episode source wrote: its
/// episode rows and its banners go, and the matches — hand-picked ones
/// included — stay.
#[test]
fn upgrading_drops_anilists_episode_rows_and_banners_but_keeps_its_matches() {
let conn = Connection::open_in_memory().expect("open");
for migration in [
MIGRATION_V1,
MIGRATION_V2,
MIGRATION_V3,
MIGRATION_V4,
MIGRATION_V5,
MIGRATION_V6,
MIGRATION_V7,
MIGRATION_V8,
] {
conn.execute_batch(migration).expect("migrate");
}
conn.pragma_update(None, "user_version", 8)
.expect("set version");
conn.execute_batch(
"INSERT INTO title_metadata (title_key, provider, matched, fetched_at, remote_id,
name, kind, backdrop_url, manual)
VALUES ('aot', 'anilist', 1, 1, '16498', 'Attack on Titan', 'series',
'banner.jpg', 1),
('got', 'tmdb', 1, 1, '1399', 'Game of Thrones', 'series',
'backdrop.jpg', 0);
INSERT INTO episode_metadata (title_key, season, number, provider, fetched_at, name)
VALUES ('aot', 2, 1, 'anilist', 1, 'Season one''s first, wrongly'),
('got', 1, 1, 'tmdb', 1, 'Winter Is Coming');",
)
.expect("seed");
let catalog = Catalog::from_connection(conn).expect("upgrade");
let records = catalog.all_metadata().expect("read");
let aot = records["aot"].metadata.as_ref().expect("still matched");
assert!(records["aot"].manual);
assert_eq!(aot.backdrop_url, None);
assert_eq!(
records["got"]
.metadata
.as_ref()
.and_then(|got| got.backdrop_url.as_deref()),
Some("backdrop.jpg")
);
let episodes = catalog.all_episode_metadata().expect("read");
assert!(!episodes.contains_key("aot"));
assert_eq!(episodes["got"].len(), 1);
assert!(catalog.enrichment_ages().expect("ages").is_empty());
}
#[test]
@@ -1423,6 +1973,9 @@ mod tests {
&[EpisodeMetadata {
season: None,
number: 1,
absolute: None,
entry_season: None,
entry_number: None,
name: Some("Fullmetal Alchemist".into()),
overview: None,
still_url: None,
+290
View File
@@ -0,0 +1,290 @@
//! Which titles in a library are one story.
//!
//! A share files a franchise however its uploader did: Made in Abyss as a
//! series folder and three film folders, Evangelion as a series, two films and
//! a folder of four more. The provider knows they belong together — AniList
//! records prequels, sequels, side stories, recaps and retellings as relations
//! — so the library can show one tile for the lot and the parts on its page.
//!
//! Grouping is by shared provider id, not by name. Two titles are one
//! franchise when one names the other as related, or when both name the same
//! entry: Mushishi's *Suzu no Shizuku* film relates to *Zoku Shou 2*, which the
//! library does not hold as a title of its own but which the series' episode
//! walk passed through (see [`crate::metadata::TitleDetails::chain`]).
use std::collections::HashMap;
use crate::library::{Title, TitleKind};
use crate::metadata::MetadataRecord;
/// One franchise: indices into the titles it was built from, in release order.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Franchise {
pub members: Vec<usize>,
}
impl Franchise {
/// The member that stands for the rest: the first series to air, or the
/// first film when there is no series. What a viewer means by "Made in
/// Abyss" is the show, not the recap film that came out after it.
pub fn lead(&self, titles: &[&Title]) -> usize {
self.members
.iter()
.copied()
.find(|&index| titles[index].kind == TitleKind::Series)
.unwrap_or(self.members[0])
}
}
/// Group `titles` into franchises, every title in exactly one.
///
/// Titles with no match are franchises of one. The groups come back in the
/// order of their first member in `titles`, so a sorted list stays sorted.
pub fn group(titles: &[&Title], metadata: &HashMap<String, MetadataRecord>) -> Vec<Franchise> {
let mut sets = DisjointSets::new(titles.len());
// The first title to name each provider id, for later titles naming it too.
let mut named: HashMap<(crate::metadata::ProviderId, &str), usize> = HashMap::new();
for (index, title) in titles.iter().enumerate() {
let Some(found) = metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
else {
continue;
};
let details = found.details.as_ref();
let ids = std::iter::once(found.remote_id.as_str())
.chain(details.into_iter().flat_map(|details| {
details
.related
.iter()
.chain(&details.chain)
.map(String::as_str)
}))
.filter(|id| !id.is_empty());
for id in ids {
match named.get(&(found.provider, id)) {
Some(&other) => sets.union(index, other),
None => {
named.insert((found.provider, id), index);
}
}
}
}
let mut groups: Vec<Franchise> = Vec::new();
let mut slot: HashMap<usize, usize> = HashMap::new();
for index in 0..titles.len() {
let root = sets.find(index);
match slot.get(&root) {
Some(&at) => groups[at].members.push(index),
None => {
slot.insert(root, groups.len());
groups.push(Franchise {
members: vec![index],
});
}
}
}
for franchise in &mut groups {
franchise
.members
.sort_by_cached_key(|&index| release_key(titles[index], metadata));
}
groups
}
/// What release order sorts on: the provider's start date, else the year the
/// files state, else last.
pub fn release_key(title: &Title, metadata: &HashMap<String, MetadataRecord>) -> String {
let found = metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref());
found
.and_then(|found| found.details.as_ref())
.and_then(|details| details.start_date.clone())
.or_else(|| {
found
.and_then(|found| found.year)
.map(|year| year.to_string())
})
.or_else(|| title.year.map(|year| year.to_string()))
// `~` sorts after every digit: undated goes last.
.unwrap_or_else(|| "~".to_string())
}
struct DisjointSets {
parent: Vec<usize>,
}
impl DisjointSets {
fn new(size: usize) -> Self {
Self {
parent: (0..size).collect(),
}
}
fn find(&mut self, mut index: usize) -> usize {
while self.parent[index] != index {
self.parent[index] = self.parent[self.parent[index]];
index = self.parent[index];
}
index
}
fn union(&mut self, left: usize, right: usize) {
let (left, right) = (self.find(left), self.find(right));
if left != right {
// The earlier title stays the root, which keeps `group` stable.
let (root, child) = (left.min(right), left.max(right));
self.parent[child] = root;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::metadata::{ProviderId, TitleDetails, TitleMetadata};
fn title(key: &str, kind: TitleKind) -> Title {
Title {
key: key.into(),
name: key.into(),
year: None,
kind,
seasons: Vec::new(),
share_ids: Vec::new(),
}
}
fn matched(
key: &str,
id: &str,
start: &str,
related: &[&str],
chain: &[&str],
) -> MetadataRecord {
MetadataRecord {
title_key: key.into(),
provider: ProviderId::AniList,
metadata: Some(TitleMetadata {
provider: ProviderId::AniList,
remote_id: id.into(),
name: key.into(),
original_name: None,
overview: None,
year: None,
kind: TitleKind::Series,
poster_url: None,
backdrop_url: None,
rating: None,
genres: Vec::new(),
episodes: None,
url: None,
details: Some(TitleDetails {
start_date: Some(start.into()),
related: related.iter().map(|id| id.to_string()).collect(),
chain: chain.iter().map(|id| id.to_string()).collect(),
..TitleDetails::default()
}),
}),
fetched_at: 0,
manual: false,
}
}
fn keys(groups: &[Franchise], titles: &[&Title]) -> Vec<Vec<String>> {
groups
.iter()
.map(|group| {
group
.members
.iter()
.map(|&index| titles[index].key.clone())
.collect()
})
.collect()
}
/// Made in Abyss: the films name the series, the series names the films.
/// Paprika names nothing in the library and stays alone.
#[test]
fn titles_that_name_each_other_are_one_franchise_in_release_order() {
let owned = [
title("dawn of the deep soul", TitleKind::Film),
title("made in abyss", TitleKind::Series),
title("paprika", TitleKind::Film),
];
let titles: Vec<&Title> = owned.iter().collect();
let metadata = HashMap::from([
(
"dawn of the deep soul".to_string(),
matched(
"dawn of the deep soul",
"100643",
"2020-01-17",
&["97986"],
&[],
),
),
(
"made in abyss".to_string(),
matched("made in abyss", "97986", "2017-07-07", &["100643"], &[]),
),
(
"paprika".to_string(),
matched("paprika", "1943", "2006-11-25", &[], &[]),
),
]);
let groups = group(&titles, &metadata);
assert_eq!(
keys(&groups, &titles),
vec![
vec!["made in abyss", "dawn of the deep soul"],
vec!["paprika"]
]
);
assert_eq!(titles[groups[0].lead(&titles)].key, "made in abyss");
}
/// Mushishi: the special names an entry the series only reached by
/// walking its sequels for episodes. That is still the same story.
#[test]
fn an_entry_the_series_walked_through_joins_what_names_it() {
let owned = [
title("mushishi", TitleKind::Series),
title("suzu no shizuku", TitleKind::Film),
];
let titles: Vec<&Title> = owned.iter().collect();
let metadata = HashMap::from([
(
"mushishi".to_string(),
matched(
"mushishi",
"457",
"2005-10-23",
&["20526"],
&["457", "20595", "20751"],
),
),
(
"suzu no shizuku".to_string(),
matched("suzu no shizuku", "21008", "2015-05-16", &["20751"], &[]),
),
]);
assert_eq!(group(&titles, &metadata).len(), 1);
}
/// Nothing matched, nothing to group by — and two unmatched titles are
/// not one franchise because they share an empty id.
#[test]
fn unmatched_titles_stand_alone() {
let owned = [
title("game of thrones", TitleKind::Series),
title("something else", TitleKind::Series),
];
let titles: Vec<&Title> = owned.iter().collect();
assert_eq!(group(&titles, &HashMap::new()).len(), 2);
}
}
+3
View File
@@ -6,15 +6,18 @@
#![forbid(unsafe_code)]
pub mod appearance;
pub mod browse;
pub mod catalog;
pub mod chapters;
pub mod config;
pub mod error;
pub mod franchise;
pub mod library;
pub mod metadata;
pub mod naming;
pub mod prefs;
pub mod shares;
pub mod shelves;
pub use error::{Error, Result};
#[cfg(not(target_os = "android"))]
+22 -1
View File
@@ -389,6 +389,27 @@ impl Library {
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
/// searching for a release group or an episode name finds its 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
/// `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();
for article in ["the ", "a ", "an "] {
if let Some(rest) = lower.strip_prefix(article) {
+338 -25
View File
@@ -20,7 +20,7 @@
//! thumbnails at upload time and that client attaches none. See
//! `docs/DEVELOPMENT.md`.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
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
/// provider this build does not recognise should read as "some other provider",
/// 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")]
pub enum ProviderId {
/// 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.
pub fn description(self) -> &'static str {
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.",
}
}
@@ -109,6 +112,156 @@ pub struct TitleMetadata {
pub episodes: Option<u32>,
/// The title's page, for a viewer who wants the rest of it.
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 {
@@ -139,6 +292,23 @@ pub struct EpisodeMetadata {
/// anime releases name their files.
pub season: Option<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 overview: Option<String>,
/// A frame from the episode, around 16:9.
@@ -149,6 +319,23 @@ pub struct EpisodeMetadata {
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.
///
/// 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
/// numbering.
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 {
pub fn new(episodes: Vec<EpisodeMetadata>) -> Self {
Self {
entries: episodes
.into_iter()
.map(|episode| ((episode.season, episode.number), episode))
.collect(),
let mut guide = Self::default();
for episode in episodes {
let key = (episode.season, episode.number);
if let Some(absolute) = episode.absolute {
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 {
@@ -182,20 +386,25 @@ impl EpisodeGuide {
/// What the provider says about the file numbered like this.
///
/// Tried in order: the season and number the filename states; the
/// 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.
/// Tried in order:
///
/// **The absolute fallback stops at season one.** A provider that numbers
/// straight through is one that split nothing, so its episode seven is the
/// seventh of the show — which is season one's, not season two's. Answering
/// `S02E07` with it 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. Season two upwards is answered from an entry that
/// states that season or not at all — see
/// `Provider::seasons_are_separate_entries`.
/// 1. the season and number the filename states;
/// 2. for a file in no season or in season one, the provider's own absolute
/// list, and for a file in no season, season one — what makes `#057`
/// line up with TMDB filing a 64-episode anime under one season;
/// 3. the absolute number, into the season the file states — what makes
/// `Season 2/… - 26.mkv` the second season's first episode;
/// 4. the entry numbering, for a season the provider files nothing under.
///
/// **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> {
if let Some(found) = self.entries.get(&(season, number)) {
return Some(found);
@@ -205,14 +414,31 @@ impl EpisodeGuide {
{
return Some(found);
}
season
.is_none()
.then(|| self.entries.get(&(Some(1), number)))?
if season.is_none()
&& let Some(found) = 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
/// provider's own absolute numbering.
pub fn seasons(&self) -> std::collections::HashSet<Option<u32>> {
pub fn seasons(&self) -> HashSet<Option<u32>> {
self.entries
.values()
.map(|episode| episode.season)
@@ -328,6 +554,7 @@ mod tests {
genres: Vec::new(),
episodes: Some(26),
url: None,
details: None,
}
}
@@ -335,6 +562,9 @@ mod tests {
EpisodeMetadata {
season,
number,
absolute: None,
entry_season: None,
entry_number: None,
name: Some(name.into()),
overview: 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]
fn a_file_that_states_its_season_is_answered_from_that_season() {
let guide = EpisodeGuide::new(vec![
+261
View File
@@ -0,0 +1,261 @@
//! Rows of titles that share something, for the top of the library.
//!
//! A grid sorted one way answers one question. Shelves answer the ones a
//! viewer did not think to ask: what is still airing, what else that studio
//! made, the director whose four films happen to be in the share. They are
//! picked from what the provider said, and only where there is enough of
//! something to be worth a row — three titles from one studio is a shelf, one
//! is a fact on its page.
use std::collections::{BTreeMap, HashMap};
use crate::library::Title;
use crate::metadata::MetadataRecord;
/// How many franchises a person or studio needs before it earns a row.
///
/// Franchises, not titles: Made in Abyss and its three films are four titles
/// with one director, and a "Directed by" row holding nothing but them says
/// what the franchise's own tile already does.
const MIN_TITLES: usize = 3;
/// A genre on more than this share of the matched titles says nothing: an
/// "Action" row in an anime library is half the library again — which is what
/// it was, at 0.6, in the library this was tuned on.
const MAX_GENRE_SHARE: f32 = 0.4;
/// One row: a heading, and the keys of the titles under it, in library order.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Shelf {
pub label: String,
pub keys: Vec<String>,
}
/// Up to `limit` shelves for `titles`, most specific first: what is airing,
/// then directors, studios and genres, each by how many titles it holds.
///
/// Two shelves holding exactly the same titles are one shelf — the first,
/// which is the more specific.
pub fn pick(
titles: &[Title],
metadata: &HashMap<String, MetadataRecord>,
limit: usize,
) -> Vec<Shelf> {
let mut airing = Vec::new();
let mut directors: BTreeMap<&str, Vec<String>> = BTreeMap::new();
let mut studios: BTreeMap<&str, Vec<String>> = BTreeMap::new();
let mut genres: BTreeMap<&str, Vec<String>> = BTreeMap::new();
let mut matched = 0usize;
let refs: Vec<&Title> = titles.iter().collect();
let franchise_of: HashMap<&str, usize> = crate::franchise::group(&refs, metadata)
.into_iter()
.enumerate()
.flat_map(|(at, franchise)| {
franchise
.members
.into_iter()
.map(move |member| (titles[member].key.as_str(), at))
})
.collect();
let franchises = |keys: &[String]| {
keys.iter()
.filter_map(|key| franchise_of.get(key.as_str()))
.collect::<std::collections::HashSet<_>>()
.len()
};
for title in titles {
let Some(found) = metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
else {
continue;
};
matched += 1;
for genre in &found.genres {
genres.entry(genre).or_default().push(title.key.clone());
}
let Some(details) = &found.details else {
continue;
};
if details.is_airing() {
airing.push(title.key.clone());
}
for director in &details.directors {
directors
.entry(director)
.or_default()
.push(title.key.clone());
}
for studio in &details.studios {
studios.entry(studio).or_default().push(title.key.clone());
}
}
let ranked = |groups: BTreeMap<&str, Vec<String>>, label: &dyn Fn(&str) -> String| {
let mut rows: Vec<Shelf> = groups
.into_iter()
.filter(|(_, keys)| franchises(keys) >= MIN_TITLES)
.map(|(name, keys)| Shelf {
label: label(name),
keys,
})
.collect();
// Stable, so equal counts keep their alphabetical order.
rows.sort_by_key(|shelf| std::cmp::Reverse(shelf.keys.len()));
rows
};
let genre_ceiling = (matched as f32 * MAX_GENRE_SHARE).floor() as usize;
let genres: BTreeMap<&str, Vec<String>> = genres
.into_iter()
.filter(|(_, keys)| keys.len() <= genre_ceiling)
.collect();
let mut shelves = Vec::new();
if !airing.is_empty() {
shelves.push(Shelf {
label: "Airing now".into(),
keys: airing,
});
}
shelves.extend(ranked(directors, &|name| format!("Directed by {name}")));
shelves.extend(ranked(studios, &|name| format!("From {name}")));
shelves.extend(ranked(genres, &|name| name.to_string()));
let mut picked: Vec<Shelf> = Vec::new();
for shelf in shelves {
if picked.len() == limit {
break;
}
if !picked.iter().any(|other| other.keys == shelf.keys) {
picked.push(shelf);
}
}
picked
}
#[cfg(test)]
mod tests {
use super::*;
use crate::library::TitleKind;
use crate::metadata::{ProviderId, TitleDetails, TitleMetadata};
fn title(key: &str) -> Title {
Title {
key: key.into(),
name: key.into(),
year: None,
kind: TitleKind::Film,
seasons: Vec::new(),
share_ids: Vec::new(),
}
}
fn record(key: &str, genres: &[&str], details: TitleDetails) -> (String, MetadataRecord) {
(
key.to_string(),
MetadataRecord {
title_key: key.into(),
provider: ProviderId::AniList,
metadata: Some(TitleMetadata {
provider: ProviderId::AniList,
remote_id: key.into(),
name: key.into(),
original_name: None,
overview: None,
year: None,
kind: TitleKind::Film,
poster_url: None,
backdrop_url: None,
rating: None,
genres: genres.iter().map(|genre| genre.to_string()).collect(),
episodes: None,
url: None,
details: Some(details),
}),
fetched_at: 0,
manual: false,
},
)
}
fn kon(studio: &str) -> TitleDetails {
TitleDetails {
directors: vec!["Satoshi Kon".into()],
studios: vec![studio.into()],
..TitleDetails::default()
}
}
/// The case this exists for: four Satoshi Kon films in one share.
#[test]
fn a_director_with_enough_titles_gets_a_shelf_ahead_of_a_studio() {
let titles: Vec<Title> = [
"perfect blue",
"millennium actress",
"tokyo godfathers",
"paprika",
]
.map(title)
.to_vec();
let metadata = HashMap::from([
record("perfect blue", &["Drama"], kon("Madhouse")),
record("millennium actress", &["Drama"], kon("Madhouse")),
record("tokyo godfathers", &["Drama"], kon("Madhouse")),
record("paprika", &["Sci-Fi"], kon("Madhouse")),
]);
let shelves = pick(&titles, &metadata, 5);
assert_eq!(shelves[0].label, "Directed by Satoshi Kon");
assert_eq!(shelves[0].keys.len(), 4);
// Madhouse holds exactly the same four, so it is not a second row.
assert!(!shelves.iter().any(|shelf| shelf.label == "From Madhouse"));
// Drama is three of four: past the share a genre may cover.
assert!(!shelves.iter().any(|shelf| shelf.label == "Drama"));
}
/// Made in Abyss: four titles, one director, one story. Not a shelf.
#[test]
fn one_franchise_is_not_a_shelf_however_many_titles_it_has() {
let titles: Vec<Title> = ["abyss", "film one", "film two", "film three"]
.map(title)
.to_vec();
let kojima = |related: &[&str]| TitleDetails {
directors: vec!["Masayuki Kojima".into()],
related: related.iter().map(|id| id.to_string()).collect(),
..TitleDetails::default()
};
let metadata = HashMap::from([
record(
"abyss",
&[],
kojima(&["film one", "film two", "film three"]),
),
record("film one", &[], kojima(&["abyss"])),
record("film two", &[], kojima(&["abyss"])),
record("film three", &[], kojima(&["abyss"])),
]);
assert!(pick(&titles, &metadata, 5).is_empty());
}
#[test]
fn what_is_airing_comes_first_and_too_few_titles_make_no_shelf() {
let titles: Vec<Title> = ["a", "b"].map(title).to_vec();
let metadata = HashMap::from([
record(
"a",
&[],
TitleDetails {
status: Some("RELEASING".into()),
studios: vec!["MAPPA".into()],
..TitleDetails::default()
},
),
record("b", &[], TitleDetails::default()),
]);
let shelves = pick(&titles, &metadata, 5);
assert_eq!(shelves.len(), 1);
assert_eq!(shelves[0].label, "Airing now");
assert_eq!(shelves[0].keys, vec!["a"]);
}
}
+501 -166
View File
@@ -5,14 +5,24 @@
//! also cost them a signup. The trade is coverage — AniList is anime and nothing
//! 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
//! 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 pstr_core::metadata::{EpisodeMetadata, ProviderId, TitleMetadata};
use std::collections::HashSet;
use pstr_core::library::{self, TitleKind};
use pstr_core::metadata::{
Enrichment, EpisodeGuide, EpisodeMetadata, ProviderId, TitleDetails, TitleMetadata,
};
use serde::Deserialize;
use crate::anizip::AniZip;
use crate::error::{Error, Result};
use crate::limiter::RateLimiter;
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.
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) {
Page(page: 1, perPage: $perPage) {
media(search: $search, type: ANIME, sort: SEARCH_MATCH) {
id
title { romaji english native }
synonyms
description(asHtml: false)
startDate { year }
coverImage { extraLarge large }
bannerImage
averageScore
genres
episodes
format
siteUrl
}
media(search: $search, type: ANIME, sort: SEARCH_MATCH) { ...entry }
}
}
"#,
entry_fragment!()
);
/// One entry by the id a match already found — how a stored match gets the
/// fields it was stored without, without being searched for and decided
/// again.
const BY_ID: &str = concat!(
r#"
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
/// per-episode overview in its API at all — and the entries are what the
/// streaming sites published: `"Episode 57 - The Immortal Legion"`, in airing
/// order, occasionally with a gap. So the number is read out of the title where
/// 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 }
}
}
"#;
/// Attack on Titan's four seasons are seven entries and Bleach's seventeen are
/// five, so twelve covers every long run in a real library while bounding what
/// a file no entry will ever answer — a special, a recap — can cost.
const MAX_CHAIN_ENTRIES: usize = 12;
pub struct AniList {
http: reqwest::Client,
/// Shared by every lookup in flight — see [`crate::limiter`].
limiter: RateLimiter,
anizip: AniZip,
}
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 {
anizip: AniZip::new(http.clone(), language),
http,
limiter: RateLimiter::per_minute(REQUESTS_PER_MINUTE),
}
@@ -103,11 +177,6 @@ impl Provider for 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>> {
// Each term is asked only because the one before it came back with
// nothing at all — see `search_terms`. A page of candidates, however
@@ -133,29 +202,113 @@ impl Provider for AniList {
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
// record written by some other version — not worth an error, and there
// is nothing to ask about.
let Ok(id) = title.remote_id.parse::<i64>() else {
return Ok(Vec::new());
let Ok(first) = found.remote_id.parse::<i64>() else {
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
.query(serde_json::json!({
"query": EPISODES,
"variables": { "id": id },
}))
.await?;
let mut enrichment = Enrichment::default();
let mut taken = HashSet::new();
let mut visited = HashSet::new();
// The same ids as `visited`, in the order they were read.
let mut chain = Vec::new();
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
.and_then(|data| data.media)
.map(|media| media.into_episodes())
.unwrap_or_default())
if answers_all(&enrichment.episodes, &wanted) || visited.len() >= MAX_CHAIN_ENTRIES {
break;
}
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 {
/// 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
/// status and an `errors` array under a 200 — folded into one.
///
@@ -278,78 +431,78 @@ struct SearchData {
page: Page,
}
#[derive(Deserialize)]
struct MediaData {
#[serde(rename = "Media")]
media: Option<EpisodeMedia>,
/// One step along a franchise.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Link {
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)]
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.
/// Which of an entry's relations the franchise continues through.
///
/// Both parts are optional: plenty of entries are just `"Episode 12"`, and a
/// few are only a name.
fn split_numbering(title: &str) -> (Option<u32>, Option<String>) {
let trimmed = title.trim();
let rest = trimmed
.strip_prefix("Episode ")
.or_else(|| trimmed.strip_prefix("episode "))
.or_else(|| trimmed.strip_prefix("EP"))
.or_else(|| trimmed.strip_prefix("E"));
/// A sequel that is itself a series wins over one that is a film or a
/// special, but a special is still followed when it is the only way on:
/// Mushishi's first series leads to its second only through an OVA.
fn next_in_chain(edges: Vec<RelationEdge>, visited: &HashSet<i64>) -> Option<Link> {
edges
.into_iter()
.filter(|edge| edge.relation_type.as_deref() == Some("SEQUEL"))
.filter_map(|edge| edge.node)
// 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 {
return (None, Some(trimmed.to_string()).filter(|t| !t.is_empty()));
};
fn is_series_format(format: Option<&str>) -> bool {
matches!(format, Some("TV" | "TV_SHORT" | "ONA"))
}
let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
if digits.is_empty() {
return (None, Some(trimmed.to_string()));
}
/// Whether every numbered file has an answer in `episodes`.
fn answers_all(episodes: &[EpisodeMetadata], wanted: &[(Option<u32>, u32)]) -> bool {
let guide = EpisodeGuide::new(episodes.to_vec());
wanted
.iter()
.all(|&(season, number)| guide.get(season, number).is_some())
}
let name = rest[digits.len()..]
.trim_start()
.trim_start_matches(['-', '–', ':'])
.trim()
.to_string();
(digits.parse().ok(), Some(name).filter(|n| !n.is_empty()))
#[derive(Deserialize)]
struct RelationsData {
#[serde(rename = "Media")]
media: Option<RelationsMedia>,
}
#[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)]
@@ -373,12 +526,76 @@ struct Media {
banner_image: Option<String>,
#[serde(rename = "averageScore")]
average_score: Option<f32>,
popularity: Option<u32>,
#[serde(default)]
genres: Vec<String>,
#[serde(default)]
tags: Vec<Tag>,
episodes: Option<u32>,
format: Option<String>,
season: Option<String>,
status: Option<String>,
#[serde(rename = "siteUrl")]
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)]
@@ -391,6 +608,20 @@ struct Title {
#[derive(Deserialize)]
struct FuzzyDate {
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)]
@@ -434,6 +665,72 @@ impl Media {
.cover_image
.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 {
metadata: TitleMetadata {
provider: ProviderId::AniList,
@@ -444,13 +741,16 @@ impl Media {
year: self.start_date.and_then(|date| date.year),
kind: kind_of(self.format.as_deref()),
poster_url: cover,
// Usually `null`. AniList only has banners for the better-known
// shows, which is why a tile has to cope with a poster's shape.
backdrop_url: self.banner_image,
// Not `bannerImage`: that is a 4.75:1 strip, and drawn into a
// 16:9 tile it is a blurred sliver of its own middle. The
// backdrop is ani.zip's fanart, found when the match is
// enriched.
backdrop_url: None,
rating: self.average_score.map(|score| score / SCORE_SCALE),
genres: self.genres,
episodes: self.episodes,
url: self.site_url,
details: Some(details),
},
aliases,
// 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"));
}
#[test]
fn an_episode_title_gives_up_its_number_and_its_name() {
assert_eq!(
split_numbering("Episode 57 - The Immortal Legion"),
(Some(57), Some("The Immortal Legion".to_string()))
);
assert_eq!(split_numbering("Episode 12"), (Some(12), None));
// 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));
fn edge(relation: &str, id: i64, kind: &str, format: &str) -> RelationEdge {
RelationEdge {
relation_type: Some(relation.into()),
node: Some(RelationNode {
id,
kind: Some(kind.into()),
format: Some(format.into()),
}),
}
}
/// 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]
fn episode_numbers_come_from_the_titles_and_not_from_the_order() {
// AniList lists what streaming sites published, and those lists have
// gaps. Numbering by position would caption episode 4 as episode 3.
let media: EpisodeMedia = serde_json::from_str(
r#"{"streamingEpisodes": [
{"title": "Episode 1 - Fullmetal Alchemist", "thumbnail": "one.jpg"},
{"title": "Episode 3 - City of Heresy", "thumbnail": null}
]}"#,
)
.expect("parse");
let episodes = media.into_episodes();
assert_eq!(episodes[0].number, 1);
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()));
fn the_chain_continues_through_the_sequel_and_nothing_else() {
let edges = vec![
edge("ADAPTATION", 53390, "MANGA", "MANGA"),
edge("ALTERNATIVE", 20691, "ANIME", "MOVIE"),
edge("PREQUEL", 20811, "ANIME", "OVA"),
edge("SPIN_OFF", 21281, "ANIME", "TV"),
edge("SEQUEL", 20958, "ANIME", "TV"),
];
assert_eq!(
next_in_chain(edges, &HashSet::new()),
Some(Link {
id: 20958,
series: true
})
);
}
/// Mushishi's first series leads on only through an OVA, which is followed
/// — but not counted as a season of the series.
#[test]
fn an_untitled_list_still_numbers_its_episodes() {
let media: EpisodeMedia = serde_json::from_str(
r#"{"streamingEpisodes": [{"title": null, "thumbnail": null},
{"title": "", "thumbnail": null}]}"#,
)
.expect("parse");
let episodes = media.into_episodes();
assert_eq!(episodes[0].number, 1);
assert_eq!(episodes[1].number, 2);
assert!(episodes.iter().all(|episode| episode.name.is_none()));
fn a_special_is_followed_when_it_is_the_only_way_on() {
let edges = vec![edge("SEQUEL", 20526, "ANIME", "SPECIAL")];
assert_eq!(
next_in_chain(edges, &HashSet::new()),
Some(Link {
id: 20526,
series: false
})
);
// 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.
#[test]
fn an_entry_with_almost_nothing_in_it_still_parses() {
+296
View File
@@ -0,0 +1,296 @@
//! ani.zip: what AniList does not say about one of its own entries.
//!
//! AniList is where a title is *identified* — its search and its aliases are
//! what make a romaji folder name match — but it keeps no episode list worth
//! the name and no landscape art. ani.zip answers both, keyed by the AniList id
//! the match already produced, so asking it sends nothing about the library the
//! match did not already send. No key and no account, like AniList.
//!
//! The episode list is TVDB's, and that is the point of it: release groups
//! number their files the way TVDB does — `Bleach - S17E01` is TVDB's season
//! 17, not AniList's — and every episode here carries TVDB's season, number and
//! absolute number together. See `docs/METADATA.md`.
//!
//! It is a community service rather than an institution, so everything it says
//! is stored, and an entry it does not know (`404`) is an empty answer rather
//! than a failure.
use pstr_core::metadata::EpisodeMetadata;
use serde::Deserialize;
use crate::error::{Error, Result};
use crate::limiter::RateLimiter;
const ENDPOINT: &str = "https://api.ani.zip/mappings";
/// ani.zip publishes no limit. One a second is far below anything a CDN-cached
/// JSON endpoint notices, and a library's enrichment is a one-off burst.
const REQUESTS_PER_MINUTE: u32 = 60;
/// TVDB's own placeholder for an episode it has not named yet.
const UNNAMED: &str = "TBA";
/// What ani.zip knows about one AniList entry.
#[derive(Debug, Default)]
pub struct Mapping {
pub episodes: Vec<EpisodeMetadata>,
/// TVDB's fanart for the whole series: 16:9, usually 1920×1080.
pub fanart: Option<String>,
}
pub struct AniZip {
http: reqwest::Client,
/// The episode-title language tried first; English, then romaji, then
/// Japanese after it.
language: String,
limiter: RateLimiter,
}
impl AniZip {
pub fn new(http: reqwest::Client, language: String) -> Self {
Self {
http,
language,
limiter: RateLimiter::per_minute(REQUESTS_PER_MINUTE),
}
}
/// Everything ani.zip has for the AniList entry `anilist_id`.
///
/// `entry_season` is where this entry sits among the franchise's series
/// entries — see [`EpisodeMetadata::entry_season`] — and `None` for a film
/// or a special, whose episodes no release numbers that way.
pub async fn mapping(&self, anilist_id: i64, entry_season: Option<u32>) -> Result<Mapping> {
self.limiter.acquire().await;
let response = self
.http
.get(ENDPOINT)
.query(&[("anilist_id", anilist_id)])
.send()
.await?;
let status = response.status();
if status.as_u16() == 404 {
return Ok(Mapping::default());
}
if status.as_u16() == 429 {
self.limiter.throttled(response.headers()).await;
return Err(Error::RateLimited);
}
if !status.is_success() {
return Err(Error::Http(format!("ani.zip answered {status}")));
}
let payload: Payload = response.json().await?;
Ok(payload.into_mapping(&self.language, entry_season))
}
}
#[derive(Deserialize, Default)]
struct Payload {
#[serde(default)]
episodes: std::collections::BTreeMap<String, Episode>,
#[serde(default)]
images: Vec<Image>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Episode {
#[serde(default, deserialize_with = "lenient_number")]
season_number: Option<u32>,
#[serde(default, deserialize_with = "lenient_number")]
episode_number: Option<u32>,
#[serde(default, deserialize_with = "lenient_number")]
absolute_episode_number: Option<u32>,
title: Option<std::collections::HashMap<String, Option<String>>>,
/// TVDB's synopsis.
overview: Option<String>,
/// AniDB's, which is there when TVDB's is not.
summary: Option<String>,
image: Option<String>,
air_date: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Image {
cover_type: Option<String>,
url: Option<String>,
}
impl Payload {
fn into_mapping(self, language: &str, entry_season: Option<u32>) -> Mapping {
let fanart = self
.images
.into_iter()
.find(|image| image.cover_type.as_deref() == Some("Fanart"))
.and_then(|image| image.url);
let episodes = self
.episodes
.into_iter()
.filter_map(|(key, episode)| {
// An episode TVDB has not placed has nothing a filename could
// be matched against — a film's own entry is the usual case.
let season = episode.season_number?;
let number = episode.episode_number?;
// Keys are the entry's own numbering: `"1"`, `"2"` for its
// episodes and `"S1"` for its specials, which no release counts
// as part of the season.
let entry_number = entry_season.and(key.parse::<u32>().ok());
Some(EpisodeMetadata {
season: Some(season),
number,
absolute: episode.absolute_episode_number,
entry_season: entry_number.and(entry_season),
entry_number,
name: episode
.title
.as_ref()
.and_then(|titles| title_in(titles, language)),
overview: text(episode.overview).or_else(|| text(episode.summary)),
still_url: text(episode.image),
air_date: text(episode.air_date),
})
})
.collect();
Mapping { episodes, fanart }
}
}
/// The episode's name in `language`, else English, romaji or Japanese.
fn title_in(
titles: &std::collections::HashMap<String, Option<String>>,
language: &str,
) -> Option<String> {
[language, "en", "x-jat", "ja"]
.into_iter()
.filter_map(|language| titles.get(language).cloned().flatten())
.map(|title| title.trim().to_string())
.find(|title| !title.is_empty() && title != UNNAMED)
}
fn text(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
/// A number, whether it arrives as one or as a string of digits.
///
/// ani.zip writes its regular episodes' numbers as JSON numbers and its
/// specials' as strings; one strict field would fail the whole entry over the
/// specials.
fn lenient_number<'de, D>(deserializer: D) -> std::result::Result<Option<u32>, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(match serde_json::Value::deserialize(deserializer)? {
serde_json::Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()),
serde_json::Value::String(text) => text.trim().parse().ok(),
_ => None,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// Attack on Titan Season 2 as ani.zip answers it, trimmed: a regular
/// episode numbered in numbers, a special numbered in strings, a film-like
/// entry TVDB never placed, and the series' art.
const SEASON_TWO: &str = r#"{
"episodes": {
"1": {"seasonNumber": 2, "episodeNumber": 1, "absoluteEpisodeNumber": 26,
"episode": "1",
"title": {"en": "Beast Titan", "ja": "獣の巨人", "x-jat": "Kemono no Kyojin"},
"overview": "Titans appear inside Wall Rose.",
"image": "https://artworks.thetvdb.com/still.jpg",
"airDate": "2017-04-01"},
"S1": {"seasonNumber": "0", "episodeNumber": "7", "episode": "S1",
"title": {"en": "TBA", "x-jat": "Ilse no Techou"},
"summary": "An OVA."},
"2": {"episode": "2", "title": {"en": "Unplaced"}}
},
"images": [
{"coverType": "Banner", "url": "banner.jpg"},
{"coverType": "Fanart", "url": "fanart.jpg"}
]
}"#;
fn mapping(entry_season: Option<u32>) -> Mapping {
serde_json::from_str::<Payload>(SEASON_TWO)
.expect("parse")
.into_mapping("en", entry_season)
}
#[test]
fn an_episode_carries_tvdbs_numbering_and_its_entrys() {
let mapping = mapping(Some(2));
let first = mapping
.episodes
.iter()
.find(|episode| episode.season == Some(2))
.expect("the regular episode");
assert_eq!(first.number, 1);
assert_eq!(first.absolute, Some(26));
assert_eq!((first.entry_season, first.entry_number), (Some(2), Some(1)));
assert_eq!(first.name.as_deref(), Some("Beast Titan"));
assert_eq!(
first.overview.as_deref(),
Some("Titans appear inside Wall Rose.")
);
assert_eq!(first.air_date.as_deref(), Some("2017-04-01"));
}
/// A special is numbered in strings and filed under season zero, and no
/// release counts it as an episode of the entry it came with.
#[test]
fn a_special_keeps_its_tvdb_place_and_takes_no_entry_number() {
let mapping = mapping(Some(2));
let special = mapping
.episodes
.iter()
.find(|episode| episode.season == Some(0))
.expect("the special");
assert_eq!(special.number, 7);
assert_eq!(special.entry_number, None);
assert_eq!(special.entry_season, None);
// `TBA` is TVDB saying it has no name yet, so the next language answers.
assert_eq!(special.name.as_deref(), Some("Ilse no Techou"));
// AniDB's summary stands in for TVDB's missing overview.
assert_eq!(special.overview.as_deref(), Some("An OVA."));
}
#[test]
fn an_episode_tvdb_never_placed_is_left_out() {
assert_eq!(mapping(Some(2)).episodes.len(), 2);
}
/// A film or a special as an entry has no place among the series' seasons.
#[test]
fn an_entry_outside_the_series_numbers_nothing_by_entry() {
assert!(
mapping(None)
.episodes
.iter()
.all(|episode| episode.entry_season.is_none() && episode.entry_number.is_none())
);
}
/// The 16:9 picture, not the banner — the banner is the 4.75:1 strip that
/// looked wrong on every desktop tile.
#[test]
fn the_fanart_is_taken_and_the_banner_is_not() {
assert_eq!(mapping(None).fanart.as_deref(), Some("fanart.jpg"));
}
#[test]
fn an_entry_ani_zip_has_nothing_for_parses_to_nothing() {
let mapping = serde_json::from_str::<Payload>("{}")
.expect("parse")
.into_mapping("en", Some(1));
assert!(mapping.episodes.is_empty());
assert_eq!(mapping.fanart, None);
}
}
+3 -1
View File
@@ -10,7 +10,8 @@
//! Title ──▶ Query ──▶ Provider::search ──▶ [Candidate] ──▶ matching::best
//! (anilist | tmdb) │
//! ▼
//! TitleMetadata
//! Enrichment ◀── Provider::enrich ◀──────────────────── TitleMetadata
//! (episodes, fanart) (anilist relations + ani.zip | tmdb)
//! ```
//!
//! Three things are worth knowing before touching any of it:
@@ -28,6 +29,7 @@
//! without either depending on this crate.
pub mod anilist;
pub mod anizip;
pub mod error;
pub mod limiter;
pub mod matching;
+1
View File
@@ -208,6 +208,7 @@ mod tests {
genres: Vec::new(),
episodes: None,
url: None,
details: None,
},
aliases: std::iter::once(name)
.chain(aliases.iter().copied())
+17 -25
View File
@@ -1,35 +1,24 @@
//! 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::matching::{Candidate, Query};
/// A source of titles.
///
/// Deliberately narrow: search, and the episode list of something search
/// already matched. Both are requests to a third party, and neither happens
/// until the viewer has turned enrichment on — but note what the second one
/// does *not* need: it takes the id search returned, so it sends the provider
/// nothing about the library it did not already learn from the match.
/// Deliberately narrow: search, and enriching something search already
/// matched. Both are requests to a third party, and neither happens until the
/// viewer has turned enrichment on — but note what the second one does *not*
/// need: it takes the id search returned, so it sends nothing about the library
/// it did not already learn from the match.
///
/// 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.
pub trait Provider: Send + Sync {
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.
///
/// Scoring is not the provider's job — it returns candidates and
@@ -40,13 +29,16 @@ pub trait Provider: Send + Sync {
query: &Query,
) -> 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
/// series entries have none listed — and the caller caches it rather than
/// asking again on the next render.
fn episodes(
/// `title` is there for its files: a provider that files each sequel as a
/// separate entry (AniList) has to know which seasons the library holds to
/// know how far along the franchise to look. An empty episode list is a
/// real answer — a film has none — and the caller records it.
fn enrich(
&self,
title: &TitleMetadata,
) -> impl std::future::Future<Output = Result<Vec<EpisodeMetadata>>> + Send;
title: &Title,
found: &TitleMetadata,
) -> impl std::future::Future<Output = Result<Enrichment>> + Send;
}
+340 -165
View File
@@ -20,11 +20,12 @@
//! Nothing here is requested unless [`MetadataConfig::enabled`] is set. See the
//! note in `pstr_core::metadata` on why that is off by default.
use std::collections::HashMap;
use std::sync::Arc;
use pstr_core::library::{Title, TitleKind, title_key};
use pstr_core::metadata::{
EpisodeMetadata, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata,
Enrichment, MATCH_TTL_SECS, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata,
};
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 {
Self::AniList(provider) => provider.episodes(title).await,
Self::Tmdb(provider) => provider.episodes(title).await,
Self::AniList(provider) => provider.entry(id).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 {
Self::AniList(provider) => provider.seasons_are_separate_entries(),
Self::Tmdb(provider) => provider.seasons_are_separate_entries(),
Self::AniList(provider) => provider.enrich(title, found).await,
Self::Tmdb(provider) => provider.enrich(title, found).await,
}
}
}
@@ -101,7 +109,9 @@ impl MetadataService {
.map_err(Error::Network)?;
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(
http.clone(),
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
/// 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.
/// 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`
/// exists to avoid.
pub async fn episodes(&self, title: &TitleMetadata) -> Result<Vec<EpisodeMetadata>> {
let episodes = self.source.episodes(title).await?;
///
/// An `Err` is a failure to ask and must not be stored: the title stays
/// un-enriched and the next match run asks again. An empty answer is an
/// answer, and is — see `Catalog::set_enrichment`.
pub async fn enrich(&self, title: &Title, found: &TitleMetadata) -> Result<Enrichment> {
let enrichment = self.source.enrich(title, found).await?;
tracing::debug!(
"{}: {} episodes from {}",
"{}: {} episodes from {}{}",
title.name,
episodes.len(),
self.provider().label()
);
Ok(episodes)
}
/// 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);
}
enrichment.episodes.len(),
self.provider().label(),
if enrichment.backdrop_url.is_some() {
", with a backdrop"
} else {
""
}
}
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
// 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())
Ok(enrichment)
}
/// Download one piece of artwork.
@@ -351,29 +254,179 @@ impl MetadataService {
}
}
/// What to search for when looking for one season of a series, best first.
///
/// `2nd Season` is AniList's own convention and matches its romaji titles
/// outright; `Season 2` is what its English titles and most western libraries
/// use. Season one is never searched for this way — that is the title's own
/// match.
fn season_names(title: &str, season: u32) -> Vec<String> {
vec![
format!("{title} {} Season", ordinal(season)),
format!("{title} Season {season}"),
]
/// What one match run has to do about one title.
#[derive(Debug, Clone)]
pub enum Work {
/// Search for it, and enrich it if it matches.
Match(Title),
/// It is already matched; only its enrichment is missing or old.
Enrich(Title, Box<TitleMetadata>),
/// It is matched, but stored before [`TitleMetadata::details`] existed:
/// fetch the same entry again by id — never searched for, so a match the
/// 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.
fn ordinal(number: u32) -> String {
let suffix = match (number % 10, number % 100) {
(_, 11..=13) => "th",
(1, _) => "st",
(2, _) => "nd",
(3, _) => "rd",
_ => "th",
};
format!("{number}{suffix}")
impl Work {
pub fn title(&self) -> &Title {
match self {
Self::Match(title) | Self::Enrich(title, _) | Self::Refresh { title, .. } => title,
}
}
}
/// What a piece of [`Work`] found, ready to store.
#[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,
})
}
}
}
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.
@@ -412,22 +465,144 @@ mod tests {
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]
fn a_season_is_searched_for_the_way_the_provider_names_it() {
assert_eq!(
season_names("Oshi no Ko", 2),
vec!["Oshi no Ko 2nd Season", "Oshi no Ko Season 2"]
fn a_run_matches_the_unasked_and_enriches_the_unenriched() {
let stored = HashMap::from([
("enriched".to_string(), matched("enriched", false)),
("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!(ordinal(3), "3rd");
assert_eq!(ordinal(4), "4th");
// 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");
assert_eq!(ordinal(13), "13th");
assert_eq!(ordinal(21), "21st");
assert_eq!(
kinds(&work),
vec![("stale", "enrich"), ("bare", "enrich"), ("new", "match")]
);
}
/// "Match again" re-searches everything except what the viewer pinned —
/// and a pinned title that was never enriched is still enriched.
#[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
+21 -10
View File
@@ -10,8 +10,8 @@
//! better evidence than the guess, and [`crate::matching`] only nudges on kind
//! anyway.
use pstr_core::library::TitleKind;
use pstr_core::metadata::{EpisodeMetadata, ProviderId, TitleMetadata};
use pstr_core::library::{Title, TitleKind};
use pstr_core::metadata::{Enrichment, EpisodeMetadata, ProviderId, TitleMetadata};
use serde::Deserialize;
use crate::error::{Error, Result};
@@ -73,12 +73,6 @@ impl Provider for 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>> {
if self.api_key.trim().is_empty() {
return Err(Error::MissingApiKey(ProviderId::Tmdb));
@@ -101,6 +95,19 @@ impl Provider for Tmdb {
.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
/// seasons appended onto one more.
///
@@ -153,6 +160,9 @@ impl Provider for Tmdb {
EpisodeMetadata {
season: Some(number),
number: episode.episode_number,
absolute: None,
entry_season: None,
entry_number: None,
name: episode.name.clone().filter(|name| !name.is_empty()),
overview: episode.overview.clone().filter(|text| !text.is_empty()),
still_url: episode
@@ -165,9 +175,7 @@ impl Provider for Tmdb {
}
Ok(episodes)
}
}
impl Tmdb {
/// One authenticated GET, paced, with TMDB's failures named.
///
/// TMDB is far more generous than AniList and a library-sized run rarely
@@ -343,6 +351,9 @@ impl Result_ {
},
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,
// Always: `search/multi` results are filtered on `media_type`
+1 -1
View File
@@ -10,7 +10,7 @@ reimplementing it in Kotlin.
## Toolchain
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
rustup target add aarch64-linux-android x86_64-linux-android
+45
View File
@@ -146,6 +146,51 @@ and [B44](#b44--r8-broke-the-app-under-instrumentation-in-five-places).
## Fixed
### 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
**Symptom.** Pasting a link without its `#` fragment, or one already added,
+20 -24
View File
@@ -106,26 +106,22 @@ card (`App::tick_up_next`, `ui::player::up_next_card`) with *Play now* and
chapters, autoplay on, a next episode, and playback actually running — a paused
episode is one somebody walked away from.
Done: **per-episode metadata.** `Provider::episodes` fetches an episode list for
a title that already matched (AniList `streamingEpisodes`, which is titles and
thumbnails only and no synopsis; TMDB `/tv/{id}` plus every season appended onto
one more request), stored in `episode_metadata` — schema **v4**. The reconciling
between a release's numbering and a provider's lives in one tested place,
`EpisodeGuide::get`: the season and number the filename states, then the
provider's absolute numbering, then season one for a file that states no season
at all. That last fallback is what makes `#057` line up with TMDB filing a
64-episode anime under one season. Episode names show on the title page's rows,
in the player's title strip and in the "next:" line autoplay prints.
Done: **per-episode metadata.** `Provider::enrich` fetches the episode list —
and, for AniList, the series' fanart — of a title that already matched, and
`Catalog::set_enrichment` stores both and records that it asked (schema **v9**),
so an empty answer is not re-asked every run. TMDB is `/tv/{id}` plus every
season appended onto one more request. AniList walks the match's `SEQUEL`
relations and takes each entry's episodes from ani.zip, which carries TVDB's
season, number and absolute number — the numbering release groups use. The
reconciling between a release's numbering and a provider's lives in one tested
place, `EpisodeGuide::get`. [METADATA.md](METADATA.md) has the audit that drove
this, the order `get` tries things in, and what comes next.
**A season is a separate lookup on AniList.** `Provider::seasons_are_separate_entries`
is what splits the two providers: TMDB files every season under one show and
returns them numbered, while AniList makes each sequel its own entry numbering
from one (`[Oshi no Ko] 2nd Season`, id 166531, is not `[Oshi no Ko]`, id
150672). So for AniList each season past the first is searched for by name —
`<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.
The first version searched AniList for `<title> 2nd Season` per season and took
episode names from `streamingEpisodes`; both were wrong often enough to show —
the search found the base entry again and captioned Attack on Titan's later
seasons with season one's names, and `streamingEpisodes` is whatever a streaming
site published. v9 deletes everything they stored.
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
@@ -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
included: a `remote_id` means nothing to the other provider.
Worth knowing before chasing a missing name: AniList's episode titles come from
`streamingEpisodes`, which is what streaming sites published, and for some shows
it is simply **empty** — all three Oshi no Ko entries have none, while Fullmetal
Alchemist Brotherhood has 64. There is nothing to fix on our side for those;
TMDB is the provider that has them.
Worth knowing before chasing a missing name: ani.zip only lists what TVDB has
placed. A file it cannot answer is usually a special (`S02E00`), an episode TVDB
skipped, or one too new to be listed — Attack on Titan's last two files and two
of Bleach's forty-four are the standing examples. `pstr metadata match` prints
how many episodes each title got.
Note the glyph trap: egui's bundled fonts cover the transport icons
(`⏮ ⏭ ⏸ ▶ 🔊 🔇 ⛶`) and *not* `← 💬 ▣`, which draw as an empty box. Anything new
+174
View File
@@ -0,0 +1,174 @@
# Metadata
How a library of filenames becomes posters, synopses and episode names, what
was wrong with the first version of it, and where it is going. The privacy
stance from `pstr_core::metadata` holds throughout: nothing here is requested
until the viewer turns enrichment on, and every new source named below sits
behind that same switch.
## What a real library showed
Audited against a 42-title, 758-file anime library matched on AniList
(schema v8, October 2026). The match itself — title to AniList entry — was
good. What hung off it was not:
1. **Later seasons captioned with season one's names.** Attack on Titan
seasons 2–4 and Jigokuraku season 2 all stored season one's episode list.
The per-season lookup searched for `Attack on Titan 2nd Season`, and the
similarity scorer, seeing the base entry's alias inside the query, picked the
base entry again. That is the failure `EpisodeGuide::get` already warned
about: not a missing caption but a wrong one.
2. **`streamingEpisodes` is not an episode list.** It is what a streaming site
published, partial and numbered however that site numbered it. Dan Da Dan
had 5 rows numbered 13–17, Kaiju No. 8 had 4–15, The Apothecary Diaries had
25–48 under two seasons at once, Bleach had 20 rows called `Untitled`, Akira
had a trailer. No row anywhere had a synopsis — AniList keeps none.
3. **A release's season is not an AniList entry.** Bleach `S17` is the
Thousand-Year Blood War, four separate AniList entries. Mushoku Tensei `S01`
is two (11 + 12 episodes). Attack on Titan's season folders hold
absolutely-numbered files — `S2` is episodes 26–37. Mushishi's `S03` is
*Zoku Shou 2*, which TVDB files as the second half of season two.
4. **Answers outlived their match.** `jujutsu kaisen 0 movie` kept the TV
series' 24 episode names after it was re-pinned to the film: an empty episode
list was never written, so the old one stayed.
5. **Desktop art was the wrong shape.** The 16:9 tile and the library's hero
prefer `backdrop_url`, which on AniList is `bannerImage` — a 1900×400 strip,
4.75:1, cached at 640×135. Cropped to 16:9 and stretched, that is a blurred
sliver from the middle of a banner. Android looks right because it draws the
2:3 cover.
6. **"Newest" sorts on nothing.** It reads the year from the filename, which 2
of 42 titles state.
## Sources
| Source | Key | What it is good for |
|---|---|---|
| **AniList** GraphQL | none | Identity. Search, every alias, cover art, relations (`SEQUEL`, `PREQUEL`, `SIDE_STORY`…), studios, staff, tags, format, season, popularity, next airing episode, the MAL id. |
| **ani.zip** `api.ani.zip/mappings?anilist_id=` | none | Everything AniList lacks, per AniList id: TVDB season, episode and absolute numbers; episode titles in several languages; TVDB synopses; 16:9 stills; air dates; runtime. Series art: TVDB **fanart at 1920×1080**, poster, banner, logo. Cross-ids for MAL, AniDB, TVDB, TMDB, Kitsu, IMDb. A community service — cache what it says. |
| **TMDB** | free key | Film backdrops and logos; live-action TV. Already implemented. |
| **TVmaze** | none | Live-action TV episodes, keyless. |
| **AniSkip** | none | Opening and ending timestamps by MAL id and episode. |
| **Jikan** (MAL) | none | Filler and recap flags. Timed out when tried; optional. |
| **manami-project/anime-offline-database** | none | Every anime with its synonyms, ids and relations, as one 6 MB `.zst`. Matching against it locally sends no title anywhere. |
| AniDB, TVDB directly | registration / paid | Covered by ani.zip, which republishes their data. |
The fact the whole design turns on: **release groups number files the way TVDB
does.** `Bleach - S17E01` is TVDB season 17; Attack on Titan's 26–96 are TVDB
absolute numbers. ani.zip gives both for every AniList entry, so the join
between a filename and an episode is a lookup, not a guess.
## Phase 1 — correct data (done)
**Seasons by relation, not by name.** `AniList::enrich` starts at the matched
entry and follows its `SEQUEL` edges — TV formats first, a special or film only
when it is the sole link onward (Mushishi's chain runs through an OVA). Each
entry's episodes come from ani.zip. The walk stops as soon as every numbered file
in the title has an answer, at the end of the chain, or after
`MAX_CHAIN_ENTRIES`, so a one-season show costs one ani.zip request and no
relations query at all.
**One episode, three numberings.** `EpisodeMetadata` carries TVDB's
`(season, number)`, TVDB's `absolute` number, and its `(entry_season,
entry_number)` — the position of its entry among the franchise's TV entries,
and its number within that entry, which is how a release that follows AniList
rather than TVDB numbers its seasons. `EpisodeGuide::get` tries, in order:
1. the season and number the file states;
2. for a file in no season or season one, the provider's own absolute list,
then season one;
3. the absolute number — but only into the season the file states, so an
absolutely-numbered season folder (`Attack on Titan/S2/… - 26`) finds
S2E1, and `S03E13` can never be answered with season one's 13th;
4. the entry numbering, only for a season TVDB does not have at all — which is
what makes Mushishi `S03E01` *Zoku Shou 2* episode 1 while Mushoku Tensei
`S02E01`, a season TVDB does have, stays TVDB's.
`streamingEpisodes` is gone.
**Enrichment is recorded, empty or not.** A title's episode list and its
fanart are one *enrichment*, stored together by `Catalog::set_enrichment` in one
transaction that always replaces the old episode rows — so an empty answer
clears what a previous match left behind. `title_metadata.enriched_at` records
that it was asked, which is what keeps a film with no episodes from being
re-asked on every match run. A new match clears it; a failed enrichment leaves it
clear, so the title is asked again.
**No banner on a 16:9 tile.** AniList's `bannerImage` is no longer stored as a
backdrop. The backdrop is ani.zip's fanart where there is one; otherwise the tile
letterboxes the poster.
**Schema v9** adds the three numbering columns and `enriched_at`, deletes every
stored AniList episode row and every AniList banner-as-backdrop — all of it
`streamingEpisodes` data and wrong-shaped art — and leaves every title
un-enriched, so the next match run (which the app starts on its own) refills
them. Matches, hand-picked ones included, are kept.
## Phase 2 — art and order (done)
**More of the answer kept.** The AniList query now also asks for the full
start date, broadcast season, banner, popularity, status, the next airing
episode, spoiler-free tags ranked 60 or more, main studios, the `Director`
among the most relevant staff, and the relations that keep to one story
(`PREQUEL`, `SEQUEL`, `PARENT`, `SIDE_STORY`, `SUMMARY`, `ALTERNATIVE`,
`COMPILATION`, `CONTAINS` — not `SPIN_OFF`, `CHARACTER` or `OTHER`, which reach
into other franchises). All of it lives in `TitleDetails`, stored as one JSON
column (**schema v10**). A match stored before that is fetched again *by id*
(`Work::Refresh`) — never searched for, so hand-picked matches stay — and a
refresh of the same entry keeps its episodes, fanart and enrichment record.
Enrichment also stores the chain of entries it walked.
**Art.** Three textures per title where there was one: the tile's (`Art::of`),
the 2:3 cover (`Art::poster`), and wide art (`Art::wide` — fanart, else the
AniList banner) decoded at up to 1920 px for the library's hero and the title
page's band. The banner, wrong in a 16:9 tile, fits those strips as drawn.
**Poster grid.** Settings → Appearance → *Library tiles*: covers (the default)
or 16:9 stills. Covers fill their tile; a cover in a still-shaped tile is still
fitted whole.
**Names.** Settings → Appearance → *Title names*: English (the default, and
what Android always showed), romaji, or the share's own. Search matches all
of them whichever is shown.
**Sorts.** `pstr_core::browse`, shared by both clients. A–Z by the shown name, recently watched, recently added (from
`node_first_seen`, which survives recrawls; files present before v10 count as
added at 0), newest by air date, highest rated, most popular.
**Franchises.** `pstr_core::franchise::group` joins titles that share a
provider id — their own, a related one, or one their episode walk passed
through. The grid folds each franchise into its lead (the first series to air,
else the first film) with a `+N` badge; the stack button beside the sort turns
that off. The title page shows the whole franchise in release order. On the
library this was built against: Evangelion ×4, Made in Abyss ×4, Heaven's Feel
×3, Mushishi with its OVA, Jujutsu Kaisen with *0*, Chainsaw Man with *Reze*.
**Shelves.** `pstr_core::shelves::pick`: airing now, then directors, studios
and genres, each only when it spans three *franchises* — Made in Abyss's four
titles are one director's, and that is not a shelf — and a genre only under 40%
of the library. Four at most, over the unfiltered library. Here: *Directed by
Satoshi Kon*, *From MADHOUSE*, *From MAPPA*, *From Production I.G*.
**The title page** adds the season, studios, director and next airing episode
to its line of facts, and the tags beside the genres.
**Android.** The same, through the bridge: `AndroidEngine::arrangement`
returns the grid's tiles and the shelves as keys into `library`'s titles, and
each `TitleRecord` carries its display name, franchise and details. The
library's bar has a sort menu with the franchise toggle in it; the grid shows
`+N` on a folded tile and up to three shelves above it; the title page has the
same facts, tags and franchise row as the desktop's; *Title names* is in
Settings → Appearance. Android's grid is always covers, so it has no
*Library tiles* choice.
Not done: TMDB fills no details.
## Phase 3 — reach
- **Local matching** against the offline database, so only ids leave the
machine.
- **Routing by title**: AniList first, then TMDB (with a key) or TVmaze for
whatever is not anime — Game of Thrones in a mostly-anime share.
- **Folders of films** matched per file: four Rebuild films in one folder are
four entries, not one.
- **Skip intro** from AniSkip for files without chapters, and filler flags from
Jikan.
+11 -7
View File
@@ -212,13 +212,17 @@ client's decisions, so a change to `pstr_app::theme::Role` that nobody carried
across fails here rather than being noticed in a screenshot.
**Screenshot tests** (`ScreenshotTest`, Roborazzi) draw each screen from fixed
records — Library on a phone, a tablet and at font scale 1.5, an empty library,
a title, History, Shares, Settings and Downloads — and `check` compares them with the
images in `app/src/test/screenshots`. A layout regression compiles and passes
everything else; here it fails, and the review shows the changed image. After a
deliberate change, `gradlew recordRoborazziDebug` rewrites the images and they
are committed with it. No artwork loads on the host, so tiles show their
placeholder, and the palette is the shipped default.
records — Library on a phone, a tablet, at font scale 1.5, while loading and
empty; a title on a phone and a tablet; History on a phone and a tablet; Shares;
the Settings index and its Playback and Storage pages; Downloads with a season
saved and empty; and the library and a title again in Catppuccin Latte — and
`check` compares them with the images in `app/src/test/screenshots`. A layout
regression compiles and passes everything else; here it fails, and the review
shows the changed image. After a deliberate change, `gradlew
recordRoborazziDebug` rewrites the images and they are committed with it.
Artwork loading is switched off (`LocalArtworkLoading`), so every tile shows
its generated placeholder whatever the timing, and the palette is the shipped
default except in the Latte captures.
**Instrumentation tests** (`app/src/androidTest`) are the things with no host
equivalent:
+1 -1
View File
@@ -14,7 +14,7 @@
# builds from the registry alone.
pkgname=proton-stream
pkgver=0.5.1
pkgver=1.1.1
pkgrel=1
pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share"
arch=('x86_64')
+6 -6
View File
@@ -4,7 +4,7 @@ set -euo pipefail
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd -- "$script_dir/.." && pwd)"
source_dir="${PSTR_MPV_SOURCE_DIR:-$repo_root/.build/mpv-android}"
revision=20a3fa526fac6d3fe267aee0d4c349893fee65a3
revision=687c7fd586d826fd0387809e14bec7a2b4fc45a1
destination="$repo_root/android/app/build/generated/mpv"
# The staged tree depends only on the pinned revision and on this script, which
@@ -32,19 +32,19 @@ git -C "$source_dir" submodule update --init --recursive
if [[ -n "${ANDROID_NDK_HOME:-}" ]]; then
ndk_dir="$ANDROID_NDK_HOME"
elif [[ -n "${ANDROID_HOME:-}" ]]; then
ndk_dir="$ANDROID_HOME/ndk/29.0.14206865"
ndk_dir="$ANDROID_HOME/ndk/30.0.16248370"
else
echo "error: set ANDROID_NDK_HOME or ANDROID_HOME to an Android NDK r29 installation" >&2
echo "error: set ANDROID_NDK_HOME or ANDROID_HOME to an Android NDK r30 installation" >&2
exit 1
fi
if [[ ! -f "$ndk_dir/source.properties" ]] ||
! grep -q '^Pkg.Revision = 29\.0\.14206865$' "$ndk_dir/source.properties"; then
echo "error: libmpv requires Android NDK 29.0.14206865: $ndk_dir" >&2
! grep -q '^Pkg.Revision = 30\.0\.16248370$' "$ndk_dir/source.properties"; then
echo "error: libmpv requires Android NDK 30.0.16248370: $ndk_dir" >&2
exit 1
fi
ndk_dir="$(cd -- "$ndk_dir" && pwd -P)"
mpv_sdk_dir="$source_dir/buildscripts/sdk"
mpv_ndk_link="$mpv_sdk_dir/android-ndk-r29"
mpv_ndk_link="$mpv_sdk_dir/android-ndk-r30"
mkdir -p "$mpv_sdk_dir"
if [[ -e "$mpv_ndk_link" && ! -L "$mpv_ndk_link" ]]; then
echo "error: refusing to replace non-symlink NDK path: $mpv_ndk_link" >&2