Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73fd8a1dfe | ||
|
|
a676f4aba8 | ||
|
|
c0545a245f | ||
|
|
e21ddae737 | ||
|
|
21f24dd998 | ||
|
|
1fb5f070c8 | ||
|
|
f6efa7208a | ||
|
|
9dd44eeada | ||
|
|
5259b47cf3 | ||
|
|
bed1030443 | ||
|
|
bb7d5dc01a | ||
|
|
a90de67c54 | ||
|
|
b98cbfe28a | ||
|
|
0f928798d7 | ||
|
|
ff21c4cd44 | ||
|
|
b3228cb4f4 | ||
|
|
e271874b1d | ||
|
|
24d85f3738 | ||
|
|
1093c5bad8 |
+150
@@ -9,6 +9,156 @@ generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
|
|||||||
For how long each fixed defect had been shipping before it was found, see
|
For how long each fixed defect had been shipping before it was found, see
|
||||||
[docs/defect-windows.md](docs/defect-windows.md).
|
[docs/defect-windows.md](docs/defect-windows.md).
|
||||||
|
|
||||||
|
## v0.13.2
|
||||||
|
|
||||||
|
A series opens with its episodes in under a second. v0.13.1 fixed the season
|
||||||
|
list; the episode list below it still took about five seconds on a Fairphone.
|
||||||
|
|
||||||
|
### ⚡ Performance
|
||||||
|
|
||||||
|
- **The episode list no longer waits for the server.** It used to wait for
|
||||||
|
"Next Up" and your resume point, fetched from the server first, although every
|
||||||
|
episode was already on the device. The list now shows as soon as it is
|
||||||
|
loaded, and Next Up answers from the device first like everything else.
|
||||||
|
(DR-101, DR-295)
|
||||||
|
- **A page loads once.** Opening a series loaded it six times over, putting
|
||||||
|
about seventy requests in flight at once. It now loads once, and re-loads only
|
||||||
|
when something actually changed (reconnecting, marking watched). (DR-295)
|
||||||
|
- **The local database finds things by index instead of reading everything.**
|
||||||
|
Each item now records the one container it is listed under, so a series,
|
||||||
|
season or album page is a single index lookup — under a millisecond on a
|
||||||
|
100,000-item library, where it used to scan the whole catalogue. The update
|
||||||
|
converts an existing library in well under a second, once. (DR-012, DR-013)
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
- **A series lists its seasons, not every episode as well.** Both the library
|
||||||
|
view and the Downloads view mixed a series' episodes in with its seasons.
|
||||||
|
(DR-013)
|
||||||
|
|
||||||
|
## v0.13.1
|
||||||
|
|
||||||
|
Pages answer from the cache again. Found on a Fairphone, where opening a
|
||||||
|
series took one to ten seconds even though everything on it was already cached.
|
||||||
|
|
||||||
|
### ⚡ Performance
|
||||||
|
|
||||||
|
- **Series and library pages load from the cache.** Frasier's season and
|
||||||
|
episode lists now appear in 34–133 ms on a Fairphone 5; they took 600–1030 ms
|
||||||
|
each, waiting on the server. Three causes stacked up. Every database read
|
||||||
|
waited behind every write, because one connection served the whole app. The
|
||||||
|
listing query scanned the entire cached catalogue whatever page it was for.
|
||||||
|
And a cache answer that took longer than 100 ms was ignored until the server
|
||||||
|
replied. Reads now have their own connections, the query uses its indexes
|
||||||
|
(about 50× faster on a 100k-item cache), and the cache and the server race:
|
||||||
|
whichever answers first with something to show wins. (DR-012, DR-013)
|
||||||
|
- **A series page walks its seasons once, in parallel.** It used to walk all of
|
||||||
|
them twice, one after another. (DR-295)
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
- **Coming back from background audio opens the right episode.** If an episode
|
||||||
|
ended while the app was in the background, returning to it reloaded the
|
||||||
|
*previous* episode at the new one's position. (DR-296)
|
||||||
|
- **Offline changes keep their own identity.** Two favourites or progress
|
||||||
|
updates queued at the same moment could be handed each other's queue entry,
|
||||||
|
so syncing one marked the other as done. (DR-014)
|
||||||
|
- **Catalog caching no longer switches off data-integrity checks for the rest
|
||||||
|
of the app** while it saves a page. (DR-012)
|
||||||
|
|
||||||
|
## v0.13.0
|
||||||
|
|
||||||
|
Android plays and downloads the original file, and offline mode works without
|
||||||
|
a network. Found on a tablet with no Dolby decoder, where nearly every film was
|
||||||
|
being transcoded by the server — slowly, and unresumably — just for its audio.
|
||||||
|
|
||||||
|
### ✨ Features
|
||||||
|
|
||||||
|
- **Dolby and DTS audio play on every Android device.** Android ships no AC-3,
|
||||||
|
E-AC-3, DTS or TrueHD decoders — they exist only where a manufacturer paid for
|
||||||
|
them. The player now decodes them itself (FFmpeg), so these films stream and
|
||||||
|
download as the original file instead of a server transcode: direct play when
|
||||||
|
streaming, and a download that runs at full speed, shows a real percentage,
|
||||||
|
and resumes after a dropped connection. Measured: a 910 MB E-AC-3 5.1 episode
|
||||||
|
in 94 seconds, where a transcode managed about 1 MB/s. (DR-293)
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
- **Downloaded films play offline.** Playing a download asked the server for
|
||||||
|
details it did not need, so with no network it waited seven seconds, failed,
|
||||||
|
and never opened the file on disk. It now answers from the download itself.
|
||||||
|
(DR-294)
|
||||||
|
- **"Failed to load item" offline.** Opening a downloaded show's details could
|
||||||
|
fail while the app was writing to its database (the catalog sync at every
|
||||||
|
launch, a download finishing): the cached answer was thrown away for being
|
||||||
|
slow, and the server — unreachable — was reported instead. The library list,
|
||||||
|
genres, playlists, search and favourites had the same flaw. They now wait for
|
||||||
|
the cache. (DR-294)
|
||||||
|
- **The TV page no longer blanks offline.** Its "Next Up" row was server-only,
|
||||||
|
and its failure took the whole page with it. It now falls back to the cache.
|
||||||
|
(DR-294)
|
||||||
|
|
||||||
|
### 🔧 Changes
|
||||||
|
|
||||||
|
- **Native video is no longer optional on Android.** The built-in web player
|
||||||
|
cannot decode Dolby or DTS audio, so with original files downloaded it would
|
||||||
|
play them silent. The "Native Video" switch is gone from Android settings (it
|
||||||
|
remains on Linux, beside mpv native video).
|
||||||
|
- **Licence.** The Android app now bundles a GPL-3.0 component (the FFmpeg audio
|
||||||
|
decoder), so the distributed APK carries GPL-3.0 terms; JellyTau's source stays
|
||||||
|
MIT. See `THIRD_PARTY_NOTICES.md`.
|
||||||
|
|
||||||
|
## v0.12.2
|
||||||
|
|
||||||
|
Two download fixes, found together on a tablet whose films all needed their
|
||||||
|
audio re-encoded. Downloads that took longer than five minutes were being cut
|
||||||
|
off and restarted, and the progress bar sat on "0%" for the whole of a
|
||||||
|
transcode.
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
- **Downloads longer than five minutes no longer fail.** The download worker's
|
||||||
|
HTTP client carried a *total* request deadline of five minutes — from
|
||||||
|
connect until the last byte — so every transfer longer than that was cut off
|
||||||
|
mid-body and retried. A transcode cannot be resumed (the server ignores
|
||||||
|
`Range` and starts over), so each retry threw away what had been fetched, hit
|
||||||
|
the same deadline, and after three attempts the download failed. No feature
|
||||||
|
film at transcode speed could complete; a large direct copy limped through in
|
||||||
|
five-minute slices with a backoff between each. The deadline is now a
|
||||||
|
30-second connect timeout plus a 60-second *stall* timeout that resets on
|
||||||
|
every chunk: a dead connection is still caught, a healthy transfer can run as
|
||||||
|
long as it needs. Present since the first release. (DR-289)
|
||||||
|
|
||||||
|
### ✨ Improvements
|
||||||
|
|
||||||
|
- **The progress bar moves during a transcode download.** A transcode is
|
||||||
|
produced as it is sent, with no `Content-Length`, and the bar had nothing to
|
||||||
|
measure against — it read "0%" until the file completed. The backend now
|
||||||
|
predicts the size when it resolves the download: the source's size for
|
||||||
|
`original` (the picture is copied byte-for-byte; only the audio changes), or
|
||||||
|
bitrate × runtime for a quality preset. The bar shows the estimate as
|
||||||
|
"~42%" and caps at 99% until the last byte lands; the server's own figure is
|
||||||
|
used whenever it gives one; and when there is no prediction at all the bar
|
||||||
|
is a moving band with the bytes so far, rather than a false "0%". (DR-290)
|
||||||
|
|
||||||
|
## v0.12.1
|
||||||
|
|
||||||
|
One change: the TLS library every connection to the server goes through has a
|
||||||
|
published vulnerability, and this build carries the fixed release of it. Nothing
|
||||||
|
in JellyTau itself changed.
|
||||||
|
|
||||||
|
### 🔒 Security
|
||||||
|
|
||||||
|
- **Updated the TLS library (rustls) to 0.23.45** for
|
||||||
|
[RUSTSEC-2026-0285](https://rustsec.org/advisories/RUSTSEC-2026-0285). The
|
||||||
|
version in v0.12.0 accepted TLS 1.3 handshake messages sent at the wrong
|
||||||
|
encryption level — the same fault as Go's CVE-2025-61730. The handshake stays
|
||||||
|
authenticated, so someone on the network could not alter or complete a
|
||||||
|
connection with it; the practical effect was that a server could send in
|
||||||
|
plaintext what should have been encrypted without the app refusing. Every
|
||||||
|
JellyTau build from the first release used an affected version. Found by the
|
||||||
|
dependency-advisory gate in CI, which is what it is there for.
|
||||||
|
|
||||||
## v0.12.0
|
## v0.12.0
|
||||||
|
|
||||||
JellyTau works against Jellyfin 12. Jellyfin 12.0 shipped on 2026-09-08 and
|
JellyTau works against Jellyfin 12. Jellyfin 12.0 shipped on 2026-09-08 and
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Third-party notices
|
||||||
|
|
||||||
|
JellyTau's own source code is licensed under the MIT License (see `LICENSE`).
|
||||||
|
Some builds bundle third-party components under other licences, listed here.
|
||||||
|
|
||||||
|
## Android: FFmpeg audio decoder (GPL-3.0)
|
||||||
|
|
||||||
|
The Android app bundles **`org.jellyfin.media3:media3-ffmpeg-decoder`**, the
|
||||||
|
Jellyfin project's build of the media3 FFmpeg extension, which contains FFmpeg.
|
||||||
|
It lets the player decode AC-3, E-AC-3, DTS and TrueHD audio, which Android does
|
||||||
|
not ship.
|
||||||
|
|
||||||
|
- Licence: **GNU General Public License v3.0**
|
||||||
|
- Source: <https://github.com/jellyfin/jellyfin-androidx-media> (build of
|
||||||
|
<https://github.com/androidx/media>), with FFmpeg from <https://ffmpeg.org>
|
||||||
|
|
||||||
|
Because this component is GPL-3.0, **the Android APK as distributed is subject
|
||||||
|
to the terms of the GPL-3.0**. The complete corresponding source for JellyTau is
|
||||||
|
available in this repository; JellyTau's own code remains available under MIT.
|
||||||
|
|
||||||
|
Desktop builds do not include this component.
|
||||||
@@ -314,57 +314,22 @@ pub struct PlaybackModeManager {
|
|||||||
|
|
||||||
**Location**: `src-tauri/src/storage/db_service.rs`
|
**Location**: `src-tauri/src/storage/db_service.rs`
|
||||||
|
|
||||||
Async database interface wrapping synchronous `rusqlite` to prevent blocking the Tokio runtime:
|
Async database interface over `rusqlite`. `RusqliteService` owns the
|
||||||
|
database: writes run as jobs on one writer thread, reads on a pool of read-only
|
||||||
|
WAL connections, so a read never waits for a write. Callers only see the
|
||||||
|
trait (`execute`, `insert`, `execute_detached`, `query_one` / `query_optional` /
|
||||||
|
`query_many`, `transaction`, `transaction_without_foreign_keys`) and build
|
||||||
|
queries with `Query` + `QueryParam`, which keeps values out of the SQL string.
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
#[async_trait]
|
let db_service = database.service(); // cheap clone of the shared owner
|
||||||
pub trait DatabaseService: Send + Sync {
|
|
||||||
async fn execute(&self, query: Query) -> Result<usize, DatabaseError>;
|
|
||||||
async fn execute_batch(&self, queries: Vec<Query>) -> Result<(), DatabaseError>;
|
|
||||||
async fn query_one<T, F>(&self, query: Query, mapper: F) -> Result<T, DatabaseError>
|
|
||||||
where F: FnOnce(&Row) -> Result<T> + Send + 'static;
|
|
||||||
async fn query_optional<T, F>(&self, query: Query, mapper: F) -> Result<Option<T>, DatabaseError>
|
|
||||||
where F: FnOnce(&Row) -> Result<T> + Send + 'static;
|
|
||||||
async fn query_many<T, F>(&self, query: Query, mapper: F) -> Result<Vec<T>, DatabaseError>
|
|
||||||
where F: Fn(&Row) -> Result<T> + Send + 'static;
|
|
||||||
async fn transaction<F, T>(&self, f: F) -> Result<T, DatabaseError>
|
|
||||||
where F: FnOnce(Transaction) -> Result<T> + Send + 'static;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct RusqliteService {
|
|
||||||
connection: Arc<Mutex<Connection>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DatabaseService for RusqliteService {
|
|
||||||
async fn execute(&self, query: Query) -> Result<usize, DatabaseError> {
|
|
||||||
let conn = self.connection.clone();
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
// Execute query on blocking thread pool
|
|
||||||
}).await?
|
|
||||||
}
|
|
||||||
// ... other methods use spawn_blocking
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Benefits:**
|
|
||||||
- **No Freezing**: All blocking DB ops run in thread pool via `spawn_blocking`
|
|
||||||
- **Type Safety**: `QueryParam` enum prevents SQL injection
|
|
||||||
- **Future Proof**: Easy to swap to native async DB (tokio-rusqlite)
|
|
||||||
- **Testable**: Can mock DatabaseService for tests
|
|
||||||
|
|
||||||
**Usage Pattern:**
|
|
||||||
```rust
|
|
||||||
// Before (blocking - causes UI freeze)
|
|
||||||
let conn = database.connection();
|
|
||||||
let conn = conn.lock().unwrap(); // BLOCKS
|
|
||||||
conn.query_row(...) // BLOCKS
|
|
||||||
|
|
||||||
// After (async - no freezing)
|
|
||||||
let db_service = database.service();
|
|
||||||
let query = Query::with_params("SELECT ...", vec![...]);
|
let query = Query::with_params("SELECT ...", vec![...]);
|
||||||
db_service.query_one(query, |row| {...}).await // spawn_blocking internally
|
db_service.query_one(query, |row| {...}).await // runs on a reader
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Ownership model, invariants and the reasons for them:
|
||||||
|
[08-database-design.md → Connection ownership](08-database-design.md#connection-ownership).
|
||||||
|
|
||||||
## Component Hierarchy
|
## Component Hierarchy
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -701,6 +666,14 @@ render behind it. There is no measurement and no reserved padding. If you
|
|||||||
restructure the shell, preserve the scroll containment — reintroducing padding
|
restructure the shell, preserve the scroll containment — reintroducing padding
|
||||||
math reintroduces the bug.
|
math reintroduces the bug.
|
||||||
|
|
||||||
|
**The route renders in exactly one element.** `+layout.svelte` switches the
|
||||||
|
wrapper's *classes* between the shell scroller and the plain clipped box that
|
||||||
|
layout-owning routes (library, settings, player) get — it must not switch
|
||||||
|
between two branches that each render `children`. The page store that decides
|
||||||
|
the mode can update a flush after the new route renders, so two branches
|
||||||
|
mounted a page under one and then remounted it under the other: every
|
||||||
|
navigation between the two kinds of route loaded the page twice (DR-295).
|
||||||
|
|
||||||
### AccountMenu
|
### AccountMenu
|
||||||
|
|
||||||
One component for both breakpoints, anchored to the username/avatar (a real
|
One component for both breakpoints, anchored to the username/avatar (a real
|
||||||
@@ -754,6 +727,22 @@ hero button labelled `Resume S2E4` / `Play S1E1`.
|
|||||||
A season is not a destination: `/library/<seasonId>` redirects to its series
|
A season is not a destination: `/library/<seasonId>` redirects to its series
|
||||||
(DR-103). Video library routes collapse to one per library (DR-105).
|
(DR-103). Video library routes collapse to one per library (DR-105).
|
||||||
|
|
||||||
|
**The episode list never waits for Next Up or resume.** `resolve_series_view`
|
||||||
|
(`series_progress.rs`, `with_hints`) returns as soon as the episodes are in;
|
||||||
|
Next Up and resume are used if they have answered by then and dropped if not,
|
||||||
|
and `pick_current_episode` falls back to the episodes' own watch state. They
|
||||||
|
only refine which episode is current, and waiting for them held the list for
|
||||||
|
the server's 2–3 s although every episode was cached. Next Up is cache-first
|
||||||
|
like every other query (03-data-flow).
|
||||||
|
|
||||||
|
**One load per item, however many triggers.** The detail page loads through
|
||||||
|
`createCoalescedLoader` (`utils/coalescedLoader.ts`): calls for the item
|
||||||
|
already loading share that load, and callers that know the data changed
|
||||||
|
(`fresh`: reconnect, filter change, mark watched, clear history) get exactly
|
||||||
|
one re-run after it. `onMount`, a mount-time `$effect`, the reachability
|
||||||
|
effect's first run and the double mount above used to each start a full load —
|
||||||
|
six per open, about seventy requests in flight.
|
||||||
|
|
||||||
`episodeStrip.ts` holds the pure logic for the "More Episodes" strip, extracted
|
`episodeStrip.ts` holds the pure logic for the "More Episodes" strip, extracted
|
||||||
from the component because it had three distinct bugs that markup made
|
from the component because it had three distinct bugs that markup made
|
||||||
untestable: the strip collapsing to just the current episode while real siblings
|
untestable: the strip collapsing to just the current episode while real siblings
|
||||||
|
|||||||
@@ -28,12 +28,17 @@ sequenceDiagram
|
|||||||
Server->>Conn: mark_unreachable() (debounced)
|
Server->>Conn: mark_unreachable() (debounced)
|
||||||
end
|
end
|
||||||
|
|
||||||
alt Cache returns with content
|
alt Cache answers first with content (inside 100ms, or later but before the server)
|
||||||
Cache-->>Hybrid: Result with items
|
Cache-->>Hybrid: Result with items
|
||||||
Hybrid-->>Rust: Return cache result
|
Hybrid-->>Rust: Return cache result
|
||||||
else Cache timeout or empty
|
Server-->>Hybrid: Fresh result (later)
|
||||||
|
Hybrid->>Cache: save_to_cache() in background
|
||||||
|
else Server answers first, or cache is empty
|
||||||
Server-->>Hybrid: Fresh result
|
Server-->>Hybrid: Fresh result
|
||||||
Hybrid-->>Rust: Return server result
|
Hybrid-->>Rust: Return server result
|
||||||
|
else Server fails
|
||||||
|
Cache-->>Hybrid: Whatever the cache has (waited for)
|
||||||
|
Hybrid-->>Rust: Return cache result, else the server error
|
||||||
end
|
end
|
||||||
|
|
||||||
Rust-->>Client: SearchResult
|
Rust-->>Client: SearchResult
|
||||||
@@ -42,11 +47,21 @@ sequenceDiagram
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Key Points:**
|
**Key Points:**
|
||||||
- Cache queries have 100ms timeout for responsiveness
|
- Both legs start together. A cache answer with content inside 100 ms
|
||||||
- Server queries always run for fresh data
|
(`CACHE_FAST_PATH`) returns at once.
|
||||||
- Cache wins if it has meaningful content
|
- **The deadline does not decide the race.** A cache read still running at
|
||||||
- Automatic fallback to server if cache is empty/stale
|
100 ms is raced against the server (`HybridRepository::race_slow_cache`), and
|
||||||
- Background cache updates (planned)
|
whichever answers first *with content* wins. It used to be that a read past
|
||||||
|
the deadline was only consulted if the server failed, so a page whose cache
|
||||||
|
read took 150 ms always paid the full server round trip — about a second on a
|
||||||
|
phone, on every visit.
|
||||||
|
- An empty or failed cache answer is not a win; the server decides. A failed
|
||||||
|
server falls back to whatever the cache said, waiting for it if necessary.
|
||||||
|
- On a cache win the server's page is still cached in the background when it
|
||||||
|
arrives, so per-user state (positions, favourites) keeps up.
|
||||||
|
- A `get_items` leg that takes 250 ms or more is logged at INFO with its row
|
||||||
|
count, so a slow page can be attributed to the cache or the server from a
|
||||||
|
device log alone.
|
||||||
- **Connectivity side-effect**: each server request feeds the `ConnectivityMonitor`, which is the source of truth for the offline/online banner (see [07-connectivity.md](07-connectivity.md)). A server-answered error (401/404/5xx) still counts as *reachable* — only network failures, sustained past a debounce window, flip the app to offline.
|
- **Connectivity side-effect**: each server request feeds the `ConnectivityMonitor`, which is the source of truth for the offline/online banner (see [07-connectivity.md](07-connectivity.md)). A server-answered error (401/404/5xx) still counts as *reachable* — only network failures, sustained past a debounce window, flip the app to offline.
|
||||||
|
|
||||||
### Listing order is decided in Rust
|
### Listing order is decided in Rust
|
||||||
|
|||||||
@@ -95,8 +95,9 @@ flowchart LR
|
|||||||
**Location**: `src/lib/player/html5Adapter.ts`, `src/lib/player/index.ts`, report commands in
|
**Location**: `src/lib/player/html5Adapter.ts`, `src/lib/player/index.ts`, report commands in
|
||||||
`src-tauri/src/commands/player/timers.rs`
|
`src-tauri/src/commands/player/timers.rs`
|
||||||
|
|
||||||
Video on desktop (Linux WebKitGTK) — and, per current interim behavior, Android — is rendered by an
|
Video on desktop (Linux WebKitGTK) is rendered by an HTML5 `<video>`/HLS element **inside the
|
||||||
HTML5 `<video>`/HLS element **inside the webview**. libmpv is initialized audio-only (`vo=null`,
|
webview**. Android no longer uses this path for video — see *The webview is not a video renderer on
|
||||||
|
Android* below. libmpv is initialized audio-only (`vo=null`,
|
||||||
`video=false`), so the native backend cannot render or observe this element. The `<video>` is therefore
|
`video=false`), so the native backend cannot render or observe this element. The `<video>` is therefore
|
||||||
the real player, living outside Rust's reach.
|
the real player, living outside Rust's reach.
|
||||||
|
|
||||||
@@ -287,6 +288,50 @@ and the trait default is still a silent `Ok(())` rather than an error, so a back
|
|||||||
that omits the method still reports success. Flipping that default waits on the
|
that omits the method still reports success. Flipping that default waits on the
|
||||||
device verification.
|
device verification.
|
||||||
|
|
||||||
|
### Licensed audio codecs: the FFmpeg extension
|
||||||
|
|
||||||
|
**TRACES**: UR-004, UR-071 | DR-293
|
||||||
|
|
||||||
|
Android does not ship AC-3, E-AC-3, DTS or TrueHD decoders — they are licensed
|
||||||
|
codecs, present only where a vendor paid for them. The ROD2-W09 test tablet has a
|
||||||
|
vendor DTS decoder and no AC-3/E-AC-3 at all. ExoPlayer has no decoders of its
|
||||||
|
own, so on such a device those tracks are undecodable, and before this every film
|
||||||
|
with Dolby audio was re-encoded by the server — for streaming *and* for download.
|
||||||
|
|
||||||
|
`JellyTauPlayer` builds ExoPlayer with `DefaultRenderersFactory` in
|
||||||
|
`EXTENSION_RENDERER_MODE_ON`: the platform's decoders are tried first (a vendor DTS
|
||||||
|
decoder stays in charge where there is one) and the FFmpeg audio renderer takes
|
||||||
|
what they cannot decode. `CodecDetector` reports the extension's codecs beside the
|
||||||
|
`MediaCodecList` ones, asking `FfmpegLibrary.supportsFormat` per MIME type rather
|
||||||
|
than assuming, so a build whose native library failed to load reports only what
|
||||||
|
the platform decodes. Rust's device profile and download policy read that list,
|
||||||
|
which is what keeps "what we tell the server" and "what actually decodes" in step.
|
||||||
|
|
||||||
|
The decoder is `org.jellyfin.media3:media3-ffmpeg-decoder` — Jellyfin's build of
|
||||||
|
media3's FFmpeg extension, versioned `<media3 version>+N`. **Bump it in the same
|
||||||
|
commit as media3.** It is GPL-3.0: the distributed APK carries those terms, the
|
||||||
|
source stays MIT (see `THIRD_PARTY_NOTICES.md`). Its JNI methods are covered by the
|
||||||
|
AAR's own consumer rules and by `-keep class androidx.media3.** { *; }` in
|
||||||
|
`proguard-jellytau.pro`, which also keeps the renderer ExoPlayer loads reflectively.
|
||||||
|
|
||||||
|
**Rejected:** re-encoding a download's audio on the device after it lands (a
|
||||||
|
remux). It costs minutes of CPU and twice the disk per film, needs a pipeline
|
||||||
|
state of its own, and does nothing for streaming. Decoding at playback fixes both
|
||||||
|
paths with no extra step.
|
||||||
|
|
||||||
|
### The webview is not a video renderer on Android
|
||||||
|
|
||||||
|
ExoPlayer is Android's only video renderer. The HTML5 path used to be reachable
|
||||||
|
through the `experimentalNativeVideo` setting (a *suppressor* of Rust's native
|
||||||
|
choice), but the webview decodes none of the codecs above — so with the original
|
||||||
|
file now downloaded as-is (DR-293), turning native video off would play every such
|
||||||
|
download as a silent film. Rust reports `webview_video_fallback` in
|
||||||
|
`PlaybackCapabilities`: **false on Android**, true only beside mpv native video on
|
||||||
|
Linux, where the webview is still the tested fallback. The frontend offers the
|
||||||
|
switch and honours a stored "off" only when it is true (`nativeVideoWanted` in
|
||||||
|
`stores/nativeVideo.ts`), so a user who once switched it off on Android is not
|
||||||
|
stranded on the silent path.
|
||||||
|
|
||||||
### The equalizer, and where its vocabulary lives
|
### The equalizer, and where its vocabulary lives
|
||||||
|
|
||||||
**TRACES**: UR-027 | DR-030, IR-020
|
**TRACES**: UR-027 | DR-030, IR-020
|
||||||
|
|||||||
@@ -186,6 +186,63 @@ Per-item disk usage comes from `repository_get_download_disk_usage`
|
|||||||
Downloaded browse cards, detail pages, the device total and the remove
|
Downloaded browse cards, detail pages, the device total and the remove
|
||||||
confirmation (DR-085).
|
confirmation (DR-085).
|
||||||
|
|
||||||
|
## What a Video Download Fetches
|
||||||
|
|
||||||
|
**TRACES**: UR-071, UR-004 | DR-171, DR-293
|
||||||
|
|
||||||
|
An `original`-quality download is the server's untouched file (`Static=true`)
|
||||||
|
unless its audio cannot be decoded by **the renderer that will play it** —
|
||||||
|
`renderer_can_decode_audio`, DR-234's per-platform answer. Only then is the
|
||||||
|
server asked to re-encode the audio on the way down (`allowVideoStreamCopy`
|
||||||
|
keeps the picture byte-for-byte).
|
||||||
|
|
||||||
|
The distinction matters because a transcode is generated as it is sent: no
|
||||||
|
`Content-Length`, `Range` ignored. It measured ~1 MB/s and restarted from byte
|
||||||
|
zero on every network blip, against a direct copy that moved a 910 MB episode in
|
||||||
|
94 s with no retries. On Android the renderer is ExoPlayer with the FFmpeg
|
||||||
|
extension ([05-platform-backends.md](05-platform-backends.md)), which decodes
|
||||||
|
AC-3/E-AC-3/DTS/TrueHD, so Android downloads are always the direct copy. On
|
||||||
|
Linux the webview still renders video and the transcode still applies.
|
||||||
|
|
||||||
|
The policy used to judge against the *webview's* codec list on every platform
|
||||||
|
(DR-171), because a download outlives the native-video setting that was active
|
||||||
|
when it arrived. That reasoning is why Android's webview video path was removed
|
||||||
|
rather than merely defaulted off: a file downloaded as the original must never
|
||||||
|
meet a renderer that cannot decode it.
|
||||||
|
|
||||||
|
## Offline Means No Network
|
||||||
|
|
||||||
|
**TRACES**: UR-002, UR-071 | DR-294
|
||||||
|
|
||||||
|
Three defects made "offline" depend on the network; the invariants that replace
|
||||||
|
them:
|
||||||
|
|
||||||
|
- **A download plays without the server.** Playing a downloaded item asked the
|
||||||
|
server for its `PlaybackInfo` only to read the media-source id; offline that
|
||||||
|
retried for seven seconds, failed, and the file was never opened.
|
||||||
|
`OfflineRepository::local_playback_info` answers for any completed download of
|
||||||
|
the current user — local path, direct play, item id as media source (a download
|
||||||
|
names no source, so the server served its default, which carries the item's id)
|
||||||
|
— and `HybridRepository::get_playback_info` consults it **first**.
|
||||||
|
- **A slow cache read is waited for, never discarded.** Reads no longer queue
|
||||||
|
behind writes (see [Connection ownership](08-database-design.md#connection-ownership)),
|
||||||
|
but a big query, a cold page cache or a busy reader pool can still push a read
|
||||||
|
past the 100 ms fast path. Such a read is raced against the server rather
|
||||||
|
than set aside (see [03-data-flow.md](03-data-flow.md)). `get_items`, the library list, genres and playlist items used to discard
|
||||||
|
such a read, wait for the server, and — offline — return its error over data on
|
||||||
|
disk; "More info" on a downloaded show failed that way. They now start the read
|
||||||
|
with `cache_try` (which keeps it running) and `settle` on it when the server
|
||||||
|
fails. Cache-only reads (search, favourites) have no server to fall back from,
|
||||||
|
so they simply await the cache.
|
||||||
|
- **Server-only sections degrade, they do not fail a page.** Next Up went only to
|
||||||
|
the server, and the TV landing page loads it in one `Promise.all`, so offline it
|
||||||
|
blanked the whole page. It now falls back to the cache when the server cannot
|
||||||
|
answer.
|
||||||
|
|
||||||
|
What still needs the server, deliberately: streaming anything not downloaded,
|
||||||
|
live TV and channels, reporting playback, and edits (favourites, playlists,
|
||||||
|
played state).
|
||||||
|
|
||||||
## Download Commands
|
## Download Commands
|
||||||
|
|
||||||
**Location**: `src-tauri/src/commands/download/` — `mod.rs` (the commands below), `pinning.rs`, `smart_cache.rs`
|
**Location**: `src-tauri/src/commands/download/` — `mod.rs` (the commands below), `pinning.rs`, `smart_cache.rs`
|
||||||
|
|||||||
@@ -243,9 +243,14 @@ CREATE TABLE items (
|
|||||||
last_sync DATETIME,
|
last_sync DATETIME,
|
||||||
|
|
||||||
UNIQUE(jellyfin_id, server_id)
|
UNIQUE(jellyfin_id, server_id)
|
||||||
|
|
||||||
|
-- Logical container (migration 027): episode → season/series, season →
|
||||||
|
-- series, track → album, else parent. See "Listing query shape".
|
||||||
|
-- container_id TEXT GENERATED ALWAYS AS (CASE item_type … END) VIRTUAL
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Performance indexes
|
-- Performance indexes
|
||||||
|
CREATE INDEX idx_items_container ON items(container_id, sort_name, name);
|
||||||
CREATE INDEX idx_items_server ON items(server_id);
|
CREATE INDEX idx_items_server ON items(server_id);
|
||||||
CREATE INDEX idx_items_library ON items(library_id);
|
CREATE INDEX idx_items_library ON items(library_id);
|
||||||
CREATE INDEX idx_items_parent ON items(parent_id);
|
CREATE INDEX idx_items_parent ON items(parent_id);
|
||||||
@@ -594,6 +599,125 @@ flowchart LR
|
|||||||
| Episode | ~3 KB | ~100 KB | 300 MB - 2 GB |
|
| Episode | ~3 KB | ~100 KB | 300 MB - 2 GB |
|
||||||
| Full music library (5000 songs) | ~10 MB | ~250 MB | 25-75 GB |
|
| Full music library (5000 songs) | ~10 MB | ~250 MB | 25-75 GB |
|
||||||
|
|
||||||
|
## Connection ownership
|
||||||
|
|
||||||
|
`storage::Database` is opened once at startup and owns the database for the
|
||||||
|
life of the app; nothing else opens the file. Everything goes through
|
||||||
|
`Database::service()`, which returns a clone of one `RusqliteService`
|
||||||
|
(`storage/db_service.rs`) — callers never hold a `Connection`.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
execute / insert / │ writer thread ("db-writer") │ one read-write connection,
|
||||||
|
transaction / ────►│ jobs run in arrival order │ foreign_keys = ON
|
||||||
|
execute_detached └─────────────────────────────┘
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
query_one / │ reader pool (3 connections) │ query_only = ON; WAL gives
|
||||||
|
query_optional / ─►│ via spawn_blocking │ each the last committed
|
||||||
|
query_many └─────────────────────────────┘ snapshot
|
||||||
|
```
|
||||||
|
|
||||||
|
Pragmas: `journal_mode = WAL`, `synchronous = NORMAL`, `busy_timeout = 5 s` on
|
||||||
|
every connection. `PRAGMA optimize` runs once at open, after migrations, so the planner has
|
||||||
|
statistics (see "Listing query shape").
|
||||||
|
|
||||||
|
**Why.** It used to be one connection behind one `std::sync::Mutex`. WAL was on,
|
||||||
|
but with a single connection its one benefit — readers running beside a writer —
|
||||||
|
never applied: every read queued behind every write. A series page's background
|
||||||
|
refresh cached hundreds of episodes one autocommit (and one fsync) at a time,
|
||||||
|
and library pages, thumbnail lookups and settings reads all waited behind the
|
||||||
|
pile; the same page measured 1.7 s or 3.95 s depending on what was queued.
|
||||||
|
|
||||||
|
**Invariants a change must keep:**
|
||||||
|
|
||||||
|
- **Writes go to the writer, reads to the pool.** A reader is `query_only`, so a
|
||||||
|
write sent through `query_*` fails loudly rather than racing the writer. A
|
||||||
|
read that must see a write *in the same unit of work* belongs inside the
|
||||||
|
`transaction` closure, which runs on the writer.
|
||||||
|
- **Connection-wide state is set inside one writer job.** `PRAGMA foreign_keys`
|
||||||
|
is per connection and ignored inside a transaction. `save_to_cache` used to
|
||||||
|
switch it off with one `execute` and back on with another, so it stayed off
|
||||||
|
for every other write that ran across the save's awaits (they did — nearly
|
||||||
|
all of 1,600 FK-violating writes got through in the regression test).
|
||||||
|
`transaction_without_foreign_keys` flips it around `BEGIN`/`COMMIT` in a single
|
||||||
|
job. Any future pragma toggle must work the same way.
|
||||||
|
- **An insert's rowid comes from the same job.** `insert()` returns it; there is
|
||||||
|
deliberately no standalone `last_insert_rowid()`, which returned whichever row
|
||||||
|
the last *anyone* inserted.
|
||||||
|
- **Batch writes into one transaction.** A commit is a queue slot on the
|
||||||
|
writer; a page of items is one `transaction`, not one `execute` per row.
|
||||||
|
- **Best-effort bookkeeping does not wait.** `execute_detached` queues a write
|
||||||
|
(the thumbnail LRU access time) in order with the rest and returns
|
||||||
|
immediately; failures are only logged.
|
||||||
|
- **The writer survives a panicking job.** The job's caller gets an error; the
|
||||||
|
loop rolls back any transaction the job left open and restores
|
||||||
|
`foreign_keys = ON`, then carries on.
|
||||||
|
|
||||||
|
**`synchronous = NORMAL`** is corruption-safe in WAL mode and survives an app
|
||||||
|
crash; only a power cut can lose the last few commits. Everything stored is
|
||||||
|
either re-fetchable from the server or (the sync queue, local positions)
|
||||||
|
recoverable at that granularity.
|
||||||
|
|
||||||
|
**In-memory databases** (tests) cannot be shared between connections, so
|
||||||
|
`RusqliteService::new` has no pool and routes reads through the writer — the
|
||||||
|
old serialized behaviour.
|
||||||
|
|
||||||
|
**Not done, and why:** grouping consecutive small `execute` jobs into one
|
||||||
|
commit on the writer. With `synchronous = NORMAL` a commit no longer fsyncs, so
|
||||||
|
the gain is small, and it would change a failed statement's error semantics
|
||||||
|
for the jobs grouped with it.
|
||||||
|
|
||||||
|
### Listing query shape
|
||||||
|
|
||||||
|
`OfflineRepository::get_items` (`items_listing_sql`) is the hot read: every
|
||||||
|
library, series, season and album page goes through it, and it must fit the
|
||||||
|
100 ms cache fast path on a phone. Every other cached read follows the same
|
||||||
|
rules.
|
||||||
|
|
||||||
|
- **Children are matched on `items.container_id`** (migration 027), a VIRTUAL
|
||||||
|
generated column holding the item's *logical* container: an episode's season
|
||||||
|
(else series, else parent), a season's series, a track's album, otherwise the
|
||||||
|
parent. Jellyfin's `ParentId` is the storage parent, not the logical one — in
|
||||||
|
a series without season folders an episode's `ParentId` is the series while
|
||||||
|
its `SeasonId` names a virtual season — so listings used to match on four
|
||||||
|
columns at once. That `OR` defeated the planner into walking the whole table,
|
||||||
|
and it was wrong: every episode carries its series id, so a series listed all
|
||||||
|
its episodes beside its seasons. Being generated, the column covers every
|
||||||
|
write path (cache, downloads, catalog crawl) without any of them knowing, and
|
||||||
|
cannot drift from the columns it is computed from.
|
||||||
|
- **`idx_items_container (container_id, sort_name, name)`** serves a listing as
|
||||||
|
one index range already in display order — no sort step. `sort_name` is
|
||||||
|
usually NULL in the cache (the cache never writes it), hence `name` in the
|
||||||
|
index too.
|
||||||
|
- **Containers exist even when never browsed.** A series lists its seasons, so
|
||||||
|
an episode whose season row was never cached (it arrived through Next Up or
|
||||||
|
Latest) would be unreachable from its series offline. `save_to_cache` — and
|
||||||
|
migration 027 for rows already on disk — inserts placeholders named from the
|
||||||
|
child's own fields (`season_name`, `series_name`, `album_name`) with
|
||||||
|
`synced_at` NULL, so they show only when a download makes them available; the
|
||||||
|
server's real row replaces them wholesale on the next browse.
|
||||||
|
- **Availability is a per-row `EXISTS`** (`downloaded_sql` / `available_sql`):
|
||||||
|
cached for browsing (only with the catalog-browse flag), downloaded, or a
|
||||||
|
container with a downloaded *descendant* — which is why that one check still
|
||||||
|
looks at all four link columns (a series is available through an episode two
|
||||||
|
levels down). It used to be a CTE that built the id of every available item
|
||||||
|
in the database before filtering, paying for the whole table on every call.
|
||||||
|
- **The library clause is added only for a library parent** (`is_library`),
|
||||||
|
never `OR`ed into an ordinary listing, where it forces a full scan.
|
||||||
|
- **`+i.server_id`** keeps the planner off the server index, which every row
|
||||||
|
shares. `PRAGMA optimize` at open (`analysis_limit = 400`) gives the planner
|
||||||
|
statistics, but even with them it chose that index for the old `OR`; the `+`
|
||||||
|
is the guarantee.
|
||||||
|
- **User data is fetched in batches** (`with_user_data`, one `IN (…)` query per
|
||||||
|
500 rows), not once per row.
|
||||||
|
|
||||||
|
Measured on a ~110k-item benchmark catalogue (desktop): a series listing went
|
||||||
|
from ~80 ms to under 1 ms; migration 027 upgrades an existing database of that
|
||||||
|
size in ~0.1 s. `listing_a_non_library_parent_uses_the_container_index` and
|
||||||
|
migration 027's tests assert the plans; `storage::tests::write_bench_database`
|
||||||
|
(ignored; set `JELLYTAU_BENCH_DB`) writes the benchmark catalogue for the
|
||||||
|
`sqlite3` CLI.
|
||||||
|
|
||||||
## Rust Module Structure
|
## Rust Module Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
+10
-2
@@ -17,8 +17,8 @@ row can be re-checked or disputed:
|
|||||||
|
|
||||||
## Present since the first release
|
## Present since the first release
|
||||||
|
|
||||||
Sixteen defects date to the initial proof of concept (v0.0.1, 2026-06-23) and
|
Eighteen defects date to the initial proof of concept (v0.0.1, 2026-06-23) and
|
||||||
shipped for between two weeks and two months before anyone hit them.
|
shipped for between two weeks and three months before anyone hit them.
|
||||||
That is the dominant pattern here: not regressions, but original assumptions that
|
That is the dominant pattern here: not regressions, but original assumptions that
|
||||||
went unexercised until a later feature leaned on them.
|
went unexercised until a later feature leaned on them.
|
||||||
|
|
||||||
@@ -47,6 +47,14 @@ for ~2.
|
|||||||
| Audio-track change asked the player to select a track the transcode never carried (DR-258) | v0.0.1 | **v0.11.1** | ~2 months | pickaxe |
|
| Audio-track change asked the player to select a track the transcode never carried (DR-258) | v0.0.1 | **v0.11.1** | ~2 months | pickaxe |
|
||||||
| Subtitle URL missing its `Stream.` route segment, so every fetch 404ed (DR-259) | v0.0.1 | **v0.11.1** | ~2 months | pickaxe |
|
| Subtitle URL missing its `Stream.` route segment, so every fetch 404ed (DR-259) | v0.0.1 | **v0.11.1** | ~2 months | pickaxe |
|
||||||
| `timeupdate` gated on `!isPlaying`, so a paused activity froze the position (DR-265) | v0.0.1 | **v0.11.5** | ~9 weeks | pickaxe |
|
| `timeupdate` gated on `!isPlaying`, so a paused activity froze the position (DR-265) | v0.0.1 | **v0.11.5** | ~9 weeks | pickaxe |
|
||||||
|
| Download client used a 5-minute *total* request deadline, so any transfer longer than that was cut off and retried (DR-289) | v0.0.1 | **v0.12.2** | ~13 weeks | pickaxe |
|
||||||
|
| Progress reported 0.0 for any response without `Content-Length` — every transcode download (DR-290) | v0.0.1 | **v0.12.2** | ~13 weeks | absence |
|
||||||
|
|
||||||
|
The two v0.12.2 rows are the same latent shape as the `Range` header above:
|
||||||
|
the deadline was inert while every download was a short direct copy, and
|
||||||
|
became fatal only once DR-171 (v0.5.3) started re-encoding audio for offline
|
||||||
|
playback — a transcode is both slow enough to exceed five minutes and
|
||||||
|
impossible to resume. Defective for ~13 weeks, hittable for ~5.
|
||||||
|
|
||||||
### Why they took so long to surface
|
### Why they took so long to surface
|
||||||
|
|
||||||
|
|||||||
+33
-10
@@ -490,6 +490,14 @@ Internal architecture, components, and application logic.
|
|||||||
| DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
|
| DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
|
||||||
| DR-287 | Authentication uses only the spellings Jellyfin 12.0 leaves enabled. 12.0 disables `X-Emby-Authorization`, `X-Emby-Token`, `X-MediaBrowser-Token`, the `Emby` scheme and the `api_key` **query parameter** by default, and a migration (`DisableLegacyAuthorization`) turns them off on upgraded servers too — so a client using them stops working against an upgraded server rather than degrading. This is not a version branch: `Authorization` with the `MediaBrowser` scheme, and `ApiKey` as a query parameter, are ungated on *both* generations, and the header value this app already built was always the correct one. So the fix is a rename at 21 header sites and 28 query sites, not a capability flag. The query-parameter spelling is load-bearing rather than cosmetic: stream URLs are handed to mpv, ExoPlayer and the webview's `<video>`, none of which can set a header, so `ApiKey` is the only way a player authenticates at all. A structural test refuses any deprecated spelling reaching a request builder, because the failure is silent until a server upgrades | Security | UR-085 | Proposed |
|
| DR-287 | Authentication uses only the spellings Jellyfin 12.0 leaves enabled. 12.0 disables `X-Emby-Authorization`, `X-Emby-Token`, `X-MediaBrowser-Token`, the `Emby` scheme and the `api_key` **query parameter** by default, and a migration (`DisableLegacyAuthorization`) turns them off on upgraded servers too — so a client using them stops working against an upgraded server rather than degrading. This is not a version branch: `Authorization` with the `MediaBrowser` scheme, and `ApiKey` as a query parameter, are ungated on *both* generations, and the header value this app already built was always the correct one. So the fix is a rename at 21 header sites and 28 query sites, not a capability flag. The query-parameter spelling is load-bearing rather than cosmetic: stream URLs are handed to mpv, ExoPlayer and the webview's `<video>`, none of which can set a header, so `ApiKey` is the only way a player authenticates at all. A structural test refuses any deprecated spelling reaching a request builder, because the failure is silent until a server upgrades | Security | UR-085 | Proposed |
|
||||||
| DR-288 | A type-filtered listing states `Recursive` explicitly. Jellyfin 12.0 defaults it to true when the parent is a library folder and `IncludeItemTypes` is set, where 10.11 returned immediate children — the identical request, a different result set, with nothing in the response to say which rule applied. Sending the value the client actually wants makes both generations agree, and the value sent is the one that shipped rather than the new server-side default, so this is a compatibility fix and not a silent behaviour change | Repository | UR-085 | Proposed |
|
| DR-288 | A type-filtered listing states `Recursive` explicitly. Jellyfin 12.0 defaults it to true when the parent is a library folder and `IncludeItemTypes` is set, where 10.11 returned immediate children — the identical request, a different result set, with nothing in the response to say which rule applied. Sending the value the client actually wants makes both generations agree, and the value sent is the one that shipped rather than the new server-side default, so this is a compatibility fix and not a silent behaviour change | Repository | UR-085 | Proposed |
|
||||||
|
| DR-289 | The download worker's HTTP client carries a **read** timeout and a connect timeout, never a total request timeout. reqwest's `Client::timeout` is a deadline that runs until the body has finished, and it was set to five minutes: every transfer longer than that was cut off mid-body as "error decoding response body" and retried. A transcode ignores `Range`, so each retry restarted from byte zero, met the same deadline, and after three attempts the download failed — no feature film at transcode speed ever completed on a device whose audio must be re-encoded, and a large direct copy limped through in five-minute slices with a backoff between each. A read timeout resets on every chunk, so it still catches a dead connection without capping how long a healthy transfer may run | Downloads | UR-071 | Done |
|
||||||
|
| DR-290 | A download whose response states no length still reports progress against a predicted total. A transcode is produced as it is sent — chunked, no `Content-Length` — and the worker reported `progress: 0.0` for its whole duration: an empty bar reading "0%" while the byte count climbed for an hour, which is the case every film whose audio must be re-encoded lands in. The backend already fetches the item to decide the audio policy, and that item carries what a prediction needs: the source's size (an `original` download copies the picture, so the output is the source give or take the audio track — and exactly the source when nothing is re-encoded) and its runtime (a preset re-encodes at fixed rates, so the size is rate × runtime, from the same preset table the URL is built from so the two cannot drift). The prediction is made where the URL is resolved and persisted as the row's `file_size`; the worker uses it **only** when the response has no length, the server's figure always wins, an estimated bar is capped at 99% so a low prediction never shows a finished download still running, and the `Completed` event carries the bytes actually written so neither side persists the prediction as the real size. With no prediction the bar is indeterminate, which is honest and was the status quo. The single-video button joins the series/season buttons on the enqueue path so all three resolve — and predict — in one place | Downloads | UR-071 | Done |
|
||||||
|
| DR-291 | The offline banner stays off the full-screen player. Every other shell rule in `layoutShell.ts` already treats `/player/*` as immersive; the amber "You're offline" strip was the one piece of chrome still rendered above it. On the native Android video path that is not cosmetic: VideoPlayer makes itself transparent so the ExoPlayer SurfaceView behind the WebView is visible (DR-185), so a shell child that still paints shows *through* the picture as a stripe across the top of the film. Offline is also precisely when a downloaded video plays, so the banner appeared when it was most in the way, and it offers the viewer nothing to act on — local playback needs no server. The rule moves into the pure module as `showOfflineBanner({ pathname, isAuthenticated, isConnected })` rather than staying an inline `{#if}` in the shell, so the immersive-route contract is stated in one tested place | UI | UR-003, UR-043 | Done |
|
||||||
|
| DR-292 | The offline catalog reveal is one rule, applied by both library views. Two defects, one cause — "server only" was a private `$derived` inside `MediaCard`. (1) The list view (`LibraryListView`, what `LibraryGrid` renders when the stored view preference is `list`) had no notion of it at all, so a library browsed as a list offline showed every revealed item as an ordinary tappable row that plays nothing, with no way to queue it. (2) The rule asked the downloads store whether *this item id* was downloaded, but only a playable leaf (Audio, Movie, Episode) ever has a download row — an album's tracks carry them, the album does not — so a fully downloaded album greyed itself out and offered to queue what was already on the device, which is what "my downloaded music is greyed out" was. The rule moves to the pure `$lib/utils/serverOnly`, both views call it, and the container half is answered by the backend: `get_download_disk_usage().sizes` already carries container subtotals beside leaf sizes (DR-085), so `deviceContentIds` is membership in a Rust-computed map rather than a frontend guess at which item types are containers. That map was loaded only by the Downloads page, so the shell now primes it at startup and re-reads it whenever the offline gate settles (the DR-143 signal). Queueing is shared too (`queueOfflineDownload`), since the list view had no copy to diverge from | UI | UR-052, UR-055 | Done |
|
||||||
|
| DR-293 | Android plays the original file: ExoPlayer decodes AC-3, E-AC-3, DTS and TrueHD in software through the FFmpeg extension, so neither a download nor a stream needs the server to re-encode its audio. These are licensed codecs that Android does not ship — the ROD2-W09 tablet has a vendor DTS decoder and no AC-3/E-AC-3 at all — so the download policy (DR-171) judged audio against the webview's list and turned most films into a server transcode: generated as it is sent, no `Content-Length`, `Range` ignored, measured at ~1 MB/s and restarting from zero on every network blip, against a direct copy that moved a 910 MB episode in 94 s. The renderer is `DefaultRenderersFactory` in `EXTENSION_RENDERER_MODE_ON` (platform decoders first, FFmpeg for what they lack), and `CodecDetector` reports the extension's codecs beside `MediaCodecList`'s, so the device profile and the download policy — now `renderer_can_decode_audio`, DR-234's per-platform answer, instead of the webview's list — agree with what actually decodes. The webview video path is gone on Android: it decodes none of those codecs, so an original-file download would play there as a silent film; `webview_video_fallback` (Rust) is false on Android and the frontend neither offers the switch nor honours a stored "off". Linux keeps the webview fallback beside mpv native video, and with it the server transcode for undecodable audio. Rejected: re-encoding audio on the device after download — minutes of CPU and twice the disk per film, and it would not have helped streaming. The decoder is Jellyfin's `media3-ffmpeg-decoder` build (GPL-3.0; the distributed APK carries its terms, the source stays MIT) and must be versioned in step with media3 | Playback | UR-004, UR-071 | Done |
|
||||||
|
| DR-294 | A download plays with no network. Playing a downloaded item asked the server for its `PlaybackInfo` — only to read the media-source id that subtitle URLs are keyed by — and `HybridRepository::get_playback_info` went to the server alone, so offline the call retried for seven seconds, failed, and the file on disk was never opened. A completed download for the current user now answers playback info from its download row, first and regardless of reachability: the local path, direct play, and the item id as media-source id (a download names no source, so the server served its default, which carries the item's id). Next Up had the same shape — server-only — and the TV landing page loads it in one `Promise.all` with its other rows, so offline that single failure blanked the whole page with Continue Watching and Latest sitting in the cache; it now falls back to the cache when the server cannot answer. And a slow cache read is waited for, never discarded: the cache is one SQLite connection behind one mutex, so any write in progress (the catalog sync at every launch, a download finishing) pushes a read past the 100 ms fast path, and `get_items`, the library list, genres and playlist items discarded such a read, waited on the server, and offline returned its error over data on disk — "More info" on a downloaded show failed exactly so. They keep the read running (`cache_try`) and wait for it when the server fails (`settle`); the cache-only reads (search, favourites) simply await the cache | Repository | UR-002, UR-071 | Done |
|
||||||
|
| DR-295 | A series page lists its episodes with one concurrent season fan-out. "More info" on Frasier took ~10 s: the page asked Rust for the episodes and for the current episode as two commands, each of which walked every season, and each walk fetched the eleven seasons one after another — so the wait was the sum of twenty-two listings, each a cache read slowed by whatever the database was writing (the catalog sync at launch measured it at ~4 s per walk). The seasons are now fetched together (`gather_season_episodes`, so the wait is the slowest season), and `repository_get_series_view` returns the episodes and the current episode from one walk, with Next Up and resume fetched alongside it | Repository | UR-062 | Done |
|
||||||
|
| DR-296 | Returning from background audio resumes the item the native player is actually on, not the one the video page was mounted with. An episode that ends while backgrounded advances in the backend (`advance_to_next_episode_audio_only`), but `player_exit_background_audio` returned only a position, so the webview reloaded the *previous* episode at the new episode's timestamp. The command now returns `BackgroundAudioResume { itemId, positionSeconds }` (`PlayerController::background_audio_resume`); `planHandoffReturn` yields `other-item` when the id differs from the mounted one, and the player page navigates to that episode with `resumeAt=<seconds>`, recording the outgoing episode as watched and suppressing the stale unmount stop report | Playback | UR-040, UR-023 | Done (pending device verification) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -500,9 +508,9 @@ Internal architecture, components, and application logic.
|
|||||||
| User Req | Integration Requirements | Development Requirements |
|
| User Req | Integration Requirements | Development Requirements |
|
||||||
|----------|-------------------------|-------------------------|
|
|----------|-------------------------|-------------------------|
|
||||||
| UR-001 | IR-001, IR-002 | - |
|
| UR-001 | IR-001, IR-002 | - |
|
||||||
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
|
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014, DR-294 |
|
||||||
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010, DR-182, DR-183, DR-184, DR-185, DR-186, DR-187, DR-188, DR-190, DR-191, DR-192, DR-193, DR-194, DR-195, DR-196 |
|
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010, DR-182, DR-183, DR-184, DR-185, DR-186, DR-187, DR-188, DR-190, DR-191, DR-192, DR-193, DR-194, DR-195, DR-196, DR-291 |
|
||||||
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188, DR-203, DR-265 |
|
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188, DR-203, DR-265, DR-293 |
|
||||||
| UR-005 | - | DR-001, DR-005, DR-009, DR-178, DR-179, DR-186, DR-193, DR-195 |
|
| UR-005 | - | DR-001, DR-005, DR-009, DR-178, DR-179, DR-186, DR-193, DR-195 |
|
||||||
| UR-006 | IR-005, IR-006, IR-007, IR-008 | DR-200, DR-201 |
|
| UR-006 | IR-005, IR-006, IR-007, IR-008 | DR-200, DR-201 |
|
||||||
| UR-007 | IR-010 | DR-007, DR-008, DR-016, DR-257, DR-262, DR-277, DR-278 |
|
| UR-007 | IR-010 | DR-007, DR-008, DR-016, DR-257, DR-262, DR-277, DR-278 |
|
||||||
@@ -521,7 +529,7 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-020 | IR-016, IR-018 | DR-023, DR-176 | <!-- IR-018 delivered by ExoPlayer + HTML5 `<track>`, not libmpv -->
|
| UR-020 | IR-016, IR-018 | DR-023, DR-176 | <!-- IR-018 delivered by ExoPlayer + HTML5 `<track>`, not libmpv -->
|
||||||
| UR-021 | IR-016, IR-019 | DR-024 | <!-- IR-019 delivered by ExoPlayer + HLS stream re-open, not libmpv -->
|
| UR-021 | IR-016, IR-019 | DR-024 | <!-- IR-019 delivered by ExoPlayer + HLS stream re-open, not libmpv -->
|
||||||
| UR-022 | IR-017 | DR-025 |
|
| UR-022 | IR-017 | DR-025 |
|
||||||
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049, DR-263 |
|
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049, DR-263, DR-296 |
|
||||||
| UR-024 | IR-010 | DR-027 |
|
| UR-024 | IR-010 | DR-027 |
|
||||||
| UR-025 | IR-015 | DR-028, DR-131, DR-132, DR-178, DR-179 |
|
| UR-025 | IR-015 | DR-028, DR-131, DR-132, DR-178, DR-179 |
|
||||||
| UR-026 | - | DR-029, DR-048, DR-050 |
|
| UR-026 | - | DR-029, DR-048, DR-050 |
|
||||||
@@ -538,10 +546,10 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-037 | IR-010 | DR-042 |
|
| UR-037 | IR-010 | DR-042 |
|
||||||
| UR-038 | IR-010 | DR-043 |
|
| UR-038 | IR-010 | DR-043 |
|
||||||
| UR-039 | - | DR-045, DR-046 |
|
| UR-039 | - | DR-045, DR-046 |
|
||||||
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190, DR-196, DR-201, DR-203, DR-263, DR-266 |
|
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190, DR-196, DR-201, DR-203, DR-263, DR-266, DR-296 |
|
||||||
| UR-041 | IR-026 | DR-053, DR-160, DR-161, DR-172, DR-182, DR-183, DR-184, DR-185, DR-188, DR-265, DR-266 |
|
| UR-041 | IR-026 | DR-053, DR-160, DR-161, DR-172, DR-182, DR-183, DR-184, DR-185, DR-188, DR-265, DR-266 |
|
||||||
| UR-042 | IR-009, IR-014 | DR-054 |
|
| UR-042 | IR-009, IR-014 | DR-054 |
|
||||||
| UR-043 | IR-027 | DR-055 |
|
| UR-043 | IR-027 | DR-055, DR-291 |
|
||||||
| UR-044 | - | DR-056 |
|
| UR-044 | - | DR-056 |
|
||||||
| UR-045 | - | DR-057 |
|
| UR-045 | - | DR-057 |
|
||||||
| UR-046 | IR-028 | DR-058 |
|
| UR-046 | IR-028 | DR-058 |
|
||||||
@@ -550,16 +558,16 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-049 | IR-010 | DR-063, DR-064, DR-065, DR-147 |
|
| UR-049 | IR-010 | DR-063, DR-064, DR-065, DR-147 |
|
||||||
| UR-050 | - | DR-066, DR-067 |
|
| UR-050 | - | DR-066, DR-067 |
|
||||||
| UR-051 | - | DR-068, DR-069, DR-070 |
|
| UR-051 | - | DR-068, DR-069, DR-070 |
|
||||||
| UR-052 | IR-027 | DR-078, DR-079, DR-080, DR-143 |
|
| UR-052 | IR-027 | DR-078, DR-079, DR-080, DR-143, DR-292 |
|
||||||
| UR-053 | IR-029 | DR-074 |
|
| UR-053 | IR-029 | DR-074 |
|
||||||
| UR-054 | - | DR-075, DR-076, DR-077, DR-147 |
|
| UR-054 | - | DR-075, DR-076, DR-077, DR-147 |
|
||||||
| UR-055 | - | DR-081, DR-082, DR-083, DR-084, DR-167, DR-168, DR-169, DR-173 |
|
| UR-055 | - | DR-081, DR-082, DR-083, DR-084, DR-167, DR-168, DR-169, DR-173, DR-292 |
|
||||||
| UR-056 | - | DR-085 |
|
| UR-056 | - | DR-085 |
|
||||||
| UR-057 | - | DR-086 |
|
| UR-057 | - | DR-086 |
|
||||||
| UR-058 | - | DR-087, DR-142 |
|
| UR-058 | - | DR-087, DR-142 |
|
||||||
| UR-060 | - | DR-090, DR-091, DR-111 |
|
| UR-060 | - | DR-090, DR-091, DR-111 |
|
||||||
| UR-061 | - | DR-092 |
|
| UR-061 | - | DR-092 |
|
||||||
| UR-062 | - | DR-101, DR-102, DR-103, DR-104, DR-107 |
|
| UR-062 | - | DR-101, DR-102, DR-103, DR-104, DR-107, DR-295 |
|
||||||
| UR-063 | - | DR-105 |
|
| UR-063 | - | DR-105 |
|
||||||
| UR-064 | - | DR-106 |
|
| UR-064 | - | DR-106 |
|
||||||
| UR-065 | IR-030 | DR-108, DR-109, DR-110, DR-111 |
|
| UR-065 | IR-030 | DR-108, DR-109, DR-110, DR-111 |
|
||||||
@@ -568,7 +576,7 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-068 | - | DR-119 |
|
| UR-068 | - | DR-119 |
|
||||||
| UR-069 | - | DR-113, DR-114, DR-120 |
|
| UR-069 | - | DR-113, DR-114, DR-120 |
|
||||||
| UR-070 | - | DR-121, DR-122 |
|
| UR-070 | - | DR-121, DR-122 |
|
||||||
| UR-071 | IR-032 | DR-123, DR-124, DR-125, DR-126, DR-127, DR-128, DR-133, DR-134, DR-135, DR-136, DR-137, DR-138, DR-170, DR-171, DR-180, DR-198, DR-199 |
|
| UR-071 | IR-032 | DR-123, DR-124, DR-125, DR-126, DR-127, DR-128, DR-133, DR-134, DR-135, DR-136, DR-137, DR-138, DR-170, DR-171, DR-180, DR-198, DR-199, DR-289, DR-290, DR-293, DR-294 |
|
||||||
| UR-072 | - | DR-156 |
|
| UR-072 | - | DR-156 |
|
||||||
| UR-073 | - | DR-158 |
|
| UR-073 | - | DR-158 |
|
||||||
| UR-074 | - | DR-162, DR-177, DR-181 |
|
| UR-074 | - | DR-162, DR-177, DR-181 |
|
||||||
@@ -836,6 +844,21 @@ Internal architecture, components, and application logic.
|
|||||||
| UT-248 | Narrowing the library clause does not starve the libraries that do have landing pages: music, movies and TV each still list their own media and none of the others | DR-277 | Done |
|
| UT-248 | Narrowing the library clause does not starve the libraries that do have landing pages: music, movies and TV each still list their own media and none of the others | DR-277 | Done |
|
||||||
| UT-249 | Opening an individual collection still lists its own children: a BoxSet's members are matched by the stored `parent_id`, not by the library clause, so narrowing that clause did not empty collections | DR-277 | Done |
|
| UT-249 | Opening an individual collection still lists its own children: a BoxSet's members are matched by the stored `parent_id`, not by the library clause, so narrowing that clause did not empty collections | DR-277 | Done |
|
||||||
| UT-250 | Two libraries of the same collection type are not interchangeable: seeded through the real cache write path, a "TV" and a "Shows" library each list their own series and not the other's | DR-278 | Done |
|
| UT-250 | Two libraries of the same collection type are not interchangeable: seeded through the real cache write path, a "TV" and a "Shows" library each list their own series and not the other's | DR-278 | Done |
|
||||||
|
| UT-251 | A download that runs longer than the stall timeout completes as long as bytes keep arriving, and one whose connection goes silent is given up on promptly as a network error — driven through the real `reqwest` client against a loopback socket, because the defect was the client's configuration | DR-289 | Done |
|
||||||
|
| UT-252 | A predicted total fills in only when the server sent no length, the server's length always wins, an estimated fraction is capped below 1.0, and the prediction is rate × runtime for a preset and the source's size for `original` | DR-290 | Done |
|
||||||
|
| UT-253 | Resolving a queued video row persists its predicted size, and resolving an audio row (no prediction) leaves a size the row already holds untouched | DR-290 | Done |
|
||||||
|
| UT-254 | The progress row renders an unknown total as indeterminate rather than "0%", an estimated total as "~N%", an exact one plainly; the store carries the estimate flag through progress and persists the worker's byte count, never the prediction, on completion | DR-290 | Done |
|
||||||
|
| UT-255 | The offline banner shows while offline on ordinary routes and never on `/player/*`, and stays off while connected or signed out | DR-291 | Done |
|
||||||
|
| UT-257 | The server-only rule: true only offline with the reveal on and nothing on the device; never for a library tile; and not for a container whose children are downloaded (the greyed-album regression) | DR-292 | Done |
|
||||||
|
| UT-258 | The list view greys a server-only row, makes it inert to tap, offers the queue button (and the Queued badge once pending), and leaves downloaded rows and containers with device content alone | DR-292 | Done |
|
||||||
|
| UT-259 | The user may send video to the webview only beside mpv native video on Linux: never on Android, where ExoPlayer is the only video renderer, and not where the webview is the only renderer | DR-293 | Done |
|
||||||
|
| UT-260 | A downloaded item gets playback info with the server unreachable — immediately, from its download row (local path, direct play, item id as media source) — while an unfinished download, another user's, or an item never downloaded is left to the server | DR-294 | Done |
|
||||||
|
| UT-261 | Next Up answers from the cache, rather than failing, when the server is unreachable | DR-294 | Done |
|
||||||
|
| UT-262 | The Android webview fallback is neither offered in Settings nor honoured by the player unless Rust reports it, so a stored "native video off" cannot route video to a renderer that plays the original file silent | DR-293 | Done |
|
||||||
|
| UT-263 | With the database held past the 100 ms fast path and the server unreachable, `get_items`, the library list, a cache-only search and cache-only favourites all answer from the cache instead of failing | DR-294 | Done |
|
||||||
|
| UT-264 | Ten seasons whose listings each take 100 ms are gathered in well under the 1 s a sequential walk takes, and a season that fails to load leaves the other nine seasons' episodes in the result | DR-295 | Done |
|
||||||
|
| UT-265 | `planHandoffReturn` switches to the item the backend advanced to while backgrounded, and reloads in place when the backend is still on the mounted item or reports none | DR-296 | Done |
|
||||||
|
| UT-266 | After a background-audio episode advance, the controller's resume point names the new episode and carries no base from the previous one | DR-296 | Done |
|
||||||
### Integration Tests
|
### Integration Tests
|
||||||
|
|
||||||
| Test ID | Test Description | Traces To | Status |
|
| Test ID | Test Description | Traces To | Status |
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ know how something *works*, read
|
|||||||
|
|
||||||
**Next free requirement ids** (always re-check
|
**Next free requirement ids** (always re-check
|
||||||
[requirements.md](../requirements.md) before allocating): **UR-086**,
|
[requirements.md](../requirements.md) before allocating): **UR-086**,
|
||||||
**IR-036**, **JA-038**, **DR-289**. Three specs below suggested ids that have
|
**IR-036**, **JA-038**, **DR-295**, **UT-264** (DR-289/290 went to the v0.12.2
|
||||||
|
download fixes; DR-291/292 and UT-255/257/258 to the offline-banner and
|
||||||
|
server-only-reveal work; DR-293/294 and UT-259-263 to Android's FFmpeg decoding
|
||||||
|
and offline-without-network). Three specs below suggested ids that have
|
||||||
since been taken by other work; each carries a ⚠️ note at the top — this line
|
since been taken by other work; each carries a ⚠️ note at the top — this line
|
||||||
was itself stale by five, two and forty-seven until 2026-09-08, which is why the
|
was itself stale by five, two and forty-seven until 2026-09-08, which is why the
|
||||||
re-check is not optional.
|
re-check is not optional.
|
||||||
@@ -82,4 +85,5 @@ Where to look for each:
|
|||||||
| Video background audio | [05-platform-backends.md](../architecture/05-platform-backends.md) — Background Audio Handoff |
|
| Video background audio | [05-platform-backends.md](../architecture/05-platform-backends.md) — Background Audio Handoff |
|
||||||
| Traceability gate repair | [traceability-ci.md](../traceability-ci.md) |
|
| Traceability gate repair | [traceability-ci.md](../traceability-ci.md) |
|
||||||
| Boundary tripwire hardening | `scripts/check-frontend-boundary.sh` (its header is the spec) |
|
| Boundary tripwire hardening | `scripts/check-frontend-boundary.sh` (its header is the spec) |
|
||||||
|
| Original-file downloads & Android FFmpeg decoding (was on-device-audio-remux) | [05-platform-backends.md](../architecture/05-platform-backends.md) — Licensed audio codecs; [06-downloads-and-offline.md](../architecture/06-downloads-and-offline.md) — What a Video Download Fetches, Offline Means No Network |
|
||||||
| Playback docs corrections · req-coverage script removal | Nothing to document — both were corrections that have been applied |
|
| Playback docs corrections · req-coverage script removal | Nothing to document — both were corrections that have been applied |
|
||||||
|
|||||||
+7953
-5087
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jellytau",
|
"name": "jellytau",
|
||||||
"version": "0.12.0",
|
"version": "0.13.2",
|
||||||
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
|
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
|
||||||
"author": "Duncan Tourolle <duncan@tourolle.paris>",
|
"author": "Duncan Tourolle <duncan@tourolle.paris>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
Generated
+3
-15
@@ -2275,7 +2275,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jellytau"
|
name = "jellytau"
|
||||||
version = "0.12.0"
|
version = "0.13.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"argon2",
|
"argon2",
|
||||||
@@ -2315,7 +2315,6 @@ dependencies = [
|
|||||||
"tempfile",
|
"tempfile",
|
||||||
"tiny_http",
|
"tiny_http",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rusqlite",
|
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -4000,9 +3999,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.35"
|
version = "0.23.45"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f"
|
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"ring",
|
"ring",
|
||||||
@@ -5340,17 +5339,6 @@ dependencies = [
|
|||||||
"syn 2.0.112",
|
"syn 2.0.112",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tokio-rusqlite"
|
|
||||||
version = "0.6.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b65501378eb676f400c57991f42cbd0986827ab5c5200c53f206d710fb32a945"
|
|
||||||
dependencies = [
|
|
||||||
"crossbeam-channel",
|
|
||||||
"rusqlite",
|
|
||||||
"tokio",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-rustls"
|
name = "tokio-rustls"
|
||||||
version = "0.26.4"
|
version = "0.26.4"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ name = "jellytau"
|
|||||||
# `player-conformance`, and a second binary makes a bare `cargo run` —
|
# `player-conformance`, and a second binary makes a bare `cargo run` —
|
||||||
# which `tauri dev` issues — ambiguous.
|
# which `tauri dev` issues — ambiguous.
|
||||||
default-run = "jellytau"
|
default-run = "jellytau"
|
||||||
version = "0.12.0"
|
version = "0.13.2"
|
||||||
description = "A cross-platform Jellyfin client"
|
description = "A cross-platform Jellyfin client"
|
||||||
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
|
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -53,7 +53,6 @@ futures-util = "0.3"
|
|||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
|
||||||
# SQLite for offline storage
|
# SQLite for offline storage
|
||||||
tokio-rusqlite = "0.6"
|
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
directories = "5"
|
directories = "5"
|
||||||
@@ -150,6 +149,9 @@ ndk-context = "0.1"
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3.24.0"
|
tempfile = "3.24.0"
|
||||||
|
# `net` for the loopback server in `download::worker::timeout_tests`; reqwest
|
||||||
|
# enables it transitively, but a test must not depend on that.
|
||||||
|
tokio = { version = "1", features = ["net"] }
|
||||||
wiremock = "0.6.5"
|
wiremock = "0.6.5"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
|||||||
@@ -172,6 +172,16 @@ dependencies {
|
|||||||
// itself: without a view to hand them to, a selected subtitle track renders
|
// itself: without a view to hand them to, a selected subtitle track renders
|
||||||
// nowhere. See JellyTauPlayer.onCues. (DR-260)
|
// nowhere. See JellyTauPlayer.onCues. (DR-260)
|
||||||
implementation("androidx.media3:media3-ui:1.5.0")
|
implementation("androidx.media3:media3-ui:1.5.0")
|
||||||
|
// Software audio decoders for what Android does not ship: AC-3, E-AC-3,
|
||||||
|
// DTS and TrueHD are licensed codecs, present only where a vendor paid for
|
||||||
|
// them (the ROD2-W09 tablet has DTS but no AC-3/E-AC-3 at all). With this,
|
||||||
|
// ExoPlayer plays the source file as-is, so neither a download nor a stream
|
||||||
|
// needs the server to re-encode its audio (DR-293). Jellyfin's own build of
|
||||||
|
// the media3 FFmpeg extension, versioned to match media3 above — keep the
|
||||||
|
// two in step. Licence: GPL-3.0 — the distributed APK carries its terms,
|
||||||
|
// the source stays MIT; see THIRD_PARTY_NOTICES.md and
|
||||||
|
// docs/architecture/05-platform-backends.md.
|
||||||
|
implementation("org.jellyfin.media3:media3-ffmpeg-decoder:1.5.0+1")
|
||||||
implementation("com.google.guava:guava:33.0.0-android")
|
implementation("com.google.guava:guava:33.0.0-android")
|
||||||
|
|
||||||
// Media library for VolumeProviderCompat (remote volume control)
|
// Media library for VolumeProviderCompat (remote volume control)
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ package com.dtourolle.jellytau.player
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.media.MediaCodecList
|
import android.media.MediaCodecList
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.annotation.OptIn
|
||||||
import androidx.media3.common.AudioAttributes
|
import androidx.media3.common.AudioAttributes
|
||||||
|
import androidx.media3.common.MimeTypes
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.decoder.ffmpeg.FfmpegLibrary
|
||||||
import androidx.media3.exoplayer.audio.AudioCapabilities
|
import androidx.media3.exoplayer.audio.AudioCapabilities
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,9 +16,24 @@ import androidx.media3.exoplayer.audio.AudioCapabilities
|
|||||||
* This class queries the device's media codec capabilities and reports
|
* This class queries the device's media codec capabilities and reports
|
||||||
* them to the Rust backend via JNI for accurate DeviceProfile generation.
|
* them to the Rust backend via JNI for accurate DeviceProfile generation.
|
||||||
*/
|
*/
|
||||||
|
@OptIn(UnstableApi::class) // FfmpegLibrary and the licensed-codec MimeTypes
|
||||||
object CodecDetector {
|
object CodecDetector {
|
||||||
private const val TAG = "CodecDetector"
|
private const val TAG = "CodecDetector"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats the FFmpeg extension can decode, as Jellyfin codec names. The
|
||||||
|
* platform decodes the rest itself; these are the licensed codecs a device
|
||||||
|
* often lacks.
|
||||||
|
*/
|
||||||
|
private val FFMPEG_AUDIO_FORMATS = listOf(
|
||||||
|
MimeTypes.AUDIO_AC3 to "ac3",
|
||||||
|
MimeTypes.AUDIO_E_AC3 to "eac3",
|
||||||
|
MimeTypes.AUDIO_E_AC3_JOC to "eac3",
|
||||||
|
MimeTypes.AUDIO_DTS to "dts",
|
||||||
|
MimeTypes.AUDIO_DTS_HD to "dts",
|
||||||
|
MimeTypes.AUDIO_TRUEHD to "truehd",
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Data class to hold detected codec capabilities.
|
* Data class to hold detected codec capabilities.
|
||||||
*/
|
*/
|
||||||
@@ -67,6 +85,25 @@ object CodecDetector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The FFmpeg extension decodes in software what the platform lacks.
|
||||||
|
// ExoPlayer uses it for playback (JellyTauPlayer's renderers factory),
|
||||||
|
// so it belongs in the same list: Rust judges both the streaming
|
||||||
|
// profile and the download policy against this set, and a codec
|
||||||
|
// missing here is re-encoded by the server for nothing. Asked per
|
||||||
|
// format rather than assumed, so a build whose native library failed
|
||||||
|
// to load reports only what the platform itself decodes.
|
||||||
|
// TRACES: UR-004, UR-071 | DR-293
|
||||||
|
if (FfmpegLibrary.isAvailable()) {
|
||||||
|
for ((mime, codec) in FFMPEG_AUDIO_FORMATS) {
|
||||||
|
if (FfmpegLibrary.supportsFormat(mime)) {
|
||||||
|
audioCodecs.add(codec)
|
||||||
|
Log.d(TAG, "Audio codec: $codec (MIME: $mime, FFmpeg extension)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "FFmpeg extension unavailable; reporting platform decoders only")
|
||||||
|
}
|
||||||
|
|
||||||
Log.i(TAG, "Detected ${videoCodecs.size} video codecs: ${videoCodecs.sorted()}")
|
Log.i(TAG, "Detected ${videoCodecs.size} video codecs: ${videoCodecs.sorted()}")
|
||||||
Log.i(TAG, "Detected ${audioCodecs.size} audio codecs: ${audioCodecs.sorted()}")
|
Log.i(TAG, "Detected ${audioCodecs.size} audio codecs: ${audioCodecs.sorted()}")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -148,7 +185,12 @@ object CodecDetector {
|
|||||||
"audio/eac3" -> "eac3"
|
"audio/eac3" -> "eac3"
|
||||||
"audio/eac3-joc" -> "eac3"
|
"audio/eac3-joc" -> "eac3"
|
||||||
"audio/dts" -> "dts"
|
"audio/dts" -> "dts"
|
||||||
|
// The platform's own spelling — what MediaCodecList reports on the
|
||||||
|
// ROD2-W09. Only the `.hd` variant was listed, so plain DTS was
|
||||||
|
// detected by luck, through the HD decoder advertising both.
|
||||||
|
"audio/vnd.dts" -> "dts"
|
||||||
"audio/vnd.dts.hd" -> "dts"
|
"audio/vnd.dts.hd" -> "dts"
|
||||||
|
"audio/true-hd" -> "truehd"
|
||||||
"audio/x-ms-wma" -> "wma"
|
"audio/x-ms-wma" -> "wma"
|
||||||
"audio/amr-nb" -> "amrnb"
|
"audio/amr-nb" -> "amrnb"
|
||||||
"audio/amr-wb" -> "amrwb"
|
"audio/amr-wb" -> "amrwb"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import androidx.media3.common.MediaMetadata
|
|||||||
import androidx.media3.common.PlaybackException
|
import androidx.media3.common.PlaybackException
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy
|
import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy
|
||||||
@@ -332,6 +333,18 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
//
|
//
|
||||||
// TRACES: UR-004, UR-006 | IR-008
|
// TRACES: UR-004, UR-006 | IR-008
|
||||||
exoPlayer = ExoPlayer.Builder(appContext)
|
exoPlayer = ExoPlayer.Builder(appContext)
|
||||||
|
// Extension renderers ON: the device's own decoders are tried first
|
||||||
|
// (a vendor DTS decoder stays in charge where there is one), and the
|
||||||
|
// FFmpeg audio renderer takes any format they cannot decode — AC-3,
|
||||||
|
// E-AC-3, TrueHD on a device without Dolby licensing. This is what
|
||||||
|
// lets the untouched source file play, instead of a server transcode.
|
||||||
|
// CodecDetector reports the same codecs to Rust, so the device
|
||||||
|
// profile and the download policy agree with what actually decodes.
|
||||||
|
// TRACES: UR-004, UR-071 | DR-293
|
||||||
|
.setRenderersFactory(
|
||||||
|
DefaultRenderersFactory(appContext)
|
||||||
|
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
|
||||||
|
)
|
||||||
// Decline the player's own load-error retry for a stream it could
|
// Decline the player's own load-error retry for a stream it could
|
||||||
// only restart (DR-203). Every other source keeps the default
|
// only restart (DR-203). Every other source keeps the default
|
||||||
// behaviour, which resumes the failed load where it stopped.
|
// behaviour, which resumes the failed load where it stopped.
|
||||||
|
|||||||
@@ -517,6 +517,34 @@ pub(crate) async fn requeue_mistyped_video_downloads(
|
|||||||
Ok(n)
|
Ok(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a resolver hands back for one queued row: the URL to fetch and, for a
|
||||||
|
/// video, the size predicted for it (see `download::estimate`).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) struct ResolvedDownloadUrl {
|
||||||
|
pub url: String,
|
||||||
|
pub expected_bytes: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for ResolvedDownloadUrl {
|
||||||
|
/// An audio stream URL: served static, so the response states its own
|
||||||
|
/// length and nothing needs predicting.
|
||||||
|
fn from(url: String) -> Self {
|
||||||
|
Self {
|
||||||
|
url,
|
||||||
|
expected_bytes: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<crate::repository::ResolvedVideoDownload> for ResolvedDownloadUrl {
|
||||||
|
fn from(r: crate::repository::ResolvedVideoDownload) -> Self {
|
||||||
|
Self {
|
||||||
|
url: r.url,
|
||||||
|
expected_bytes: r.expected_bytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Core of [`resume_queued_downloads`], factored out for testing: select every
|
/// Core of [`resume_queued_downloads`], factored out for testing: select every
|
||||||
/// `pending`/`stream_url IS NULL` row, resolve each via `resolve` (returning
|
/// `pending`/`stream_url IS NULL` row, resolve each via `resolve` (returning
|
||||||
/// `None` leaves the row pending), and heal the row so the pump can start it.
|
/// `None` leaves the row pending), and heal the row so the pump can start it.
|
||||||
@@ -534,7 +562,7 @@ pub(crate) async fn resolve_pending_download_urls<F, Fut>(
|
|||||||
) -> Result<ResumeQueuedResult, String>
|
) -> Result<ResumeQueuedResult, String>
|
||||||
where
|
where
|
||||||
F: Fn(String, String, String) -> Fut,
|
F: Fn(String, String, String) -> Fut,
|
||||||
Fut: std::future::Future<Output = Option<String>>,
|
Fut: std::future::Future<Output = Option<ResolvedDownloadUrl>>,
|
||||||
{
|
{
|
||||||
if only_ids.is_some_and(|ids| ids.is_empty()) {
|
if only_ids.is_some_and(|ids| ids.is_empty()) {
|
||||||
return Ok(ResumeQueuedResult {
|
return Ok(ResumeQueuedResult {
|
||||||
@@ -600,8 +628,8 @@ where
|
|||||||
let mut failed = 0usize;
|
let mut failed = 0usize;
|
||||||
|
|
||||||
for (download_id, item_id, media_type, quality) in rows {
|
for (download_id, item_id, media_type, quality) in rows {
|
||||||
let stream_url = match resolve(item_id.clone(), media_type, quality).await {
|
let target = match resolve(item_id.clone(), media_type, quality).await {
|
||||||
Some(url) => url,
|
Some(target) => target,
|
||||||
None => {
|
None => {
|
||||||
failed += 1;
|
failed += 1;
|
||||||
continue;
|
continue;
|
||||||
@@ -609,13 +637,21 @@ where
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Heal the row so the pump can start it. Guard on stream_url IS NULL so a
|
// Heal the row so the pump can start it. Guard on stream_url IS NULL so a
|
||||||
// concurrent resolver doesn't clobber an already-started row.
|
// concurrent resolver doesn't clobber an already-started row. The
|
||||||
|
// predicted size, when there is one, gives the worker a progress total
|
||||||
|
// for a response that carries none (DR-290).
|
||||||
|
let expected = target
|
||||||
|
.expected_bytes
|
||||||
|
.and_then(|n| i64::try_from(n).ok())
|
||||||
|
.map_or(QueryParam::Null, QueryParam::Int64);
|
||||||
let update = Query::with_params(
|
let update = Query::with_params(
|
||||||
"UPDATE downloads SET stream_url = ?, target_dir = ?
|
"UPDATE downloads SET stream_url = ?, target_dir = ?,
|
||||||
|
file_size = COALESCE(?, file_size)
|
||||||
WHERE id = ? AND status = 'pending' AND stream_url IS NULL",
|
WHERE id = ? AND status = 'pending' AND stream_url IS NULL",
|
||||||
vec![
|
vec![
|
||||||
QueryParam::String(stream_url),
|
QueryParam::String(target.url),
|
||||||
QueryParam::String(target_dir.to_string()),
|
QueryParam::String(target_dir.to_string()),
|
||||||
|
expected,
|
||||||
QueryParam::Int64(download_id),
|
QueryParam::Int64(download_id),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -705,17 +741,18 @@ pub async fn resume_queued_downloads(
|
|||||||
async move {
|
async move {
|
||||||
if media_type == "video" {
|
if media_type == "video" {
|
||||||
Some(
|
Some(
|
||||||
crate::repository::resolve_video_download_url(
|
crate::repository::resolve_video_download(
|
||||||
repo.as_ref(),
|
repo.as_ref(),
|
||||||
&item_id,
|
&item_id,
|
||||||
&quality,
|
&quality,
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await,
|
.await
|
||||||
|
.into(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
match repo.get_audio_stream_url(&item_id).await {
|
match repo.get_audio_stream_url(&item_id).await {
|
||||||
Ok(url) => Some(url),
|
Ok(url) => Some(url.into()),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(
|
warn!(
|
||||||
"[Catalog] Failed to resolve audio URL for {}: {:?}",
|
"[Catalog] Failed to resolve audio URL for {}: {:?}",
|
||||||
@@ -807,6 +844,7 @@ mod tests {
|
|||||||
target_dir TEXT,
|
target_dir TEXT,
|
||||||
media_type TEXT,
|
media_type TEXT,
|
||||||
quality_preset TEXT,
|
quality_preset TEXT,
|
||||||
|
file_size INTEGER,
|
||||||
progress REAL DEFAULT 0,
|
progress REAL DEFAULT 0,
|
||||||
bytes_downloaded INTEGER DEFAULT 0,
|
bytes_downloaded INTEGER DEFAULT 0,
|
||||||
started_at TEXT,
|
started_at TEXT,
|
||||||
@@ -890,7 +928,7 @@ mod tests {
|
|||||||
&db,
|
&db,
|
||||||
"/data/downloads",
|
"/data/downloads",
|
||||||
None,
|
None,
|
||||||
|item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}")) },
|
|item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}").into()) },
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -932,7 +970,7 @@ mod tests {
|
|||||||
&db,
|
&db,
|
||||||
"/data",
|
"/data",
|
||||||
Some(&[mine]),
|
Some(&[mine]),
|
||||||
|item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}")) },
|
|item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}").into()) },
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -962,7 +1000,7 @@ mod tests {
|
|||||||
|
|
||||||
let out =
|
let out =
|
||||||
resolve_pending_download_urls(&db, "/data", Some(&[]), |item_id, _mt, _q| async move {
|
resolve_pending_download_urls(&db, "/data", Some(&[]), |item_id, _mt, _q| async move {
|
||||||
Some(format!("http://resolved/{item_id}"))
|
Some(format!("http://resolved/{item_id}").into())
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1015,7 +1053,7 @@ mod tests {
|
|||||||
let seen = Arc::clone(&seen_c);
|
let seen = Arc::clone(&seen_c);
|
||||||
async move {
|
async move {
|
||||||
seen.lock_safe().push((item_id.clone(), media_type));
|
seen.lock_safe().push((item_id.clone(), media_type));
|
||||||
Some(format!("http://resolved/{item_id}"))
|
Some(format!("http://resolved/{item_id}").into())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -1048,7 +1086,7 @@ mod tests {
|
|||||||
let seen = Arc::clone(&seen_c);
|
let seen = Arc::clone(&seen_c);
|
||||||
async move {
|
async move {
|
||||||
*seen.lock_safe() = media_type;
|
*seen.lock_safe() = media_type;
|
||||||
Some("http://x".to_string())
|
Some("http://x".to_string().into())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -1072,7 +1110,7 @@ mod tests {
|
|||||||
let seen = Arc::clone(&seen_c);
|
let seen = Arc::clone(&seen_c);
|
||||||
async move {
|
async move {
|
||||||
*seen.lock_safe() = media_type;
|
*seen.lock_safe() = media_type;
|
||||||
Some("http://x".to_string())
|
Some("http://x".to_string().into())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -1123,6 +1161,51 @@ mod tests {
|
|||||||
assert_eq!(status, "completed", "a correct video download is untouched");
|
assert_eq!(status, "completed", "a correct video download is untouched");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A transcode answers with no `Content-Length`, so the worker's only
|
||||||
|
/// chance at a progress total is the size predicted at resolve time. That
|
||||||
|
/// prediction has to reach the row, and only where there is one — an
|
||||||
|
/// audio row's `None` must not null out a size the row already holds.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-290 | UT-253
|
||||||
|
#[tokio::test]
|
||||||
|
async fn resolving_persists_the_predicted_size_without_erasing_a_known_one() {
|
||||||
|
let db = test_db();
|
||||||
|
insert_download(&db, "film", "pending", None, Some("video")).await;
|
||||||
|
insert_download(&db, "track", "pending", None, Some("audio")).await;
|
||||||
|
db.execute(Query::with_params(
|
||||||
|
"UPDATE downloads SET file_size = 777 WHERE item_id = ?",
|
||||||
|
vec![QueryParam::String("track".to_string())],
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
resolve_pending_download_urls(&db, "/data", None, |item_id, media_type, _q| async move {
|
||||||
|
Some(ResolvedDownloadUrl {
|
||||||
|
url: format!("http://resolved/{item_id}"),
|
||||||
|
expected_bytes: (media_type == "video").then_some(1_500_000_000),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let size = |item: &'static str| {
|
||||||
|
let db = Arc::clone(&db);
|
||||||
|
async move {
|
||||||
|
db.query_one(
|
||||||
|
Query::with_params(
|
||||||
|
"SELECT file_size FROM downloads WHERE item_id = ?",
|
||||||
|
vec![QueryParam::String(item.to_string())],
|
||||||
|
),
|
||||||
|
|row| row.get::<_, Option<i64>>(0),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert_eq!(size("film").await, Some(1_500_000_000));
|
||||||
|
assert_eq!(size("track").await, Some(777));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn video_rows_use_media_type_in_resolver() {
|
async fn video_rows_use_media_type_in_resolver() {
|
||||||
let db = test_db();
|
let db = test_db();
|
||||||
@@ -1134,7 +1217,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
|item_id, media_type, _q| async move {
|
|item_id, media_type, _q| async move {
|
||||||
assert_eq!(media_type, "video");
|
assert_eq!(media_type, "video");
|
||||||
Some(format!("http://transcode/{item_id}"))
|
Some(format!("http://transcode/{item_id}").into())
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -759,7 +759,7 @@ pub async fn download_album(
|
|||||||
async move {
|
async move {
|
||||||
use crate::repository::MediaRepository;
|
use crate::repository::MediaRepository;
|
||||||
match repo.get_audio_stream_url(&item_id).await {
|
match repo.get_audio_stream_url(&item_id).await {
|
||||||
Ok(url) => Some(url),
|
Ok(url) => Some(url.into()),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(
|
warn!(
|
||||||
"[download_album] Failed to resolve stream URL for {}: {:?}",
|
"[download_album] Failed to resolve stream URL for {}: {:?}",
|
||||||
@@ -1604,6 +1604,9 @@ pub async fn start_download(
|
|||||||
item_id,
|
item_id,
|
||||||
stream_url,
|
stream_url,
|
||||||
target_path,
|
target_path,
|
||||||
|
file_size_from_server
|
||||||
|
.or(file_size)
|
||||||
|
.and_then(|n| u64::try_from(n).ok()),
|
||||||
active_downloads,
|
active_downloads,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1703,15 +1706,20 @@ pub async fn enqueue_video_downloads(
|
|||||||
// Build the download URL, resolving the source's audio codec first so a
|
// Build the download URL, resolving the source's audio codec first so a
|
||||||
// track this device cannot decode is re-encoded on the way down rather
|
// track this device cannot decode is re-encoded on the way down rather
|
||||||
// than saved as a silent file (DR-167).
|
// than saved as a silent file (DR-167).
|
||||||
let stream_url =
|
let resolved =
|
||||||
crate::repository::resolve_video_download_url(repo.as_ref(), &item_id, &quality, None)
|
crate::repository::resolve_video_download(repo.as_ref(), &item_id, &quality, None)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
// The predicted size becomes the row's `file_size` so the worker has a
|
||||||
|
// total to report against when the response has none (DR-290). A
|
||||||
|
// size the server states later replaces it on completion.
|
||||||
let update_query = Query::with_params(
|
let update_query = Query::with_params(
|
||||||
"UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
|
"UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ?, \
|
||||||
|
file_size = COALESCE(?, file_size) WHERE id = ?",
|
||||||
vec![
|
vec![
|
||||||
QueryParam::String(stream_url),
|
QueryParam::String(resolved.url),
|
||||||
QueryParam::String(target_dir.clone()),
|
QueryParam::String(target_dir.clone()),
|
||||||
|
expected_bytes_param(resolved.expected_bytes),
|
||||||
QueryParam::Int64(download_id),
|
QueryParam::Int64(download_id),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -1819,7 +1827,7 @@ pub(crate) async fn pump_download_queue(
|
|||||||
// Find the next pending, startable download (has a stream URL). Exclude
|
// Find the next pending, startable download (has a stream URL). Exclude
|
||||||
// anything already registered as active to avoid double-starting.
|
// anything already registered as active to avoid double-starting.
|
||||||
let next_query = Query::with_params(
|
let next_query = Query::with_params(
|
||||||
"SELECT id, item_id, file_path, stream_url, target_dir
|
"SELECT id, item_id, file_path, stream_url, target_dir, file_size
|
||||||
FROM downloads
|
FROM downloads
|
||||||
WHERE status = 'pending'
|
WHERE status = 'pending'
|
||||||
AND stream_url IS NOT NULL
|
AND stream_url IS NOT NULL
|
||||||
@@ -1828,7 +1836,7 @@ pub(crate) async fn pump_download_queue(
|
|||||||
vec![],
|
vec![],
|
||||||
);
|
);
|
||||||
|
|
||||||
let candidates: Vec<(i64, String, String, String, String)> = match db_service
|
let candidates: Vec<(i64, String, String, String, String, Option<i64>)> = match db_service
|
||||||
.query_many(next_query, |row| {
|
.query_many(next_query, |row| {
|
||||||
Ok((
|
Ok((
|
||||||
row.get(0)?,
|
row.get(0)?,
|
||||||
@@ -1836,6 +1844,7 @@ pub(crate) async fn pump_download_queue(
|
|||||||
row.get(2)?,
|
row.get(2)?,
|
||||||
row.get(3)?,
|
row.get(3)?,
|
||||||
row.get(4)?,
|
row.get(4)?,
|
||||||
|
row.get(5)?,
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -1848,14 +1857,14 @@ pub(crate) async fn pump_download_queue(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Pick the first candidate not already active.
|
// Pick the first candidate not already active.
|
||||||
let next = candidates.into_iter().find(|(id, _, _, _, _)| {
|
let next = candidates.into_iter().find(|(id, _, _, _, _, _)| {
|
||||||
active_downloads
|
active_downloads
|
||||||
.lock()
|
.lock()
|
||||||
.map(|active| !active.contains(id))
|
.map(|active| !active.contains(id))
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
let (download_id, item_id, file_path, stream_url, target_dir) = match next {
|
let (download_id, item_id, file_path, stream_url, target_dir, file_size) = match next {
|
||||||
Some(n) => n,
|
Some(n) => n,
|
||||||
None => return, // Nothing pending to start
|
None => return, // Nothing pending to start
|
||||||
};
|
};
|
||||||
@@ -1943,11 +1952,20 @@ pub(crate) async fn pump_download_queue(
|
|||||||
item_id,
|
item_id,
|
||||||
stream_url,
|
stream_url,
|
||||||
target_path,
|
target_path,
|
||||||
|
file_size.and_then(|n| u64::try_from(n).ok()),
|
||||||
active_downloads.clone(),
|
active_downloads.clone(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A predicted size as a bind parameter: `NULL` keeps whatever the row holds.
|
||||||
|
fn expected_bytes_param(expected: Option<u64>) -> QueryParam {
|
||||||
|
match expected.and_then(|n| i64::try_from(n).ok()) {
|
||||||
|
Some(n) => QueryParam::Int64(n),
|
||||||
|
None => QueryParam::Null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawn the background worker for one download. On completion or failure it
|
/// Spawn the background worker for one download. On completion or failure it
|
||||||
/// unregisters the slot, emits the terminal event, and pumps the queue so the
|
/// unregisters the slot, emits the terminal event, and pumps the queue so the
|
||||||
/// next pending download starts automatically.
|
/// next pending download starts automatically.
|
||||||
@@ -1957,6 +1975,7 @@ fn spawn_download_worker(
|
|||||||
item_id: String,
|
item_id: String,
|
||||||
stream_url: String,
|
stream_url: String,
|
||||||
target_path: std::path::PathBuf,
|
target_path: std::path::PathBuf,
|
||||||
|
expected_bytes: Option<u64>,
|
||||||
active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
|
active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
|
||||||
) {
|
) {
|
||||||
use crate::download::events::DownloadEvent;
|
use crate::download::events::DownloadEvent;
|
||||||
@@ -1975,18 +1994,19 @@ fn spawn_download_worker(
|
|||||||
// Progress callback that emits events to the frontend
|
// Progress callback that emits events to the frontend
|
||||||
let progress_app = app.clone();
|
let progress_app = app.clone();
|
||||||
let progress_item_id = item_id.clone();
|
let progress_item_id = item_id.clone();
|
||||||
let on_progress = move |bytes_downloaded: u64, total_bytes: Option<u64>| {
|
let on_progress = move |bytes_downloaded: u64, content_length: Option<u64>| {
|
||||||
let progress = total_bytes
|
// The server's length when it gave one; the prediction made at
|
||||||
.filter(|&t| t > 0)
|
// resolve time when it did not (a transcode). DR-290
|
||||||
.map(|t| bytes_downloaded as f64 / t as f64)
|
let total = crate::download::estimate::progress_total(content_length, expected_bytes);
|
||||||
.unwrap_or(0.0);
|
let progress = crate::download::estimate::progress_fraction(bytes_downloaded, total);
|
||||||
|
|
||||||
let event = DownloadEvent::Progress {
|
let event = DownloadEvent::Progress {
|
||||||
download_id,
|
download_id,
|
||||||
item_id: progress_item_id.clone(),
|
item_id: progress_item_id.clone(),
|
||||||
bytes_downloaded: bytes_downloaded as i64,
|
bytes_downloaded: bytes_downloaded as i64,
|
||||||
total_bytes: total_bytes.map(|t| t as i64),
|
total_bytes: total.map(|t| t.bytes as i64),
|
||||||
progress,
|
progress,
|
||||||
|
estimated: total.is_some_and(|t| t.estimated),
|
||||||
};
|
};
|
||||||
let _ = progress_app.emit("download-event", event);
|
let _ = progress_app.emit("download-event", event);
|
||||||
};
|
};
|
||||||
@@ -2060,6 +2080,7 @@ fn spawn_download_worker(
|
|||||||
download_id,
|
download_id,
|
||||||
item_id,
|
item_id,
|
||||||
file_path,
|
file_path,
|
||||||
|
bytes_downloaded: res.bytes_downloaded as i64,
|
||||||
};
|
};
|
||||||
match app.emit("download-event", completed_event) {
|
match app.emit("download-event", completed_event) {
|
||||||
Ok(_) => debug!(" Completed event emitted successfully"),
|
Ok(_) => debug!(" Completed event emitted successfully"),
|
||||||
|
|||||||
@@ -939,12 +939,17 @@ pub async fn player_background_action(
|
|||||||
Ok(action)
|
Ok(action)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: UR-040 | DR-052 | UT-061, IT-013
|
/// Returns the item the native player is on and its absolute position. The
|
||||||
|
/// item matters: an episode that ended while backgrounded has already advanced
|
||||||
|
/// in the backend, so reloading the video the webview was mounted with would
|
||||||
|
/// bring back the previous episode. (DR-296)
|
||||||
|
///
|
||||||
|
/// TRACES: UR-040, UR-023 | DR-052, DR-296 | UT-061, IT-013
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn player_exit_background_audio(
|
pub async fn player_exit_background_audio(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
) -> Result<f64, String> {
|
) -> Result<crate::player::BackgroundAudioResume, String> {
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
|
|
||||||
// Read the position BEFORE clearing either base. The position tick applies the
|
// Read the position BEFORE clearing either base. The position tick applies the
|
||||||
@@ -954,23 +959,23 @@ pub async fn player_exit_background_audio(
|
|||||||
// lock discipline from CLAUDE.md: never hold work across a re-entrant call.
|
// lock discipline from CLAUDE.md: never hold work across a re-entrant call.
|
||||||
// (DR-159)
|
// (DR-159)
|
||||||
//
|
//
|
||||||
// `absolute_position` rather than `position`, because a tick that has not
|
// `background_audio_resume` reads `absolute_position` rather than `position`, because a tick that has not
|
||||||
// landed *yet* is the same hazard from the other side: returning to the
|
// landed *yet* is the same hazard from the other side: returning to the
|
||||||
// foreground while the audio-only transcode is still opening read 0.0, and
|
// foreground while the audio-only transcode is still opening read 0.0, and
|
||||||
// the video reloaded at StartTimeTicks=0 — the episode restarting from the
|
// the video reloaded at StartTimeTicks=0 — the episode restarting from the
|
||||||
// beginning. Flooring at the handoff base cannot overshoot: the stream is
|
// beginning. Flooring at the handoff base cannot overshoot: the stream is
|
||||||
// physically incapable of being behind its own starting point. (DR-178)
|
// physically incapable of being behind its own starting point. (DR-178)
|
||||||
let absolute = controller.absolute_position();
|
let resume = controller.background_audio_resume();
|
||||||
|
|
||||||
// Now safe to tear the handoff down, native side first.
|
// Now safe to tear the handoff down, native side first.
|
||||||
let _ = crate::player::set_lockscreen_position_offset(0.0);
|
let _ = crate::player::set_lockscreen_position_offset(0.0);
|
||||||
controller.exit_background_audio();
|
controller.exit_background_audio();
|
||||||
controller.stop().map_err(|e| e.to_string())?;
|
controller.stop().map_err(|e| e.to_string())?;
|
||||||
info!(
|
info!(
|
||||||
"player_exit_background_audio: resuming the video at {:.1}s",
|
"player_exit_background_audio: resuming {:?} at {:.1}s",
|
||||||
absolute
|
resume.item_id, resume.position_seconds
|
||||||
);
|
);
|
||||||
Ok(absolute)
|
Ok(resume)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Play a queue of media items
|
/// Play a queue of media items
|
||||||
@@ -2187,6 +2192,25 @@ pub struct PlaybackCapabilities {
|
|||||||
/// beneath the WebView. Linux cannot do this (WebKitGTK/Wayland
|
/// beneath the WebView. Linux cannot do this (WebKitGTK/Wayland
|
||||||
/// compositing), so it stays on the HTML5 element.
|
/// compositing), so it stays on the HTML5 element.
|
||||||
pub supports_native_video: bool,
|
pub supports_native_video: bool,
|
||||||
|
/// True when the user may send video to the webview element instead of the
|
||||||
|
/// native renderer — the frontend offers the switch only then, and honours
|
||||||
|
/// the stored preference only then. See [`webview_video_fallback`].
|
||||||
|
pub webview_video_fallback: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the user may send video to the webview `<video>` element instead of
|
||||||
|
/// the native renderer.
|
||||||
|
///
|
||||||
|
/// Never on Android: ExoPlayer is its only video renderer. Downloads there are
|
||||||
|
/// the untouched source file (DR-293), and the webview decodes none of the
|
||||||
|
/// AC-3/E-AC-3/DTS/TrueHD that ExoPlayer plays through the FFmpeg extension, so
|
||||||
|
/// the fallback would be a silent film. Beside mpv's native video on Linux the
|
||||||
|
/// webview is still the tested fallback; everywhere else it is the only
|
||||||
|
/// renderer and there is nothing to switch.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-003, UR-071 | DR-293 | UT-259
|
||||||
|
pub fn webview_video_fallback(is_android: bool, native_video_enabled: bool) -> bool {
|
||||||
|
!is_android && native_video_enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Report this platform's playback capabilities to the frontend.
|
/// Report this platform's playback capabilities to the frontend.
|
||||||
@@ -2203,6 +2227,11 @@ pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
|
|||||||
// TRACES: UR-080 | DR-235
|
// TRACES: UR-080 | DR-235
|
||||||
supports_native_video: cfg!(target_os = "android")
|
supports_native_video: cfg!(target_os = "android")
|
||||||
|| crate::player::native_video::enabled(),
|
|| crate::player::native_video::enabled(),
|
||||||
|
// TRACES: UR-003, UR-071 | DR-293
|
||||||
|
webview_video_fallback: webview_video_fallback(
|
||||||
|
cfg!(target_os = "android"),
|
||||||
|
crate::player::native_video::enabled(),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3058,6 +3087,37 @@ pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> R
|
|||||||
mod tests {
|
mod tests {
|
||||||
use crate::utils::lock::MutexSafe;
|
use crate::utils::lock::MutexSafe;
|
||||||
|
|
||||||
|
/// Android has one video renderer, ExoPlayer. The webview element could only
|
||||||
|
/// be reached by the user switching native video off, and a file downloaded
|
||||||
|
/// as the untouched original — AC-3 audio included — plays silent there,
|
||||||
|
/// so the switch is gone on Android (DR-293). Where mpv draws video on Linux
|
||||||
|
/// the webview is still the tested fallback, so the switch stays there;
|
||||||
|
/// everywhere else the webview is the only renderer and there is nothing to
|
||||||
|
/// switch.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-003, UR-071 | DR-293 | UT-259
|
||||||
|
#[test]
|
||||||
|
fn test_webview_video_fallback_is_offered_only_beside_mpv_native_video() {
|
||||||
|
use super::webview_video_fallback;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!webview_video_fallback(true, false),
|
||||||
|
"Android: ExoPlayer is the only video renderer"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!webview_video_fallback(true, true),
|
||||||
|
"Android never falls back, whatever else is switched on"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
webview_video_fallback(false, true),
|
||||||
|
"Linux with mpv native video: the webview is the fallback"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!webview_video_fallback(false, false),
|
||||||
|
"the webview is the only renderer; nothing to fall back from"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// UT-206 — the volume the command hands on is always a real number in
|
/// UT-206 — the volume the command hands on is always a real number in
|
||||||
/// 0.0..=1.0.
|
/// 0.0..=1.0.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -514,6 +514,24 @@ pub async fn repository_get_series_current_episode(
|
|||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A series' episodes and the viewer's current episode, from one season
|
||||||
|
/// fan-out. The series page used to ask for these as two commands, each of
|
||||||
|
/// which walked every season.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-062 | DR-101, DR-295
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn repository_get_series_view(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
series_id: String,
|
||||||
|
) -> Result<series_progress::SeriesView, String> {
|
||||||
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
series_progress::resolve_series_view(repo.as_ref(), &series_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{:?}", e))
|
||||||
|
}
|
||||||
|
|
||||||
/// Erase the viewer's watch history for an item.
|
/// Erase the viewer's watch history for an item.
|
||||||
///
|
///
|
||||||
/// Clears the played flag and the resume position; on a series or season the
|
/// Clears the played flag and the resume position; on a series or season the
|
||||||
|
|||||||
@@ -49,6 +49,19 @@ pub async fn sync_queue_mutation(
|
|||||||
Arc::new(database.service())
|
Arc::new(database.service())
|
||||||
};
|
};
|
||||||
|
|
||||||
|
enqueue_mutation(&*db_service, user_id, operation, item_id, payload).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert one pending mutation and return the id of *that* row.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-002, UR-017 | DR-014
|
||||||
|
pub(crate) async fn enqueue_mutation<S: DatabaseService>(
|
||||||
|
db_service: &S,
|
||||||
|
user_id: String,
|
||||||
|
operation: String,
|
||||||
|
item_id: Option<String>,
|
||||||
|
payload: Option<String>,
|
||||||
|
) -> Result<i64, String> {
|
||||||
let query = Query::with_params(
|
let query = Query::with_params(
|
||||||
"INSERT INTO sync_queue (user_id, operation, item_id, payload, status, created_at)
|
"INSERT INTO sync_queue (user_id, operation, item_id, payload, status, created_at)
|
||||||
VALUES (?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP)",
|
VALUES (?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP)",
|
||||||
@@ -60,13 +73,9 @@ pub async fn sync_queue_mutation(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
// `insert`, not `execute` + `last_insert_rowid`: the id must be read in
|
||||||
let id = db_service
|
// the same job as the insert, or a concurrent write hands us its row.
|
||||||
.last_insert_rowid()
|
db_service.insert(query).await
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
Ok(id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all pending sync operations for a user
|
/// Get all pending sync operations for a user
|
||||||
@@ -387,4 +396,58 @@ mod tests {
|
|||||||
assert!(item.retry_count == i);
|
assert!(item.retry_count == i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Each queued mutation must get back the id of its *own* row.
|
||||||
|
///
|
||||||
|
/// The id used to come from a separate `last_insert_rowid()` call — a
|
||||||
|
/// second trip to the shared connection — so another insert landing in
|
||||||
|
/// between handed this mutation someone else's id, and marking it synced
|
||||||
|
/// later completed the wrong row.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-002, UR-017 | DR-014 | UT-014
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
|
||||||
|
async fn concurrent_enqueues_each_get_their_own_row_id() {
|
||||||
|
let database = crate::storage::Database::open_in_memory().unwrap();
|
||||||
|
let service = Arc::new(database.service());
|
||||||
|
service
|
||||||
|
.execute(Query::new(
|
||||||
|
"INSERT INTO servers (id, name, url) VALUES ('s', 'S', 'http://s')",
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
service
|
||||||
|
.execute(Query::new(
|
||||||
|
"INSERT INTO users (id, server_id, username) VALUES ('u', 's', 'u')",
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let tasks: Vec<_> = (0..200)
|
||||||
|
.map(|i| {
|
||||||
|
let service = Arc::clone(&service);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let op = format!("op-{i}");
|
||||||
|
let id = enqueue_mutation(&*service, "u".into(), op.clone(), None, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
(op, id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for task in tasks {
|
||||||
|
let (op, id) = task.await.unwrap();
|
||||||
|
let stored: String = service
|
||||||
|
.query_one(
|
||||||
|
Query::with_params(
|
||||||
|
"SELECT operation FROM sync_queue WHERE id = ?",
|
||||||
|
vec![QueryParam::Int64(id)],
|
||||||
|
),
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(stored, op, "mutation {op} was handed row {id}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
//! How big a download is going to be when the server will not say.
|
||||||
|
//!
|
||||||
|
//! A direct copy answers with `Content-Length`, and the worker reports exact
|
||||||
|
//! progress from it. A transcode is produced as it is sent — chunked, with no
|
||||||
|
//! length — and the worker used to report `progress: 0.0` for its whole
|
||||||
|
//! duration: an empty bar and "0%" while the byte count climbed for an hour.
|
||||||
|
//! That is the case every film whose audio must be re-encoded lands in.
|
||||||
|
//!
|
||||||
|
//! The backend does know enough to estimate. It fetches the item to decide the
|
||||||
|
//! audio policy anyway, and that item carries the source's size and runtime;
|
||||||
|
//! the preset it chose fixes the bitrate. So the estimate is made where the
|
||||||
|
//! URL is, persisted on the row as its `file_size`, and used only as a
|
||||||
|
//! fallback: a real `Content-Length` always wins, and an estimated bar never
|
||||||
|
//! claims completion.
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-071 | DR-290
|
||||||
|
|
||||||
|
use super::presets::download_preset;
|
||||||
|
|
||||||
|
/// Ticks per second in Jellyfin's runtime unit.
|
||||||
|
const TICKS_PER_SECOND: u64 = 10_000_000;
|
||||||
|
|
||||||
|
/// The progress bar never reports more than this from an estimate, so a source
|
||||||
|
/// that encodes a little larger than predicted shows 99% until the last byte
|
||||||
|
/// rather than 104% — completion is the worker's to announce.
|
||||||
|
pub const ESTIMATED_PROGRESS_CEILING: f64 = 0.99;
|
||||||
|
|
||||||
|
/// The size a download for `quality` is expected to produce, in bytes.
|
||||||
|
///
|
||||||
|
/// - A preset re-encodes both streams at fixed rates, so the size is rate ×
|
||||||
|
/// runtime. Jellyfin encodes to a target bitrate (`-b:v` with `-maxrate`), so
|
||||||
|
/// the average lands near the cap rather than well under it.
|
||||||
|
/// - `original` copies the picture and at most re-encodes the audio, so the
|
||||||
|
/// output is the source's size give or take the audio track — and when no
|
||||||
|
/// transcode is needed at all it is exactly the source's size.
|
||||||
|
///
|
||||||
|
/// `None` when the inputs needed are missing; the caller then has no total and
|
||||||
|
/// the bar is indeterminate, which is honest and was the status quo.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-290 | UT-252
|
||||||
|
pub fn expected_download_bytes(
|
||||||
|
quality: &str,
|
||||||
|
runtime_ticks: Option<i64>,
|
||||||
|
source_size: Option<i64>,
|
||||||
|
) -> Option<u64> {
|
||||||
|
match download_preset(quality) {
|
||||||
|
Some(preset) => {
|
||||||
|
let seconds = u64::try_from(runtime_ticks?).ok()? / TICKS_PER_SECOND;
|
||||||
|
(seconds > 0).then(|| preset.total_bit_rate() / 8 * seconds)
|
||||||
|
}
|
||||||
|
None => source_size
|
||||||
|
.and_then(|s| u64::try_from(s).ok())
|
||||||
|
.filter(|&s| s > 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the progress bar measures against.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct ProgressTotal {
|
||||||
|
pub bytes: u64,
|
||||||
|
/// The total is a prediction, not the server's word.
|
||||||
|
pub estimated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The total to report progress against, given what the response said and what
|
||||||
|
/// was predicted before it was made. The server's `Content-Length` always
|
||||||
|
/// wins; the estimate fills in only when the server sent none.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-290 | UT-252
|
||||||
|
pub fn progress_total(content_length: Option<u64>, expected: Option<u64>) -> Option<ProgressTotal> {
|
||||||
|
match (
|
||||||
|
content_length.filter(|&n| n > 0),
|
||||||
|
expected.filter(|&n| n > 0),
|
||||||
|
) {
|
||||||
|
(Some(bytes), _) => Some(ProgressTotal {
|
||||||
|
bytes,
|
||||||
|
estimated: false,
|
||||||
|
}),
|
||||||
|
(None, Some(bytes)) => Some(ProgressTotal {
|
||||||
|
bytes,
|
||||||
|
estimated: true,
|
||||||
|
}),
|
||||||
|
(None, None) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fraction complete, in `0.0..=1.0`. An estimated total is capped at
|
||||||
|
/// [`ESTIMATED_PROGRESS_CEILING`] so a prediction that ran low never shows a
|
||||||
|
/// finished bar on a download still running.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-290 | UT-252
|
||||||
|
pub fn progress_fraction(downloaded: u64, total: Option<ProgressTotal>) -> f64 {
|
||||||
|
let Some(total) = total else { return 0.0 };
|
||||||
|
let fraction = downloaded as f64 / total.bytes as f64;
|
||||||
|
let ceiling = if total.estimated {
|
||||||
|
ESTIMATED_PROGRESS_CEILING
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
fraction.clamp(0.0, ceiling)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const HOUR_TICKS: i64 = 3600 * TICKS_PER_SECOND as i64;
|
||||||
|
|
||||||
|
/// A transcode has no `Content-Length`, and this is the case that showed
|
||||||
|
/// "0%" for its whole duration: with a prediction in hand the bar must move.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-290 | UT-252
|
||||||
|
#[test]
|
||||||
|
fn test_estimate_fills_in_when_the_server_sent_no_length() {
|
||||||
|
let total = progress_total(None, Some(4_000));
|
||||||
|
assert_eq!(
|
||||||
|
total,
|
||||||
|
Some(ProgressTotal {
|
||||||
|
bytes: 4_000,
|
||||||
|
estimated: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let fraction = progress_fraction(1_000, total);
|
||||||
|
assert!((fraction - 0.25).abs() < 1e-9, "got {fraction}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server's own figure is never second-guessed by a prediction.
|
||||||
|
#[test]
|
||||||
|
fn test_content_length_wins_over_the_estimate() {
|
||||||
|
let total = progress_total(Some(10_000), Some(4_000)).unwrap();
|
||||||
|
assert_eq!(total.bytes, 10_000);
|
||||||
|
assert!(!total.estimated);
|
||||||
|
assert_eq!(progress_fraction(10_000, Some(total)), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A prediction that ran low must not announce completion: that is the
|
||||||
|
/// worker's to do when the last byte lands.
|
||||||
|
#[test]
|
||||||
|
fn test_estimated_progress_never_reaches_one() {
|
||||||
|
let total = progress_total(None, Some(1_000));
|
||||||
|
assert_eq!(progress_fraction(1_200, total), ESTIMATED_PROGRESS_CEILING);
|
||||||
|
assert_eq!(progress_fraction(0, total), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nothing known → nothing claimed, and a zero length is "nothing known".
|
||||||
|
#[test]
|
||||||
|
fn test_no_total_means_no_progress_claim() {
|
||||||
|
assert_eq!(progress_total(None, None), None);
|
||||||
|
assert_eq!(progress_total(Some(0), Some(0)), None);
|
||||||
|
assert_eq!(progress_fraction(500, None), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A preset's size is its combined rate over the runtime — one hour of the
|
||||||
|
/// medium preset (4 Mb/s + 256 kb/s) is about 1.9 GB.
|
||||||
|
#[test]
|
||||||
|
fn test_preset_estimate_is_rate_times_runtime() {
|
||||||
|
let bytes = expected_download_bytes("medium", Some(HOUR_TICKS), Some(9_999)).unwrap();
|
||||||
|
assert_eq!(bytes, (4_000_000 + 256_000) / 8 * 3600);
|
||||||
|
// Without a runtime there is nothing to multiply.
|
||||||
|
assert_eq!(expected_download_bytes("medium", None, Some(9_999)), None);
|
||||||
|
assert_eq!(expected_download_bytes("medium", Some(0), None), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `original` copies the picture, so the source's size is the prediction —
|
||||||
|
/// with or without the audio being re-encoded on the way.
|
||||||
|
#[test]
|
||||||
|
fn test_original_estimate_is_the_source_size() {
|
||||||
|
assert_eq!(
|
||||||
|
expected_download_bytes("original", Some(HOUR_TICKS), Some(3_000_000_000)),
|
||||||
|
Some(3_000_000_000)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
expected_download_bytes("original", Some(HOUR_TICKS), None),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(expected_download_bytes("original", None, Some(0)), None);
|
||||||
|
// An unknown quality name is treated as original by the URL builder,
|
||||||
|
// so it is here too.
|
||||||
|
assert_eq!(expected_download_bytes("wat", None, Some(10)), Some(10));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,10 @@ pub enum DownloadEvent {
|
|||||||
bytes_downloaded: i64,
|
bytes_downloaded: i64,
|
||||||
total_bytes: Option<i64>,
|
total_bytes: Option<i64>,
|
||||||
progress: f64, // 0.0 to 1.0
|
progress: f64, // 0.0 to 1.0
|
||||||
|
/// `total_bytes` is a prediction rather than the server's
|
||||||
|
/// `Content-Length`, so `progress` stops short of 1.0 until the
|
||||||
|
/// download completes. TRACES: UR-071 | DR-290
|
||||||
|
estimated: bool,
|
||||||
},
|
},
|
||||||
/// Download completed successfully
|
/// Download completed successfully
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -27,6 +31,10 @@ pub enum DownloadEvent {
|
|||||||
download_id: i64,
|
download_id: i64,
|
||||||
item_id: String,
|
item_id: String,
|
||||||
file_path: String,
|
file_path: String,
|
||||||
|
/// Bytes actually written. The frontend persists completion too, and
|
||||||
|
/// without this it fell back to the row's `file_size` — which is a
|
||||||
|
/// prediction for a transcode (DR-290), not the real size.
|
||||||
|
bytes_downloaded: i64,
|
||||||
},
|
},
|
||||||
/// Download failed with error
|
/// Download failed with error
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -60,6 +68,7 @@ mod tests {
|
|||||||
bytes_downloaded: 1024,
|
bytes_downloaded: 1024,
|
||||||
total_bytes: Some(2048),
|
total_bytes: Some(2048),
|
||||||
progress: 0.5,
|
progress: 0.5,
|
||||||
|
estimated: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&event).unwrap();
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
@@ -86,6 +95,7 @@ mod tests {
|
|||||||
download_id: 42,
|
download_id: 42,
|
||||||
item_id: "song456".to_string(),
|
item_id: "song456".to_string(),
|
||||||
file_path: "/path/to/file.mp3".to_string(),
|
file_path: "/path/to/file.mp3".to_string(),
|
||||||
|
bytes_downloaded: 4096,
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&event).unwrap();
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
|
|||||||
@@ -7,8 +7,10 @@
|
|||||||
//! - Resume support via HTTP Range requests
|
//! - Resume support via HTTP Range requests
|
||||||
|
|
||||||
pub mod cache;
|
pub mod cache;
|
||||||
|
pub mod estimate;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod network;
|
pub mod network;
|
||||||
|
pub mod presets;
|
||||||
pub mod stop;
|
pub mod stop;
|
||||||
pub mod worker;
|
pub mod worker;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
//! The quality presets a video download can be asked for.
|
||||||
|
//!
|
||||||
|
//! One table, read by both the URL builder (which turns a preset into transcode
|
||||||
|
//! parameters) and the size estimate (which turns the same numbers into an
|
||||||
|
//! expected byte count). They were the same literals in two places before,
|
||||||
|
//! which is how a bar can claim 40% of a file that is nearly done.
|
||||||
|
|
||||||
|
/// Transcode caps for one named preset.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct DownloadPreset {
|
||||||
|
/// Video target, bits per second.
|
||||||
|
pub video_bit_rate: u64,
|
||||||
|
/// Longest edge the picture is scaled down to.
|
||||||
|
pub max_height: u32,
|
||||||
|
/// Audio target, bits per second.
|
||||||
|
pub audio_bit_rate: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DownloadPreset {
|
||||||
|
/// Combined stream rate, bits per second.
|
||||||
|
pub fn total_bit_rate(&self) -> u64 {
|
||||||
|
self.video_bit_rate + self.audio_bit_rate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The preset a quality name denotes; `None` for `original` and anything
|
||||||
|
/// unrecognised, both of which mean "do not cap the picture".
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-123, DR-290
|
||||||
|
pub fn download_preset(quality: &str) -> Option<DownloadPreset> {
|
||||||
|
match quality {
|
||||||
|
"high" => Some(DownloadPreset {
|
||||||
|
video_bit_rate: 8_000_000,
|
||||||
|
max_height: 1080,
|
||||||
|
audio_bit_rate: 384_000,
|
||||||
|
}),
|
||||||
|
"medium" => Some(DownloadPreset {
|
||||||
|
video_bit_rate: 4_000_000,
|
||||||
|
max_height: 720,
|
||||||
|
audio_bit_rate: 256_000,
|
||||||
|
}),
|
||||||
|
"low" => Some(DownloadPreset {
|
||||||
|
video_bit_rate: 1_500_000,
|
||||||
|
max_height: 480,
|
||||||
|
audio_bit_rate: 128_000,
|
||||||
|
}),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_presets_are_ordered_and_original_has_none() {
|
||||||
|
let high = download_preset("high").unwrap();
|
||||||
|
let medium = download_preset("medium").unwrap();
|
||||||
|
let low = download_preset("low").unwrap();
|
||||||
|
assert!(high.total_bit_rate() > medium.total_bit_rate());
|
||||||
|
assert!(medium.total_bit_rate() > low.total_bit_rate());
|
||||||
|
assert!(high.max_height > medium.max_height && medium.max_height > low.max_height);
|
||||||
|
assert_eq!(download_preset("original"), None);
|
||||||
|
assert_eq!(download_preset("nonsense"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,11 +18,40 @@ pub struct DownloadWorker {
|
|||||||
max_retries: u32,
|
max_retries: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How long a transfer may go without receiving a single byte before it is
|
||||||
|
/// treated as dead and retried. Generous because a transcode download waits on
|
||||||
|
/// ffmpeg, which pauses when Jellyfin throttles it.
|
||||||
|
const STALL_TIMEOUT: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
|
/// How long to wait for the TCP/TLS handshake before giving up.
|
||||||
|
const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
impl DownloadWorker {
|
impl DownloadWorker {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
|
Self::with_stall_timeout(STALL_TIMEOUT, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a worker whose HTTP client gives up on a transfer that receives
|
||||||
|
/// nothing for `stall`. `https_only` is relaxed only by tests, which serve
|
||||||
|
/// from a loopback socket.
|
||||||
|
///
|
||||||
|
/// The timeouts are a *connect* timeout and a *read* timeout — never
|
||||||
|
/// `Client::timeout`. That one is a total deadline that runs until the body
|
||||||
|
/// has finished, and it was set to five minutes: every download longer than
|
||||||
|
/// that was cut off mid-body with "error decoding response body", then
|
||||||
|
/// retried. A transcode ignores `Range`, so each retry restarted from byte
|
||||||
|
/// zero, ran into the same five minutes, and after three attempts the
|
||||||
|
/// download failed — which is why no feature film at transcode speed ever
|
||||||
|
/// completed on a device that needs the audio re-encoded. A read timeout
|
||||||
|
/// resets on every chunk, so it catches a dead connection without putting a
|
||||||
|
/// ceiling on how long a healthy transfer may run.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-289 | UT-251
|
||||||
|
fn with_stall_timeout(stall: Duration, https_only: bool) -> Self {
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(Duration::from_secs(300)) // 5 minute timeout
|
.connect_timeout(CONNECT_TIMEOUT)
|
||||||
.https_only(true)
|
.read_timeout(stall)
|
||||||
|
.https_only(https_only)
|
||||||
.build()
|
.build()
|
||||||
.expect("Failed to create HTTP client");
|
.expect("Failed to create HTTP client");
|
||||||
|
|
||||||
@@ -436,3 +465,117 @@ mod tests {
|
|||||||
assert!(!DownloadError::Network("timeout".to_string()).is_stopped());
|
assert!(!DownloadError::Network("timeout".to_string()).is_stopped());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Transfers that outlive the timeout. These drive the real `reqwest` client
|
||||||
|
/// against a loopback socket because the defect lived in how that client was
|
||||||
|
/// configured, not in any code of ours a mock could stand in for.
|
||||||
|
#[cfg(test)]
|
||||||
|
mod timeout_tests {
|
||||||
|
use super::*;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
/// Serve one HTTP/1.1 response of `chunks` bodies of `chunk_len` bytes,
|
||||||
|
/// pausing `gap` between them. `hang_after` chunks, the server stops sending
|
||||||
|
/// and never closes — a stalled connection.
|
||||||
|
async fn dribbling_server(
|
||||||
|
chunks: usize,
|
||||||
|
chunk_len: usize,
|
||||||
|
gap: Duration,
|
||||||
|
hang_after: Option<usize>,
|
||||||
|
) -> String {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let (mut sock, _) = listener.accept().await.unwrap();
|
||||||
|
// Drain the request head; we answer the same thing regardless.
|
||||||
|
let mut buf = [0u8; 4096];
|
||||||
|
let _ = tokio::io::AsyncReadExt::read(&mut sock, &mut buf).await;
|
||||||
|
let head = format!(
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||||
|
chunks * chunk_len
|
||||||
|
);
|
||||||
|
if sock.write_all(head.as_bytes()).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let body = vec![b'x'; chunk_len];
|
||||||
|
for i in 0..chunks {
|
||||||
|
if hang_after == Some(i) {
|
||||||
|
// Hold the socket open forever without writing.
|
||||||
|
tokio::time::sleep(Duration::from_secs(3600)).await;
|
||||||
|
}
|
||||||
|
// The client hanging up (as it does once it times out) is not
|
||||||
|
// the server's failure to report.
|
||||||
|
if sock.write_all(&body).await.is_err() || sock.flush().await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(gap).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
format!("http://{}/file.bin", addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A download that takes longer than the timeout but never stalls must
|
||||||
|
/// finish. The worker set `Client::timeout`, which in reqwest is a *total*
|
||||||
|
/// deadline covering the body, so every transfer longer than five minutes —
|
||||||
|
/// any film at transcode speed — was cut off with "error decoding response
|
||||||
|
/// body", retried from byte zero (a transcode ignores `Range`), and cut off
|
||||||
|
/// again until the retry budget ran out.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-289 | UT-251
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_download_longer_than_the_stall_timeout_completes_when_bytes_keep_flowing() {
|
||||||
|
let stall = Duration::from_millis(400);
|
||||||
|
// 12 chunks × 100 ms ≈ 1.2 s of transfer, three times the stall timeout,
|
||||||
|
// with every gap comfortably inside it.
|
||||||
|
let url = dribbling_server(12, 1024, Duration::from_millis(100), None).await;
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let task = DownloadTask {
|
||||||
|
url,
|
||||||
|
target_path: dir.path().join("file.bin"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let worker = DownloadWorker::with_stall_timeout(stall, false);
|
||||||
|
let result = worker
|
||||||
|
.download(&task, &AtomicBool::new(false), |_, _| {})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let result = result
|
||||||
|
.unwrap_or_else(|e| panic!("a transfer that never stalls must not time out: {e:?}"));
|
||||||
|
assert_eq!(result.bytes_downloaded, 12 * 1024);
|
||||||
|
assert!(task.target_path.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The converse: a connection that goes silent is still given up on, so
|
||||||
|
/// dropping the total deadline did not turn a dead wifi link into a download
|
||||||
|
/// that hangs forever with no retry.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-289 | UT-251
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_download_that_stalls_is_given_up_on() {
|
||||||
|
let stall = Duration::from_millis(300);
|
||||||
|
let url = dribbling_server(4, 1024, Duration::from_millis(10), Some(2)).await;
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let task = DownloadTask {
|
||||||
|
url,
|
||||||
|
target_path: dir.path().join("file.bin"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let worker = DownloadWorker::with_stall_timeout(stall, false);
|
||||||
|
// `download()` retries with 5 s/15 s/45 s backoff; a single attempt is
|
||||||
|
// what proves the stall is detected.
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let result = worker
|
||||||
|
.try_download(&task, &AtomicBool::new(false), &|_, _| {})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(DownloadError::Network(_))),
|
||||||
|
"a stalled transfer must fail as a network error: {result:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
started.elapsed() < Duration::from_secs(5),
|
||||||
|
"the stall must be detected promptly, took {:?}",
|
||||||
|
started.elapsed()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ use commands::{
|
|||||||
repository_get_resume_movies,
|
repository_get_resume_movies,
|
||||||
repository_get_series_current_episode,
|
repository_get_series_current_episode,
|
||||||
repository_get_series_episodes,
|
repository_get_series_episodes,
|
||||||
|
repository_get_series_view,
|
||||||
repository_get_similar_items,
|
repository_get_similar_items,
|
||||||
repository_get_stream_selection,
|
repository_get_stream_selection,
|
||||||
repository_get_subtitle_url,
|
repository_get_subtitle_url,
|
||||||
@@ -1014,6 +1015,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
|||||||
repository_get_next_up_episodes,
|
repository_get_next_up_episodes,
|
||||||
repository_get_series_episodes,
|
repository_get_series_episodes,
|
||||||
repository_get_series_current_episode,
|
repository_get_series_current_episode,
|
||||||
|
repository_get_series_view,
|
||||||
repository_clear_watch_history,
|
repository_clear_watch_history,
|
||||||
repository_get_recently_played_audio,
|
repository_get_recently_played_audio,
|
||||||
repository_get_resume_movies,
|
repository_get_resume_movies,
|
||||||
|
|||||||
@@ -177,6 +177,20 @@ fn completion_report_position(runtime: Option<f64>, last_position: f64) -> f64 {
|
|||||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||||
const RESUME_BACKOFF_STEP_SECS: u64 = 2;
|
const RESUME_BACKOFF_STEP_SECS: u64 = 2;
|
||||||
|
|
||||||
|
/// Where playback stands when a background-audio handoff returns to the
|
||||||
|
/// foreground. See [`PlayerController::background_audio_resume`].
|
||||||
|
///
|
||||||
|
/// TRACES: UR-040, UR-023 | DR-296
|
||||||
|
#[derive(specta::Type, Debug, Clone, PartialEq, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct BackgroundAudioResume {
|
||||||
|
/// Item the native audio player is on — `None` if the queue emptied (e.g.
|
||||||
|
/// the sleep timer stopped playback while backgrounded).
|
||||||
|
pub item_id: Option<String>,
|
||||||
|
/// Absolute position in that item, in seconds.
|
||||||
|
pub position_seconds: f64,
|
||||||
|
}
|
||||||
|
|
||||||
/// Metadata for the lockscreen / media notification.
|
/// Metadata for the lockscreen / media notification.
|
||||||
///
|
///
|
||||||
/// Used to drive the Android MediaSession from Rust in remote (cast) mode, where
|
/// Used to drive the Android MediaSession from Rust in remote (cast) mode, where
|
||||||
@@ -1728,6 +1742,23 @@ impl PlayerController {
|
|||||||
/// reports are honoured from here on.
|
/// reports are honoured from here on.
|
||||||
///
|
///
|
||||||
/// TRACES: UR-040, UR-005 | DR-052, DR-097
|
/// TRACES: UR-040, UR-005 | DR-052, DR-097
|
||||||
|
/// Where the foreground should pick up from a background-audio handoff: the
|
||||||
|
/// item the native player is on now, and its absolute position.
|
||||||
|
///
|
||||||
|
/// The item is not necessarily the one the handoff started from — an episode
|
||||||
|
/// that ends while backgrounded advances in the backend
|
||||||
|
/// (`advance_to_next_episode_audio_only`) — so the webview must not assume
|
||||||
|
/// it can reload the video it was mounted with. Read-only: call it before
|
||||||
|
/// `exit_background_audio` clears the base the position depends on.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-040, UR-023 | DR-296 | UT-266
|
||||||
|
pub fn background_audio_resume(&self) -> BackgroundAudioResume {
|
||||||
|
BackgroundAudioResume {
|
||||||
|
item_id: self.queue.lock_safe().current().map(|item| item.id.clone()),
|
||||||
|
position_seconds: self.absolute_position(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn exit_background_audio(&self) -> f64 {
|
pub fn exit_background_audio(&self) -> f64 {
|
||||||
*self.background_audio_active.lock_safe() = false;
|
*self.background_audio_active.lock_safe() = false;
|
||||||
self.take_background_audio_base()
|
self.take_background_audio_base()
|
||||||
@@ -4411,6 +4442,52 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returning to the foreground after the backend advanced to the next episode
|
||||||
|
/// must bring back THAT episode, not the one the handoff started from.
|
||||||
|
///
|
||||||
|
/// The return used to carry only a position; the webview reloaded the video it
|
||||||
|
/// was mounted with, so the user came back to the previous episode — at the new
|
||||||
|
/// episode's timestamp. The resume point therefore names the item the native
|
||||||
|
/// player is actually on.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-040 | DR-296 | UT-266
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_background_audio_resume_names_the_advanced_episode() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
||||||
|
|
||||||
|
let episode = MediaItem {
|
||||||
|
transport: None,
|
||||||
|
id: "ep1".to_string(),
|
||||||
|
item_type: Some("Episode".to_string()),
|
||||||
|
media_type: MediaType::Audio,
|
||||||
|
series_id: Some("series1".to_string()),
|
||||||
|
..create_test_items(1).remove(0)
|
||||||
|
};
|
||||||
|
controller.play_queue(vec![episode], 0).unwrap();
|
||||||
|
controller.enter_background_audio(1200.0);
|
||||||
|
|
||||||
|
let before = controller.background_audio_resume();
|
||||||
|
assert_eq!(before.item_id.as_deref(), Some("ep1"));
|
||||||
|
assert_eq!(before.position_seconds, 1200.0);
|
||||||
|
|
||||||
|
controller
|
||||||
|
.advance_to_next_episode_audio_only("ep2")
|
||||||
|
.await
|
||||||
|
.expect("advance should succeed");
|
||||||
|
|
||||||
|
let after = controller.background_audio_resume();
|
||||||
|
assert_eq!(
|
||||||
|
after.item_id.as_deref(),
|
||||||
|
Some("ep2"),
|
||||||
|
"the foreground must resume the episode the backend advanced to"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
after.position_seconds < 1200.0,
|
||||||
|
"the previous episode's handoff base must not leak into the new one"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// A background audio-only episode must advance IN THE BACKEND when the
|
/// A background audio-only episode must advance IN THE BACKEND when the
|
||||||
/// autoplay decision comes back as ShowNextEpisodePopup — never by starting a
|
/// autoplay decision comes back as ShowNextEpisodePopup — never by starting a
|
||||||
/// countdown the frontend is supposed to act on.
|
/// countdown the frontend is supposed to act on.
|
||||||
|
|||||||
@@ -195,17 +195,15 @@ pub fn subtitle_supports_external_delivery(codec: Option<&str>) -> bool {
|
|||||||
/// the raw list makes Jellyfin direct-play a track the webview cannot decode, and
|
/// the raw list makes Jellyfin direct-play a track the webview cannot decode, and
|
||||||
/// the user gets picture with no sound.
|
/// the user gets picture with no sound.
|
||||||
///
|
///
|
||||||
/// Which renderer gets it is not fixed: Linux is always the element, and Android
|
/// Which renderer gets it depends on the platform. Linux draws video in the
|
||||||
/// follows `experimentalNativeVideo`, which took ExoPlayer as its default in
|
/// element (unless mpv native video is switched on), so it gets the narrow list.
|
||||||
/// DR-161 but is a user setting either way. So the *narrow* list is the only one
|
/// Android draws video only in ExoPlayer: it used to follow the
|
||||||
/// that holds on both sides of that switch. The cost is a Dolby-licensed Android
|
/// `experimentalNativeVideo` setting, which could send video to the webview, and
|
||||||
/// device transcoding an E-AC-3 track its ExoPlayer could have direct-played;
|
/// while that switch existed the narrow list was the only one true on both sides
|
||||||
/// the alternative is silence for everyone the switch lands the other way, which
|
/// of it. DR-293 removed the webview video path on Android, so there the
|
||||||
/// is the bug this exists to prevent.
|
/// platform list is the whole answer — it includes the FFmpeg extension's
|
||||||
///
|
/// AC-3/E-AC-3/DTS/TrueHD, which `CodecDetector` reports alongside the
|
||||||
/// The gap is widest on devices whose vendor licenses Dolby: a phone with
|
/// `MediaCodecList` decoders.
|
||||||
/// `c2.dolby.eac3.decoder` reports `eac3`, so it — and only it — gets a silent
|
|
||||||
/// direct play where a leaner device is transcoded to AAC and plays fine.
|
|
||||||
///
|
///
|
||||||
/// This applies to the *video* direct-play profile only. Audio-only playback
|
/// This applies to the *video* direct-play profile only. Audio-only playback
|
||||||
/// really is ExoPlayer's, so its profile keeps the full platform list.
|
/// really is ExoPlayer's, so its profile keeps the full platform list.
|
||||||
@@ -323,6 +321,9 @@ pub fn renderer_can_decode_audio(codec: &str) -> bool {
|
|||||||
/// Whether the webview `<video>` element can decode this audio codec.
|
/// Whether the webview `<video>` element can decode this audio codec.
|
||||||
///
|
///
|
||||||
/// TRACES: UR-004 | DR-149 | UT-148
|
/// TRACES: UR-004 | DR-149 | UT-148
|
||||||
|
// Unreachable on Android since DR-293: video renders only in ExoPlayer there,
|
||||||
|
// so every caller goes through `renderer_can_decode_audio`'s device-list arm.
|
||||||
|
#[cfg_attr(target_os = "android", allow(dead_code))]
|
||||||
pub fn webview_can_decode_audio(codec: &str) -> bool {
|
pub fn webview_can_decode_audio(codec: &str) -> bool {
|
||||||
WEBVIEW_AUDIO_CODECS
|
WEBVIEW_AUDIO_CODECS
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
+431
-111
@@ -50,6 +50,42 @@ impl<T> CacheLeg<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Log a `get_items` leg that took long enough to be felt, so a slow page can
|
||||||
|
/// be attributed to the cache or the server from a device log alone.
|
||||||
|
fn log_slow_leg(
|
||||||
|
leg: &str,
|
||||||
|
parent_id: &str,
|
||||||
|
started: std::time::Instant,
|
||||||
|
result: &Result<SearchResult, RepoError>,
|
||||||
|
) {
|
||||||
|
let ms = started.elapsed().as_millis();
|
||||||
|
let outcome = match result {
|
||||||
|
Ok(data) => format!("{} rows", data.items.len()),
|
||||||
|
Err(e) => format!("error: {e:?}"),
|
||||||
|
};
|
||||||
|
let parent = &parent_id[..8.min(parent_id.len())];
|
||||||
|
if ms >= 250 {
|
||||||
|
log::info!("[HybridRepo] get_items {leg} leg for {parent} took {ms} ms ({outcome})");
|
||||||
|
} else {
|
||||||
|
debug!("[HybridRepo] get_items {leg} leg for {parent} took {ms} ms ({outcome})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of racing a cache read that missed the fast path against the
|
||||||
|
/// server. See [`HybridRepository::race_slow_cache`].
|
||||||
|
enum Raced<T> {
|
||||||
|
/// The cache answered first with something to show (exclusions applied).
|
||||||
|
Cache(T),
|
||||||
|
/// The server answered first, or the cache had nothing. Raw: the caller
|
||||||
|
/// caches the full page and applies exclusions to what it returns.
|
||||||
|
Server(T),
|
||||||
|
/// The server failed; this is what the cache said instead (exclusions
|
||||||
|
/// applied), possibly an empty listing — which still beats an error.
|
||||||
|
Fallback(T),
|
||||||
|
/// Neither could answer; the server's error.
|
||||||
|
Failed(RepoError),
|
||||||
|
}
|
||||||
|
|
||||||
/// Hybrid repository combining online and offline data sources
|
/// Hybrid repository combining online and offline data sources
|
||||||
///
|
///
|
||||||
/// Uses cache-first parallel racing strategy:
|
/// Uses cache-first parallel racing strategy:
|
||||||
@@ -235,7 +271,10 @@ impl HybridRepository {
|
|||||||
) -> Result<SearchResult, RepoError> {
|
) -> Result<SearchResult, RepoError> {
|
||||||
let offline = Arc::clone(&self.offline);
|
let offline = Arc::clone(&self.offline);
|
||||||
let query = query.to_string();
|
let query = query.to_string();
|
||||||
self.cache_with_timeout(async move { offline.search(&query, options).await })
|
// Cache-only: there is no server to fall back to, so a busy database
|
||||||
|
// delays the answer rather than failing it (DR-294).
|
||||||
|
offline
|
||||||
|
.search(&query, options)
|
||||||
.await
|
.await
|
||||||
.map(ExcludeHidden::without_excluded)
|
.map(ExcludeHidden::without_excluded)
|
||||||
}
|
}
|
||||||
@@ -250,7 +289,9 @@ impl HybridRepository {
|
|||||||
options: Option<GetItemsOptions>,
|
options: Option<GetItemsOptions>,
|
||||||
) -> Result<SearchResult, RepoError> {
|
) -> Result<SearchResult, RepoError> {
|
||||||
let offline = Arc::clone(&self.offline);
|
let offline = Arc::clone(&self.offline);
|
||||||
self.cache_with_timeout(async move { offline.get_favorites(scope, options).await })
|
// Cache-only, as `search_cache_only` above (DR-294).
|
||||||
|
offline
|
||||||
|
.get_favorites(scope, options)
|
||||||
.await
|
.await
|
||||||
.map(ExcludeHidden::without_excluded)
|
.map(ExcludeHidden::without_excluded)
|
||||||
}
|
}
|
||||||
@@ -392,9 +433,67 @@ impl HybridRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Race a cache read that missed the fast path against the server:
|
||||||
|
/// whichever answers first *with something to show* wins.
|
||||||
|
///
|
||||||
|
/// The fast-path deadline bounds how long a cache hit may delay the UI; it
|
||||||
|
/// must not also decide the race. It used to: past 100 ms the query waited
|
||||||
|
/// for the server even when the cache answered moments later, so a page
|
||||||
|
/// whose cache read took 150 ms always paid the full server round trip
|
||||||
|
/// (about a second on a phone). An empty or failed cache answer is not a
|
||||||
|
/// win — the server decides then — and a failed server falls back to
|
||||||
|
/// whatever the cache said.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-002 | DR-013
|
||||||
|
async fn race_slow_cache<T, F>(
|
||||||
|
mut slow: tokio::task::JoinHandle<Result<T, RepoError>>,
|
||||||
|
server: F,
|
||||||
|
) -> Raced<T>
|
||||||
|
where
|
||||||
|
T: MeaningfulContent + ExcludeHidden,
|
||||||
|
F: std::future::Future<Output = Result<T, RepoError>>,
|
||||||
|
{
|
||||||
|
tokio::pin!(server);
|
||||||
|
// `biased`, server first: if both are ready at once, the fresher
|
||||||
|
// answer wins.
|
||||||
|
tokio::select! {
|
||||||
|
biased;
|
||||||
|
server_result = &mut server => match server_result {
|
||||||
|
Ok(data) => Raced::Server(data),
|
||||||
|
Err(e) => {
|
||||||
|
debug!("[HybridRepo] Server failed; waiting for the slow cache query");
|
||||||
|
match slow.await {
|
||||||
|
Ok(Ok(data)) => Raced::Fallback(data.without_excluded()),
|
||||||
|
_ => Raced::Failed(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cache_result = &mut slow => {
|
||||||
|
let cache = cache_result
|
||||||
|
.unwrap_or_else(|join| Err(RepoError::Database {
|
||||||
|
message: format!("Cache query failed: {join}"),
|
||||||
|
}))
|
||||||
|
.map(ExcludeHidden::without_excluded);
|
||||||
|
match cache {
|
||||||
|
Ok(data) if data.has_content() => {
|
||||||
|
debug!("[HybridRepo] Slow cache answered before the server");
|
||||||
|
Raced::Cache(data)
|
||||||
|
}
|
||||||
|
other => match server.await {
|
||||||
|
Ok(data) => Raced::Server(data),
|
||||||
|
Err(e) => match other {
|
||||||
|
Ok(data) => Raced::Fallback(data),
|
||||||
|
Err(_) => Raced::Failed(e),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Cache-first query: try cache, fall back to server on miss.
|
/// Cache-first query: try cache, fall back to server on miss.
|
||||||
///
|
///
|
||||||
/// 1. Check cache (100ms timeout applied by caller via cache_with_timeout)
|
/// 1. Check cache (100ms fast path, via `cache_leg`; a slow read keeps running)
|
||||||
/// 2. If cache has meaningful content → return immediately (fast path)
|
/// 2. If cache has meaningful content → return immediately (fast path)
|
||||||
/// 3. If cache is empty/stale → query server (fresh data)
|
/// 3. If cache is empty/stale → query server (fresh data)
|
||||||
/// 4. If server fails → return cache even if empty (offline fallback)
|
/// 4. If server fails → return cache even if empty (offline fallback)
|
||||||
@@ -454,33 +553,28 @@ impl HybridRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debug!("[HybridRepo] Cache miss or slow, querying server");
|
// Still running: race it against the server rather than waiting the
|
||||||
match server_future.await {
|
// server out. If the server then fails, the cache is the only thing
|
||||||
Ok(data) => Ok(data.without_excluded()),
|
// that can answer — offline, a cache read slowed by a concurrent write
|
||||||
Err(e) => {
|
// used to surface as a network error.
|
||||||
// The server cannot answer. If the cache is still working, it is
|
|
||||||
// now the only thing that can, so wait it out rather than
|
|
||||||
// reporting the server's failure over data we are about to hold.
|
|
||||||
// This is the offline path: a cache read slowed by a concurrent
|
|
||||||
// write used to surface as a network error.
|
|
||||||
if let Some(handle) = slow {
|
if let Some(handle) = slow {
|
||||||
debug!("[HybridRepo] Server failed; waiting for the slow cache query");
|
return match Self::race_slow_cache(handle, server_future).await {
|
||||||
return match handle.await {
|
Raced::Cache(data) => {
|
||||||
Ok(Ok(data)) => Ok(data.without_excluded()),
|
on_cache_hit();
|
||||||
Ok(Err(cache_err)) => {
|
Ok(data)
|
||||||
debug!("[HybridRepo] Slow cache query also failed: {cache_err}");
|
|
||||||
Err(e)
|
|
||||||
}
|
|
||||||
Err(join) => {
|
|
||||||
debug!("[HybridRepo] Slow cache query panicked: {join}");
|
|
||||||
Err(e)
|
|
||||||
}
|
}
|
||||||
|
Raced::Server(data) => Ok(data.without_excluded()),
|
||||||
|
Raced::Fallback(data) => Ok(data),
|
||||||
|
Raced::Failed(e) => Err(e),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debug!("[HybridRepo] Cache miss, querying server");
|
||||||
|
match server_future.await {
|
||||||
|
Ok(data) => Ok(data.without_excluded()),
|
||||||
// Cache answered in time but had nothing: return that, so an
|
// Cache answered in time but had nothing: return that, so an
|
||||||
// empty-but-valid cached listing still beats a network error.
|
// empty-but-valid cached listing still beats a network error.
|
||||||
fast.unwrap_or(Err(e))
|
Err(e) => fast.unwrap_or(Err(e)),
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,10 +587,10 @@ impl HybridRepository {
|
|||||||
///
|
///
|
||||||
/// Missing the deadline does **not** cancel the query — it keeps running on
|
/// Missing the deadline does **not** cancel the query — it keeps running on
|
||||||
/// its own task and [`CacheLeg::settle`] can still collect it. That
|
/// its own task and [`CacheLeg::settle`] can still collect it. That
|
||||||
/// distinction is the whole point. The database is one SQLite connection
|
/// distinction is the whole point. A read can miss the deadline for many
|
||||||
/// behind one mutex, so a concurrent write (a sync drain, a bulk
|
/// reasons — a large listing, slow storage, a busy reader pool (and, before
|
||||||
/// `save_to_cache`) blocks reads for its duration and this deadline trips
|
/// reads had their own connections, any write in progress). Treating that
|
||||||
/// routinely on slow storage. Treating that as "the cache is empty" while
|
/// as "the cache is empty" while
|
||||||
/// throwing the answer away meant that offline — where the server leg also
|
/// throwing the answer away meant that offline — where the server leg also
|
||||||
/// fails — browsing surfaced a network error instead of the cached content
|
/// fails — browsing surfaced a network error instead of the cached content
|
||||||
/// sitting right there on disk.
|
/// sitting right there on disk.
|
||||||
@@ -523,18 +617,106 @@ impl HybridRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Await a cache query that is still running, however long it takes.
|
/// Start a cache read with [`Self::CACHE_FAST_PATH`] to answer: its result
|
||||||
async fn cache_with_timeout<T>(
|
/// if it made it, and otherwise the read itself, still running.
|
||||||
&self,
|
///
|
||||||
future: impl std::future::Future<Output = Result<T, RepoError>> + Send,
|
/// The cache-then-server queries used to *discard* a read that missed the
|
||||||
) -> Result<T, RepoError> {
|
/// deadline. When reads shared one connection with writes, any write in
|
||||||
timeout(Self::CACHE_FAST_PATH, future)
|
/// progress — the catalog sync that starts at every launch, a download
|
||||||
.await
|
/// finishing — pushed a read past 100 ms routinely; offline the
|
||||||
.unwrap_or_else(|_| {
|
/// server then failed too, and the page reported a network error over data
|
||||||
|
/// sitting on disk. Keeping the read lets [`Self::settle`] wait for it.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-002 | DR-013, DR-294
|
||||||
|
async fn cache_try<T>(
|
||||||
|
future: impl std::future::Future<Output = Result<T, RepoError>> + Send + 'static,
|
||||||
|
) -> (
|
||||||
|
Result<T, RepoError>,
|
||||||
|
Option<tokio::task::JoinHandle<Result<T, RepoError>>>,
|
||||||
|
)
|
||||||
|
where
|
||||||
|
T: Send + 'static,
|
||||||
|
{
|
||||||
|
let (fast, slow) = Self::cache_leg(future).await.split();
|
||||||
|
let fast = fast.unwrap_or_else(|| {
|
||||||
Err(RepoError::Database {
|
Err(RepoError::Database {
|
||||||
message: "Cache query timeout".to_string(),
|
message: "Cache query still running".to_string(),
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
});
|
||||||
|
(fast, slow)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server could not answer: the cache's answer if it has one —
|
||||||
|
/// waiting for a read still in flight — else the server's error.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-002 | DR-294 | UT-263
|
||||||
|
async fn settle<T>(
|
||||||
|
fast: Result<T, RepoError>,
|
||||||
|
slow: Option<tokio::task::JoinHandle<Result<T, RepoError>>>,
|
||||||
|
server_err: RepoError,
|
||||||
|
) -> Result<T, RepoError> {
|
||||||
|
if let Some(handle) = slow {
|
||||||
|
debug!("[HybridRepo] Server failed; waiting for the slow cache read");
|
||||||
|
return match handle.await {
|
||||||
|
Ok(Ok(data)) => Ok(data),
|
||||||
|
_ => Err(server_err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
fast.or(Err(server_err))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache the server's page once it arrives, without holding up the
|
||||||
|
/// caller, who has already answered from the cache. A failed or empty
|
||||||
|
/// server answer leaves the existing cache alone.
|
||||||
|
fn save_when_server_answers(
|
||||||
|
server: tokio::task::JoinHandle<Result<SearchResult, RepoError>>,
|
||||||
|
offline: Arc<OfflineRepository>,
|
||||||
|
parent_id: String,
|
||||||
|
) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Ok(Ok(server_data)) = server.await {
|
||||||
|
Self::save_in_background(offline, parent_id, &server_data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache a server page in the background (one transaction; see
|
||||||
|
/// `OfflineRepository::save_to_cache`).
|
||||||
|
fn save_in_background(
|
||||||
|
offline: Arc<OfflineRepository>,
|
||||||
|
parent_id: String,
|
||||||
|
server_data: &SearchResult,
|
||||||
|
) {
|
||||||
|
if server_data.items.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let items = server_data.items.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
match offline.save_to_cache(&parent_id, &items).await {
|
||||||
|
Ok(_) => debug!(
|
||||||
|
"[HybridRepo] Cached {} items for parent {}",
|
||||||
|
items.len(),
|
||||||
|
&parent_id[..8.min(parent_id.len())]
|
||||||
|
),
|
||||||
|
Err(e) => warn!(
|
||||||
|
"[HybridRepo] Failed to cache {} items: {:?}",
|
||||||
|
items.len(),
|
||||||
|
e
|
||||||
|
),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Self::settle`] for `get_items`, whose slow read has not yet had
|
||||||
|
/// exclusions applied.
|
||||||
|
async fn cache_or(
|
||||||
|
fast: Result<SearchResult, RepoError>,
|
||||||
|
slow: Option<tokio::task::JoinHandle<Result<SearchResult, RepoError>>>,
|
||||||
|
server_err: RepoError,
|
||||||
|
) -> Result<SearchResult, RepoError> {
|
||||||
|
Self::settle(fast, slow, server_err)
|
||||||
|
.await
|
||||||
|
.map(ExcludeHidden::without_excluded)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,7 +726,9 @@ impl MediaRepository for HybridRepository {
|
|||||||
// Cache-first (100ms). On a cache hit, refresh the cache from the server
|
// Cache-first (100ms). On a cache hit, refresh the cache from the server
|
||||||
// in the background. On a miss, fetch from the server and persist so the
|
// in the background. On a miss, fetch from the server and persist so the
|
||||||
// list is available on the next (possibly offline) startup.
|
// list is available on the next (possibly offline) startup.
|
||||||
let cache_result = self.cache_with_timeout(self.offline.get_libraries()).await;
|
let offline_read = Arc::clone(&self.offline);
|
||||||
|
let (cache_result, slow_cache) =
|
||||||
|
Self::cache_try(async move { offline_read.get_libraries().await }).await;
|
||||||
|
|
||||||
if let Ok(libs) = &cache_result {
|
if let Ok(libs) = &cache_result {
|
||||||
if libs.has_content() {
|
if libs.has_content() {
|
||||||
@@ -581,7 +765,8 @@ impl MediaRepository for HybridRepository {
|
|||||||
}
|
}
|
||||||
Ok(server_libs)
|
Ok(server_libs)
|
||||||
}
|
}
|
||||||
Err(e) => cache_result.or(Err(e)),
|
// TRACES: UR-002 | DR-294 | UT-263
|
||||||
|
Err(e) => Self::settle(cache_result, slow_cache, e).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,8 +784,12 @@ impl MediaRepository for HybridRepository {
|
|||||||
let opts_clone = options.clone();
|
let opts_clone = options.clone();
|
||||||
|
|
||||||
// Start server request in background (non-blocking)
|
// Start server request in background (non-blocking)
|
||||||
let server_handle =
|
let mut server_handle = tokio::spawn(async move {
|
||||||
tokio::spawn(async move { online.get_items(&parent_id_clone, options).await });
|
let started = std::time::Instant::now();
|
||||||
|
let result = online.get_items(&parent_id_clone, options).await;
|
||||||
|
log_slow_leg("server", &parent_id_clone, started, &result);
|
||||||
|
result
|
||||||
|
});
|
||||||
|
|
||||||
// Check cache first (fast, 100ms timeout).
|
// Check cache first (fast, 100ms timeout).
|
||||||
//
|
//
|
||||||
@@ -610,10 +799,21 @@ impl MediaRepository for HybridRepository {
|
|||||||
// `parallel_race` — it interleaves the downloads-only gate and a
|
// `parallel_race` — it interleaves the downloads-only gate and a
|
||||||
// background cache write — so it applies the filter itself.
|
// background cache write — so it applies the filter itself.
|
||||||
// TRACES: UR-076 | DR-209
|
// TRACES: UR-076 | DR-209
|
||||||
let cache_result = self
|
//
|
||||||
.cache_with_timeout(async move { offline.get_items(&parent_id, opts_clone).await })
|
// A read that misses the fast path is kept running, not discarded: if
|
||||||
.await
|
// the server then fails, the cache is the only thing that can answer,
|
||||||
.map(ExcludeHidden::without_excluded);
|
// and it is waited for (see the end of this function). Discarding it
|
||||||
|
// is what made a downloaded show fail offline ("Failed to load item")
|
||||||
|
// whenever a write held the database past 100 ms — the catalog sync
|
||||||
|
// that starts at every launch does, routinely. TRACES: UR-002 | DR-294
|
||||||
|
let (cache_result, slow_cache) = Self::cache_try(async move {
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let result = offline.get_items(&parent_id, opts_clone).await;
|
||||||
|
log_slow_leg("cache", &parent_id, started, &result);
|
||||||
|
result
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let cache_result = cache_result.map(ExcludeHidden::without_excluded);
|
||||||
|
|
||||||
// Downloads-only gate: when the "Show all server media" toggle is off
|
// Downloads-only gate: when the "Show all server media" toggle is off
|
||||||
// (offline), an empty offline result is authoritative — the user asked
|
// (offline), an empty offline result is authoritative — the user asked
|
||||||
@@ -642,54 +842,45 @@ impl MediaRepository for HybridRepository {
|
|||||||
"[HybridRepo] Cache hit for get_items, returning immediately for parent {}",
|
"[HybridRepo] Cache hit for get_items, returning immediately for parent {}",
|
||||||
&parent_id_for_save[..8.min(parent_id_for_save.len())]
|
&parent_id_for_save[..8.min(parent_id_for_save.len())]
|
||||||
);
|
);
|
||||||
// Background: save server result to cache when it arrives
|
Self::save_when_server_answers(server_handle, offline_for_save, parent_id_for_save);
|
||||||
tokio::spawn(async move {
|
|
||||||
match server_handle.await {
|
|
||||||
Ok(Ok(server_data)) if !server_data.items.is_empty() => {
|
|
||||||
if let Err(e) = offline_for_save
|
|
||||||
.save_to_cache(&parent_id_for_save, &server_data.items)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
warn!("[HybridRepo] Background cache update failed: {:?}", e);
|
|
||||||
} else {
|
|
||||||
debug!(
|
|
||||||
"[HybridRepo] Background updated {} cached items for parent {}",
|
|
||||||
server_data.items.len(),
|
|
||||||
&parent_id_for_save[..8.min(parent_id_for_save.len())]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {} // Server failed or returned empty — keep existing cache
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return Ok(data.clone());
|
return Ok(data.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache miss — wait for server result
|
// The cache missed the fast path but is still reading: race it against
|
||||||
|
// the server instead of waiting the server out (DR-013).
|
||||||
|
if let Some(slow) = slow_cache {
|
||||||
|
let server = async {
|
||||||
|
(&mut server_handle).await.unwrap_or_else(|join| {
|
||||||
|
Err(RepoError::Network {
|
||||||
|
message: format!("Server task failed: {}", join),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
};
|
||||||
|
return match Self::race_slow_cache(slow, server).await {
|
||||||
|
Raced::Cache(data) => {
|
||||||
|
// The server is still in flight: cache its answer when it
|
||||||
|
// lands, exactly as on a fast-path hit.
|
||||||
|
Self::save_when_server_answers(
|
||||||
|
server_handle,
|
||||||
|
offline_for_save,
|
||||||
|
parent_id_for_save,
|
||||||
|
);
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
Raced::Server(server_data) => {
|
||||||
|
Self::save_in_background(offline_for_save, parent_id_for_save, &server_data);
|
||||||
|
Ok(server_data.without_excluded())
|
||||||
|
}
|
||||||
|
Raced::Fallback(data) => Ok(data),
|
||||||
|
Raced::Failed(e) => Err(e),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache answered in time with nothing — wait for the server.
|
||||||
match server_handle.await {
|
match server_handle.await {
|
||||||
Ok(Ok(server_data)) => {
|
Ok(Ok(server_data)) => {
|
||||||
if !server_data.items.is_empty() {
|
Self::save_in_background(offline_for_save, parent_id_for_save, &server_data);
|
||||||
let items_clone = server_data.items.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Err(e) = offline_for_save
|
|
||||||
.save_to_cache(&parent_id_for_save, &items_clone)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
warn!(
|
|
||||||
"[HybridRepo] Failed to save {} items to cache: {:?}",
|
|
||||||
items_clone.len(),
|
|
||||||
e
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
debug!(
|
|
||||||
"[HybridRepo] Saved {} items to cache for parent {}",
|
|
||||||
items_clone.len(),
|
|
||||||
&parent_id_for_save[..8.min(parent_id_for_save.len())]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// The cache keeps the server's full page (above) — an exclusion
|
// The cache keeps the server's full page (above) — an exclusion
|
||||||
// is a view preference and can be undone, so hiding items from
|
// is a view preference and can be undone, so hiding items from
|
||||||
// the *cache* would make un-hiding them require a re-crawl. Only
|
// the *cache* would make un-hiding them require a re-crawl. Only
|
||||||
@@ -697,10 +888,13 @@ impl MediaRepository for HybridRepository {
|
|||||||
// TRACES: UR-076 | DR-209
|
// TRACES: UR-076 | DR-209
|
||||||
Ok(server_data.without_excluded())
|
Ok(server_data.without_excluded())
|
||||||
}
|
}
|
||||||
Ok(Err(e)) => cache_result.or(Err(e)),
|
Ok(Err(e)) => Self::cache_or(cache_result, None, e).await,
|
||||||
Err(join_err) => cache_result.or(Err(RepoError::Network {
|
Err(join_err) => {
|
||||||
|
let e = RepoError::Network {
|
||||||
message: format!("Server task failed: {}", join_err),
|
message: format!("Server task failed: {}", join_err),
|
||||||
})),
|
};
|
||||||
|
Self::cache_or(cache_result, None, e).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -708,9 +902,9 @@ impl MediaRepository for HybridRepository {
|
|||||||
/// background so the stored copy keeps up with the server.
|
/// background so the stored copy keeps up with the server.
|
||||||
///
|
///
|
||||||
/// The background refresh is what carries per-user state home: caching an
|
/// The background refresh is what carries per-user state home: caching an
|
||||||
/// item runs `mirror_user_data`, which is the only path by which a watch
|
/// item also writes its `user_data_mirror_query` row, the only path by
|
||||||
/// position set on another device reaches the local `user_data` row the
|
/// which a watch position set on another device reaches the local
|
||||||
/// resume check reads. Without it a cache hit returned this device's own
|
/// `user_data` row the resume check reads. Without it a cache hit returned this device's own
|
||||||
/// stale position forever and cross-device resume silently did nothing —
|
/// stale position forever and cross-device resume silently did nothing —
|
||||||
/// `get_items` already refreshes this way, so browsing a season worked
|
/// `get_items` already refreshes this way, so browsing a season worked
|
||||||
/// while opening the episode directly did not.
|
/// while opening the episode directly did not.
|
||||||
@@ -806,11 +1000,32 @@ impl MediaRepository for HybridRepository {
|
|||||||
series_id: Option<&str>,
|
series_id: Option<&str>,
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
) -> Result<Vec<MediaItem>, RepoError> {
|
) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
// Next up is dynamic, always fetch from server
|
// Cache-first like every other query: the local answer is computed
|
||||||
self.online
|
// from the same watch state the cache refreshes from the server in the
|
||||||
.get_next_up_episodes(series_id, limit)
|
// background (`user_data_mirror_query`), and whichever answers first
|
||||||
|
// with content wins. It used to wait for the server outright, which
|
||||||
|
// held the series page's episode list for 2-3 s on a phone. An empty
|
||||||
|
// local answer still defers to the server, and a failed server falls
|
||||||
|
// back to the cache — offline, the TV page's Next Up row must not blank
|
||||||
|
// the page (DR-294).
|
||||||
|
// TRACES: UR-002 | DR-013, DR-294 | UT-261
|
||||||
|
let offline = Arc::clone(&self.offline);
|
||||||
|
let online = Arc::clone(&self.online);
|
||||||
|
let series = series_id.map(str::to_string);
|
||||||
|
let series_for_server = series.clone();
|
||||||
|
|
||||||
|
let cache_future =
|
||||||
|
Self::cache_leg(
|
||||||
|
async move { offline.get_next_up_episodes(series.as_deref(), limit).await },
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let server_future = async move {
|
||||||
|
online
|
||||||
|
.get_next_up_episodes(series_for_server.as_deref(), limit)
|
||||||
.await
|
.await
|
||||||
.map(ExcludeHidden::without_excluded)
|
};
|
||||||
|
|
||||||
|
Self::parallel_race(cache_future, server_future).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_recently_played_audio(
|
async fn get_recently_played_audio(
|
||||||
@@ -878,8 +1093,8 @@ impl MediaRepository for HybridRepository {
|
|||||||
|
|
||||||
let cache_offline = Arc::clone(&self.offline);
|
let cache_offline = Arc::clone(&self.offline);
|
||||||
let cache_pid = parent_id_str.clone();
|
let cache_pid = parent_id_str.clone();
|
||||||
let cache_result = self
|
let (cache_result, slow_cache) =
|
||||||
.cache_with_timeout(async move { cache_offline.get_genres(cache_pid.as_deref()).await })
|
Self::cache_try(async move { cache_offline.get_genres(cache_pid.as_deref()).await })
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
if let Ok(genres) = &cache_result {
|
if let Ok(genres) = &cache_result {
|
||||||
@@ -922,7 +1137,7 @@ impl MediaRepository for HybridRepository {
|
|||||||
}
|
}
|
||||||
Ok(server_genres)
|
Ok(server_genres)
|
||||||
}
|
}
|
||||||
Err(e) => cache_result.or(Err(e)),
|
Err(e) => Self::settle(cache_result, slow_cache, e).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -946,7 +1161,15 @@ impl MediaRepository for HybridRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError> {
|
async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError> {
|
||||||
// Playback info requires server communication for transcoding decisions
|
// A downloaded item is played from disk and needs nothing the server
|
||||||
|
// negotiates, so its answer comes from the download row — first, and
|
||||||
|
// whether or not the server is reachable. Asking the server first is
|
||||||
|
// what made a download unplayable offline (DR-294). Anything not held
|
||||||
|
// locally is a streaming question, and only the server can answer it.
|
||||||
|
// TRACES: UR-002, UR-071 | DR-294 | UT-260
|
||||||
|
if let Some(local) = self.offline.local_playback_info(item_id).await? {
|
||||||
|
return Ok(local);
|
||||||
|
}
|
||||||
self.online.get_playback_info(item_id).await
|
self.online.get_playback_info(item_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1205,9 +1428,8 @@ impl MediaRepository for HybridRepository {
|
|||||||
tokio::spawn(async move { online.get_playlist_items(&playlist_id_clone).await });
|
tokio::spawn(async move { online.get_playlist_items(&playlist_id_clone).await });
|
||||||
|
|
||||||
// Check cache first (fast, 100ms timeout)
|
// Check cache first (fast, 100ms timeout)
|
||||||
let cache_result = self
|
let (cache_result, slow_cache) =
|
||||||
.cache_with_timeout(async move { offline.get_playlist_items(&playlist_id).await })
|
Self::cache_try(async move { offline.get_playlist_items(&playlist_id).await }).await;
|
||||||
.await;
|
|
||||||
|
|
||||||
// Cache hit: return immediately, update cache in background
|
// Cache hit: return immediately, update cache in background
|
||||||
if let Ok(data) = &cache_result {
|
if let Ok(data) = &cache_result {
|
||||||
@@ -1244,10 +1466,13 @@ impl MediaRepository for HybridRepository {
|
|||||||
});
|
});
|
||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
Ok(Err(e)) => cache_result.or(Err(e)),
|
Ok(Err(e)) => Self::settle(cache_result, slow_cache, e).await,
|
||||||
Err(join_err) => cache_result.or(Err(RepoError::Network {
|
Err(join_err) => {
|
||||||
|
let e = RepoError::Network {
|
||||||
message: format!("Server task failed: {}", join_err),
|
message: format!("Server task failed: {}", join_err),
|
||||||
})),
|
};
|
||||||
|
Self::settle(cache_result, slow_cache, e).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1301,9 +1526,9 @@ mod tests {
|
|||||||
|
|
||||||
/// Offline, a cache read slowed past the fast path must still answer.
|
/// Offline, a cache read slowed past the fast path must still answer.
|
||||||
///
|
///
|
||||||
/// The database is one SQLite connection behind one mutex, so a concurrent
|
/// The 100 ms fast path trips routinely on slow storage (and, while reads
|
||||||
/// write blocks reads for its duration and the 100 ms fast path trips on
|
/// shared one connection with writes, behind any write). The deadline used
|
||||||
/// slow storage. The deadline used to *cancel* the read and report it as a
|
/// to *cancel* the read and report it as a
|
||||||
/// miss; with the server leg also failing (offline), the user got a network
|
/// miss; with the server leg also failing (offline), the user got a network
|
||||||
/// error over cached content that was sitting on disk.
|
/// error over cached content that was sitting on disk.
|
||||||
///
|
///
|
||||||
@@ -1380,6 +1605,101 @@ mod tests {
|
|||||||
assert!(matches!(err, RepoError::Network { .. }), "got {err:?}");
|
assert!(matches!(err, RepoError::Network { .. }), "got {err:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A cache read that misses the fast path but lands before the server must
|
||||||
|
/// be what the user sees.
|
||||||
|
///
|
||||||
|
/// The 100 ms deadline used to decide the race outright: past it, the page
|
||||||
|
/// waited for the server even when the cache answered a few milliseconds
|
||||||
|
/// later — on a phone, a series page showed its seasons after the ~1 s
|
||||||
|
/// server round trip instead of the ~150 ms cache read, on every visit.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-002 | DR-013
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_slow_cache_that_beats_the_server_wins() {
|
||||||
|
let cache = HybridRepository::cache_leg(async {
|
||||||
|
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||||
|
Ok(vec![MediaItem {
|
||||||
|
id: "slow-cache".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let server = async {
|
||||||
|
tokio::time::sleep(Duration::from_millis(1500)).await;
|
||||||
|
Ok(vec![MediaItem {
|
||||||
|
id: "server".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}])
|
||||||
|
};
|
||||||
|
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let got = HybridRepository::parallel_race(cache, server)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
got[0].id, "slow-cache",
|
||||||
|
"waited for the server over a cache answer"
|
||||||
|
);
|
||||||
|
assert!(started.elapsed() < Duration::from_millis(1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A server that beats a slow cache still answers first.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-002 | DR-013
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_server_that_beats_a_slow_cache_wins() {
|
||||||
|
let cache = HybridRepository::cache_leg(async {
|
||||||
|
tokio::time::sleep(Duration::from_millis(1500)).await;
|
||||||
|
Ok(vec![MediaItem {
|
||||||
|
id: "slow-cache".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let server = async {
|
||||||
|
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||||
|
Ok(vec![MediaItem {
|
||||||
|
id: "server".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}])
|
||||||
|
};
|
||||||
|
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let got = HybridRepository::parallel_race(cache, server)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(got[0].id, "server");
|
||||||
|
assert!(started.elapsed() < Duration::from_millis(1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A slow cache that comes back *empty* is not an answer: the server
|
||||||
|
/// decides.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-002 | DR-013
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_empty_slow_cache_defers_to_the_server() {
|
||||||
|
let cache = HybridRepository::cache_leg(async {
|
||||||
|
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||||
|
Ok(Vec::<MediaItem>::new())
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let server = async {
|
||||||
|
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||||
|
Ok(vec![MediaItem {
|
||||||
|
id: "server".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}])
|
||||||
|
};
|
||||||
|
|
||||||
|
let got = HybridRepository::parallel_race(cache, server)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(got[0].id, "server");
|
||||||
|
}
|
||||||
|
|
||||||
/// Mock offline repository that tracks queries and saves
|
/// Mock offline repository that tracks queries and saves
|
||||||
struct MockOfflineRepo {
|
struct MockOfflineRepo {
|
||||||
items: Arc<Mutex<Vec<MediaItem>>>,
|
items: Arc<Mutex<Vec<MediaItem>>>,
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ pub trait MediaRepository: Send + Sync {
|
|||||||
/// [`resolve_video_download_url`] rather than calling it directly.
|
/// [`resolve_video_download_url`] rather than calling it directly.
|
||||||
///
|
///
|
||||||
/// `source_audio_codec` is the codec of the audio track the server would
|
/// `source_audio_codec` is the codec of the audio track the server would
|
||||||
/// serve (see [`served_audio_codec`]); `None` when it is not known. At
|
/// serve (see [`resolve_video_download`]); `None` when it is not known. At
|
||||||
/// `original` quality it decides whether the file can be copied byte-for-byte
|
/// `original` quality it decides whether the file can be copied byte-for-byte
|
||||||
/// or has to have its audio re-encoded on the way down — a downloaded file is
|
/// or has to have its audio re-encoded on the way down — a downloaded file is
|
||||||
/// played back with no server in reach, so it has to be decodable *here*.
|
/// played back with no server in reach, so it has to be decodable *here*.
|
||||||
@@ -345,35 +345,70 @@ pub trait MediaRepository: Send + Sync {
|
|||||||
) -> Result<(), RepoError>;
|
) -> Result<(), RepoError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The audio codec the server would serve for `item_id` — the default track, or
|
/// A video download, resolved: the URL to fetch and, where the item told us
|
||||||
/// the first when none is marked, matching the track Jellyfin picks.
|
/// enough, how many bytes to expect from it.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ResolvedVideoDownload {
|
||||||
|
pub url: String,
|
||||||
|
/// Predicted size (see `download::estimate`), used as the progress total
|
||||||
|
/// when the response carries no `Content-Length` — a transcode never does.
|
||||||
|
pub expected_bytes: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the download URL for a video, applying the audio-codec policy that
|
||||||
|
/// keeps the saved file playable offline (DR-171), and predict its size from
|
||||||
|
/// the same item lookup (DR-290).
|
||||||
///
|
///
|
||||||
/// `None` when the item has no audio, names no codec, or cannot be fetched. A
|
/// Every video download goes through here rather than calling the builder
|
||||||
/// caller must read that as "unknown", never as "fine": it is the input to a
|
/// directly: the builder is pure and cannot look the codec up, and a caller that
|
||||||
/// policy that only *adds* a transcode, so an unknown codec leaves behaviour
|
/// forgets to is exactly how the silent downloads shipped.
|
||||||
/// exactly as it was.
|
|
||||||
///
|
///
|
||||||
/// TRACES: UR-071 | DR-171 | UT-166
|
/// TRACES: UR-071 | DR-171, DR-290
|
||||||
pub async fn served_audio_codec(repo: &dyn MediaRepository, item_id: &str) -> Option<String> {
|
pub async fn resolve_video_download(
|
||||||
let item = repo.get_item(item_id).await.ok()?;
|
repo: &dyn MediaRepository,
|
||||||
|
item_id: &str,
|
||||||
|
quality: &str,
|
||||||
|
media_source_id: Option<&str>,
|
||||||
|
) -> ResolvedVideoDownload {
|
||||||
|
let item = repo.get_item(item_id).await.ok();
|
||||||
|
|
||||||
let audio: Vec<(Option<&str>, bool)> = item
|
let audio: Vec<(Option<&str>, bool)> = item
|
||||||
.media_streams
|
.as_ref()
|
||||||
.as_deref()
|
.and_then(|i| i.media_streams.as_deref())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|s| s.stream_type == "Audio")
|
.filter(|s| s.stream_type == "Audio")
|
||||||
.map(|s| (s.codec.as_deref(), s.is_default))
|
.map(|s| (s.codec.as_deref(), s.is_default))
|
||||||
.collect();
|
.collect();
|
||||||
|
// The default track, or the first when none is marked, matching the track
|
||||||
|
// Jellyfin picks. `None` reads as "unknown", never as "fine": it feeds a
|
||||||
|
// policy that only *adds* a transcode, so an unknown codec leaves behaviour
|
||||||
|
// exactly as it was. TRACES: UR-071 | DR-171 | UT-166
|
||||||
|
let codec = device_profile::served_audio_codec(&audio);
|
||||||
|
|
||||||
device_profile::served_audio_codec(&audio).map(str::to_string)
|
// The source that will be served: the one asked for, else the first —
|
||||||
|
// the same choice Jellyfin makes when no `mediaSourceId` is given.
|
||||||
|
let source_size = item.as_ref().and_then(|i| {
|
||||||
|
let sources = i.media_sources.as_deref()?;
|
||||||
|
let source = match media_source_id {
|
||||||
|
Some(id) => sources.iter().find(|s| s.id == id),
|
||||||
|
None => sources.first(),
|
||||||
|
};
|
||||||
|
source?.size
|
||||||
|
});
|
||||||
|
let expected_bytes = crate::download::estimate::expected_download_bytes(
|
||||||
|
quality,
|
||||||
|
item.as_ref().and_then(|i| i.runtime_ticks),
|
||||||
|
source_size,
|
||||||
|
);
|
||||||
|
|
||||||
|
ResolvedVideoDownload {
|
||||||
|
url: repo.get_video_download_url(item_id, quality, media_source_id, codec),
|
||||||
|
expected_bytes,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the download URL for a video, applying the audio-codec policy that
|
/// [`resolve_video_download`] for callers that only need the URL.
|
||||||
/// keeps the saved file playable offline (DR-171).
|
|
||||||
///
|
|
||||||
/// Every video download goes through here rather than calling the builder
|
|
||||||
/// directly: the builder is pure and cannot look the codec up, and a caller that
|
|
||||||
/// forgets to is exactly how the silent downloads shipped.
|
|
||||||
///
|
///
|
||||||
/// TRACES: UR-071 | DR-171
|
/// TRACES: UR-071 | DR-171
|
||||||
pub async fn resolve_video_download_url(
|
pub async fn resolve_video_download_url(
|
||||||
@@ -382,6 +417,7 @@ pub async fn resolve_video_download_url(
|
|||||||
quality: &str,
|
quality: &str,
|
||||||
media_source_id: Option<&str>,
|
media_source_id: Option<&str>,
|
||||||
) -> String {
|
) -> String {
|
||||||
let codec = served_audio_codec(repo, item_id).await;
|
resolve_video_download(repo, item_id, quality, media_source_id)
|
||||||
repo.get_video_download_url(item_id, quality, media_source_id, codec.as_deref())
|
.await
|
||||||
|
.url
|
||||||
}
|
}
|
||||||
|
|||||||
+1145
-489
File diff suppressed because it is too large
Load Diff
@@ -2520,54 +2520,42 @@ impl MediaRepository for OnlineRepository {
|
|||||||
// fine in itself, but it also means a mis-typed cap degrades silently.
|
// fine in itself, but it also means a mis-typed cap degrades silently.
|
||||||
// Note `enableAutoStreamCopy=false` alone does NOT stop a *video* copy;
|
// Note `enableAutoStreamCopy=false` alone does NOT stop a *video* copy;
|
||||||
// video copy is gated by `allowVideoStreamCopy`.
|
// video copy is gated by `allowVideoStreamCopy`.
|
||||||
match quality {
|
match crate::download::presets::download_preset(quality) {
|
||||||
"high" => {
|
Some(preset) => {
|
||||||
params.push("videoBitRate=8000000".to_string());
|
params.push(format!("videoBitRate={}", preset.video_bit_rate));
|
||||||
params.push("maxHeight=1080".to_string());
|
params.push(format!("maxHeight={}", preset.max_height));
|
||||||
params.push("audioBitRate=384000".to_string());
|
params.push(format!("audioBitRate={}", preset.audio_bit_rate));
|
||||||
params.push("videoCodec=h264".to_string());
|
|
||||||
params.push("audioCodec=aac".to_string());
|
|
||||||
params.push("allowVideoStreamCopy=false".to_string());
|
|
||||||
}
|
|
||||||
"medium" => {
|
|
||||||
params.push("videoBitRate=4000000".to_string());
|
|
||||||
params.push("maxHeight=720".to_string());
|
|
||||||
params.push("audioBitRate=256000".to_string());
|
|
||||||
params.push("videoCodec=h264".to_string());
|
|
||||||
params.push("audioCodec=aac".to_string());
|
|
||||||
params.push("allowVideoStreamCopy=false".to_string());
|
|
||||||
}
|
|
||||||
"low" => {
|
|
||||||
params.push("videoBitRate=1500000".to_string());
|
|
||||||
params.push("maxHeight=480".to_string());
|
|
||||||
params.push("audioBitRate=128000".to_string());
|
|
||||||
params.push("videoCodec=h264".to_string());
|
params.push("videoCodec=h264".to_string());
|
||||||
params.push("audioCodec=aac".to_string());
|
params.push("audioCodec=aac".to_string());
|
||||||
params.push("allowVideoStreamCopy=false".to_string());
|
params.push("allowVideoStreamCopy=false".to_string());
|
||||||
}
|
}
|
||||||
// "original" (and any unknown value) → direct, resumable copy —
|
// "original" (and any unknown value) → direct, resumable copy —
|
||||||
// unless the audio in that copy is undecodable where the file will
|
// unless the audio in that copy is undecodable by the renderer that
|
||||||
// be played back. A download is watched with no server in reach, so
|
// will play the file. A download is watched with no server in reach,
|
||||||
// it has to satisfy the same constraint DR-149 applies to streams:
|
// so there is nothing to fall back to: copying a track the renderer
|
||||||
// the webview `<video>` element renders video on both platforms and
|
// cannot decode is what made a downloaded film play offline as
|
||||||
// decodes none of AC-3/E-AC-3/DTS/TrueHD. Copying those bytes to
|
// picture with no sound while the same film had sound when streamed
|
||||||
// disk is what made a downloaded film play offline as picture with
|
// (DR-171).
|
||||||
// no sound while the same film had sound when streamed.
|
|
||||||
//
|
//
|
||||||
// Only the *audio* is re-encoded. `allowVideoStreamCopy` keeps an
|
// "The renderer" is DR-234's per-platform answer, not the webview's
|
||||||
// h264 source's picture byte-for-byte, so "original" still means
|
// list. On Android that is ExoPlayer — the only video renderer there
|
||||||
// original quality, and no bitrate or resolution cap is added. A
|
// since DR-293 removed the webview path — which decodes the device's
|
||||||
// source the webview could not have rendered anyway (HEVC) is
|
// own codecs plus AC-3/E-AC-3/DTS/TrueHD through the FFmpeg
|
||||||
// re-encoded to h264 as a side effect, which is the only form of it
|
// extension. So on Android every `original` download is a
|
||||||
// that would have played.
|
// `Static=true` copy: fast, resumable (HTTP 206), and the real file.
|
||||||
|
// Judging against the webview's list instead turned most films into a
|
||||||
|
// server transcode — generated as it is sent, no `Content-Length`,
|
||||||
|
// `Range` ignored — measured at ~1 MB/s against 14.5 MB/s for the
|
||||||
|
// copy, and restarting from zero on every network blip.
|
||||||
//
|
//
|
||||||
// The cost of the transcode is that the response is no longer
|
// On Linux the webview still draws video, so the renderer's list *is*
|
||||||
// range-resumable, which is exactly why this is decided per item
|
// the webview's and the transcode below still applies there. Only the
|
||||||
// rather than applied to every `original` download.
|
// *audio* is re-encoded: `allowVideoStreamCopy` keeps an h264 source's
|
||||||
|
// picture byte-for-byte, so "original" still means original quality.
|
||||||
//
|
//
|
||||||
// TRACES: UR-071, UR-004 | DR-171 | UT-166
|
// TRACES: UR-071, UR-004 | DR-171, DR-293 | UT-166
|
||||||
_ => match source_audio_codec {
|
None => match source_audio_codec {
|
||||||
Some(codec) if !super::device_profile::webview_can_decode_audio(codec) => {
|
Some(codec) if !super::device_profile::renderer_can_decode_audio(codec) => {
|
||||||
params.push("videoCodec=h264".to_string());
|
params.push("videoCodec=h264".to_string());
|
||||||
params.push("allowVideoStreamCopy=true".to_string());
|
params.push("allowVideoStreamCopy=true".to_string());
|
||||||
params.push("audioCodec=aac".to_string());
|
params.push("audioCodec=aac".to_string());
|
||||||
@@ -3747,13 +3735,17 @@ mod tests {
|
|||||||
/// holds audio this device cannot decode.
|
/// holds audio this device cannot decode.
|
||||||
///
|
///
|
||||||
/// `Static=true` hands back the source bytes untouched, E-AC-3/AC-3/DTS
|
/// `Static=true` hands back the source bytes untouched, E-AC-3/AC-3/DTS
|
||||||
/// track included, and video plays through the webview `<video>` element on
|
/// track included. Where the webview `<video>` element renders video —
|
||||||
/// both platforms — which decodes none of them. Streaming already knows this
|
/// Linux, which is where this test runs — none of them decode. Streaming
|
||||||
/// (DR-149 forces a transcode over the server's own direct-play offer); the
|
/// already knew this (DR-149); the download path did not, so a downloaded
|
||||||
/// download path did not, so a downloaded film played offline as picture with
|
/// film played offline as picture with no sound.
|
||||||
/// no sound while the very same film had sound when streamed.
|
|
||||||
///
|
///
|
||||||
/// TRACES: UR-071, UR-004 | DR-171 | UT-166
|
/// On Android the renderer is ExoPlayer with the FFmpeg extension, which
|
||||||
|
/// decodes all of these, so the same call there yields a `Static=true` copy
|
||||||
|
/// (DR-293). The policy is `renderer_can_decode_audio`; this test pins its
|
||||||
|
/// webview half.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071, UR-004 | DR-171, DR-293 | UT-166
|
||||||
#[test]
|
#[test]
|
||||||
fn test_video_download_url_original_transcodes_undecodable_audio() {
|
fn test_video_download_url_original_transcodes_undecodable_audio() {
|
||||||
let repo = create_test_repository();
|
let repo = create_test_repository();
|
||||||
|
|||||||
@@ -209,21 +209,16 @@ pub async fn fetch_series_episodes(
|
|||||||
) -> Result<Vec<MediaItem>, RepoError> {
|
) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
let children = repo.get_items(series_id, list_options()).await?;
|
let children = repo.get_items(series_id, list_options()).await?;
|
||||||
|
|
||||||
let mut episodes: Vec<MediaItem> = Vec::new();
|
let seasons: Vec<MediaItem> = children
|
||||||
for season in children.items.iter().filter(|i| is_season(i)) {
|
.items
|
||||||
// One failing season must not blank the whole show.
|
.iter()
|
||||||
match repo.get_items(&season.id, list_options()).await {
|
.filter(|i| is_season(i))
|
||||||
Ok(result) => episodes.extend(result.items.into_iter().filter(is_episode)),
|
.cloned()
|
||||||
Err(e) => {
|
.collect();
|
||||||
log::warn!(
|
let mut episodes = gather_season_episodes(&seasons, |season_id| async move {
|
||||||
"[series] season {} of {} failed to load: {:?}",
|
repo.get_items(&season_id, list_options()).await
|
||||||
season.id,
|
})
|
||||||
series_id,
|
.await;
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flat series: the children *are* the episodes.
|
// Flat series: the children *are* the episodes.
|
||||||
if episodes.is_empty() {
|
if episodes.is_empty() {
|
||||||
@@ -234,6 +229,114 @@ pub async fn fetch_series_episodes(
|
|||||||
Ok(episodes)
|
Ok(episodes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every episode in `seasons`, fetched with `fetch_season` (a season id → its
|
||||||
|
/// children), **all at once**.
|
||||||
|
///
|
||||||
|
/// They used to be fetched one after another, so the wait was the sum of every
|
||||||
|
/// season's listing: ~4 s for Frasier's eleven, on a phone whose cache reads
|
||||||
|
/// were slowed by a catalog sync writing in the background. Concurrently it is
|
||||||
|
/// the slowest single season. Order is restored afterwards by
|
||||||
|
/// `sort_series_order`, so completion order does not matter.
|
||||||
|
///
|
||||||
|
/// One failing season must not blank the whole show: its episodes are left out
|
||||||
|
/// and the rest returned.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-062 | DR-295 | UT-264
|
||||||
|
pub async fn gather_season_episodes<F, Fut>(
|
||||||
|
seasons: &[MediaItem],
|
||||||
|
fetch_season: F,
|
||||||
|
) -> Vec<MediaItem>
|
||||||
|
where
|
||||||
|
F: Fn(String) -> Fut,
|
||||||
|
Fut: std::future::Future<Output = Result<super::SearchResult, RepoError>>,
|
||||||
|
{
|
||||||
|
let results = futures_util::future::join_all(
|
||||||
|
seasons.iter().map(|season| fetch_season(season.id.clone())),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut episodes = Vec::new();
|
||||||
|
for (season, result) in seasons.iter().zip(results) {
|
||||||
|
match result {
|
||||||
|
Ok(result) => episodes.extend(result.items.into_iter().filter(is_episode)),
|
||||||
|
Err(e) => log::warn!("[series] season {} failed to load: {:?}", season.id, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
episodes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A series' episodes and the one the viewer is up to, from **one** season
|
||||||
|
/// fan-out.
|
||||||
|
///
|
||||||
|
/// The series page needs both, and asked for them as two commands; each walked
|
||||||
|
/// every season, so every visit listed the show twice. One call, one walk.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-062 | DR-101, DR-295
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SeriesView {
|
||||||
|
pub episodes: Vec<MediaItem>,
|
||||||
|
pub current: Option<MediaItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a [`SeriesView`]: one fan-out, with Next Up and resume fetched
|
||||||
|
/// alongside it rather than after.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-062 | DR-101, DR-295
|
||||||
|
pub async fn resolve_series_view(
|
||||||
|
repo: &dyn MediaRepository,
|
||||||
|
series_id: &str,
|
||||||
|
) -> Result<SeriesView, RepoError> {
|
||||||
|
let (episodes, (next_up, resume)) = with_hints(fetch_series_episodes(repo, series_id), async {
|
||||||
|
futures_util::join!(
|
||||||
|
async {
|
||||||
|
repo.get_next_up_episodes(Some(series_id), Some(1))
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
},
|
||||||
|
async {
|
||||||
|
repo.get_resume_items(Some(series_id), Some(10))
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let episodes = episodes?;
|
||||||
|
let current = pick_current_episode(series_id, &episodes, &next_up, &resume);
|
||||||
|
Ok(SeriesView { episodes, current })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `primary` and `hints` together, but never hold `primary` back for
|
||||||
|
/// `hints`: once `primary` is ready, the hints are taken if they have already
|
||||||
|
/// answered and dropped (`H::default()`) if not.
|
||||||
|
///
|
||||||
|
/// For the series view the primary is the episode list and the hints are Next
|
||||||
|
/// Up and resume, which only refine which episode is "current" — and the
|
||||||
|
/// picker falls back to the episodes' own watch state without them. Waiting
|
||||||
|
/// for them made the episode list wait for the server (2-3 s on a phone)
|
||||||
|
/// although every episode was in the cache in 50 ms. The cache legs of the
|
||||||
|
/// hints usually answer before the episodes do, so they are normally kept.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-062 | DR-101, DR-295
|
||||||
|
async fn with_hints<P, H>(
|
||||||
|
primary: impl std::future::Future<Output = P>,
|
||||||
|
hints: impl std::future::Future<Output = H>,
|
||||||
|
) -> (P, H)
|
||||||
|
where
|
||||||
|
H: Default,
|
||||||
|
{
|
||||||
|
use futures_util::future::{select, Either};
|
||||||
|
use futures_util::FutureExt;
|
||||||
|
|
||||||
|
let primary = std::pin::pin!(primary);
|
||||||
|
let hints = std::pin::pin!(hints);
|
||||||
|
match select(primary, hints).await {
|
||||||
|
Either::Left((primary, hints)) => (primary, hints.now_or_never().unwrap_or_default()),
|
||||||
|
Either::Right((hints, primary)) => (primary.await, hints),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve the current episode, fetching everything the policy needs.
|
/// Resolve the current episode, fetching everything the policy needs.
|
||||||
///
|
///
|
||||||
/// Next Up and resume are best-effort: offline they fail or come back empty, and
|
/// Next Up and resume are best-effort: offline they fail or come back empty, and
|
||||||
@@ -293,6 +396,95 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "More info" on Frasier took ~4 s to list its episodes, twice over: the
|
||||||
|
/// eleven seasons were fetched one after another, so the wait was the *sum*
|
||||||
|
/// of eleven listings. Fetched together it is the slowest one.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-062 | DR-295 | UT-264
|
||||||
|
#[tokio::test]
|
||||||
|
async fn seasons_are_fetched_concurrently_not_one_after_another() {
|
||||||
|
let seasons: Vec<MediaItem> = (1..=10)
|
||||||
|
.map(|n| MediaItem {
|
||||||
|
id: format!("season-{n}"),
|
||||||
|
item_type: "Season".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let episodes = gather_season_episodes(&seasons, |season_id| async move {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
if season_id == "season-3" {
|
||||||
|
// One failing season must not blank the show.
|
||||||
|
return Err(RepoError::Network {
|
||||||
|
message: "gone".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let n: i32 = season_id.trim_start_matches("season-").parse().unwrap();
|
||||||
|
Ok(crate::repository::SearchResult {
|
||||||
|
items: vec![episode(&format!("e{n}"), n, 1)],
|
||||||
|
total_record_count: 1,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let elapsed = started.elapsed();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
elapsed < std::time::Duration::from_millis(400),
|
||||||
|
"ten 100 ms seasons took {elapsed:?} — fetched in sequence, not together"
|
||||||
|
);
|
||||||
|
assert_eq!(episodes.len(), 9, "every season but the failing one");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The episode list must not wait for Next Up or resume.
|
||||||
|
///
|
||||||
|
/// The series page rendered its episodes only once Next Up had come back
|
||||||
|
/// from the server — 2-3 s on a phone while the page's other requests were
|
||||||
|
/// in flight — although every episode was in the cache after 50 ms. Those
|
||||||
|
/// two only refine which episode is "current", and the picker falls back
|
||||||
|
/// to the episodes' own watch state without them.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-062 | DR-101, DR-295
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_episode_list_does_not_wait_for_slow_hints() {
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let (episodes, hints) = with_hints(
|
||||||
|
async {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
|
vec![episode("e1", 1, 1)]
|
||||||
|
},
|
||||||
|
async {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(2000)).await;
|
||||||
|
vec![episode("from-server", 1, 2)]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let elapsed = started.elapsed();
|
||||||
|
|
||||||
|
assert_eq!(episodes.len(), 1);
|
||||||
|
assert!(hints.is_empty(), "late hints are dropped, not waited for");
|
||||||
|
assert!(
|
||||||
|
elapsed < std::time::Duration::from_millis(500),
|
||||||
|
"the episode list waited {elapsed:?} for Next Up / resume"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hints that are already in (a cache answer) are used.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-062 | DR-101, DR-295
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hints_that_answer_first_are_kept() {
|
||||||
|
let (_, hints) = with_hints(
|
||||||
|
async {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
vec![episode("e1", 1, 1)]
|
||||||
|
},
|
||||||
|
async { vec![episode("cached", 1, 2)] },
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(hints.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
fn watched(mut item: MediaItem) -> MediaItem {
|
fn watched(mut item: MediaItem) -> MediaItem {
|
||||||
item.user_data = Some(UserData {
|
item.user_data = Some(UserData {
|
||||||
is_played: Some(true),
|
is_played: Some(true),
|
||||||
|
|||||||
@@ -1,16 +1,31 @@
|
|||||||
//! Database service abstraction layer
|
//! Database service: the single owner of the SQLite database.
|
||||||
//!
|
//!
|
||||||
//! This module provides an async database interface that abstracts away
|
//! Every query in the app goes through [`RusqliteService`], which owns the
|
||||||
//! the underlying database implementation. This makes it easy to:
|
//! connections and hands work to them — callers never touch a `Connection`.
|
||||||
//! - Switch between sync (rusqlite) and async (tokio-rusqlite) implementations
|
//!
|
||||||
//! - Prevent blocking the async runtime with synchronous database calls
|
//! - **Writes** (`execute`, `insert`, `transaction`, …) are sent as jobs to one
|
||||||
//! - Test with different database backends
|
//! dedicated writer thread that owns the read-write connection. SQLite allows
|
||||||
//! - Migrate to other database systems in the future
|
//! one writer at a time anyway; owning it on one thread makes that explicit,
|
||||||
|
//! keeps connection-wide state (pragmas) out of reach of concurrent callers,
|
||||||
|
//! and parks no tokio blocking threads on a mutex while writes queue up.
|
||||||
|
//! - **Reads** (`query_*`) run on a small pool of read-only connections. The
|
||||||
|
//! database is in WAL mode, so readers see the last committed state and never
|
||||||
|
//! wait for the writer — a large catalog-cache transaction no longer stalls
|
||||||
|
//! library pages, thumbnail lookups or settings reads.
|
||||||
|
//!
|
||||||
|
//! A service built with [`RusqliteService::new`] has no reader pool (in-memory
|
||||||
|
//! databases cannot be shared between connections) and routes reads through
|
||||||
|
//! the writer, which is the old single-connection behaviour tests rely on.
|
||||||
|
//!
|
||||||
|
//! See `docs/architecture/08-database-design.md` → "Connection ownership".
|
||||||
|
|
||||||
use crate::utils::lock::MutexSafe;
|
use crate::utils::lock::MutexSafe;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use log::{debug, error};
|
||||||
use rusqlite::{params_from_iter, Connection, Result as SqliteResult, Row};
|
use rusqlite::{params_from_iter, Connection, Result as SqliteResult, Row};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::panic::AssertUnwindSafe;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::sync::{Arc, Condvar, Mutex};
|
||||||
|
|
||||||
/// Database query result type
|
/// Database query result type
|
||||||
pub type DbResult<T> = Result<T, String>;
|
pub type DbResult<T> = Result<T, String>;
|
||||||
@@ -84,8 +99,28 @@ pub trait DatabaseService: Send + Sync {
|
|||||||
F: FnOnce(&mut Transaction) -> DbResult<T> + Send + 'static,
|
F: FnOnce(&mut Transaction) -> DbResult<T> + Send + 'static,
|
||||||
T: Send + 'static;
|
T: Send + 'static;
|
||||||
|
|
||||||
/// Get the row ID of the most recent successful INSERT
|
/// Run a transaction with foreign-key enforcement switched off for its
|
||||||
async fn last_insert_rowid(&self) -> DbResult<i64>;
|
/// duration only.
|
||||||
|
///
|
||||||
|
/// `PRAGMA foreign_keys` is per connection and is a no-op inside a
|
||||||
|
/// transaction, so it has to be flipped around the `BEGIN`/`COMMIT` — and
|
||||||
|
/// all of that must happen as one job on the writer, or any other write
|
||||||
|
/// that got in between would run unchecked too.
|
||||||
|
async fn transaction_without_foreign_keys<F, T>(&self, f: F) -> DbResult<T>
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut Transaction) -> DbResult<T> + Send + 'static,
|
||||||
|
T: Send + 'static;
|
||||||
|
|
||||||
|
/// Execute an INSERT and return the rowid of the row it inserted.
|
||||||
|
///
|
||||||
|
/// The rowid is read in the same job as the insert. Reading it with a
|
||||||
|
/// second call would race every other write, returning someone else's id.
|
||||||
|
async fn insert(&self, query: Query) -> DbResult<i64>;
|
||||||
|
|
||||||
|
/// Queue a write without waiting for it — for best-effort bookkeeping (an
|
||||||
|
/// LRU access time) that must not hold up the caller. It still runs in
|
||||||
|
/// order with every other write; failures are only logged.
|
||||||
|
fn execute_detached(&self, query: Query);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transaction handle for batching multiple operations
|
/// Transaction handle for batching multiple operations
|
||||||
@@ -110,48 +145,183 @@ impl<'a> Transaction<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rusqlite-based database service implementation
|
type Job = Box<dyn FnOnce(&Connection) + Send>;
|
||||||
///
|
|
||||||
/// This implementation wraps synchronous rusqlite operations in tokio::task::spawn_blocking
|
/// The thread that owns the read-write connection. Jobs run one at a time, in
|
||||||
/// to prevent blocking the async runtime.
|
/// the order they were sent; the thread exits when the last service handle
|
||||||
|
/// (and so the last sender) is dropped.
|
||||||
|
struct Writer {
|
||||||
|
jobs: mpsc::Sender<Job>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Writer {
|
||||||
|
fn spawn(conn: Arc<Mutex<Connection>>) -> Self {
|
||||||
|
let (jobs, queue) = mpsc::channel::<Job>();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("db-writer".into())
|
||||||
|
.spawn(move || {
|
||||||
|
for job in queue {
|
||||||
|
// The connection stays behind a mutex only so migrations and
|
||||||
|
// tests can reach it; in the app this thread is its sole user.
|
||||||
|
// `lock_safe` so a poisoned lock is recovered, not fatal.
|
||||||
|
let conn = conn.lock_safe();
|
||||||
|
// A panicking row mapper must not take the owner down with
|
||||||
|
// it: the job's reply channel drops, its caller gets an
|
||||||
|
// error, and the next job runs normally. The guard lives
|
||||||
|
// outside the unwind, so the mutex is not poisoned either.
|
||||||
|
if std::panic::catch_unwind(AssertUnwindSafe(|| job(&conn))).is_err() {
|
||||||
|
error!("[db] a database job panicked; the writer carries on");
|
||||||
|
// Undo whatever connection state the job was midway
|
||||||
|
// through: an open transaction would make the next
|
||||||
|
// job's BEGIN fail, and a job that switched foreign
|
||||||
|
// keys off would leave them off for everyone.
|
||||||
|
if !conn.is_autocommit() {
|
||||||
|
let _ = conn.execute_batch("ROLLBACK");
|
||||||
|
}
|
||||||
|
let _ = conn.execute_batch("PRAGMA foreign_keys = ON");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.expect("failed to spawn the database writer thread");
|
||||||
|
Self { jobs }
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run<T, F>(&self, f: F) -> DbResult<T>
|
||||||
|
where
|
||||||
|
T: Send + 'static,
|
||||||
|
F: FnOnce(&Connection) -> DbResult<T> + Send + 'static,
|
||||||
|
{
|
||||||
|
let (reply, result) = tokio::sync::oneshot::channel();
|
||||||
|
self.jobs
|
||||||
|
.send(Box::new(move |conn| {
|
||||||
|
let _ = reply.send(f(conn));
|
||||||
|
}))
|
||||||
|
.map_err(|_| "database writer has stopped".to_string())?;
|
||||||
|
result
|
||||||
|
.await
|
||||||
|
.map_err(|_| "database job panicked".to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_detached(&self, f: impl FnOnce(&Connection) + Send + 'static) {
|
||||||
|
if self.jobs.send(Box::new(f)).is_err() {
|
||||||
|
debug!("[db] writer stopped; dropped a detached write");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-only connections, checked out one per query. WAL gives each a
|
||||||
|
/// snapshot of the last commit, so they never wait for the writer.
|
||||||
|
struct ReaderPool {
|
||||||
|
idle: Mutex<Vec<Connection>>,
|
||||||
|
returned: Condvar,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReaderPool {
|
||||||
|
/// Blocking: waits for a free connection. Call from `spawn_blocking`.
|
||||||
|
fn run<T>(&self, f: impl FnOnce(&Connection) -> T) -> T {
|
||||||
|
let conn = {
|
||||||
|
let mut idle = self.idle.lock_safe();
|
||||||
|
loop {
|
||||||
|
if let Some(conn) = idle.pop() {
|
||||||
|
break conn;
|
||||||
|
}
|
||||||
|
idle = self
|
||||||
|
.returned
|
||||||
|
.wait(idle)
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Returned on drop, so a panicking mapper does not leak the connection.
|
||||||
|
let checkout = Checkout {
|
||||||
|
pool: self,
|
||||||
|
conn: Some(conn),
|
||||||
|
};
|
||||||
|
f(checkout.conn.as_ref().expect("checked-out connection"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Checkout<'a> {
|
||||||
|
pool: &'a ReaderPool,
|
||||||
|
conn: Option<Connection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Checkout<'_> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some(conn) = self.conn.take() {
|
||||||
|
self.pool.idle.lock_safe().push(conn);
|
||||||
|
self.pool.returned.notify_one();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rusqlite-based database service: a cheap, cloneable handle to the writer
|
||||||
|
/// thread and reader pool. See the module docs.
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct RusqliteService {
|
pub struct RusqliteService {
|
||||||
conn: Arc<Mutex<Connection>>,
|
writer: Arc<Writer>,
|
||||||
|
readers: Option<Arc<ReaderPool>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RusqliteService {
|
impl RusqliteService {
|
||||||
|
/// A service over a single connection: writes *and* reads go through the
|
||||||
|
/// writer thread. Used for in-memory databases, which cannot be shared
|
||||||
|
/// between connections.
|
||||||
|
#[cfg_attr(not(test), allow(dead_code))]
|
||||||
pub fn new(conn: Arc<Mutex<Connection>>) -> Self {
|
pub fn new(conn: Arc<Mutex<Connection>>) -> Self {
|
||||||
Self { conn }
|
Self {
|
||||||
|
writer: Arc::new(Writer::spawn(conn)),
|
||||||
|
readers: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A service whose reads run on `readers` — read-only connections to the
|
||||||
|
/// same (file-backed, WAL-mode) database — alongside the writer.
|
||||||
|
pub fn with_readers(conn: Arc<Mutex<Connection>>, readers: Vec<Connection>) -> Self {
|
||||||
|
let readers = (!readers.is_empty()).then(|| {
|
||||||
|
Arc::new(ReaderPool {
|
||||||
|
idle: Mutex::new(readers),
|
||||||
|
returned: Condvar::new(),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
Self {
|
||||||
|
writer: Arc::new(Writer::spawn(conn)),
|
||||||
|
readers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read<T, F>(&self, f: F) -> DbResult<T>
|
||||||
|
where
|
||||||
|
T: Send + 'static,
|
||||||
|
F: FnOnce(&Connection) -> DbResult<T> + Send + 'static,
|
||||||
|
{
|
||||||
|
match &self.readers {
|
||||||
|
Some(pool) => {
|
||||||
|
let pool = Arc::clone(pool);
|
||||||
|
tokio::task::spawn_blocking(move || pool.run(f))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Task join error: {}", e))?
|
||||||
|
}
|
||||||
|
None => self.writer.run(f).await,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl DatabaseService for RusqliteService {
|
impl DatabaseService for RusqliteService {
|
||||||
async fn execute(&self, query: Query) -> DbResult<usize> {
|
async fn execute(&self, query: Query) -> DbResult<usize> {
|
||||||
let conn = Arc::clone(&self.conn);
|
self.writer
|
||||||
tokio::task::spawn_blocking(move || {
|
.run(move |conn| execute_query(conn, query))
|
||||||
// `lock_safe`, not `lock`: this is the busiest lock in the app and a
|
|
||||||
// panic under the guard would otherwise poison it, failing every
|
|
||||||
// later query with "poisoned lock" until the process restarts.
|
|
||||||
let conn = conn.lock_safe();
|
|
||||||
execute_query(&conn, query)
|
|
||||||
})
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Task join error: {}", e))?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute_batch(&self, sql: &str) -> DbResult<()> {
|
async fn execute_batch(&self, sql: &str) -> DbResult<()> {
|
||||||
let conn = Arc::clone(&self.conn);
|
|
||||||
let sql = sql.to_string();
|
let sql = sql.to_string();
|
||||||
tokio::task::spawn_blocking(move || {
|
self.writer
|
||||||
// `lock_safe`, not `lock`: this is the busiest lock in the app and a
|
.run(move |conn| {
|
||||||
// panic under the guard would otherwise poison it, failing every
|
|
||||||
// later query with "poisoned lock" until the process restarts.
|
|
||||||
let conn = conn.lock_safe();
|
|
||||||
conn.execute_batch(&sql)
|
conn.execute_batch(&sql)
|
||||||
.map_err(|e| format!("Execute batch failed: {}", e))
|
.map_err(|e| format!("Execute batch failed: {}", e))
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Task join error: {}", e))?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn query_one<T, F>(&self, query: Query, mapper: F) -> DbResult<T>
|
async fn query_one<T, F>(&self, query: Query, mapper: F) -> DbResult<T>
|
||||||
@@ -159,16 +329,7 @@ impl DatabaseService for RusqliteService {
|
|||||||
T: Send + 'static,
|
T: Send + 'static,
|
||||||
F: Fn(&Row) -> SqliteResult<T> + Send + 'static,
|
F: Fn(&Row) -> SqliteResult<T> + Send + 'static,
|
||||||
{
|
{
|
||||||
let conn = Arc::clone(&self.conn);
|
self.read(move |conn| query_one(conn, query, mapper)).await
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
// `lock_safe`, not `lock`: this is the busiest lock in the app and a
|
|
||||||
// panic under the guard would otherwise poison it, failing every
|
|
||||||
// later query with "poisoned lock" until the process restarts.
|
|
||||||
let conn = conn.lock_safe();
|
|
||||||
query_one(&conn, query, mapper)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Task join error: {}", e))?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn query_optional<T, F>(&self, query: Query, mapper: F) -> DbResult<Option<T>>
|
async fn query_optional<T, F>(&self, query: Query, mapper: F) -> DbResult<Option<T>>
|
||||||
@@ -176,16 +337,8 @@ impl DatabaseService for RusqliteService {
|
|||||||
T: Send + 'static,
|
T: Send + 'static,
|
||||||
F: Fn(&Row) -> SqliteResult<T> + Send + 'static,
|
F: Fn(&Row) -> SqliteResult<T> + Send + 'static,
|
||||||
{
|
{
|
||||||
let conn = Arc::clone(&self.conn);
|
self.read(move |conn| query_optional(conn, query, mapper))
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
// `lock_safe`, not `lock`: this is the busiest lock in the app and a
|
|
||||||
// panic under the guard would otherwise poison it, failing every
|
|
||||||
// later query with "poisoned lock" until the process restarts.
|
|
||||||
let conn = conn.lock_safe();
|
|
||||||
query_optional(&conn, query, mapper)
|
|
||||||
})
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Task join error: {}", e))?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn query_many<T, F>(&self, query: Query, mapper: F) -> DbResult<Vec<T>>
|
async fn query_many<T, F>(&self, query: Query, mapper: F) -> DbResult<Vec<T>>
|
||||||
@@ -193,16 +346,7 @@ impl DatabaseService for RusqliteService {
|
|||||||
T: Send + 'static,
|
T: Send + 'static,
|
||||||
F: Fn(&Row) -> SqliteResult<T> + Send + 'static,
|
F: Fn(&Row) -> SqliteResult<T> + Send + 'static,
|
||||||
{
|
{
|
||||||
let conn = Arc::clone(&self.conn);
|
self.read(move |conn| query_many(conn, query, mapper)).await
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
// `lock_safe`, not `lock`: this is the busiest lock in the app and a
|
|
||||||
// panic under the guard would otherwise poison it, failing every
|
|
||||||
// later query with "poisoned lock" until the process restarts.
|
|
||||||
let conn = conn.lock_safe();
|
|
||||||
query_many(&conn, query, mapper)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Task join error: {}", e))?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn transaction<F, T>(&self, f: F) -> DbResult<T>
|
async fn transaction<F, T>(&self, f: F) -> DbResult<T>
|
||||||
@@ -210,20 +354,55 @@ impl DatabaseService for RusqliteService {
|
|||||||
F: FnOnce(&mut Transaction) -> DbResult<T> + Send + 'static,
|
F: FnOnce(&mut Transaction) -> DbResult<T> + Send + 'static,
|
||||||
T: Send + 'static,
|
T: Send + 'static,
|
||||||
{
|
{
|
||||||
let conn = Arc::clone(&self.conn);
|
self.writer.run(move |conn| run_transaction(conn, f)).await
|
||||||
tokio::task::spawn_blocking(move || {
|
}
|
||||||
// `lock_safe`, not `lock`: this is the busiest lock in the app and a
|
|
||||||
// panic under the guard would otherwise poison it, failing every
|
|
||||||
// later query with "poisoned lock" until the process restarts.
|
|
||||||
let conn = conn.lock_safe();
|
|
||||||
|
|
||||||
|
async fn transaction_without_foreign_keys<F, T>(&self, f: F) -> DbResult<T>
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut Transaction) -> DbResult<T> + Send + 'static,
|
||||||
|
T: Send + 'static,
|
||||||
|
{
|
||||||
|
self.writer
|
||||||
|
.run(move |conn| {
|
||||||
|
conn.execute_batch("PRAGMA foreign_keys = OFF")
|
||||||
|
.map_err(|e| format!("Failed to disable foreign keys: {}", e))?;
|
||||||
|
let result = run_transaction(conn, f);
|
||||||
|
// Always restored, whatever the transaction did.
|
||||||
|
if let Err(e) = conn.execute_batch("PRAGMA foreign_keys = ON") {
|
||||||
|
error!("[db] failed to re-enable foreign keys: {}", e);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn insert(&self, query: Query) -> DbResult<i64> {
|
||||||
|
self.writer
|
||||||
|
.run(move |conn| {
|
||||||
|
execute_query(conn, query)?;
|
||||||
|
Ok(conn.last_insert_rowid())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_detached(&self, query: Query) {
|
||||||
|
self.writer.run_detached(move |conn| {
|
||||||
|
if let Err(e) = execute_query(conn, query) {
|
||||||
|
debug!("[db] detached write failed: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_transaction<F, T>(conn: &Connection, f: F) -> DbResult<T>
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut Transaction) -> DbResult<T>,
|
||||||
|
{
|
||||||
conn.execute("BEGIN TRANSACTION", [])
|
conn.execute("BEGIN TRANSACTION", [])
|
||||||
.map_err(|e| format!("Failed to begin transaction: {}", e))?;
|
.map_err(|e| format!("Failed to begin transaction: {}", e))?;
|
||||||
|
|
||||||
let mut transaction = Transaction::new(&conn);
|
let mut transaction = Transaction::new(conn);
|
||||||
let result = f(&mut transaction);
|
match f(&mut transaction) {
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(value) => {
|
Ok(value) => {
|
||||||
conn.execute("COMMIT", [])
|
conn.execute("COMMIT", [])
|
||||||
.map_err(|e| format!("Failed to commit transaction: {}", e))?;
|
.map_err(|e| format!("Failed to commit transaction: {}", e))?;
|
||||||
@@ -235,23 +414,6 @@ impl DatabaseService for RusqliteService {
|
|||||||
Err(e)
|
Err(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Task join error: {}", e))?
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn last_insert_rowid(&self) -> DbResult<i64> {
|
|
||||||
let conn = Arc::clone(&self.conn);
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
// `lock_safe`, not `lock`: this is the busiest lock in the app and a
|
|
||||||
// panic under the guard would otherwise poison it, failing every
|
|
||||||
// later query with "poisoned lock" until the process restarts.
|
|
||||||
let conn = conn.lock_safe();
|
|
||||||
Ok(conn.last_insert_rowid())
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Task join error: {}", e))?
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions for executing queries synchronously
|
// Helper functions for executing queries synchronously
|
||||||
|
|||||||
+327
-26
@@ -17,9 +17,27 @@ use rusqlite::{Connection, Result as SqliteResult};
|
|||||||
pub use db_service::{DatabaseService, RusqliteService};
|
pub use db_service::{DatabaseService, RusqliteService};
|
||||||
use schema::MIGRATIONS;
|
use schema::MIGRATIONS;
|
||||||
|
|
||||||
/// Database connection wrapper with thread-safe access
|
/// How long a connection retries a locked database before erroring — covers a
|
||||||
|
/// reader meeting a WAL checkpoint, or the writer meeting a reader's snapshot.
|
||||||
|
const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// How many read-only connections serve queries alongside the writer. Reads
|
||||||
|
/// are short; a few cover a library page's parallel fetches plus background
|
||||||
|
/// work without holding many file handles.
|
||||||
|
const READER_CONNECTIONS: usize = 3;
|
||||||
|
|
||||||
|
/// The database: opened once at startup and owned for the life of the app.
|
||||||
|
///
|
||||||
|
/// All access goes through [`Database::service`], which hands out clones of one
|
||||||
|
/// [`RusqliteService`] — a writer thread plus a pool of read-only connections
|
||||||
|
/// (see `db_service`). Nothing else opens the database file.
|
||||||
pub struct Database {
|
pub struct Database {
|
||||||
|
/// The read-write connection. Owned by the service's writer thread; kept
|
||||||
|
/// here only for migrations (which run before that thread starts taking
|
||||||
|
/// work) and for tests.
|
||||||
|
#[cfg_attr(not(test), allow(dead_code))]
|
||||||
conn: Arc<Mutex<Connection>>,
|
conn: Arc<Mutex<Connection>>,
|
||||||
|
service: RusqliteService,
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,18 +54,48 @@ impl Database {
|
|||||||
// Enable foreign keys
|
// Enable foreign keys
|
||||||
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
||||||
|
|
||||||
// Enable WAL mode for better concurrent access
|
// WAL lets the reader connections run alongside the writer.
|
||||||
conn.execute_batch("PRAGMA journal_mode = WAL;")?;
|
conn.execute_batch("PRAGMA journal_mode = WAL;")?;
|
||||||
|
// In WAL mode NORMAL is corruption-safe and skips the fsync FULL pays on
|
||||||
|
// every commit (a power cut can lose the last commits, an app crash
|
||||||
|
// cannot). On Android flash that fsync dominated every small write.
|
||||||
|
conn.execute_batch("PRAGMA synchronous = NORMAL;")?;
|
||||||
|
conn.busy_timeout(BUSY_TIMEOUT)?;
|
||||||
|
|
||||||
let db = Self {
|
let conn = Arc::new(Mutex::new(conn));
|
||||||
conn: Arc::new(Mutex::new(conn)),
|
Self::migrate_connection(&conn, MIGRATIONS)?;
|
||||||
|
|
||||||
|
// Planner statistics. Without them SQLite guesses between indexes, and
|
||||||
|
// guessed badly for the listing query (see 08-database-design.md →
|
||||||
|
// "Listing query shape"). `optimize` only analyses what is missing or
|
||||||
|
// stale; `analysis_limit` bounds each table's scan so this stays in the
|
||||||
|
// milliseconds on a large catalogue. Failure is not fatal.
|
||||||
|
if let Err(e) = conn
|
||||||
|
.lock_safe()
|
||||||
|
.execute_batch("PRAGMA analysis_limit = 400; PRAGMA optimize = 0x10002;")
|
||||||
|
{
|
||||||
|
error!("PRAGMA optimize failed: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Readers open after migrations, so they only ever see the final schema.
|
||||||
|
let readers = (0..READER_CONNECTIONS)
|
||||||
|
.map(|_| Self::open_reader(path))
|
||||||
|
.collect::<SqliteResult<Vec<_>>>()?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
service: RusqliteService::with_readers(Arc::clone(&conn), readers),
|
||||||
|
conn,
|
||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
};
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Run migrations
|
/// A connection that can only read. `query_only` makes an accidental write
|
||||||
db.migrate()?;
|
/// routed to the pool fail loudly instead of racing the writer.
|
||||||
|
fn open_reader(path: &PathBuf) -> SqliteResult<Connection> {
|
||||||
Ok(db)
|
let conn = Connection::open(path)?;
|
||||||
|
conn.busy_timeout(BUSY_TIMEOUT)?;
|
||||||
|
conn.execute_batch("PRAGMA query_only = ON;")?;
|
||||||
|
Ok(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open an in-memory database (for testing)
|
/// Open an in-memory database (for testing)
|
||||||
@@ -58,15 +106,16 @@ impl Database {
|
|||||||
// Enable foreign keys
|
// Enable foreign keys
|
||||||
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
||||||
|
|
||||||
let db = Self {
|
let conn = Arc::new(Mutex::new(conn));
|
||||||
conn: Arc::new(Mutex::new(conn)),
|
Self::migrate_connection(&conn, MIGRATIONS)?;
|
||||||
|
|
||||||
|
// An in-memory database cannot be shared between connections, so this
|
||||||
|
// one has no reader pool: reads go through the writer.
|
||||||
|
Ok(Self {
|
||||||
|
service: RusqliteService::new(Arc::clone(&conn)),
|
||||||
|
conn,
|
||||||
path: PathBuf::from(":memory:"),
|
path: PathBuf::from(":memory:"),
|
||||||
};
|
})
|
||||||
|
|
||||||
// Run migrations
|
|
||||||
db.migrate()?;
|
|
||||||
|
|
||||||
Ok(db)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get connection (for testing)
|
/// Get connection (for testing)
|
||||||
@@ -75,11 +124,19 @@ impl Database {
|
|||||||
Arc::clone(&self.conn)
|
Arc::clone(&self.conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run all pending migrations.
|
/// Re-run all migrations against an open database (tests only; `open` runs
|
||||||
|
/// them before the service starts).
|
||||||
|
#[cfg(test)]
|
||||||
pub fn migrate(&self) -> SqliteResult<()> {
|
pub fn migrate(&self) -> SqliteResult<()> {
|
||||||
self.migrate_with(MIGRATIONS)
|
self.migrate_with(MIGRATIONS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Test seam: inject a failing migration. See [`Self::migrate_connection`].
|
||||||
|
#[cfg(test)]
|
||||||
|
fn migrate_with(&self, migrations: &[(&str, &str)]) -> SqliteResult<()> {
|
||||||
|
Self::migrate_connection(&self.conn, migrations)
|
||||||
|
}
|
||||||
|
|
||||||
/// Apply `migrations` in order, skipping ones `_migrations` already records.
|
/// Apply `migrations` in order, skipping ones `_migrations` already records.
|
||||||
///
|
///
|
||||||
/// **Each migration is one transaction, and the `_migrations` row is written
|
/// **Each migration is one transaction, and the `_migrations` row is written
|
||||||
@@ -96,12 +153,16 @@ impl Database {
|
|||||||
/// Every migration is pure DDL/DML, which SQLite runs transactionally — a
|
/// Every migration is pure DDL/DML, which SQLite runs transactionally — a
|
||||||
/// `PRAGMA` or `VACUUM` added to one would not roll back and must not be.
|
/// `PRAGMA` or `VACUUM` added to one would not roll back and must not be.
|
||||||
///
|
///
|
||||||
/// Split out from [`Self::migrate`] so tests can inject a failing migration.
|
/// Runs on the bare connection, before the writer thread takes it, so
|
||||||
|
/// tests can also inject a failing migration through `migrate_with`.
|
||||||
///
|
///
|
||||||
/// TRACES: UR-002 | DR-012 | UT-014
|
/// TRACES: UR-002 | DR-012 | UT-014
|
||||||
fn migrate_with(&self, migrations: &[(&str, &str)]) -> SqliteResult<()> {
|
fn migrate_connection(
|
||||||
|
conn: &Mutex<Connection>,
|
||||||
|
migrations: &[(&str, &str)],
|
||||||
|
) -> SqliteResult<()> {
|
||||||
info!("Starting database migrations...");
|
info!("Starting database migrations...");
|
||||||
let conn = self.conn.lock_safe();
|
let conn = conn.lock_safe();
|
||||||
|
|
||||||
// Create migrations table if it doesn't exist
|
// Create migrations table if it doesn't exist
|
||||||
debug!("Creating _migrations table if it doesn't exist...");
|
debug!("Creating _migrations table if it doesn't exist...");
|
||||||
@@ -160,12 +221,10 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a database service for async-safe operations
|
/// A handle to the database service. Cheap: every call returns a clone of
|
||||||
///
|
/// the same writer thread and reader pool.
|
||||||
/// This wraps all blocking database operations in spawn_blocking to prevent
|
|
||||||
/// freezing the async runtime.
|
|
||||||
pub fn service(&self) -> RusqliteService {
|
pub fn service(&self) -> RusqliteService {
|
||||||
RusqliteService::new(Arc::clone(&self.conn))
|
self.service.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the database file path
|
/// Get the database file path
|
||||||
@@ -880,4 +939,246 @@ mod tests {
|
|||||||
assert_eq!(user_id, "user2");
|
assert_eq!(user_id, "user2");
|
||||||
assert_eq!(username, "recent_user");
|
assert_eq!(username, "recent_user");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A read must not queue behind a long write.
|
||||||
|
///
|
||||||
|
/// The database is in WAL mode precisely so readers can run alongside a
|
||||||
|
/// writer, but every query used to go through one connection behind one
|
||||||
|
/// mutex — so a big catalog-cache transaction stalled every library page,
|
||||||
|
/// thumbnail lookup and settings read in the app until it committed.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-002 | DR-012 | UT-014
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
async fn reads_do_not_wait_for_an_in_flight_write() {
|
||||||
|
use crate::storage::db_service::{DatabaseService, Query};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let db = Database::open(&dir.path().join("jellytau.db")).unwrap();
|
||||||
|
let service = db.service();
|
||||||
|
|
||||||
|
let writer = service.clone();
|
||||||
|
let write = tokio::spawn(async move {
|
||||||
|
writer
|
||||||
|
.transaction(|_tx| {
|
||||||
|
std::thread::sleep(Duration::from_millis(600));
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
// Let the write take the connection first.
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
|
||||||
|
let started = Instant::now();
|
||||||
|
let servers: i64 = service
|
||||||
|
.query_one(Query::new("SELECT COUNT(*) FROM servers"), |r| r.get(0))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let waited = started.elapsed();
|
||||||
|
|
||||||
|
assert_eq!(servers, 0);
|
||||||
|
assert!(
|
||||||
|
waited < Duration::from_millis(250),
|
||||||
|
"a read waited {waited:?} for an unrelated write to commit"
|
||||||
|
);
|
||||||
|
write.await.unwrap().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commit cost: in WAL mode `synchronous = NORMAL` is corruption-safe and
|
||||||
|
/// skips the per-commit fsync that FULL (the default) pays — on Android
|
||||||
|
/// flash that is the dominant cost of every small write. A busy timeout
|
||||||
|
/// lets the reader connections ride out a checkpoint instead of failing.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-002 | DR-012 | UT-014
|
||||||
|
#[test]
|
||||||
|
fn open_configures_wal_for_interactive_use() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let db = Database::open(&dir.path().join("jellytau.db")).unwrap();
|
||||||
|
let conn = db.connection();
|
||||||
|
let conn = conn.lock_safe();
|
||||||
|
|
||||||
|
let mode: String = conn
|
||||||
|
.query_row("PRAGMA journal_mode", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
let synchronous: i64 = conn
|
||||||
|
.query_row("PRAGMA synchronous", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
let busy_timeout: i64 = conn
|
||||||
|
.query_row("PRAGMA busy_timeout", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(mode, "wal");
|
||||||
|
assert_eq!(synchronous, 1, "expected synchronous = NORMAL");
|
||||||
|
assert!(busy_timeout > 0, "expected a busy timeout");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The planner gets statistics: the app used to never run `ANALYZE`, so
|
||||||
|
/// SQLite guessed between indexes — and for the listing query guessed the
|
||||||
|
/// `server_id` index, which every row shares, turning an index lookup into
|
||||||
|
/// a walk of the whole catalogue. `PRAGMA optimize` at open refreshes
|
||||||
|
/// whatever statistics are missing or stale, bounded by `analysis_limit`.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-002 | DR-012 | UT-014
|
||||||
|
#[test]
|
||||||
|
fn open_gives_the_planner_statistics() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("jellytau.db");
|
||||||
|
{
|
||||||
|
let db = Database::open(&path).unwrap();
|
||||||
|
let conn = db.connection();
|
||||||
|
let conn = conn.lock_safe();
|
||||||
|
conn.execute_batch(
|
||||||
|
"INSERT INTO servers (id, name, url) VALUES ('s', 'S', 'http://s');",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
for i in 0..2000 {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO items (id, server_id, name, item_type) VALUES (?1, 's', 'n', 'Audio')",
|
||||||
|
[format!("i{i}")],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let db = Database::open(&path).unwrap();
|
||||||
|
let conn = db.connection();
|
||||||
|
let conn = conn.lock_safe();
|
||||||
|
let analysed: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE name = 'sqlite_stat1'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(analysed, 1, "the planner has no statistics");
|
||||||
|
let items_stats: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_stat1 WHERE tbl = 'items'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(items_stats > 0, "no statistics for items");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a phone-sized catalogue to `$JELLYTAU_BENCH_DB` for timing
|
||||||
|
/// queries with the `sqlite3` CLI. Not a test; run explicitly with
|
||||||
|
/// `--ignored`.
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
fn write_bench_database() {
|
||||||
|
let Ok(path) = std::env::var("JELLYTAU_BENCH_DB") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let db = Database::open(&PathBuf::from(&path)).unwrap();
|
||||||
|
let conn = db.connection();
|
||||||
|
let conn = conn.lock_safe();
|
||||||
|
conn.execute_batch(
|
||||||
|
"BEGIN;
|
||||||
|
INSERT INTO servers (id, name, url) VALUES ('srv', 'S', 'http://s');
|
||||||
|
INSERT INTO users (id, server_id, username) VALUES ('u', 'srv', 'u');
|
||||||
|
INSERT INTO libraries (id, server_id, name, collection_type) VALUES ('tv', 'srv', 'TV', 'tvshows');
|
||||||
|
INSERT INTO libraries (id, server_id, name, collection_type) VALUES ('music', 'srv', 'Music', 'music');",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let now = "2026-09-23T00:00:00Z";
|
||||||
|
let mut item = conn
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO items (id, server_id, library_id, parent_id, name, sort_name, item_type,
|
||||||
|
series_id, season_id, album_id, synced_at)
|
||||||
|
VALUES (?1, 'srv', ?2, ?3, ?4, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let none: Option<String> = None;
|
||||||
|
for s in 0..300 {
|
||||||
|
let series = format!("series-{s}");
|
||||||
|
item.execute(rusqlite::params![
|
||||||
|
series,
|
||||||
|
"tv",
|
||||||
|
none,
|
||||||
|
format!("Show {s}"),
|
||||||
|
"Series",
|
||||||
|
none,
|
||||||
|
none,
|
||||||
|
none,
|
||||||
|
now
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
for n in 0..11 {
|
||||||
|
let season = format!("{series}-s{n}");
|
||||||
|
item.execute(rusqlite::params![
|
||||||
|
season,
|
||||||
|
"tv",
|
||||||
|
series,
|
||||||
|
format!("Season {n}"),
|
||||||
|
"Season",
|
||||||
|
series,
|
||||||
|
none,
|
||||||
|
none,
|
||||||
|
now
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
for e in 0..24 {
|
||||||
|
let ep = format!("{season}-e{e}");
|
||||||
|
item.execute(rusqlite::params![
|
||||||
|
ep,
|
||||||
|
"tv",
|
||||||
|
season,
|
||||||
|
format!("A Title {e}"),
|
||||||
|
"Episode",
|
||||||
|
series,
|
||||||
|
season,
|
||||||
|
none,
|
||||||
|
now
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO user_data (user_id, item_id, playback_position_ticks) VALUES ('u', ?1, 5)",
|
||||||
|
[&ep],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for a in 0..2000 {
|
||||||
|
let album = format!("album-{a}");
|
||||||
|
item.execute(rusqlite::params![
|
||||||
|
album,
|
||||||
|
"music",
|
||||||
|
none,
|
||||||
|
format!("Album {a}"),
|
||||||
|
"MusicAlbum",
|
||||||
|
none,
|
||||||
|
none,
|
||||||
|
none,
|
||||||
|
now
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
for t in 0..12 {
|
||||||
|
item.execute(rusqlite::params![
|
||||||
|
format!("{album}-t{t}"),
|
||||||
|
"music",
|
||||||
|
album,
|
||||||
|
format!("Track {t}"),
|
||||||
|
"Audio",
|
||||||
|
none,
|
||||||
|
none,
|
||||||
|
album,
|
||||||
|
now
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for d in 0..400 {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO downloads (item_id, user_id, file_path, status) VALUES (?1, 'u', '/x', 'completed')",
|
||||||
|
[format!("series-{}-s1-e{}", d % 300, d % 24)],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
drop(item);
|
||||||
|
// No ANALYZE: the app never runs it, so the planner works without stats.
|
||||||
|
conn.execute_batch("COMMIT;").unwrap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ pub const MIGRATIONS: &[(&str, &str)] = &[
|
|||||||
("024_multi_user_profiles", MIGRATION_024),
|
("024_multi_user_profiles", MIGRATION_024),
|
||||||
("025_backfill_item_library_id", MIGRATION_025),
|
("025_backfill_item_library_id", MIGRATION_025),
|
||||||
("026_server_catalog_generation", MIGRATION_026),
|
("026_server_catalog_generation", MIGRATION_026),
|
||||||
|
("027_items_container_id", MIGRATION_027),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Initial schema migration
|
/// Initial schema migration
|
||||||
@@ -941,6 +942,169 @@ const MIGRATION_026: &str = r#"
|
|||||||
ALTER TABLE servers ADD COLUMN catalog_generation TEXT;
|
ALTER TABLE servers ADD COLUMN catalog_generation TEXT;
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
/// One canonical "which container lists this item" link, plus an index that
|
||||||
|
/// serves a listing in display order.
|
||||||
|
///
|
||||||
|
/// Jellyfin's `ParentId` is the *storage* parent, not the logical one: in a
|
||||||
|
/// series without season folders an episode's `ParentId` is the series while
|
||||||
|
/// its `SeasonId` names a virtual season, and a cached episode may arrive
|
||||||
|
/// without its season row at all. So listings matched children on four
|
||||||
|
/// columns at once (`parent_id`, `album_id`, `season_id`, `series_id`). That
|
||||||
|
/// was slow — the `OR` defeated the planner into walking the whole table — and
|
||||||
|
/// wrong: every episode carries its series id, so a series answered with its
|
||||||
|
/// seasons *and* all their episodes.
|
||||||
|
///
|
||||||
|
/// `container_id` resolves the logical container once, by rule: an episode
|
||||||
|
/// belongs to its season (else its series, else its parent), a season to its
|
||||||
|
/// series, a track to its album, anything else to its parent. It is a VIRTUAL
|
||||||
|
/// generated column, so every write path — cache, downloads, catalog crawl —
|
||||||
|
/// is covered without touching any of them, and it cannot drift from the
|
||||||
|
/// columns it is computed from. The index covers the listing's
|
||||||
|
/// `ORDER BY sort_name, name` (`sort_name` is usually NULL in the cache).
|
||||||
|
///
|
||||||
|
/// The placeholders keep offline navigation intact: an episode whose season
|
||||||
|
/// or series row was never cached used to surface directly under the series
|
||||||
|
/// through the `series_id` match. Now it lists under its season, so the season
|
||||||
|
/// (and series, and a track's album) must exist. They are built from the
|
||||||
|
/// names the child rows already carry, with `synced_at` NULL — they only show
|
||||||
|
/// when a download makes them available, and a real row from the server
|
||||||
|
/// replaces them wholesale (`save_to_cache` upserts every field).
|
||||||
|
///
|
||||||
|
/// TRACES: UR-002, UR-007 | DR-013
|
||||||
|
const MIGRATION_027: &str = r#"
|
||||||
|
ALTER TABLE items ADD COLUMN container_id TEXT GENERATED ALWAYS AS (
|
||||||
|
CASE item_type
|
||||||
|
WHEN 'Episode' THEN COALESCE(season_id, series_id, parent_id)
|
||||||
|
WHEN 'Season' THEN COALESCE(series_id, parent_id)
|
||||||
|
WHEN 'Audio' THEN COALESCE(album_id, parent_id)
|
||||||
|
ELSE parent_id
|
||||||
|
END
|
||||||
|
) VIRTUAL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_items_container ON items(container_id, sort_name, name);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO items (id, server_id, library_id, name, item_type, is_folder, series_id, series_name)
|
||||||
|
SELECT season_id, server_id, MAX(library_id), COALESCE(MAX(season_name), 'Season'), 'Season', 1,
|
||||||
|
MAX(series_id), MAX(series_name)
|
||||||
|
FROM items
|
||||||
|
WHERE item_type = 'Episode' AND season_id IS NOT NULL
|
||||||
|
GROUP BY season_id;
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO items (id, server_id, library_id, name, item_type, is_folder)
|
||||||
|
SELECT series_id, server_id, MAX(library_id), COALESCE(MAX(series_name), 'Series'), 'Series', 1
|
||||||
|
FROM items
|
||||||
|
WHERE item_type IN ('Episode', 'Season') AND series_id IS NOT NULL
|
||||||
|
GROUP BY series_id;
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO items (id, server_id, library_id, name, item_type, is_folder, album_artist)
|
||||||
|
SELECT album_id, server_id, MAX(library_id), COALESCE(MAX(album_name), 'Album'), 'MusicAlbum', 1,
|
||||||
|
MAX(album_artist)
|
||||||
|
FROM items
|
||||||
|
WHERE item_type = 'Audio' AND album_id IS NOT NULL
|
||||||
|
GROUP BY album_id;
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod migration_027_tests {
|
||||||
|
use super::*;
|
||||||
|
use rusqlite::Connection;
|
||||||
|
|
||||||
|
fn pre_027_db() -> Connection {
|
||||||
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
|
let upto = MIGRATIONS
|
||||||
|
.iter()
|
||||||
|
.position(|(name, _)| *name == "027_items_container_id")
|
||||||
|
.expect("migration 027 must be registered");
|
||||||
|
for (_, sql) in &MIGRATIONS[..upto] {
|
||||||
|
conn.execute_batch(sql).unwrap();
|
||||||
|
}
|
||||||
|
conn.execute_batch(
|
||||||
|
"INSERT INTO servers (id, name, url) VALUES ('s', 'S', 'http://s');
|
||||||
|
-- An episode cached without its season or series rows.
|
||||||
|
INSERT INTO items (id, server_id, name, item_type, parent_id, season_id, season_name,
|
||||||
|
series_id, series_name, library_id)
|
||||||
|
VALUES ('ep', 's', 'Pilot', 'Episode', NULL, 'season', 'Season 1',
|
||||||
|
'show', 'Show', NULL);
|
||||||
|
-- A track cached without its album.
|
||||||
|
INSERT INTO items (id, server_id, name, item_type, album_id, album_name, album_artist)
|
||||||
|
VALUES ('trk', 's', 'Song', 'Audio', 'alb', 'Record', 'Band');
|
||||||
|
-- A folder child: its container is just its parent.
|
||||||
|
INSERT INTO items (id, server_id, name, item_type) VALUES ('box', 's', 'Box', 'BoxSet');
|
||||||
|
INSERT INTO items (id, server_id, name, item_type, parent_id)
|
||||||
|
VALUES ('film', 's', 'Film', 'Movie', 'box');",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn
|
||||||
|
}
|
||||||
|
|
||||||
|
fn container(conn: &Connection, id: &str) -> Option<String> {
|
||||||
|
conn.query_row("SELECT container_id FROM items WHERE id = ?1", [id], |r| {
|
||||||
|
r.get(0)
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: UR-002, UR-007 | DR-013
|
||||||
|
#[test]
|
||||||
|
fn every_item_resolves_to_its_logical_container() {
|
||||||
|
let conn = pre_027_db();
|
||||||
|
conn.execute_batch(MIGRATION_027).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(container(&conn, "ep").as_deref(), Some("season"));
|
||||||
|
assert_eq!(container(&conn, "season").as_deref(), Some("show"));
|
||||||
|
assert_eq!(container(&conn, "trk").as_deref(), Some("alb"));
|
||||||
|
assert_eq!(container(&conn, "film").as_deref(), Some("box"));
|
||||||
|
assert_eq!(container(&conn, "show"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Containers that were never cached get placeholders named from their
|
||||||
|
/// children, so an offline episode is still reachable series → season.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-002, UR-007 | DR-013
|
||||||
|
#[test]
|
||||||
|
fn missing_containers_get_named_placeholders() {
|
||||||
|
let conn = pre_027_db();
|
||||||
|
conn.execute_batch(MIGRATION_027).unwrap();
|
||||||
|
|
||||||
|
let row = |id: &str| -> (String, String, Option<String>) {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT name, item_type, synced_at FROM items WHERE id = ?1",
|
||||||
|
[id],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
assert_eq!(row("season"), ("Season 1".into(), "Season".into(), None));
|
||||||
|
assert_eq!(row("show"), ("Show".into(), "Series".into(), None));
|
||||||
|
assert_eq!(row("alb"), ("Record".into(), "MusicAlbum".into(), None));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Listing a container is one index range, already in display order.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-002, UR-007 | DR-013
|
||||||
|
#[test]
|
||||||
|
fn a_container_listing_is_an_ordered_index_range() {
|
||||||
|
let conn = pre_027_db();
|
||||||
|
conn.execute_batch(MIGRATION_027).unwrap();
|
||||||
|
let plan: Vec<String> = conn
|
||||||
|
.prepare(
|
||||||
|
"EXPLAIN QUERY PLAN SELECT id FROM items
|
||||||
|
WHERE container_id = ?1 ORDER BY sort_name, name",
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.query_map(["season"], |r| r.get::<_, String>(3))
|
||||||
|
.unwrap()
|
||||||
|
.map(Result::unwrap)
|
||||||
|
.collect();
|
||||||
|
let plan = plan.join("\n");
|
||||||
|
assert!(plan.contains("idx_items_container"), "{plan}");
|
||||||
|
assert!(
|
||||||
|
!plan.contains("TEMP B-TREE"),
|
||||||
|
"listing needs a sort step:\n{plan}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod migration_024_tests {
|
mod migration_024_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ impl ThumbnailCache {
|
|||||||
{
|
{
|
||||||
let path = PathBuf::from(&path_str);
|
let path = PathBuf::from(&path_str);
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
self.touch(&db, item_id, image_type, Some(tag)).await;
|
self.touch(&db, item_id, image_type, Some(tag));
|
||||||
return Some(path);
|
return Some(path);
|
||||||
}
|
}
|
||||||
// File gone — drop the stale row and fall through to the tag-agnostic
|
// File gone — drop the stale row and fall through to the tag-agnostic
|
||||||
@@ -157,7 +157,7 @@ impl ThumbnailCache {
|
|||||||
let path_str: String = db.query_optional(any_tag, |row| row.get(0)).await.ok()??;
|
let path_str: String = db.query_optional(any_tag, |row| row.get(0)).await.ok()??;
|
||||||
let path = PathBuf::from(&path_str);
|
let path = PathBuf::from(&path_str);
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
self.touch(&db, item_id, image_type, None).await;
|
self.touch(&db, item_id, image_type, None);
|
||||||
Some(path)
|
Some(path)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -166,13 +166,7 @@ impl ThumbnailCache {
|
|||||||
|
|
||||||
/// Update `last_accessed` for LRU tracking. When `tag` is `Some`, scope to
|
/// Update `last_accessed` for LRU tracking. When `tag` is `Some`, scope to
|
||||||
/// that exact row; when `None`, touch every row for the item + type.
|
/// that exact row; when `None`, touch every row for the item + type.
|
||||||
async fn touch(
|
fn touch(&self, db: &Arc<RusqliteService>, item_id: &str, image_type: &str, tag: Option<&str>) {
|
||||||
&self,
|
|
||||||
db: &Arc<RusqliteService>,
|
|
||||||
item_id: &str,
|
|
||||||
image_type: &str,
|
|
||||||
tag: Option<&str>,
|
|
||||||
) {
|
|
||||||
let query = match tag {
|
let query = match tag {
|
||||||
Some(tag) => Query::with_params(
|
Some(tag) => Query::with_params(
|
||||||
"UPDATE thumbnails SET last_accessed = CURRENT_TIMESTAMP
|
"UPDATE thumbnails SET last_accessed = CURRENT_TIMESTAMP
|
||||||
@@ -192,7 +186,10 @@ impl ThumbnailCache {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
let _ = db.execute(query).await;
|
// Detached: an LRU timestamp is bookkeeping, and a grid scroll does
|
||||||
|
// one of these per visible poster — awaiting each write held every
|
||||||
|
// thumbnail lookup behind the writer queue.
|
||||||
|
db.execute_detached(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save thumbnail to cache
|
/// Save thumbnail to cache
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "JellyTau",
|
"productName": "JellyTau",
|
||||||
"version": "0.12.0",
|
"version": "0.13.2",
|
||||||
"identifier": "com.dtourolle.jellytau",
|
"identifier": "com.dtourolle.jellytau",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
|
|||||||
+50
-3
@@ -66,9 +66,14 @@ async playerEnterBackgroundAudio(item: PlayItemRequest, positionSeconds: number)
|
|||||||
return await TAURI_INVOKE("player_enter_background_audio", { item, positionSeconds });
|
return await TAURI_INVOKE("player_enter_background_audio", { item, positionSeconds });
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* TRACES: UR-040 | DR-052 | UT-061, IT-013
|
* Returns the item the native player is on and its absolute position. The
|
||||||
|
* item matters: an episode that ended while backgrounded has already advanced
|
||||||
|
* in the backend, so reloading the video the webview was mounted with would
|
||||||
|
* bring back the previous episode. (DR-296)
|
||||||
|
*
|
||||||
|
* TRACES: UR-040, UR-023 | DR-052, DR-296 | UT-061, IT-013
|
||||||
*/
|
*/
|
||||||
async playerExitBackgroundAudio() : Promise<number> {
|
async playerExitBackgroundAudio() : Promise<BackgroundAudioResume> {
|
||||||
return await TAURI_INVOKE("player_exit_background_audio");
|
return await TAURI_INVOKE("player_exit_background_audio");
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
@@ -1590,6 +1595,16 @@ async repositoryGetSeriesEpisodes(handle: string, seriesId: string) : Promise<Me
|
|||||||
async repositoryGetSeriesCurrentEpisode(handle: string, seriesId: string) : Promise<MediaItem | null> {
|
async repositoryGetSeriesCurrentEpisode(handle: string, seriesId: string) : Promise<MediaItem | null> {
|
||||||
return await TAURI_INVOKE("repository_get_series_current_episode", { handle, seriesId });
|
return await TAURI_INVOKE("repository_get_series_current_episode", { handle, seriesId });
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* A series' episodes and the viewer's current episode, from one season
|
||||||
|
* fan-out. The series page used to ask for these as two commands, each of
|
||||||
|
* which walked every season.
|
||||||
|
*
|
||||||
|
* TRACES: UR-062 | DR-101, DR-295
|
||||||
|
*/
|
||||||
|
async repositoryGetSeriesView(handle: string, seriesId: string) : Promise<SeriesView> {
|
||||||
|
return await TAURI_INVOKE("repository_get_series_view", { handle, seriesId });
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Erase the viewer's watch history for an item.
|
* Erase the viewer's watch history for an item.
|
||||||
*
|
*
|
||||||
@@ -2180,6 +2195,22 @@ export type BackgroundAction =
|
|||||||
* Stop making sound. The user did not ask for background playback.
|
* Stop making sound. The user did not ask for background playback.
|
||||||
*/
|
*/
|
||||||
"pause"
|
"pause"
|
||||||
|
/**
|
||||||
|
* Where playback stands when a background-audio handoff returns to the
|
||||||
|
* foreground. See [`PlayerController::background_audio_resume`].
|
||||||
|
*
|
||||||
|
* TRACES: UR-040, UR-023 | DR-296
|
||||||
|
*/
|
||||||
|
export type BackgroundAudioResume = {
|
||||||
|
/**
|
||||||
|
* Item the native audio player is on — `None` if the queue emptied (e.g.
|
||||||
|
* the sleep timer stopped playback while backgrounded).
|
||||||
|
*/
|
||||||
|
itemId: string | null;
|
||||||
|
/**
|
||||||
|
* Absolute position in that item, in seconds.
|
||||||
|
*/
|
||||||
|
positionSeconds: number }
|
||||||
/**
|
/**
|
||||||
* Smart caching configuration
|
* Smart caching configuration
|
||||||
*/
|
*/
|
||||||
@@ -2886,7 +2917,13 @@ usesWebviewAudio: boolean;
|
|||||||
* beneath the WebView. Linux cannot do this (WebKitGTK/Wayland
|
* beneath the WebView. Linux cannot do this (WebKitGTK/Wayland
|
||||||
* compositing), so it stays on the HTML5 element.
|
* compositing), so it stays on the HTML5 element.
|
||||||
*/
|
*/
|
||||||
supportsNativeVideo: boolean }
|
supportsNativeVideo: boolean;
|
||||||
|
/**
|
||||||
|
* True when the user may send video to the webview element instead of the
|
||||||
|
* native renderer — the frontend offers the switch only then, and honours
|
||||||
|
* the stored preference only then. See [`webview_video_fallback`].
|
||||||
|
*/
|
||||||
|
webviewVideoFallback: boolean }
|
||||||
/**
|
/**
|
||||||
* Playback information
|
* Playback information
|
||||||
*/
|
*/
|
||||||
@@ -3439,6 +3476,16 @@ export type SecurityStatus = { usingKeyring: boolean; storageType: string }
|
|||||||
* Audio track preference for a series
|
* Audio track preference for a series
|
||||||
*/
|
*/
|
||||||
export type SeriesAudioPreference = { seriesId: string; audioTrackDisplayTitle: string | null; audioTrackLanguage: string | null; audioTrackIndex: number | null }
|
export type SeriesAudioPreference = { seriesId: string; audioTrackDisplayTitle: string | null; audioTrackLanguage: string | null; audioTrackIndex: number | null }
|
||||||
|
/**
|
||||||
|
* A series' episodes and the one the viewer is up to, from **one** season
|
||||||
|
* fan-out.
|
||||||
|
*
|
||||||
|
* The series page needs both, and asked for them as two commands; each walked
|
||||||
|
* every season, so every visit listed the show twice. One call, one walk.
|
||||||
|
*
|
||||||
|
* TRACES: UR-062 | DR-101, DR-295
|
||||||
|
*/
|
||||||
|
export type SeriesView = { episodes: MediaItem[]; current: MediaItem | null }
|
||||||
/**
|
/**
|
||||||
* The verdict on a server's version.
|
* The verdict on a server's version.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -3,7 +3,13 @@
|
|||||||
// NO direct HTTP calls - everything routes through Rust backend
|
// NO direct HTTP calls - everything routes through Rust backend
|
||||||
|
|
||||||
import { commands } from "./bindings";
|
import { commands } from "./bindings";
|
||||||
import type { DownloadDiskUsage, JRayActor, SearchScope, StreamSelection } from "./bindings";
|
import type {
|
||||||
|
DownloadDiskUsage,
|
||||||
|
JRayActor,
|
||||||
|
SearchScope,
|
||||||
|
SeriesView,
|
||||||
|
StreamSelection,
|
||||||
|
} from "./bindings";
|
||||||
import type { QualityPreset } from "./quality-presets";
|
import type { QualityPreset } from "./quality-presets";
|
||||||
import type {
|
import type {
|
||||||
Library,
|
Library,
|
||||||
@@ -164,6 +170,17 @@ export class RepositoryClient {
|
|||||||
return commands.repositoryGetSeriesCurrentEpisode(this.ensureHandle(), seriesId);
|
return commands.repositoryGetSeriesCurrentEpisode(this.ensureHandle(), seriesId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A series' episodes and the episode the viewer is up to, from one season
|
||||||
|
* fan-out in Rust. Prefer this over calling `getSeriesEpisodes` and
|
||||||
|
* `getSeriesCurrentEpisode` together — each of those walks every season.
|
||||||
|
*
|
||||||
|
* TRACES: UR-062 | DR-101, DR-295
|
||||||
|
*/
|
||||||
|
async getSeriesView(seriesId: string): Promise<SeriesView> {
|
||||||
|
return commands.repositoryGetSeriesView(this.ensureHandle(), seriesId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erase watch history for an item. On a series or season the server applies
|
* Erase watch history for an item. On a series or season the server applies
|
||||||
* it to everything inside, so the container returns to "never watched".
|
* it to everything inside, so the container returns to "never watched".
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { downloads, type DownloadInfo } from "$lib/stores/downloads";
|
import { downloads, type DownloadInfo } from "$lib/stores/downloads";
|
||||||
|
import { describeProgress, formatBytes } from "./downloadProgress";
|
||||||
import { createLogger } from "$lib/utils/logger";
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
const log = createLogger("DownloadItem");
|
const log = createLogger("DownloadItem");
|
||||||
@@ -10,20 +11,8 @@
|
|||||||
|
|
||||||
let { download }: Props = $props();
|
let { download }: Props = $props();
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
// Exact, estimated, or unknown total — see downloadProgress.ts (DR-290).
|
||||||
if (bytes === 0) return "0 B";
|
const view = $derived(describeProgress(download));
|
||||||
const k = 1024;
|
|
||||||
const sizes = ["B", "KB", "MB", "GB"];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatProgress(): string {
|
|
||||||
if (!download.fileSize) {
|
|
||||||
return formatBytes(download.bytesDownloaded);
|
|
||||||
}
|
|
||||||
return `${formatBytes(download.bytesDownloaded)} / ${formatBytes(download.fileSize)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusColor(): string {
|
function getStatusColor(): string {
|
||||||
switch (download.status) {
|
switch (download.status) {
|
||||||
@@ -201,15 +190,24 @@
|
|||||||
|
|
||||||
<!-- Progress Bar (for active/paused downloads) -->
|
<!-- Progress Bar (for active/paused downloads) -->
|
||||||
{#if download.status === "downloading" || download.status === "paused"}
|
{#if download.status === "downloading" || download.status === "paused"}
|
||||||
<div class="w-full bg-gray-700 rounded-full h-2 mb-2">
|
<div class="w-full bg-gray-700 rounded-full h-2 mb-2 overflow-hidden">
|
||||||
|
{#if view.kind === "indeterminate"}
|
||||||
|
<!-- No total to measure against: a moving band, not a bar stuck at 0% -->
|
||||||
|
<div
|
||||||
|
class="h-2 w-1/3 rounded-full {getStatusColor()} {download.status === 'downloading'
|
||||||
|
? 'animate-indeterminate'
|
||||||
|
: ''}"
|
||||||
|
></div>
|
||||||
|
{:else}
|
||||||
<div
|
<div
|
||||||
class="h-2 rounded-full transition-all duration-300 {getStatusColor()}"
|
class="h-2 rounded-full transition-all duration-300 {getStatusColor()}"
|
||||||
style="width: {download.progress * 100}%"
|
style="width: {view.percent}%"
|
||||||
></div>
|
></div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between text-xs text-gray-400">
|
<div class="flex items-center justify-between text-xs text-gray-400">
|
||||||
<span>{Math.round(download.progress * 100)}%</span>
|
<span>{view.percentLabel}</span>
|
||||||
<span>{formatProgress()}</span>
|
<span>{view.label}</span>
|
||||||
</div>
|
</div>
|
||||||
{:else if download.status === "completed"}
|
{:else if download.status === "completed"}
|
||||||
<p class="text-xs text-gray-400">{formatBytes(download.bytesDownloaded)}</p>
|
<p class="text-xs text-gray-400">{formatBytes(download.bytesDownloaded)}</p>
|
||||||
@@ -365,3 +363,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* A band sweeping the track: "still moving, size unknown". */
|
||||||
|
@keyframes indeterminate {
|
||||||
|
from {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(300%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animate-indeterminate {
|
||||||
|
animation: indeterminate 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { describeProgress } from "./downloadProgress";
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
status: "downloading" as const,
|
||||||
|
progress: 0,
|
||||||
|
bytesDownloaded: 0,
|
||||||
|
fileSize: undefined as number | undefined,
|
||||||
|
fileSizeEstimated: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// TRACES: UR-071 | DR-290 | UT-254
|
||||||
|
describe("describeProgress", () => {
|
||||||
|
it("shows an indeterminate bar, not 0%, when the size is unknown", () => {
|
||||||
|
// A transcode has no Content-Length and (before the estimate) no total:
|
||||||
|
// 200 MB in, the bar read "0%". That is not progress information.
|
||||||
|
const view = describeProgress({ ...base, bytesDownloaded: 200 * 1024 * 1024 });
|
||||||
|
expect(view.kind).toBe("indeterminate");
|
||||||
|
expect(view.percent).toBeNull();
|
||||||
|
expect(view.label).toBe("200.0 MB");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks an estimated total as approximate", () => {
|
||||||
|
const view = describeProgress({
|
||||||
|
...base,
|
||||||
|
progress: 0.42,
|
||||||
|
bytesDownloaded: 420,
|
||||||
|
fileSize: 1000,
|
||||||
|
fileSizeEstimated: true,
|
||||||
|
});
|
||||||
|
expect(view.kind).toBe("estimated");
|
||||||
|
expect(view.percent).toBe(42);
|
||||||
|
expect(view.percentLabel).toBe("~42%");
|
||||||
|
expect(view.label).toBe("420 B / ~1000 B");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an exact total plainly", () => {
|
||||||
|
const view = describeProgress({
|
||||||
|
...base,
|
||||||
|
progress: 0.5,
|
||||||
|
bytesDownloaded: 512,
|
||||||
|
fileSize: 1024,
|
||||||
|
});
|
||||||
|
expect(view.kind).toBe("exact");
|
||||||
|
expect(view.percent).toBe(50);
|
||||||
|
expect(view.percentLabel).toBe("50%");
|
||||||
|
expect(view.label).toBe("512 B / 1.0 KB");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a paused download's bar where it stopped", () => {
|
||||||
|
const view = describeProgress({
|
||||||
|
...base,
|
||||||
|
status: "paused",
|
||||||
|
progress: 0.25,
|
||||||
|
bytesDownloaded: 256,
|
||||||
|
fileSize: 1024,
|
||||||
|
});
|
||||||
|
expect(view.kind).toBe("exact");
|
||||||
|
expect(view.percent).toBe(25);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* What the progress bar on a download row should say.
|
||||||
|
*
|
||||||
|
* Extracted from `DownloadItem.svelte` so the three cases can be unit-tested:
|
||||||
|
* an exact total (the server sent `Content-Length`), an estimated one (a
|
||||||
|
* transcode sends none, so the backend predicted it from the source), and no
|
||||||
|
* total at all — which used to render as an empty bar reading "0%" for the
|
||||||
|
* whole of a transcode download. Progress with no denominator is not 0%; it
|
||||||
|
* is unknown, and the bar says so.
|
||||||
|
*
|
||||||
|
* TRACES: UR-071 | DR-290 | UT-254
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ProgressSource {
|
||||||
|
status: "pending" | "downloading" | "completed" | "failed" | "paused";
|
||||||
|
/** 0..1 as the backend reports it; already capped short of 1 when estimated. */
|
||||||
|
progress: number;
|
||||||
|
bytesDownloaded: number;
|
||||||
|
fileSize?: number;
|
||||||
|
/** `fileSize` is the backend's prediction, not the server's word. */
|
||||||
|
fileSizeEstimated?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProgressKind = "exact" | "estimated" | "indeterminate";
|
||||||
|
|
||||||
|
export interface ProgressView {
|
||||||
|
kind: ProgressKind;
|
||||||
|
/** Whole percent for the bar width; `null` when there is nothing to measure against. */
|
||||||
|
percent: number | null;
|
||||||
|
/** "42%" / "~42%"; empty when indeterminate. */
|
||||||
|
percentLabel: string;
|
||||||
|
/** "512 B / 1.0 KB", "420 B / ~1000 B", or just the bytes so far. */
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return "0 B";
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ["B", "KB", "MB", "GB"];
|
||||||
|
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
|
||||||
|
return `${(bytes / Math.pow(k, i)).toFixed(i === 0 ? 0 : 1)} ${sizes[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeProgress(d: ProgressSource): ProgressView {
|
||||||
|
const downloaded = formatBytes(d.bytesDownloaded);
|
||||||
|
if (!d.fileSize) {
|
||||||
|
return { kind: "indeterminate", percent: null, percentLabel: "", label: downloaded };
|
||||||
|
}
|
||||||
|
const percent = Math.round(Math.min(Math.max(d.progress, 0), 1) * 100);
|
||||||
|
if (d.fileSizeEstimated) {
|
||||||
|
return {
|
||||||
|
kind: "estimated",
|
||||||
|
percent,
|
||||||
|
percentLabel: `~${percent}%`,
|
||||||
|
label: `${downloaded} / ~${formatBytes(d.fileSize)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "exact",
|
||||||
|
percent,
|
||||||
|
percentLabel: `${percent}%`,
|
||||||
|
label: `${downloaded} / ${formatBytes(d.fileSize)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -57,25 +57,29 @@
|
|||||||
const episodeNumber = $derived(episode.indexNumber || 0);
|
const episodeNumber = $derived(episode.indexNumber || 0);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<!-- Narrow rows stack the title under a full-width thumbnail instead of
|
||||||
|
squeezing it beside a fixed 160px one. A container query, not a viewport
|
||||||
|
breakpoint, so it follows the row's own width. -->
|
||||||
|
<div class="@container">
|
||||||
|
<button
|
||||||
bind:this={buttonRef}
|
bind:this={buttonRef}
|
||||||
type="button"
|
type="button"
|
||||||
class="group/row flex gap-4 w-full text-left p-3 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors {focused
|
class="group/row flex flex-col @md:flex-row gap-3 @md:gap-4 w-full text-left p-3 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors {focused
|
||||||
? 'ring-2 ring-[var(--color-jellyfin)] bg-[var(--color-surface)]'
|
? 'ring-2 ring-[var(--color-jellyfin)] bg-[var(--color-surface)]'
|
||||||
: current
|
: current
|
||||||
? 'ring-2 ring-yellow-400 bg-[var(--color-surface)]'
|
? 'ring-2 ring-yellow-400 bg-[var(--color-surface)]'
|
||||||
: ''}"
|
: ''}"
|
||||||
{onclick}
|
{onclick}
|
||||||
>
|
>
|
||||||
<!-- Thumbnail -->
|
<!-- Thumbnail -->
|
||||||
<div
|
<div
|
||||||
class="relative flex-shrink-0 w-40 aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]"
|
class="relative flex-shrink-0 w-full @md:w-40 aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]"
|
||||||
>
|
>
|
||||||
<CachedImage
|
<CachedImage
|
||||||
itemId={episode.id}
|
itemId={episode.id}
|
||||||
imageType="Primary"
|
imageType="Primary"
|
||||||
tag={episode.imageId}
|
tag={episode.imageId}
|
||||||
maxWidth={320}
|
maxWidth={640}
|
||||||
alt={episode.name}
|
alt={episode.name}
|
||||||
class="w-full h-full object-cover transition-transform group-hover/row:scale-105"
|
class="w-full h-full object-cover transition-transform group-hover/row:scale-105"
|
||||||
/>
|
/>
|
||||||
@@ -104,9 +108,14 @@
|
|||||||
|
|
||||||
<!-- Download indicator -->
|
<!-- Download indicator -->
|
||||||
{#if isDownloaded || isDownloading}
|
{#if isDownloaded || isDownloading}
|
||||||
<div class="absolute bottom-2 right-2" title={isDownloaded ? "Downloaded" : "Downloading..."}>
|
<div
|
||||||
|
class="absolute bottom-2 right-2"
|
||||||
|
title={isDownloaded ? "Downloaded" : "Downloading..."}
|
||||||
|
>
|
||||||
{#if isDownloaded}
|
{#if isDownloaded}
|
||||||
<div class="w-5 h-5 rounded-full bg-green-600 flex items-center justify-center shadow-lg">
|
<div
|
||||||
|
class="w-5 h-5 rounded-full bg-green-600 flex items-center justify-center shadow-lg"
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
class="w-3 h-3 text-white"
|
class="w-3 h-3 text-white"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -114,7 +123,11 @@
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke-width="2.5"
|
stroke-width="2.5"
|
||||||
>
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M12 4v12m0 0l-4-4m4 4l4-4"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{:else if isDownloading}
|
{:else if isDownloading}
|
||||||
@@ -148,7 +161,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Episode info -->
|
<!-- Episode info -->
|
||||||
<div class="flex-1 min-w-0 py-1">
|
<div class="flex-1 min-w-0 @md:py-1">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<!-- Episode number and title -->
|
<!-- Episode number and title -->
|
||||||
@@ -157,7 +170,7 @@
|
|||||||
{episodeNumber}.
|
{episodeNumber}.
|
||||||
</span>
|
</span>
|
||||||
<h3
|
<h3
|
||||||
class="text-white font-medium truncate group-hover/row:text-[var(--color-jellyfin)] transition-colors"
|
class="text-white font-medium line-clamp-2 @md:truncate group-hover/row:text-[var(--color-jellyfin)] transition-colors"
|
||||||
>
|
>
|
||||||
{truncateMiddle(episode.name, 56)}
|
{truncateMiddle(episode.name, 56)}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -220,4 +233,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* The list view must grey and offer to queue server-only media exactly as the
|
||||||
|
* grid does. It did neither: switching a library to list view offline turned
|
||||||
|
* every non-downloaded item back into a normal, tappable row that plays
|
||||||
|
* nothing.
|
||||||
|
*
|
||||||
|
* TRACES: UR-052 | DR-292 | UT-258
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/svelte";
|
||||||
|
|
||||||
|
const h = vi.hoisted(() => {
|
||||||
|
function shim<T>(initial: T) {
|
||||||
|
let value = initial;
|
||||||
|
const subs = new Set<(v: T) => void>();
|
||||||
|
return {
|
||||||
|
set(v: T) {
|
||||||
|
value = v;
|
||||||
|
subs.forEach((fn) => fn(value));
|
||||||
|
},
|
||||||
|
subscribe(fn: (v: T) => void) {
|
||||||
|
subs.add(fn);
|
||||||
|
fn(value);
|
||||||
|
return () => subs.delete(fn);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
isConnectedStore: shim(true),
|
||||||
|
showServerCatalogStore: shim(false),
|
||||||
|
downloadsStore: shim({ downloads: {} as Record<string, unknown> }),
|
||||||
|
deviceContentIdsStore: shim(new Set<string>()),
|
||||||
|
downloadItem: vi.fn(async () => 1),
|
||||||
|
getUserId: vi.fn(() => "user-1"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("$lib/stores/connectivity", () => ({
|
||||||
|
isConnected: { subscribe: h.isConnectedStore.subscribe },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("$lib/services/offlineCatalog", () => ({
|
||||||
|
showServerCatalog: { subscribe: h.showServerCatalogStore.subscribe },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("$lib/services/downloadedCatalog", () => ({
|
||||||
|
deviceContentIds: { subscribe: h.deviceContentIdsStore.subscribe },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("$lib/stores/downloads", () => ({
|
||||||
|
downloads: { subscribe: h.downloadsStore.subscribe, downloadItem: h.downloadItem },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("$lib/stores/auth", () => ({
|
||||||
|
auth: { getUserId: h.getUserId },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("$lib/components/common/CachedImage.svelte", async () => ({
|
||||||
|
default: (await import("./__mocks__/StubImage.svelte")).default,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import LibraryListView from "./LibraryListView.svelte";
|
||||||
|
|
||||||
|
const movie = {
|
||||||
|
id: "movie-1",
|
||||||
|
name: "Some Film",
|
||||||
|
type: "Movie" as const,
|
||||||
|
serverId: "server-1",
|
||||||
|
productionYear: 1999,
|
||||||
|
};
|
||||||
|
|
||||||
|
const album = {
|
||||||
|
id: "album-1",
|
||||||
|
name: "Some Album",
|
||||||
|
type: "MusicAlbum" as const,
|
||||||
|
serverId: "server-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("LibraryListView server-only rows", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
h.isConnectedStore.set(true);
|
||||||
|
h.showServerCatalogStore.set(false);
|
||||||
|
h.downloadsStore.set({ downloads: {} });
|
||||||
|
h.deviceContentIdsStore.set(new Set());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows no queue control while online", () => {
|
||||||
|
render(LibraryListView, { props: { items: [movie] } });
|
||||||
|
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers to queue a server-only row when offline with the reveal on", () => {
|
||||||
|
h.isConnectedStore.set(false);
|
||||||
|
h.showServerCatalogStore.set(true);
|
||||||
|
render(LibraryListView, { props: { items: [movie] } });
|
||||||
|
expect(screen.getByLabelText(/Queue download for Some Film/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("queues the row's item on click", async () => {
|
||||||
|
h.isConnectedStore.set(false);
|
||||||
|
h.showServerCatalogStore.set(true);
|
||||||
|
render(LibraryListView, { props: { items: [movie] } });
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByLabelText(/Queue download for Some Film/i));
|
||||||
|
|
||||||
|
expect(h.downloadItem).toHaveBeenCalledTimes(1);
|
||||||
|
const args = h.downloadItem.mock.calls[0] as unknown as unknown[];
|
||||||
|
expect(args[0]).toBe("movie-1");
|
||||||
|
expect(args[1]).toBe("user-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes a server-only row inert: tapping it cannot start playback", async () => {
|
||||||
|
h.isConnectedStore.set(false);
|
||||||
|
h.showServerCatalogStore.set(true);
|
||||||
|
const onItemClick = vi.fn();
|
||||||
|
render(LibraryListView, { props: { items: [movie], onItemClick } });
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByText("Some Film"));
|
||||||
|
|
||||||
|
expect(onItemClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a downloaded row alone", () => {
|
||||||
|
h.isConnectedStore.set(false);
|
||||||
|
h.showServerCatalogStore.set(true);
|
||||||
|
h.downloadsStore.set({
|
||||||
|
downloads: { "movie-1": { itemId: "movie-1", status: "completed", progress: 1 } },
|
||||||
|
});
|
||||||
|
render(LibraryListView, { props: { items: [movie] } });
|
||||||
|
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a container alone when the device holds its children", () => {
|
||||||
|
h.isConnectedStore.set(false);
|
||||||
|
h.showServerCatalogStore.set(true);
|
||||||
|
h.deviceContentIdsStore.set(new Set(["album-1"]));
|
||||||
|
render(LibraryListView, { props: { items: [album] } });
|
||||||
|
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a Queued badge instead of the button for a pending row", () => {
|
||||||
|
h.isConnectedStore.set(false);
|
||||||
|
h.showServerCatalogStore.set(true);
|
||||||
|
h.downloadsStore.set({
|
||||||
|
downloads: { "movie-1": { itemId: "movie-1", status: "pending", progress: 0 } },
|
||||||
|
});
|
||||||
|
render(LibraryListView, { props: { items: [movie] } });
|
||||||
|
|
||||||
|
expect(screen.getByText(/Queued/i)).toBeTruthy();
|
||||||
|
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,26 @@
|
|||||||
|
<!--
|
||||||
|
List counterpart of the grid's MediaCard. The two must agree about what a row
|
||||||
|
offline means: before DR-292 this view knew nothing about the offline catalog
|
||||||
|
reveal, so a library switched to list view showed every server-only item as a
|
||||||
|
normal, tappable row that plays nothing.
|
||||||
|
|
||||||
|
TRACES: UR-052 | DR-292
|
||||||
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { MediaItem, Library } from "$lib/api/types";
|
import type { MediaItem, Library } from "$lib/api/types";
|
||||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||||
import { downloads } from "$lib/stores/downloads";
|
import { downloads } from "$lib/stores/downloads";
|
||||||
|
import { isConnected } from "$lib/stores/connectivity";
|
||||||
|
import { showServerCatalog } from "$lib/services/offlineCatalog";
|
||||||
|
import { deviceContentIds } from "$lib/services/downloadedCatalog";
|
||||||
|
import { isServerOnly } from "$lib/utils/serverOnly";
|
||||||
|
import { queueOfflineDownload } from "$lib/services/queueOfflineDownload";
|
||||||
import { formatDuration } from "$lib/utils/duration";
|
import { formatDuration } from "$lib/utils/duration";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
|
|
||||||
|
const log = createLogger("LibraryListView");
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
items: (MediaItem | Library)[];
|
items: (MediaItem | Library)[];
|
||||||
showProgress?: boolean;
|
showProgress?: boolean;
|
||||||
@@ -18,6 +34,33 @@
|
|||||||
return Object.values($downloads.downloads).find((d) => d.itemId === itemId);
|
return Object.values($downloads.downloads).find((d) => d.itemId === itemId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Same rule the grid card applies — see $lib/utils/serverOnly. */
|
||||||
|
function serverOnly(item: MediaItem | Library): boolean {
|
||||||
|
const info = getDownloadInfo(item.id);
|
||||||
|
return isServerOnly({
|
||||||
|
isMediaItem: "type" in item,
|
||||||
|
isConnected: $isConnected,
|
||||||
|
revealServerCatalog: $showServerCatalog,
|
||||||
|
isDownloaded: info?.status === "completed",
|
||||||
|
isActivelyDownloading: info?.status === "downloading",
|
||||||
|
hasDeviceContent: $deviceContentIds.has(item.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let queueError = $state<string | null>(null);
|
||||||
|
|
||||||
|
async function queueForDownload(e: Event, item: MediaItem | Library) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!("type" in item)) return;
|
||||||
|
try {
|
||||||
|
queueError = null;
|
||||||
|
await queueOfflineDownload(item);
|
||||||
|
} catch (err) {
|
||||||
|
log.error("Failed to queue download:", err);
|
||||||
|
queueError = err instanceof Error ? err.message : "Failed to queue";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getImageTag(item: MediaItem | Library): string | undefined {
|
function getImageTag(item: MediaItem | Library): string | undefined {
|
||||||
return "imageId" in item
|
return "imageId" in item
|
||||||
? (item.imageId ?? undefined)
|
? (item.imageId ?? undefined)
|
||||||
@@ -78,12 +121,20 @@
|
|||||||
{@const isDownloaded = downloadInfo?.status === "completed"}
|
{@const isDownloaded = downloadInfo?.status === "completed"}
|
||||||
{@const isDownloading =
|
{@const isDownloading =
|
||||||
downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"}
|
downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"}
|
||||||
|
{@const isQueued = downloadInfo?.status === "pending"}
|
||||||
|
{@const rowServerOnly = serverOnly(item)}
|
||||||
|
|
||||||
<button
|
<!-- A server-only row is not a button: there is nothing to open offline,
|
||||||
type="button"
|
and the queue control it carries cannot live inside one. -->
|
||||||
|
<svelte:element
|
||||||
|
this={rowServerOnly ? "div" : "button"}
|
||||||
|
type={rowServerOnly ? undefined : "button"}
|
||||||
|
role={rowServerOnly ? "group" : undefined}
|
||||||
data-grid-index={index}
|
data-grid-index={index}
|
||||||
onclick={() => onItemClick?.(item)}
|
onclick={rowServerOnly ? undefined : () => onItemClick?.(item)}
|
||||||
class="w-full flex items-center gap-3 p-2 rounded-lg hover:bg-[var(--color-surface)] transition-colors group"
|
class="w-full flex items-center gap-3 p-2 rounded-lg transition-colors group {rowServerOnly
|
||||||
|
? 'opacity-60'
|
||||||
|
: 'hover:bg-[var(--color-surface)]'}"
|
||||||
>
|
>
|
||||||
<!-- Track number or index -->
|
<!-- Track number or index -->
|
||||||
<span class="text-gray-500 w-6 text-right text-sm flex-shrink-0">
|
<span class="text-gray-500 w-6 text-right text-sm flex-shrink-0">
|
||||||
@@ -103,9 +154,11 @@
|
|||||||
class="w-full h-full object-cover"
|
class="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Play overlay on hover -->
|
<!-- Play overlay on hover (never on an inert server-only row) -->
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
class="absolute inset-0 bg-black/50 opacity-0 transition-opacity flex items-center justify-center {rowServerOnly
|
||||||
|
? ''
|
||||||
|
: 'group-hover:opacity-100'}"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M8 5v14l11-7z" />
|
<path d="M8 5v14l11-7z" />
|
||||||
@@ -123,7 +176,9 @@
|
|||||||
<!-- Title & Subtitle -->
|
<!-- Title & Subtitle -->
|
||||||
<div class="flex-1 min-w-0 text-left">
|
<div class="flex-1 min-w-0 text-left">
|
||||||
<p
|
<p
|
||||||
class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors"
|
class="text-sm font-medium text-white truncate transition-colors {rowServerOnly
|
||||||
|
? ''
|
||||||
|
: 'group-hover:text-[var(--color-jellyfin)]'}"
|
||||||
>
|
>
|
||||||
{truncateMiddle(item.name, 56)}
|
{truncateMiddle(item.name, 56)}
|
||||||
</p>
|
</p>
|
||||||
@@ -192,6 +247,39 @@
|
|||||||
{#if duration}
|
{#if duration}
|
||||||
<span class="text-xs text-gray-400 flex-shrink-0">{duration}</span>
|
<span class="text-xs text-gray-400 flex-shrink-0">{duration}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Server-only: queue for the next reconnect, mirroring the grid card.
|
||||||
|
A row already queued shows the badge instead of the button. -->
|
||||||
|
{#if rowServerOnly}
|
||||||
|
{#if isQueued}
|
||||||
|
<span
|
||||||
|
class="text-[10px] font-medium bg-white/10 text-gray-300 px-2 py-0.5 rounded-full flex-shrink-0"
|
||||||
|
title="Queued — will download on reconnect">Queued</span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={(e) => queueForDownload(e, item)}
|
||||||
|
class="w-8 h-8 rounded-full bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin)]/90 flex items-center justify-center flex-shrink-0 transition-colors"
|
||||||
|
title="Queue download for next connection"
|
||||||
|
aria-label="Queue download for {item.name}"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 text-white"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</svelte:element>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
{#if queueError}
|
||||||
|
<p class="text-xs text-red-400 px-2">{queueError}</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,12 +24,20 @@ const h = vi.hoisted(() => {
|
|||||||
isConnectedStore: shim(true),
|
isConnectedStore: shim(true),
|
||||||
showServerCatalogStore: shim(false),
|
showServerCatalogStore: shim(false),
|
||||||
downloadsStore: shim({ downloads: {} as Record<string, any> }),
|
downloadsStore: shim({ downloads: {} as Record<string, any> }),
|
||||||
|
deviceContentIdsStore: shim(new Set<string>()),
|
||||||
downloadItem: vi.fn(async () => 1),
|
downloadItem: vi.fn(async () => 1),
|
||||||
getUserId: vi.fn(() => "user-1"),
|
getUserId: vi.fn(() => "user-1"),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const { isConnectedStore, showServerCatalogStore, downloadsStore, downloadItem, getUserId } = h;
|
const {
|
||||||
|
isConnectedStore,
|
||||||
|
showServerCatalogStore,
|
||||||
|
downloadsStore,
|
||||||
|
deviceContentIdsStore,
|
||||||
|
downloadItem,
|
||||||
|
getUserId,
|
||||||
|
} = h;
|
||||||
|
|
||||||
vi.mock("$lib/stores/connectivity", () => ({
|
vi.mock("$lib/stores/connectivity", () => ({
|
||||||
isConnected: { subscribe: h.isConnectedStore.subscribe },
|
isConnected: { subscribe: h.isConnectedStore.subscribe },
|
||||||
@@ -39,6 +47,10 @@ vi.mock("$lib/services/offlineCatalog", () => ({
|
|||||||
showServerCatalog: { subscribe: h.showServerCatalogStore.subscribe },
|
showServerCatalog: { subscribe: h.showServerCatalogStore.subscribe },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("$lib/services/downloadedCatalog", () => ({
|
||||||
|
deviceContentIds: { subscribe: h.deviceContentIdsStore.subscribe },
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("$lib/stores/downloads", () => ({
|
vi.mock("$lib/stores/downloads", () => ({
|
||||||
downloads: { subscribe: h.downloadsStore.subscribe, downloadItem: h.downloadItem },
|
downloads: { subscribe: h.downloadsStore.subscribe, downloadItem: h.downloadItem },
|
||||||
}));
|
}));
|
||||||
@@ -69,6 +81,7 @@ describe("MediaCard server-only (offline browse & queue)", () => {
|
|||||||
isConnectedStore.set(true);
|
isConnectedStore.set(true);
|
||||||
showServerCatalogStore.set(false);
|
showServerCatalogStore.set(false);
|
||||||
downloadsStore.set({ downloads: {} });
|
downloadsStore.set({ downloads: {} });
|
||||||
|
deviceContentIdsStore.set(new Set());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows no queue button while online", () => {
|
it("shows no queue button while online", () => {
|
||||||
@@ -117,6 +130,23 @@ describe("MediaCard server-only (offline browse & queue)", () => {
|
|||||||
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
|
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// An album carries no download row of its own — its tracks do — so the card
|
||||||
|
// greyed out a fully downloaded album and offered to queue what was already
|
||||||
|
// on the device. The backend's disk-usage map answers for containers too.
|
||||||
|
// TRACES: UR-052 | DR-292 | UT-257
|
||||||
|
it("does not grey a container whose children are on the device", () => {
|
||||||
|
isConnectedStore.set(false);
|
||||||
|
showServerCatalogStore.set(true);
|
||||||
|
deviceContentIdsStore.set(new Set(["album-1"]));
|
||||||
|
render(MediaCard, {
|
||||||
|
props: {
|
||||||
|
item: { id: "album-1", name: "Album X", type: "MusicAlbum", serverId: "server-1" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("does not grey out a completed download", () => {
|
it("does not grey out a completed download", () => {
|
||||||
isConnectedStore.set(false);
|
isConnectedStore.set(false);
|
||||||
showServerCatalogStore.set(true);
|
showServerCatalogStore.set(true);
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<!-- TRACES: UR-037, UR-051, UR-052, UR-068 | DR-042, DR-068, DR-078, DR-119 -->
|
<!-- TRACES: UR-037, UR-051, UR-052, UR-068 | DR-042, DR-068, DR-078, DR-119, DR-292 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { MediaItem, Library } from "$lib/api/types";
|
import type { MediaItem, Library } from "$lib/api/types";
|
||||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||||
import { downloads } from "$lib/stores/downloads";
|
import { downloads } from "$lib/stores/downloads";
|
||||||
import { isConnected } from "$lib/stores/connectivity";
|
import { isConnected } from "$lib/stores/connectivity";
|
||||||
import { showServerCatalog } from "$lib/services/offlineCatalog";
|
import { showServerCatalog } from "$lib/services/offlineCatalog";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { deviceContentIds } from "$lib/services/downloadedCatalog";
|
||||||
|
import { isServerOnly as computeIsServerOnly } from "$lib/utils/serverOnly";
|
||||||
|
import { queueOfflineDownload } from "$lib/services/queueOfflineDownload";
|
||||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
||||||
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
|
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
|
||||||
@@ -143,11 +145,21 @@
|
|||||||
const isQueued = $derived(downloadInfo?.status === "pending");
|
const isQueued = $derived(downloadInfo?.status === "pending");
|
||||||
// Actively transferring (as opposed to merely queued/pending for reconnect).
|
// Actively transferring (as opposed to merely queued/pending for reconnect).
|
||||||
const isActivelyDownloading = $derived(downloadInfo?.status === "downloading");
|
const isActivelyDownloading = $derived(downloadInfo?.status === "downloading");
|
||||||
// "Server only" = offline, reveal on, and not already downloaded or actively
|
// "Server only" = offline, reveal on, and nothing on the device behind this
|
||||||
// transferring. A `pending` (queued-for-reconnect) item stays server-only so
|
// card — neither its own download nor, for a container, its children's (the
|
||||||
// it can show the Queued badge in place of the queue button.
|
// album case: only tracks carry download rows). A `pending`
|
||||||
|
// (queued-for-reconnect) item stays server-only so it can show the Queued
|
||||||
|
// badge in place of the queue button. Shared with the list view; see
|
||||||
|
// $lib/utils/serverOnly.
|
||||||
const isServerOnly = $derived(
|
const isServerOnly = $derived(
|
||||||
isMediaItem && !$isConnected && $showServerCatalog && !isDownloaded && !isActivelyDownloading,
|
computeIsServerOnly({
|
||||||
|
isMediaItem,
|
||||||
|
isConnected: $isConnected,
|
||||||
|
revealServerCatalog: $showServerCatalog,
|
||||||
|
isDownloaded,
|
||||||
|
isActivelyDownloading,
|
||||||
|
hasDeviceContent: $deviceContentIds.has(item.id),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// The heart is about an item, so libraries never get one, and a greyed
|
// The heart is about an item, so libraries never get one, and a greyed
|
||||||
@@ -165,29 +177,13 @@
|
|||||||
async function queueForDownload(e: Event) {
|
async function queueForDownload(e: Event) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!isMediaItem) return;
|
if (!isMediaItem) return;
|
||||||
const media = item as MediaItem;
|
|
||||||
const userId = auth.getUserId();
|
|
||||||
if (!userId) {
|
|
||||||
queueError = "Not signed in";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
queueError = null;
|
queueError = null;
|
||||||
// Derive a sensible on-disk path; the backend heals stream_url on reconnect.
|
await queueOfflineDownload(item as MediaItem);
|
||||||
const filePath = `downloads/${media.id}`;
|
|
||||||
await downloads.downloadItem(
|
|
||||||
media.id,
|
|
||||||
userId,
|
|
||||||
filePath,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
media.name,
|
|
||||||
media.artists?.join(", ") ?? undefined,
|
|
||||||
media.albumName ?? undefined,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error("Failed to queue download:", err);
|
log.error("Failed to queue download:", err);
|
||||||
queueError = "Failed to queue";
|
queueError =
|
||||||
|
err instanceof Error && err.message === "Not signed in" ? err.message : "Failed to queue";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,14 +64,10 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const repo = auth.getRepository();
|
const handle = auth.getRepository().getHandle();
|
||||||
|
|
||||||
log.debug("🎬 Starting video download for item:", itemId, "quality:", quality);
|
log.debug("🎬 Starting video download for item:", itemId, "quality:", quality);
|
||||||
|
|
||||||
// Get stream URL based on quality
|
|
||||||
const streamUrl = await repo.getVideoDownloadUrl(itemId, quality);
|
|
||||||
log.debug(" Stream URL obtained");
|
|
||||||
|
|
||||||
// Get target directory
|
// Get target directory
|
||||||
const targetDir = await commands.storageGetPath();
|
const targetDir = await commands.storageGetPath();
|
||||||
|
|
||||||
@@ -109,9 +105,12 @@
|
|||||||
// Pin the item metadata
|
// Pin the item metadata
|
||||||
await downloads.pinItem(itemId);
|
await downloads.pinItem(itemId);
|
||||||
|
|
||||||
// Actually start the download
|
// Resolve the URL and start it the same way the series/season buttons
|
||||||
await commands.startDownload(downloadId, streamUrl, targetDir);
|
// do: Rust picks the transcode from the row's preset, predicts the size
|
||||||
log.debug(" Download started");
|
// so the bar has a total when the response has no length (DR-290), and
|
||||||
|
// the queue pump starts it when a slot is free.
|
||||||
|
await commands.enqueueVideoDownloads(handle, [downloadId], targetDir);
|
||||||
|
log.debug(" Download enqueued");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to start video download:", error);
|
log.error("Failed to start video download:", error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -44,6 +44,18 @@ vi.mock("$lib/stores/nativeVideo", async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The webview path only exists where Rust offers a fallback from the native
|
||||||
|
// renderer — beside mpv native video on Linux; never on Android since DR-293,
|
||||||
|
// where a stored "off" is ignored. These tests guard that path's scrubbing, so
|
||||||
|
// they declare a platform that has it.
|
||||||
|
vi.mock("$lib/services/playbackCapabilities", () => ({
|
||||||
|
getPlaybackCapabilities: async () => ({
|
||||||
|
usesWebviewAudio: false,
|
||||||
|
supportsNativeVideo: true,
|
||||||
|
webviewVideoFallback: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@tauri-apps/api/event", () => ({
|
vi.mock("@tauri-apps/api/event", () => ({
|
||||||
listen: vi.fn(async (channel: string, handler: any) => {
|
listen: vi.fn(async (channel: string, handler: any) => {
|
||||||
channelHandlers[channel] = handler;
|
channelHandlers[channel] = handler;
|
||||||
|
|||||||
@@ -41,7 +41,8 @@
|
|||||||
type Html5ElementBridge,
|
type Html5ElementBridge,
|
||||||
} from "$lib/player/adapters";
|
} from "$lib/player/adapters";
|
||||||
import { createRustReportHost } from "$lib/player/adapters/rustReportHost";
|
import { createRustReportHost } from "$lib/player/adapters/rustReportHost";
|
||||||
import { experimentalNativeVideo } from "$lib/stores/nativeVideo";
|
import { experimentalNativeVideo, nativeVideoWanted } from "$lib/stores/nativeVideo";
|
||||||
|
import { getPlaybackCapabilities } from "$lib/services/playbackCapabilities";
|
||||||
import {
|
import {
|
||||||
enableNativeVideoCompositing,
|
enableNativeVideoCompositing,
|
||||||
disableNativeVideoCompositing,
|
disableNativeVideoCompositing,
|
||||||
@@ -119,6 +120,12 @@
|
|||||||
onNext?: () => void; // Called when user clicks next episode button
|
onNext?: () => void; // Called when user clicks next episode button
|
||||||
hasNext?: boolean; // Whether there is a next episode available
|
hasNext?: boolean; // Whether there is a next episode available
|
||||||
isLive?: boolean; // Live stream (Live TV) - no seek bar, no resume, no progress reporting
|
isLive?: boolean; // Live stream (Live TV) - no seek bar, no resume, no progress reporting
|
||||||
|
/**
|
||||||
|
* Returning from background audio found the backend on a different item
|
||||||
|
* (the episode advanced while backgrounded). The page switches to it; this
|
||||||
|
* player must not reload the one it was mounted with. (DR-296)
|
||||||
|
*/
|
||||||
|
onResumeOtherItem?: (itemId: string, positionSeconds: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -136,6 +143,7 @@
|
|||||||
onNext,
|
onNext,
|
||||||
hasNext = false,
|
hasNext = false,
|
||||||
isLive = false,
|
isLive = false,
|
||||||
|
onResumeOtherItem,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
// The id this player instance reports progress against. Snapshotted from the
|
// The id this player instance reports progress against. Snapshotted from the
|
||||||
@@ -1020,7 +1028,12 @@
|
|||||||
// it is off we must also stop the native backend that player_play_item
|
// it is off we must also stop the native backend that player_play_item
|
||||||
// just started, or ExoPlayer and the <video> element both decode the
|
// just started, or ExoPlayer and the <video> element both decode the
|
||||||
// same stream and the audio doubles.
|
// same stream and the audio doubles.
|
||||||
if (!useHtml5Element && !$experimentalNativeVideo) {
|
// The stored choice only counts where Rust says a webview fallback
|
||||||
|
// exists — never on Android, where the webview would play an
|
||||||
|
// original-file download silent (DR-293).
|
||||||
|
const { webviewVideoFallback } = await getPlaybackCapabilities();
|
||||||
|
const wantNative = nativeVideoWanted($experimentalNativeVideo, webviewVideoFallback);
|
||||||
|
if (!useHtml5Element && !wantNative) {
|
||||||
log.debug("Native backend available but experimentalNativeVideo is off - using HTML5");
|
log.debug("Native backend available but experimentalNativeVideo is off - using HTML5");
|
||||||
useHtml5Element = true;
|
useHtml5Element = true;
|
||||||
try {
|
try {
|
||||||
@@ -1077,7 +1090,7 @@
|
|||||||
bridge: adapterBridge,
|
bridge: adapterBridge,
|
||||||
// useHtml5Element is already the resolved decision above, so the
|
// useHtml5Element is already the resolved decision above, so the
|
||||||
// flag has had its say; pass it through for the invariant check.
|
// flag has had its say; pass it through for the invariant check.
|
||||||
experimentalNativeVideo: $experimentalNativeVideo,
|
experimentalNativeVideo: wantNative,
|
||||||
});
|
});
|
||||||
// No-op for the native adapter, which owns no DOM element.
|
// No-op for the native adapter, which owns no DOM element.
|
||||||
playerAdapter.attach(videoElement);
|
playerAdapter.attach(videoElement);
|
||||||
@@ -2029,9 +2042,12 @@
|
|||||||
const wasPlaying = shouldResumeOnForeground(handoffState.wasPlaying, get(playerState).kind);
|
const wasPlaying = shouldResumeOnForeground(handoffState.wasPlaying, get(playerState).kind);
|
||||||
handoffState = { ...initialHandoffState };
|
handoffState = { ...initialHandoffState };
|
||||||
try {
|
try {
|
||||||
// Absolute position the native audio reached (base offset applied in Rust).
|
// The item the native audio is on and the absolute position it reached
|
||||||
const pos = await commands.playerExitBackgroundAudio();
|
// (base offset applied in Rust). The item may not be `media`: an episode
|
||||||
log.debug("Returning from background audio at:", pos.toFixed(1));
|
// that ended while backgrounded has already advanced in the backend.
|
||||||
|
const resume = await commands.playerExitBackgroundAudio();
|
||||||
|
const pos = resume.positionSeconds;
|
||||||
|
log.debug("Returning from background audio at:", pos.toFixed(1), "item:", resume.itemId);
|
||||||
|
|
||||||
isMediaReady = false;
|
isMediaReady = false;
|
||||||
// The foreground seek below (pendingForegroundSeek/handleCanPlay) OWNS the
|
// The foreground seek below (pendingForegroundSeek/handleCanPlay) OWNS the
|
||||||
@@ -2050,8 +2066,18 @@
|
|||||||
position: pos,
|
position: pos,
|
||||||
wasPlaying,
|
wasPlaying,
|
||||||
nativeStateKind: get(playerState).kind,
|
nativeStateKind: get(playerState).kind,
|
||||||
|
mountedItemId: media?.id ?? null,
|
||||||
|
resumeItemId: resume.itemId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reloading `media` here would bring back the previous episode at the new
|
||||||
|
// one's timestamp. Hand the switch to the page instead.
|
||||||
|
// TRACES: UR-040, UR-023 | DR-296
|
||||||
|
if (plan.target === "other-item" && plan.itemId) {
|
||||||
|
onResumeOtherItem?.(plan.itemId, plan.position);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
pendingForegroundPlay = plan.shouldPlay;
|
pendingForegroundPlay = plan.shouldPlay;
|
||||||
|
|
||||||
// Determine the target stream + how the element/offset should be
|
// Determine the target stream + how the element/offset should be
|
||||||
|
|||||||
@@ -47,6 +47,18 @@ vi.mock("$lib/stores/nativeVideo", async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The webview path only exists where Rust offers a fallback from the native
|
||||||
|
// renderer — beside mpv native video on Linux; never on Android since DR-293,
|
||||||
|
// where a stored "off" is ignored. These tests guard that path's scrubbing, so
|
||||||
|
// they declare a platform that has it.
|
||||||
|
vi.mock("$lib/services/playbackCapabilities", () => ({
|
||||||
|
getPlaybackCapabilities: async () => ({
|
||||||
|
usesWebviewAudio: false,
|
||||||
|
supportsNativeVideo: true,
|
||||||
|
webviewVideoFallback: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@tauri-apps/api/event", () => ({
|
vi.mock("@tauri-apps/api/event", () => ({
|
||||||
listen: vi.fn(async (channel: string, handler: any) => {
|
listen: vi.fn(async (channel: string, handler: any) => {
|
||||||
channelHandlers[channel] = handler;
|
channelHandlers[channel] = handler;
|
||||||
|
|||||||
@@ -138,6 +138,51 @@ describe("backgroundAudioHandoff", () => {
|
|||||||
});
|
});
|
||||||
expect(plan.position).toBe(0);
|
expect(plan.position).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// An episode that ends while backgrounded advances in the backend. Reloading
|
||||||
|
// the video the player was mounted with brought back the PREVIOUS episode,
|
||||||
|
// at the new episode's timestamp.
|
||||||
|
//
|
||||||
|
// TRACES: UR-040, UR-023 | DR-296 | UT-265
|
||||||
|
it("switches to the item the backend advanced to", () => {
|
||||||
|
const plan = planHandoffReturn({
|
||||||
|
useHtml5Element: false,
|
||||||
|
position: 95,
|
||||||
|
wasPlaying: true,
|
||||||
|
nativeStateKind: "playing",
|
||||||
|
mountedItemId: "ep1",
|
||||||
|
resumeItemId: "ep2",
|
||||||
|
});
|
||||||
|
expect(plan.target).toBe("other-item");
|
||||||
|
expect(plan.itemId).toBe("ep2");
|
||||||
|
expect(plan.position).toBe(95);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reloads in place when the backend is still on the mounted item", () => {
|
||||||
|
const plan = planHandoffReturn({
|
||||||
|
useHtml5Element: true,
|
||||||
|
position: 95,
|
||||||
|
wasPlaying: true,
|
||||||
|
nativeStateKind: "playing",
|
||||||
|
mountedItemId: "ep1",
|
||||||
|
resumeItemId: "ep1",
|
||||||
|
});
|
||||||
|
expect(plan.target).toBe("html5-element");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reloads in place when the backend reports no item", () => {
|
||||||
|
// Queue emptied while backgrounded (e.g. the sleep timer): there is no
|
||||||
|
// other item to go to, so the mounted one is the best we have.
|
||||||
|
const plan = planHandoffReturn({
|
||||||
|
useHtml5Element: false,
|
||||||
|
position: 95,
|
||||||
|
wasPlaying: false,
|
||||||
|
nativeStateKind: undefined,
|
||||||
|
mountedItemId: "ep1",
|
||||||
|
resumeItemId: null,
|
||||||
|
});
|
||||||
|
expect(plan.target).toBe("native-backend");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -77,8 +77,14 @@ export function shouldResumeOnForeground(
|
|||||||
|
|
||||||
/** What has to be restarted to put picture back on screen, and how. */
|
/** What has to be restarted to put picture back on screen, and how. */
|
||||||
export interface HandoffReturn {
|
export interface HandoffReturn {
|
||||||
/** Which renderer must be brought back. */
|
/**
|
||||||
target: "html5-element" | "native-backend";
|
* Which renderer must be brought back — or `other-item` when the backend is
|
||||||
|
* no longer on the item this player was mounted with, so the player must
|
||||||
|
* switch to `itemId` instead of reloading itself.
|
||||||
|
*/
|
||||||
|
target: "html5-element" | "native-backend" | "other-item";
|
||||||
|
/** The item to switch to; set only for `other-item`. */
|
||||||
|
itemId?: string;
|
||||||
/** Absolute position the background audio reached. */
|
/** Absolute position the background audio reached. */
|
||||||
position: number;
|
position: number;
|
||||||
/** Whether playback should be running once it is back. */
|
/** Whether playback should be running once it is back. */
|
||||||
@@ -107,18 +113,31 @@ export interface HandoffReturn {
|
|||||||
* `shouldPlay` folds in [shouldResumeOnForeground], so a lockscreen pause during
|
* `shouldPlay` folds in [shouldResumeOnForeground], so a lockscreen pause during
|
||||||
* the handoff still wins over the snapshot taken on the way out.
|
* the handoff still wins over the snapshot taken on the way out.
|
||||||
*
|
*
|
||||||
* TRACES: UR-040, UR-003 | DR-196 | UT-060
|
* An episode that ends while backgrounded advances in the backend, so the item
|
||||||
|
* the native player returns on (`resumeItemId`) can differ from the one this
|
||||||
|
* player was mounted with. Reloading the mounted one brought back the previous
|
||||||
|
* episode at the new one's timestamp; in that case the plan is `other-item`.
|
||||||
|
* A missing `resumeItemId` (queue emptied) keeps the in-place reload.
|
||||||
|
*
|
||||||
|
* TRACES: UR-040, UR-003, UR-023 | DR-196, DR-296 | UT-060, UT-265
|
||||||
*/
|
*/
|
||||||
export function planHandoffReturn(opts: {
|
export function planHandoffReturn(opts: {
|
||||||
useHtml5Element: boolean;
|
useHtml5Element: boolean;
|
||||||
position: number;
|
position: number;
|
||||||
wasPlaying: boolean;
|
wasPlaying: boolean;
|
||||||
nativeStateKind: string | undefined;
|
nativeStateKind: string | undefined;
|
||||||
|
mountedItemId?: string | null;
|
||||||
|
resumeItemId?: string | null;
|
||||||
}): HandoffReturn {
|
}): HandoffReturn {
|
||||||
|
const position = opts.position > 0 ? opts.position : 0;
|
||||||
|
const shouldPlay = shouldResumeOnForeground(opts.wasPlaying, opts.nativeStateKind);
|
||||||
|
if (opts.resumeItemId && opts.resumeItemId !== opts.mountedItemId) {
|
||||||
|
return { target: "other-item", itemId: opts.resumeItemId, position, shouldPlay };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
target: opts.useHtml5Element ? "html5-element" : "native-backend",
|
target: opts.useHtml5Element ? "html5-element" : "native-backend",
|
||||||
position: opts.position > 0 ? opts.position : 0,
|
position,
|
||||||
shouldPlay: shouldResumeOnForeground(opts.wasPlaying, opts.nativeStateKind),
|
shouldPlay,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,12 +32,12 @@ describe("background-audio player commands (param naming)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("player_exit_background_audio takes no params and returns a position", async () => {
|
it("player_exit_background_audio takes no params and returns the resume point", async () => {
|
||||||
(invoke as any).mockResolvedValueOnce(193.5);
|
(invoke as any).mockResolvedValueOnce({ itemId: "ep2", positionSeconds: 193.5 });
|
||||||
|
|
||||||
const pos = await commands.playerExitBackgroundAudio();
|
const resume = await commands.playerExitBackgroundAudio();
|
||||||
|
|
||||||
expect(pos).toBe(193.5);
|
expect(resume).toEqual({ itemId: "ep2", positionSeconds: 193.5 });
|
||||||
expect(invoke).toHaveBeenCalledWith("player_exit_background_audio");
|
expect(invoke).toHaveBeenCalledWith("player_exit_background_audio");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -112,6 +112,22 @@ function createDownloadedCatalogStore() {
|
|||||||
|
|
||||||
export const downloadedCatalog = createDownloadedCatalogStore();
|
export const downloadedCatalog = createDownloadedCatalogStore();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every item id the device holds bytes for — playable leaves *and* the
|
||||||
|
* containers above them, as the backend's disk-usage map reports them.
|
||||||
|
*
|
||||||
|
* This is what stops the offline browse greying out a fully downloaded album:
|
||||||
|
* only a leaf (Audio, Movie, Episode) ever has a download row of its own, so
|
||||||
|
* asking the downloads store about an album id always answered "no". Which ids
|
||||||
|
* are containers, and which children roll up into them, stays a Rust question
|
||||||
|
* (`get_download_disk_usage`); the frontend only reads membership.
|
||||||
|
*
|
||||||
|
* Refreshed with the rest of the catalog — see `downloadedCatalog.refresh()`.
|
||||||
|
*
|
||||||
|
* TRACES: UR-052, UR-056 | DR-292
|
||||||
|
*/
|
||||||
|
export const deviceContentIds = derived(downloadedCatalog, ($c) => new Set(Object.keys($c.sizes)));
|
||||||
|
|
||||||
export const downloadedLibraries = derived(downloadedCatalog, ($c) => $c.libraries);
|
export const downloadedLibraries = derived(downloadedCatalog, ($c) => $c.libraries);
|
||||||
export const downloadedDeviceTotal = derived(downloadedCatalog, ($c) => $c.deviceTotalBytes);
|
export const downloadedDeviceTotal = derived(downloadedCatalog, ($c) => $c.deviceTotalBytes);
|
||||||
export const downloadedItemCount = derived(downloadedCatalog, ($c) => $c.itemCount);
|
export const downloadedItemCount = derived(downloadedCatalog, ($c) => $c.itemCount);
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ export interface PlaybackCapabilities {
|
|||||||
usesWebviewAudio: boolean;
|
usesWebviewAudio: boolean;
|
||||||
/** Video can render on a native surface behind a transparent webview. */
|
/** Video can render on a native surface behind a transparent webview. */
|
||||||
supportsNativeVideo: boolean;
|
supportsNativeVideo: boolean;
|
||||||
|
/**
|
||||||
|
* The user may send video to the webview element instead of the native
|
||||||
|
* renderer. False on Android, where ExoPlayer is the only video renderer
|
||||||
|
* (DR-293). Rust decides; see `webview_video_fallback`.
|
||||||
|
*/
|
||||||
|
webviewVideoFallback: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,6 +39,7 @@ export interface PlaybackCapabilities {
|
|||||||
const FALLBACK: PlaybackCapabilities = {
|
const FALLBACK: PlaybackCapabilities = {
|
||||||
usesWebviewAudio: false,
|
usesWebviewAudio: false,
|
||||||
supportsNativeVideo: false,
|
supportsNativeVideo: false,
|
||||||
|
webviewVideoFallback: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let cached: PlaybackCapabilities | null = null;
|
let cached: PlaybackCapabilities | null = null;
|
||||||
@@ -52,6 +59,7 @@ export async function getPlaybackCapabilities(): Promise<PlaybackCapabilities> {
|
|||||||
cached = {
|
cached = {
|
||||||
usesWebviewAudio: !!caps?.usesWebviewAudio,
|
usesWebviewAudio: !!caps?.usesWebviewAudio,
|
||||||
supportsNativeVideo: !!caps?.supportsNativeVideo,
|
supportsNativeVideo: !!caps?.supportsNativeVideo,
|
||||||
|
webviewVideoFallback: !!caps?.webviewVideoFallback,
|
||||||
};
|
};
|
||||||
return cached;
|
return cached;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Queue a server-only item for download on the next reconnect.
|
||||||
|
*
|
||||||
|
* Offline this only persists a `pending` downloads row with no `stream_url`;
|
||||||
|
* the reconnect handler resolves the URL and the pump starts it (see
|
||||||
|
* `offlineCatalog`). Shared by the grid card and the list row so the two
|
||||||
|
* surfaces queue identically — the list view previously had no way to queue at
|
||||||
|
* all.
|
||||||
|
*
|
||||||
|
* TRACES: UR-052 | DR-292
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { MediaItem } from "$lib/api/types";
|
||||||
|
import { downloads } from "$lib/stores/downloads";
|
||||||
|
import { auth } from "$lib/stores/auth";
|
||||||
|
|
||||||
|
export async function queueOfflineDownload(item: MediaItem): Promise<void> {
|
||||||
|
const userId = auth.getUserId();
|
||||||
|
if (!userId) throw new Error("Not signed in");
|
||||||
|
|
||||||
|
// A sensible on-disk path; the backend heals `stream_url` on reconnect.
|
||||||
|
const filePath = `downloads/${item.id}`;
|
||||||
|
await downloads.downloadItem(
|
||||||
|
item.id,
|
||||||
|
userId,
|
||||||
|
filePath,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
item.name,
|
||||||
|
item.artists?.join(", ") ?? undefined,
|
||||||
|
item.albumName ?? undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -568,6 +568,79 @@ describe("downloads store", () => {
|
|||||||
expect(state.downloads[123].status).toBe("completed");
|
expect(state.downloads[123].status).toBe("completed");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// A transcode's total is a prediction. The bar must carry that so it can
|
||||||
|
/// say "~", and the row's persisted size on completion must be the bytes
|
||||||
|
/// the worker counted — never the prediction.
|
||||||
|
/// TRACES: UR-071 | DR-290 | UT-254
|
||||||
|
it("carries the estimate flag through progress and persists the real size on completion", async () => {
|
||||||
|
const { downloads, initDownloadEvents } = await import("./downloads");
|
||||||
|
|
||||||
|
mockInvoke.mockResolvedValueOnce({
|
||||||
|
downloads: [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
itemId: "film",
|
||||||
|
userId: "user-1",
|
||||||
|
filePath: "videos/movies/film.mp4",
|
||||||
|
fileSize: 3_000_000_000, // predicted at resolve time
|
||||||
|
status: "downloading",
|
||||||
|
progress: 0,
|
||||||
|
bytesDownloaded: 0,
|
||||||
|
queuedAt: "2024-01-01T00:00:00Z",
|
||||||
|
retryCount: 0,
|
||||||
|
priority: 0,
|
||||||
|
mediaType: "video",
|
||||||
|
downloadSource: "user",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stats: {
|
||||||
|
total: 1,
|
||||||
|
activeCount: 1,
|
||||||
|
queuedCount: 0,
|
||||||
|
completedCount: 0,
|
||||||
|
failedCount: 0,
|
||||||
|
pausedCount: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await downloads.refresh("user-1");
|
||||||
|
await initDownloadEvents();
|
||||||
|
|
||||||
|
eventHandler!({
|
||||||
|
payload: {
|
||||||
|
type: "progress",
|
||||||
|
downloadId: 7,
|
||||||
|
itemId: "film",
|
||||||
|
bytesDownloaded: 1_500_000_000,
|
||||||
|
totalBytes: 3_000_000_000,
|
||||||
|
progress: 0.5,
|
||||||
|
estimated: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let row = get(downloads).downloads[7];
|
||||||
|
expect(row.fileSizeEstimated).toBe(true);
|
||||||
|
expect(row.progress).toBe(0.5);
|
||||||
|
|
||||||
|
mockInvoke.mockClear();
|
||||||
|
mockInvoke.mockResolvedValueOnce(undefined);
|
||||||
|
eventHandler!({
|
||||||
|
payload: {
|
||||||
|
type: "completed",
|
||||||
|
downloadId: 7,
|
||||||
|
itemId: "film",
|
||||||
|
filePath: "videos/movies/film.mp4",
|
||||||
|
bytesDownloaded: 3_120_000_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
row = get(downloads).downloads[7];
|
||||||
|
expect(row.status).toBe("completed");
|
||||||
|
expect(row.fileSizeEstimated).toBe(false);
|
||||||
|
expect(row.fileSize).toBe(3_120_000_000);
|
||||||
|
const persisted = mockInvoke.mock.calls.find((c) => c[0] === "mark_download_completed");
|
||||||
|
expect(persisted?.[1]).toMatchObject({ bytesDownloaded: 3_120_000_000 });
|
||||||
|
});
|
||||||
|
|
||||||
it("should handle failed event and refresh", async () => {
|
it("should handle failed event and refresh", async () => {
|
||||||
const { downloads, initDownloadEvents } = await import("./downloads");
|
const { downloads, initDownloadEvents } = await import("./downloads");
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export interface DownloadInfo {
|
|||||||
userId: string;
|
userId: string;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
fileSize?: number;
|
fileSize?: number;
|
||||||
|
/** `fileSize` is the backend's prediction (a transcode states no length), not the server's word. */
|
||||||
|
fileSizeEstimated?: boolean;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
status: "pending" | "downloading" | "completed" | "failed" | "paused";
|
status: "pending" | "downloading" | "completed" | "failed" | "paused";
|
||||||
progress: number;
|
progress: number;
|
||||||
@@ -58,6 +60,8 @@ export interface DownloadEvent {
|
|||||||
bytesDownloaded?: number;
|
bytesDownloaded?: number;
|
||||||
totalBytes?: number;
|
totalBytes?: number;
|
||||||
progress?: number;
|
progress?: number;
|
||||||
|
/** On 'progress': `totalBytes` is an estimate, so `progress` stays below 1 until 'completed'. */
|
||||||
|
estimated?: boolean;
|
||||||
filePath?: string;
|
filePath?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
@@ -598,17 +602,24 @@ function handleDownloadEvent(payload: DownloadEvent): void {
|
|||||||
progress: payload.progress,
|
progress: payload.progress,
|
||||||
bytesDownloaded: payload.bytesDownloaded || download.bytesDownloaded,
|
bytesDownloaded: payload.bytesDownloaded || download.bytesDownloaded,
|
||||||
fileSize: payload.totalBytes || download.fileSize,
|
fileSize: payload.totalBytes || download.fileSize,
|
||||||
|
fileSizeEstimated: payload.estimated ?? download.fileSizeEstimated ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "completed":
|
case "completed":
|
||||||
if (download) {
|
if (download) {
|
||||||
|
// The bytes actually written, as the worker counted them. A predicted
|
||||||
|
// fileSize must never be persisted as the real one (DR-290).
|
||||||
|
const finalBytes =
|
||||||
|
payload.bytesDownloaded ||
|
||||||
|
(download.fileSizeEstimated ? 0 : download.fileSize) ||
|
||||||
|
download.bytesDownloaded;
|
||||||
// Persist to database
|
// Persist to database
|
||||||
commands
|
commands
|
||||||
.markDownloadCompleted(
|
.markDownloadCompleted(
|
||||||
payload.downloadId,
|
payload.downloadId,
|
||||||
payload.totalBytes || download.fileSize || download.bytesDownloaded,
|
finalBytes,
|
||||||
payload.filePath || download.filePath,
|
payload.filePath || download.filePath,
|
||||||
)
|
)
|
||||||
.catch((err) => log.error("Failed to persist download completion:", err));
|
.catch((err) => log.error("Failed to persist download completion:", err));
|
||||||
@@ -616,6 +627,9 @@ function handleDownloadEvent(payload: DownloadEvent): void {
|
|||||||
updateDownloadInStore(payload.downloadId, {
|
updateDownloadInStore(payload.downloadId, {
|
||||||
status: "completed",
|
status: "completed",
|
||||||
progress: 1.0,
|
progress: 1.0,
|
||||||
|
bytesDownloaded: finalBytes,
|
||||||
|
fileSize: finalBytes,
|
||||||
|
fileSizeEstimated: false,
|
||||||
completedAt: new Date().toISOString(),
|
completedAt: new Date().toISOString(),
|
||||||
filePath: payload.filePath || download.filePath,
|
filePath: payload.filePath || download.filePath,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -116,6 +116,23 @@ function createExperimentalNativeVideoStore() {
|
|||||||
*/
|
*/
|
||||||
export const experimentalNativeVideo = createExperimentalNativeVideoStore();
|
export const experimentalNativeVideo = createExperimentalNativeVideoStore();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether video should take the native path, given the user's stored choice
|
||||||
|
* and whether this platform lets the user choose at all.
|
||||||
|
*
|
||||||
|
* On Android the answer is always native: ExoPlayer is the only video renderer
|
||||||
|
* there, and the webview element decodes none of the AC-3/E-AC-3/DTS/TrueHD
|
||||||
|
* that ExoPlayer plays through the FFmpeg extension — so a stored "off" would
|
||||||
|
* turn every original-file download into a silent film (DR-293). Rust reports
|
||||||
|
* whether a fallback exists (`webviewVideoFallback`); only then does the
|
||||||
|
* stored choice count.
|
||||||
|
*
|
||||||
|
* TRACES: UR-003, UR-071 | DR-293 | UT-262
|
||||||
|
*/
|
||||||
|
export function nativeVideoWanted(storedChoice: boolean, webviewVideoFallback: boolean): boolean {
|
||||||
|
return webviewVideoFallback ? storedChoice : true;
|
||||||
|
}
|
||||||
|
|
||||||
function createNativeVideoActiveStore() {
|
function createNativeVideoActiveStore() {
|
||||||
const { subscribe, set } = writable<boolean>(false);
|
const { subscribe, set } = writable<boolean>(false);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { nativeVideoWanted } from "./nativeVideo";
|
||||||
|
|
||||||
|
// TRACES: UR-003, UR-071 | DR-293 | UT-262
|
||||||
|
describe("nativeVideoWanted", () => {
|
||||||
|
it("ignores a stored 'off' where there is no webview fallback (Android)", () => {
|
||||||
|
// Someone who once switched native video off on Android must not be
|
||||||
|
// routed to the webview, which plays original-file downloads silent.
|
||||||
|
expect(nativeVideoWanted(false, false)).toBe(true);
|
||||||
|
expect(nativeVideoWanted(true, false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours the stored choice where a fallback exists (Linux beside mpv)", () => {
|
||||||
|
expect(nativeVideoWanted(false, true)).toBe(false);
|
||||||
|
expect(nativeVideoWanted(true, true)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { createCoalescedLoader } from "./coalescedLoader";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TRACES: UR-062 | DR-295
|
||||||
|
*
|
||||||
|
* The series page loaded itself six times on every open: `onMount` and a
|
||||||
|
* `$effect` both ran on mount, the "server became reachable" effect fired on
|
||||||
|
* its first run, and navigation updates re-ran the effect. Each load repeated
|
||||||
|
* the item, the season list and the whole series view — about six times a
|
||||||
|
* dozen requests in flight at once, which alone slowed every server call on a
|
||||||
|
* phone to 2-3 s.
|
||||||
|
*/
|
||||||
|
function deferred() {
|
||||||
|
let resolve!: () => void;
|
||||||
|
const promise = new Promise<void>((r) => (resolve = r));
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createCoalescedLoader", () => {
|
||||||
|
it("shares one run between calls for the same key while it is in flight", async () => {
|
||||||
|
const gate = deferred();
|
||||||
|
const run = vi.fn(() => gate.promise);
|
||||||
|
const loader = createCoalescedLoader(run);
|
||||||
|
|
||||||
|
const calls = [1, 2, 3, 4, 5, 6].map(() => loader.load("frasier"));
|
||||||
|
gate.resolve();
|
||||||
|
await Promise.all(calls);
|
||||||
|
|
||||||
|
expect(run).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-runs once after the in-flight load when a caller needs fresh data", async () => {
|
||||||
|
const gates = [deferred(), deferred()];
|
||||||
|
let n = 0;
|
||||||
|
const run = vi.fn(() => gates[n++].promise);
|
||||||
|
const loader = createCoalescedLoader(run);
|
||||||
|
|
||||||
|
const first = loader.load("frasier");
|
||||||
|
// e.g. "mark watched" finished while the page was still loading: the
|
||||||
|
// in-flight load may predate the change, so it must not be the answer.
|
||||||
|
const fresh = loader.load("frasier", { fresh: true });
|
||||||
|
const fresh2 = loader.load("frasier", { fresh: true });
|
||||||
|
gates[0].resolve();
|
||||||
|
await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(2));
|
||||||
|
gates[1].resolve();
|
||||||
|
// Every caller is answered by the load that includes the re-run.
|
||||||
|
await Promise.all([first, fresh, fresh2]);
|
||||||
|
|
||||||
|
expect(run).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not share a run between different keys", async () => {
|
||||||
|
const run = vi.fn(() => Promise.resolve());
|
||||||
|
const loader = createCoalescedLoader(run);
|
||||||
|
|
||||||
|
await Promise.all([loader.load("frasier"), loader.load("cheers")]);
|
||||||
|
|
||||||
|
expect(run).toHaveBeenCalledTimes(2);
|
||||||
|
expect(run).toHaveBeenNthCalledWith(1, "frasier");
|
||||||
|
expect(run).toHaveBeenNthCalledWith(2, "cheers");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs again once the previous load has finished", async () => {
|
||||||
|
const run = vi.fn(() => Promise.resolve());
|
||||||
|
const loader = createCoalescedLoader(run);
|
||||||
|
|
||||||
|
await loader.load("frasier");
|
||||||
|
await loader.load("frasier");
|
||||||
|
|
||||||
|
expect(run).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases the key when a load fails", async () => {
|
||||||
|
const run = vi
|
||||||
|
.fn<(key: string) => Promise<void>>()
|
||||||
|
.mockRejectedValueOnce(new Error("offline"))
|
||||||
|
.mockResolvedValueOnce(undefined);
|
||||||
|
const loader = createCoalescedLoader(run);
|
||||||
|
|
||||||
|
await expect(loader.load("frasier")).rejects.toThrow("offline");
|
||||||
|
await loader.load("frasier");
|
||||||
|
|
||||||
|
expect(run).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Load one keyed thing at a time, however many triggers ask for it.
|
||||||
|
*
|
||||||
|
* Calls for the key already loading share that load instead of starting their
|
||||||
|
* own. A caller that knows the data changed (`fresh` — after "mark watched",
|
||||||
|
* on reconnect, when a filter flips) must not be answered by a load that may
|
||||||
|
* predate the change, so it gets exactly one re-run once the current load
|
||||||
|
* ends, however many such callers there were.
|
||||||
|
*
|
||||||
|
* Exists because the series page loaded itself six times on every open —
|
||||||
|
* `onMount`, a mount-time `$effect`, the reachability effect's first run and
|
||||||
|
* navigation updates each started a full load — putting about six times a
|
||||||
|
* dozen requests in flight at once.
|
||||||
|
*
|
||||||
|
* TRACES: UR-062 | DR-295
|
||||||
|
*/
|
||||||
|
export interface CoalescedLoader {
|
||||||
|
/** Load `key`. `fresh`: the caller knows the data changed. */
|
||||||
|
load(key: string, options?: { fresh?: boolean }): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InFlight {
|
||||||
|
key: string;
|
||||||
|
/** Settles when this load and any re-run it owes have finished. */
|
||||||
|
done: Promise<void>;
|
||||||
|
rerun: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCoalescedLoader(run: (key: string) => Promise<void>): CoalescedLoader {
|
||||||
|
let inFlight: InFlight | null = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
load(key, options = {}) {
|
||||||
|
if (inFlight && inFlight.key === key) {
|
||||||
|
if (options.fresh) inFlight.rerun = true;
|
||||||
|
return inFlight.done;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry: InFlight = { key, rerun: false, done: Promise.resolve() };
|
||||||
|
entry.done = (async () => {
|
||||||
|
try {
|
||||||
|
await run(key);
|
||||||
|
while (entry.rerun) {
|
||||||
|
entry.rerun = false;
|
||||||
|
await run(key);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (inFlight === entry) inFlight = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
inFlight = entry;
|
||||||
|
return entry.done;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
routeOwnsLayout,
|
routeOwnsLayout,
|
||||||
showBottomUi,
|
showBottomUi,
|
||||||
shellReservesBottomInset,
|
shellReservesBottomInset,
|
||||||
|
showOfflineBanner,
|
||||||
} from "./layoutShell";
|
} from "./layoutShell";
|
||||||
|
|
||||||
const authed = (pathname: string) => ({ pathname, isAuthenticated: true });
|
const authed = (pathname: string) => ({ pathname, isAuthenticated: true });
|
||||||
@@ -236,3 +237,31 @@ describe("profile picker chrome", () => {
|
|||||||
expect(shellReservesBottomInset({ pathname, isAuthenticated: true })).toBe(true);
|
expect(shellReservesBottomInset({ pathname, isAuthenticated: true })).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The offline banner is shell chrome, and the full-screen player is chrome-free
|
||||||
|
* like every other immersive route in this module. On the native Android video
|
||||||
|
* path VideoPlayer makes itself transparent so the ExoPlayer SurfaceView behind
|
||||||
|
* the WebView shows through (DR-185), which means any shell element that still
|
||||||
|
* paints — the amber banner — appears as a stripe across the top of the film.
|
||||||
|
*
|
||||||
|
* TRACES: UR-003 | DR-291 | UT-255
|
||||||
|
*/
|
||||||
|
describe("showOfflineBanner", () => {
|
||||||
|
it("shows while offline on ordinary routes", () => {
|
||||||
|
expect(showOfflineBanner({ ...authed("/"), isConnected: false })).toBe(true);
|
||||||
|
expect(showOfflineBanner({ ...authed("/library/movies"), isConnected: false })).toBe(true);
|
||||||
|
expect(showOfflineBanner({ ...authed("/downloads"), isConnected: false })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays off the full-screen player, where it would paint over the video", () => {
|
||||||
|
expect(showOfflineBanner({ ...authed("/player/abc123"), isConnected: false })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays off while connected, and while signed out", () => {
|
||||||
|
expect(showOfflineBanner({ ...authed("/"), isConnected: true })).toBe(false);
|
||||||
|
expect(showOfflineBanner({ pathname: "/", isAuthenticated: false, isConnected: false })).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -129,3 +129,26 @@ export function showBottomUi(input: BottomUiVisibilityInput): boolean {
|
|||||||
export function shellReservesBottomInset(input: BottomUiVisibilityInput): boolean {
|
export function shellReservesBottomInset(input: BottomUiVisibilityInput): boolean {
|
||||||
return !showBottomUi(input);
|
return !showBottomUi(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the shell renders the offline banner ("You're offline…").
|
||||||
|
*
|
||||||
|
* The banner is shell chrome, and it is the last piece of it that still
|
||||||
|
* rendered over the full-screen player — every other rule in this module
|
||||||
|
* already treats `/player/*` as immersive. On the native Android video path
|
||||||
|
* that is not merely untidy: VideoPlayer makes itself transparent so the
|
||||||
|
* ExoPlayer SurfaceView behind the WebView can be seen (DR-185), so any shell
|
||||||
|
* element that still paints shows through the film. Offline is also exactly
|
||||||
|
* when a downloaded video plays, so the banner was most likely to be there
|
||||||
|
* precisely when it was most in the way — and it says nothing the viewer can
|
||||||
|
* act on while watching: local playback needs no server.
|
||||||
|
*
|
||||||
|
* TRACES: UR-003, UR-043 | DR-291
|
||||||
|
*/
|
||||||
|
export function showOfflineBanner({
|
||||||
|
pathname,
|
||||||
|
isAuthenticated,
|
||||||
|
isConnected,
|
||||||
|
}: BottomUiVisibilityInput & { isConnected: boolean }): boolean {
|
||||||
|
return isAuthenticated && !isConnected && !pathname.startsWith("/player/");
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the "server only" card rule shared by the grid and the list view.
|
||||||
|
*
|
||||||
|
* TRACES: UR-052 | DR-292 | UT-257
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { isServerOnly, type ServerOnlyInput } from "./serverOnly";
|
||||||
|
|
||||||
|
const offlineReveal: ServerOnlyInput = {
|
||||||
|
isMediaItem: true,
|
||||||
|
isConnected: false,
|
||||||
|
revealServerCatalog: true,
|
||||||
|
isDownloaded: false,
|
||||||
|
isActivelyDownloading: false,
|
||||||
|
hasDeviceContent: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("isServerOnly", () => {
|
||||||
|
it("is true only offline, with the reveal on, for an item with nothing on the device", () => {
|
||||||
|
expect(isServerOnly(offlineReveal)).toBe(true);
|
||||||
|
expect(isServerOnly({ ...offlineReveal, isConnected: true })).toBe(false);
|
||||||
|
expect(isServerOnly({ ...offlineReveal, revealServerCatalog: false })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never greys a library tile: there is nothing to queue", () => {
|
||||||
|
expect(isServerOnly({ ...offlineReveal, isMediaItem: false })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not grey the item's own completed or in-flight download", () => {
|
||||||
|
expect(isServerOnly({ ...offlineReveal, isDownloaded: true })).toBe(false);
|
||||||
|
expect(isServerOnly({ ...offlineReveal, isActivelyDownloading: true })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not grey a container whose children are on the device", () => {
|
||||||
|
// The regression: an album has no download row of its own — its *tracks*
|
||||||
|
// do — so a fully downloaded album greyed itself out and offered to queue
|
||||||
|
// what was already there.
|
||||||
|
expect(isServerOnly({ ...offlineReveal, hasDeviceContent: true })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still greys a container with nothing downloaded under it", () => {
|
||||||
|
expect(isServerOnly({ ...offlineReveal, hasDeviceContent: false })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Is a card "server only" — revealed by the offline "Show all server media"
|
||||||
|
* toggle, but with nothing on the device behind it?
|
||||||
|
*
|
||||||
|
* Such cards render greyed out, are inert to tap (there is nothing to play),
|
||||||
|
* and offer to queue a download for the next reconnect instead.
|
||||||
|
*
|
||||||
|
* This lives in its own module because two surfaces answer the question — the
|
||||||
|
* grid (`MediaCard`) and the list (`LibraryListView`) — and they disagreed:
|
||||||
|
* the list had no notion of server-only at all, so switching a library to list
|
||||||
|
* view offline turned every non-downloaded item back into a normal, tappable
|
||||||
|
* row that plays nothing.
|
||||||
|
*
|
||||||
|
* `hasDeviceContent` is the fix for the second half of that defect. An item's
|
||||||
|
* *own* download row only ever exists for a playable leaf (Audio, Movie,
|
||||||
|
* Episode); an album or a season never has one, so a fully downloaded album
|
||||||
|
* greyed itself out and offered to queue what was already on the device. The
|
||||||
|
* caller passes the backend's answer — `get_download_disk_usage().sizes`
|
||||||
|
* carries container subtotals as well as leaf sizes — rather than the frontend
|
||||||
|
* deciding which item types are containers, which is taxonomy that belongs in
|
||||||
|
* Rust.
|
||||||
|
*
|
||||||
|
* TRACES: UR-052 | DR-292 | UT-257
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ServerOnlyInput {
|
||||||
|
/** False for a `Library` tile — a library is never queued or greyed. */
|
||||||
|
isMediaItem: boolean;
|
||||||
|
/** Server reachability (`$isConnected`). */
|
||||||
|
isConnected: boolean;
|
||||||
|
/** The offline banner's "Show all server media" toggle. */
|
||||||
|
revealServerCatalog: boolean;
|
||||||
|
/** This item's own download row is `completed`. */
|
||||||
|
isDownloaded: boolean;
|
||||||
|
/** This item's own download row is actively transferring. */
|
||||||
|
isActivelyDownloading: boolean;
|
||||||
|
/** The device holds bytes at or under this item (leaf file or container). */
|
||||||
|
hasDeviceContent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isServerOnly({
|
||||||
|
isMediaItem,
|
||||||
|
isConnected,
|
||||||
|
revealServerCatalog,
|
||||||
|
isDownloaded,
|
||||||
|
isActivelyDownloading,
|
||||||
|
hasDeviceContent,
|
||||||
|
}: ServerOnlyInput): boolean {
|
||||||
|
if (!isMediaItem) return false;
|
||||||
|
if (isConnected || !revealServerCatalog) return false;
|
||||||
|
return !isDownloaded && !isActivelyDownloading && !hasDeviceContent;
|
||||||
|
}
|
||||||
+45
-17
@@ -19,6 +19,8 @@
|
|||||||
showServerCatalog,
|
showServerCatalog,
|
||||||
lastCatalogSync,
|
lastCatalogSync,
|
||||||
} from "$lib/services/offlineCatalog";
|
} from "$lib/services/offlineCatalog";
|
||||||
|
import { downloadedCatalog } from "$lib/services/downloadedCatalog";
|
||||||
|
import { useOfflineFilterReload } from "$lib/composables/useOfflineFilterReload";
|
||||||
import { playbackMode } from "$lib/stores/playbackMode";
|
import { playbackMode } from "$lib/stores/playbackMode";
|
||||||
import { sessions } from "$lib/stores/sessions";
|
import { sessions } from "$lib/stores/sessions";
|
||||||
import ReauthModal from "$lib/components/auth/ReauthModal.svelte";
|
import ReauthModal from "$lib/components/auth/ReauthModal.svelte";
|
||||||
@@ -39,6 +41,7 @@
|
|||||||
showGlobalHeader as computeShowGlobalHeader,
|
showGlobalHeader as computeShowGlobalHeader,
|
||||||
routeOwnsLayout as computeRouteOwnsLayout,
|
routeOwnsLayout as computeRouteOwnsLayout,
|
||||||
shellReservesBottomInset,
|
shellReservesBottomInset,
|
||||||
|
showOfflineBanner as computeShowOfflineBanner,
|
||||||
} from "$lib/utils/layoutShell";
|
} from "$lib/utils/layoutShell";
|
||||||
import { registerNavigationTracking } from "$lib/utils/navigation";
|
import { registerNavigationTracking } from "$lib/utils/navigation";
|
||||||
import { useScrollRestore } from "$lib/utils/scrollContainer";
|
import { useScrollRestore } from "$lib/utils/scrollContainer";
|
||||||
@@ -70,7 +73,8 @@
|
|||||||
// a new page inherits the previous page's offset. Must be registered here at
|
// a new page inherits the previous page's offset. Must be registered here at
|
||||||
// init, alongside the tracker above, for the same reason. (DR-156)
|
// init, alongside the tracker above, for the same reason. (DR-156)
|
||||||
let shellScroller = $state<HTMLElement>();
|
let shellScroller = $state<HTMLElement>();
|
||||||
useScrollRestore(() => shellScroller, "shell");
|
// Owned routes scroll inside their own column; the shell box does not.
|
||||||
|
useScrollRestore(() => (routeOwnsLayout ? undefined : shellScroller), "shell");
|
||||||
|
|
||||||
// Layout-shell visibility rules live in one pure, unit-tested module
|
// Layout-shell visibility rules live in one pure, unit-tested module
|
||||||
// ($lib/utils/layoutShell) so they can't drift per route/platform.
|
// ($lib/utils/layoutShell) so they can't drift per route/platform.
|
||||||
@@ -108,6 +112,22 @@
|
|||||||
shellReservesBottomInset({ pathname, isAuthenticated: $isAuthenticated }),
|
shellReservesBottomInset({ pathname, isAuthenticated: $isAuthenticated }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The device-content map decides which revealed cards are greyed, so it has
|
||||||
|
// to be current whenever the offline gate settles — going offline, or
|
||||||
|
// flipping "Show all server media" — not just at startup. (DR-292)
|
||||||
|
useOfflineFilterReload(() => downloadedCatalog.refresh());
|
||||||
|
|
||||||
|
// The offline banner is shell chrome like the rest, so it stays off the
|
||||||
|
// full-screen player — where on the native video path it would paint a
|
||||||
|
// stripe straight through the film (DR-291).
|
||||||
|
const offlineBannerVisible = $derived(
|
||||||
|
computeShowOfflineBanner({
|
||||||
|
pathname,
|
||||||
|
isAuthenticated: $isAuthenticated,
|
||||||
|
isConnected: $isConnected,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
// Detect platform first (synchronously, before any await) so the global
|
// Detect platform first (synchronously, before any await) so the global
|
||||||
// mini player's Android visibility gate is correct from the first render.
|
// mini player's Android visibility gate is correct from the first render.
|
||||||
@@ -164,6 +184,14 @@
|
|||||||
const userId = get(auth).user?.id;
|
const userId = get(auth).user?.id;
|
||||||
if (userId) {
|
if (userId) {
|
||||||
downloads.refresh(userId).catch((err) => log.error("Initial downloads refresh failed:", err));
|
downloads.refresh(userId).catch((err) => log.error("Initial downloads refresh failed:", err));
|
||||||
|
// Which containers hold downloaded children is a backend question
|
||||||
|
// (`get_download_disk_usage`), and the offline browse needs the answer to
|
||||||
|
// avoid greying out a fully downloaded album — its *tracks* carry the
|
||||||
|
// download rows, never the album. Primed here because the map used to be
|
||||||
|
// loaded only by the Downloads page (DR-292).
|
||||||
|
downloadedCatalog
|
||||||
|
.refresh()
|
||||||
|
.catch((err) => log.error("Initial downloaded-catalog refresh failed:", err));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start sync service for offline mutation queue
|
// Start sync service for offline mutation queue
|
||||||
@@ -283,8 +311,8 @@
|
|||||||
style:padding-bottom={shellPadsBottom ? "var(--safe-bottom)" : undefined}
|
style:padding-bottom={shellPadsBottom ? "var(--safe-bottom)" : undefined}
|
||||||
>
|
>
|
||||||
{#if isInitialized}
|
{#if isInitialized}
|
||||||
<!-- Offline indicator banner -->
|
<!-- Offline indicator banner (never over the player — DR-291) -->
|
||||||
{#if $isAuthenticated && !$isConnected}
|
{#if offlineBannerVisible}
|
||||||
<div
|
<div
|
||||||
class="bg-amber-600/90 text-white px-4 py-2 text-sm flex items-center justify-center gap-2 shrink-0"
|
class="bg-amber-600/90 text-white px-4 py-2 text-sm flex items-center justify-center gap-2 shrink-0"
|
||||||
>
|
>
|
||||||
@@ -330,29 +358,29 @@
|
|||||||
scrolling internally. All other top-level pages render directly here, so
|
scrolling internally. All other top-level pages render directly here, so
|
||||||
this wrapper must scroll and reserve the fixed bottom UI's measured
|
this wrapper must scroll and reserve the fixed bottom UI's measured
|
||||||
height so the mini player / bottom nav never overlap the last rows. -->
|
height so the mini player / bottom nav never overlap the last rows. -->
|
||||||
{#if routeOwnsLayout}
|
|
||||||
<!-- These routes own their own full-height flex column (header + scroller
|
|
||||||
+ their own in-flow BottomUi), so the root just clips and steps back. -->
|
|
||||||
<div class="flex-1 overflow-hidden">
|
|
||||||
{@render children()}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<!-- Shared header (account menu, desktop nav) as a flex-shrink-0 sibling
|
<!-- Shared header (account menu, desktop nav) as a flex-shrink-0 sibling
|
||||||
above the scroller, so it never eats into the scroller's bounds. -->
|
above the scroller, so it never eats into the scroller's bounds. -->
|
||||||
{#if showGlobalHeader}
|
{#if !routeOwnsLayout && showGlobalHeader}
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
{/if}
|
{/if}
|
||||||
<!-- Scroller is flex-1/min-h-0; the in-flow BottomUi below is a flex
|
<!-- ONE element renders the route, whatever the layout mode; only its
|
||||||
sibling, so the list is physically bounded above it and can never
|
classes change. Routes that own their full-height column (header +
|
||||||
render behind it. No measurement, no reserved padding. -->
|
scroller + their own in-flow BottomUi) get a plain clipped box; every
|
||||||
|
other route gets the shell scroller (flex-1/min-h-0, bounded above the
|
||||||
|
in-flow BottomUi, so no measurement or reserved padding).
|
||||||
|
|
||||||
|
This used to be two branches, each rendering `children`. The page
|
||||||
|
store that decides the mode can update a flush after the new route
|
||||||
|
renders, so navigating between the two kinds of route (Search → a
|
||||||
|
library page) mounted the page under one branch and then *remounted*
|
||||||
|
it under the other — every load it started, twice (DR-295). -->
|
||||||
<div
|
<div
|
||||||
bind:this={shellScroller}
|
bind:this={shellScroller}
|
||||||
class="flex-1 overflow-y-auto min-h-0"
|
class={routeOwnsLayout ? "flex-1 overflow-hidden" : "flex-1 overflow-y-auto min-h-0"}
|
||||||
style="overscroll-behavior: contain"
|
style={routeOwnsLayout ? undefined : "overscroll-behavior: contain"}
|
||||||
>
|
>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Re-authentication modal -->
|
<!-- Re-authentication modal -->
|
||||||
<ReauthModal isOpen={$needsReauth} />
|
<ReauthModal isOpen={$needsReauth} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<!-- TRACES: UR-035, UR-038, UR-048, UR-058, UR-062 | DR-043, DR-062, DR-102, DR-103, DR-142 -->
|
<!-- TRACES: UR-035, UR-038, UR-048, UR-058, UR-062 | DR-043, DR-062, DR-102, DR-103, DR-142 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import { formatDuration } from "$lib/utils/duration";
|
import { formatDuration } from "$lib/utils/duration";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
@@ -51,6 +51,7 @@
|
|||||||
type SeasonData,
|
type SeasonData,
|
||||||
} from "$lib/components/library/seriesNavigation";
|
} from "$lib/components/library/seriesNavigation";
|
||||||
import { createLogger } from "$lib/utils/logger";
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
import { createCoalescedLoader } from "$lib/utils/coalescedLoader";
|
||||||
|
|
||||||
const log = createLogger("LibraryDetail");
|
const log = createLogger("LibraryDetail");
|
||||||
|
|
||||||
@@ -65,18 +66,27 @@
|
|||||||
// preference, so it resets with each load (DR-107).
|
// preference, so it resets with each load (DR-107).
|
||||||
let expandedSeasons = $state<Set<string>>(new Set());
|
let expandedSeasons = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
// Track if we've done an initial load and previous server state
|
// Track if we've done an initial load and previous server state. The
|
||||||
|
// previous state starts as "unknown" (null): the effect's first run only
|
||||||
|
// records it. Starting at `false` made that first run look like a
|
||||||
|
// reconnect and force a second, fresh load of the page on every open.
|
||||||
let hasLoadedOnce = false;
|
let hasLoadedOnce = false;
|
||||||
let previousServerReachable = false;
|
let previousServerReachable: boolean | null = null;
|
||||||
|
|
||||||
const itemId = $derived($page.params.id);
|
const itemId = $derived($page.params.id);
|
||||||
const focusedEpisodeId = $derived($page.url.searchParams.get("episode"));
|
const focusedEpisodeId = $derived($page.url.searchParams.get("episode"));
|
||||||
|
|
||||||
onMount(async () => {
|
// Every trigger below goes through one coalesced loader: they used to each
|
||||||
await loadItem();
|
// start a full load, so opening a page loaded it six times over (DR-295).
|
||||||
hasLoadedOnce = true;
|
// `fresh` marks triggers that know the data changed.
|
||||||
});
|
const loader = createCoalescedLoader(() => loadItemNow());
|
||||||
|
function loadItem(options?: { fresh?: boolean }): Promise<void> {
|
||||||
|
if (!itemId) return Promise.resolve();
|
||||||
|
return loader.load(itemId, options);
|
||||||
|
}
|
||||||
|
const reloadFresh = () => loadItem({ fresh: true });
|
||||||
|
|
||||||
|
// Runs on mount and whenever the item changes.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (itemId) {
|
if (itemId) {
|
||||||
loadItem();
|
loadItem();
|
||||||
@@ -89,8 +99,8 @@
|
|||||||
const serverReachable = $isServerReachable;
|
const serverReachable = $isServerReachable;
|
||||||
|
|
||||||
// If server just became reachable and we've already loaded, reload to get fresh data
|
// If server just became reachable and we've already loaded, reload to get fresh data
|
||||||
if (serverReachable && !previousServerReachable && hasLoadedOnce && itemId) {
|
if (serverReachable && previousServerReachable === false && hasLoadedOnce && itemId) {
|
||||||
loadItem();
|
reloadFresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
previousServerReachable = serverReachable;
|
previousServerReachable = serverReachable;
|
||||||
@@ -100,10 +110,10 @@
|
|||||||
// contents follow the filter the same way a library listing does.
|
// contents follow the filter the same way a library listing does.
|
||||||
// TRACES: UR-052 | DR-143
|
// TRACES: UR-052 | DR-143
|
||||||
useOfflineFilterReload(() => {
|
useOfflineFilterReload(() => {
|
||||||
if (itemId) loadItem();
|
if (itemId) reloadFresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadItem() {
|
async function loadItemNow() {
|
||||||
if (!itemId) return;
|
if (!itemId) return;
|
||||||
// Only show spinner when navigating to a different item
|
// Only show spinner when navigating to a different item
|
||||||
// untrack prevents $effect from tracking `item` as a dependency (avoids infinite loop)
|
// untrack prevents $effect from tracking `item` as a dependency (avoids infinite loop)
|
||||||
@@ -211,14 +221,9 @@
|
|||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
const seasons = $libraryItems.filter((i) => i.kind === "season");
|
const seasons = $libraryItems.filter((i) => i.kind === "season");
|
||||||
|
|
||||||
const [episodes, current] = await Promise.all([
|
// One call, one season fan-out: asking for episodes and the current
|
||||||
repo.getSeriesEpisodes(itemId),
|
// episode separately walked every season twice (DR-295).
|
||||||
// Best-effort: a series still renders if the anchor cannot be resolved.
|
const { episodes, current } = await repo.getSeriesView(itemId);
|
||||||
repo.getSeriesCurrentEpisode(itemId).catch((e) => {
|
|
||||||
log.warn("Could not resolve the current episode:", e);
|
|
||||||
return null;
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
seasonData = groupEpisodesBySeason(seasons, episodes);
|
seasonData = groupEpisodesBySeason(seasons, episodes);
|
||||||
currentEpisode = current;
|
currentEpisode = current;
|
||||||
@@ -584,13 +589,13 @@
|
|||||||
watched={allEpisodes.length > 0 && allEpisodes.every((e) => e.userData?.isPlayed)}
|
watched={allEpisodes.length > 0 && allEpisodes.every((e) => e.userData?.isPlayed)}
|
||||||
scope="series"
|
scope="series"
|
||||||
showLabel={true}
|
showLabel={true}
|
||||||
onChanged={loadItem}
|
onChanged={reloadFresh}
|
||||||
/>
|
/>
|
||||||
<ClearHistoryButton
|
<ClearHistoryButton
|
||||||
itemId={item.id}
|
itemId={item.id}
|
||||||
itemName={item.name}
|
itemName={item.name}
|
||||||
scope="series"
|
scope="series"
|
||||||
onCleared={loadItem}
|
onCleared={reloadFresh}
|
||||||
/>
|
/>
|
||||||
{:else if item.kind === "movie"}
|
{:else if item.kind === "movie"}
|
||||||
<VideoDownloadButton
|
<VideoDownloadButton
|
||||||
@@ -605,7 +610,7 @@
|
|||||||
watched={item.userData?.isPlayed ?? false}
|
watched={item.userData?.isPlayed ?? false}
|
||||||
scope="episode"
|
scope="episode"
|
||||||
showLabel={true}
|
showLabel={true}
|
||||||
onChanged={loadItem}
|
onChanged={reloadFresh}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<!-- Favourite. Sits with Play/Download rather than in the header,
|
<!-- Favourite. Sits with Play/Download rather than in the header,
|
||||||
@@ -737,7 +742,7 @@
|
|||||||
expanded={expandedSeasons.has(season.id)}
|
expanded={expandedSeasons.has(season.id)}
|
||||||
onToggle={() => toggleSeason(season.id)}
|
onToggle={() => toggleSeason(season.id)}
|
||||||
onEpisodeClick={handleEpisodeClick}
|
onEpisodeClick={handleEpisodeClick}
|
||||||
onHistoryCleared={loadItem}
|
onHistoryCleared={reloadFresh}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -50,6 +50,9 @@
|
|||||||
// When advancing to a next episode we always start from the beginning,
|
// When advancing to a next episode we always start from the beginning,
|
||||||
// even if the episode was previously started or watched.
|
// even if the episode was previously started or watched.
|
||||||
const restartParam = $derived($page.url.searchParams.get("restart") === "true");
|
const restartParam = $derived($page.url.searchParams.get("restart") === "true");
|
||||||
|
// Explicit start position in seconds — set when returning from background
|
||||||
|
// audio onto an episode the backend advanced to (DR-296).
|
||||||
|
const resumeAtParam = $derived(Number($page.url.searchParams.get("resumeAt")) || 0);
|
||||||
|
|
||||||
// Derive playback context from URL query params
|
// Derive playback context from URL query params
|
||||||
const playbackContext = $derived.by(() => {
|
const playbackContext = $derived.by(() => {
|
||||||
@@ -121,6 +124,7 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
const id = itemId;
|
const id = itemId;
|
||||||
const restart = restartParam;
|
const restart = restartParam;
|
||||||
|
const resumeAt = resumeAtParam;
|
||||||
if (id && id !== loadedItemId) {
|
if (id && id !== loadedItemId) {
|
||||||
autoPlayLog.debug(
|
autoPlayLog.debug(
|
||||||
"$effect triggered: loading new item",
|
"$effect triggered: loading new item",
|
||||||
@@ -132,8 +136,12 @@
|
|||||||
);
|
);
|
||||||
// restart=true (advancing to next episode) forces start-from-beginning,
|
// restart=true (advancing to next episode) forces start-from-beginning,
|
||||||
// bypassing the resume-progress check.
|
// bypassing the resume-progress check.
|
||||||
|
if (resumeAt > 0) {
|
||||||
|
loadAndPlay(id, resumeAt);
|
||||||
|
} else {
|
||||||
loadAndPlay(id, restart ? 0 : undefined, restart);
|
loadAndPlay(id, restart ? 0 : undefined, restart);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update currentMedia when queue item changes (for skip/next/previous)
|
// Update currentMedia when queue item changes (for skip/next/previous)
|
||||||
@@ -797,6 +805,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Background audio advanced to another episode; show that one where the audio
|
||||||
|
* left off, rather than the episode this page was opened on.
|
||||||
|
*
|
||||||
|
* The outgoing episode was played out (the backend advances only past its
|
||||||
|
* end), so it is recorded as watched and the VideoPlayer's unmount stop
|
||||||
|
* report — which would carry the stale handoff position — is suppressed, as
|
||||||
|
* for a manual skip.
|
||||||
|
*
|
||||||
|
* TRACES: UR-040, UR-023 | DR-296
|
||||||
|
*/
|
||||||
|
function handleResumeOtherItem(nextId: string, positionSeconds: number) {
|
||||||
|
void reportSkippedEpisode(currentMedia?.id ?? itemId ?? null);
|
||||||
|
const start = positionSeconds > 0 ? `resumeAt=${Math.floor(positionSeconds)}` : "restart=true";
|
||||||
|
goto(`/player/${nextId}?${start}`, { replaceState: true });
|
||||||
|
}
|
||||||
|
|
||||||
function handleSkipToNextEpisode() {
|
function handleSkipToNextEpisode() {
|
||||||
if (nextEpisode) {
|
if (nextEpisode) {
|
||||||
// Skipping means "I'm done with this one" — record the outgoing episode as
|
// Skipping means "I'm done with this one" — record the outgoing episode as
|
||||||
@@ -889,6 +914,7 @@
|
|||||||
onEnded={handleVideoEnded}
|
onEnded={handleVideoEnded}
|
||||||
hasNext={nextEpisode !== null}
|
hasNext={nextEpisode !== null}
|
||||||
onNext={handleSkipToNextEpisode}
|
onNext={handleSkipToNextEpisode}
|
||||||
|
onResumeOtherItem={handleResumeOtherItem}
|
||||||
/>
|
/>
|
||||||
<NextEpisodePopup />
|
<NextEpisodePopup />
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -131,10 +131,12 @@
|
|||||||
{ label: "Unlimited", bytes: 0 },
|
{ label: "Unlimited", bytes: 0 },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Native-video opt-in (Android). `supportsNativeVideo` comes from Rust, which
|
// Native-video switch. Shown only where Rust reports a webview fallback —
|
||||||
// owns the "does this platform have a native video surface" decision; the
|
// beside mpv native video on Linux. Never on Android: ExoPlayer is the only
|
||||||
// toggle is hidden entirely where it cannot apply.
|
// video renderer there, and the webview would play original-file downloads
|
||||||
let supportsNativeVideo = $state(false);
|
// silent (DR-293). Rust owns the decision; the toggle is hidden where it
|
||||||
|
// cannot apply.
|
||||||
|
let offerNativeVideoSwitch = $state(false);
|
||||||
let nativeVideoEnabled = $state(false);
|
let nativeVideoEnabled = $state(false);
|
||||||
|
|
||||||
const unsubscribeNativeVideo = experimentalNativeVideo.subscribe((v) => {
|
const unsubscribeNativeVideo = experimentalNativeVideo.subscribe((v) => {
|
||||||
@@ -156,7 +158,7 @@
|
|||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await loadSettings();
|
await loadSettings();
|
||||||
askOnStart = await commands.profilesGetAskOnStart();
|
askOnStart = await commands.profilesGetAskOnStart();
|
||||||
supportsNativeVideo = (await getPlaybackCapabilities()).supportsNativeVideo;
|
offerNativeVideoSwitch = (await getPlaybackCapabilities()).webviewVideoFallback;
|
||||||
|
|
||||||
// Which update story this platform gets. Android cannot install its own
|
// Which update story this platform gets. Android cannot install its own
|
||||||
// APK, so it is offered the releases page instead of an install button.
|
// APK, so it is offered the releases page instead of an install button.
|
||||||
@@ -952,9 +954,9 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Native video (experimental). Only rendered where the platform's Rust
|
<!-- Native video. Only rendered where Rust reports a webview fallback
|
||||||
backend actually has a native video surface (Android). -->
|
(Linux beside mpv native video); never on Android (DR-293). -->
|
||||||
{#if supportsNativeVideo}
|
{#if offerNativeVideoSwitch}
|
||||||
<div class="bg-[var(--color-surface)] rounded-lg p-6 mt-4">
|
<div class="bg-[var(--color-surface)] rounded-lg p-6 mt-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="pr-4">
|
<div class="pr-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user