Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99ceeadb83 | ||
|
|
7387f35c7e | ||
|
|
ac4fccd499 | ||
|
|
a5535f2941 | ||
|
|
d49d027020 | ||
|
|
9c352fdb77 | ||
|
|
dda2ff86a3 | ||
|
|
8ad3dc5c4f | ||
|
|
9f5f57cba4 | ||
|
|
50934e2ac6 | ||
|
|
8fbc080733 | ||
|
|
ba5fd55204 | ||
|
|
fec4b7ae8c | ||
|
|
2ca2174cea | ||
|
|
0ca2857c3a | ||
|
|
1f32e4040b | ||
|
|
e4632bb2b2 | ||
|
|
2d50744320 | ||
|
|
adc460f35d | ||
|
|
9d7cb085e9 | ||
|
|
85bd227714 | ||
|
|
3619f71aba | ||
|
|
8e081845d0 | ||
|
|
5fa74d9e34 | ||
|
|
c480276a97 | ||
|
|
ca490c34ec | ||
|
|
e144e62b31 | ||
|
|
07d10dfed7 | ||
|
|
acddcdd6fa | ||
|
|
6a712c46cb | ||
|
|
211792947d | ||
|
|
2c3955914e | ||
|
|
1b70926c36 | ||
|
|
7b531a40be | ||
|
|
19bc265a8d | ||
|
|
cc7f1cece0 | ||
|
|
a53042fe80 | ||
|
|
db520c6551 | ||
|
|
1ef6180776 | ||
|
|
878ac5fa59 | ||
|
|
6aaa80ff92 | ||
|
|
f7bcfe521d | ||
|
|
30dc3ba7f6 | ||
|
|
32f8de5c91 | ||
|
|
62873cab3d | ||
|
|
c55ff45692 |
@@ -96,6 +96,12 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install
|
run: bun install
|
||||||
|
|
||||||
|
# The Linux job previously had no version step at all, so a tagged release
|
||||||
|
# built Linux packages from whatever version happened to be committed.
|
||||||
|
- name: Set app version from tag
|
||||||
|
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
|
||||||
- name: Build for Linux
|
- name: Build for Linux
|
||||||
run: bun run tauri build
|
run: bun run tauri build
|
||||||
env:
|
env:
|
||||||
@@ -156,15 +162,13 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-bun-
|
${{ runner.os }}-bun-
|
||||||
|
|
||||||
|
# The tag is the single source of truth for a release version; the script
|
||||||
|
# stamps every file that carries it (package.json, tauri.conf.json,
|
||||||
|
# Cargo.toml, Cargo.lock). This step used to sed only tauri.conf.json, so
|
||||||
|
# the other three shipped whatever was committed.
|
||||||
- name: Set app version from tag
|
- name: Set app version from tag
|
||||||
run: |
|
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
|
||||||
# On a tag build the tag is the single source of truth for the version.
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then
|
|
||||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
|
||||||
echo "Setting version to $VERSION"
|
|
||||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json
|
|
||||||
fi
|
|
||||||
grep '"version"' src-tauri/tauri.conf.json
|
|
||||||
|
|
||||||
- name: Build Windows (NSIS installer + exe)
|
- name: Build Windows (NSIS installer + exe)
|
||||||
run: OUTPUT_DIR="$PWD/dist/windows" WIN_BUNDLES=nsis ./scripts/build-windows-cross.sh
|
run: OUTPUT_DIR="$PWD/dist/windows" WIN_BUNDLES=nsis ./scripts/build-windows-cross.sh
|
||||||
@@ -217,48 +221,22 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install
|
run: bun install
|
||||||
|
|
||||||
|
# Stamp before `android init`: it derives its generated project (including
|
||||||
|
# the initial versionCode) from tauri.conf.json.
|
||||||
- name: Set app version from tag
|
- name: Set app version from tag
|
||||||
run: |
|
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
|
||||||
# On a tag build, the tag is the single source of truth for the
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
# version name. On non-tag runs keep whatever is in tauri.conf.json.
|
|
||||||
if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then
|
|
||||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
|
||||||
echo "Setting version to $VERSION"
|
|
||||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json
|
|
||||||
fi
|
|
||||||
grep '"version"' src-tauri/tauri.conf.json
|
|
||||||
|
|
||||||
- name: Initialize Android project
|
- name: Initialize Android project
|
||||||
run: bun run tauri android init
|
run: bun run tauri android init
|
||||||
|
|
||||||
|
# Re-run after init: tauri.properties only exists now, and its
|
||||||
|
# autogenerated versionCode (0.0.15 -> 15) is both tiny and NOT monotonic
|
||||||
|
# against the 1000 floor already shipped in the field. The script rewrites
|
||||||
|
# it as 1000 + major*10000 + minor*100 + patch. Runs unconditionally so
|
||||||
|
# untagged builds get a sane code too, derived from git describe.
|
||||||
- name: Pin a monotonic Android versionCode
|
- name: Pin a monotonic Android versionCode
|
||||||
run: |
|
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
|
||||||
# `tauri android init` autogenerates src-tauri/gen/android/app/tauri.properties
|
|
||||||
# with a versionCode derived from the semver (e.g. 0.0.15 -> 15). That
|
|
||||||
# number is (a) tiny and (b) NOT monotonic across our history: earlier
|
|
||||||
# local/dev builds shipped versionCode 1000 (from a 0.1.0 config), so a
|
|
||||||
# plain 15 would be a *downgrade* and Android would refuse the update.
|
|
||||||
#
|
|
||||||
# Derive an explicit code that is both monotonic in semver order and
|
|
||||||
# always above the 1000 floor already in the field:
|
|
||||||
# code = 1000 + major*10000 + minor*100 + patch
|
|
||||||
# e.g. 0.0.14 -> 1014, 0.0.15 -> 1015, 0.1.0 -> 1100, 1.0.0 -> 11000.
|
|
||||||
# POSIX sh only (the runner uses dash): no here-strings, no \s in sed.
|
|
||||||
PROPS="src-tauri/gen/android/app/tauri.properties"
|
|
||||||
VERSION=$(grep '"version"' src-tauri/tauri.conf.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
|
|
||||||
MAJ=$(echo "$VERSION" | cut -d. -f1)
|
|
||||||
MIN=$(echo "$VERSION" | cut -d. -f2)
|
|
||||||
PAT=$(echo "$VERSION" | cut -d. -f3)
|
|
||||||
# Guard against a malformed/missing component so we never emit code 0.
|
|
||||||
: "${MAJ:=0}" "${MIN:=0}" "${PAT:=0}"
|
|
||||||
CODE=$(( 1000 + MAJ*10000 + MIN*100 + PAT ))
|
|
||||||
echo "version=$VERSION -> versionCode=$CODE"
|
|
||||||
if grep -q '^tauri.android.versionCode=' "$PROPS"; then
|
|
||||||
sed -i "s/^tauri.android.versionCode=.*/tauri.android.versionCode=$CODE/" "$PROPS"
|
|
||||||
else
|
|
||||||
echo "tauri.android.versionCode=$CODE" >> "$PROPS"
|
|
||||||
fi
|
|
||||||
cat "$PROPS"
|
|
||||||
|
|
||||||
- name: Sync custom Android sources & gradle config
|
- name: Sync custom Android sources & gradle config
|
||||||
run: ./scripts/sync-android-sources.sh
|
run: ./scripts/sync-android-sources.sh
|
||||||
|
|||||||
+162
@@ -6,6 +6,168 @@ Entries are grouped by the capability they change, not by commit. Requirement
|
|||||||
IDs in parentheses point at [docs/requirements.md](docs/requirements.md); the
|
IDs in parentheses point at [docs/requirements.md](docs/requirements.md); the
|
||||||
generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
|
generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
|
||||||
|
|
||||||
|
## v0.5.0
|
||||||
|
|
||||||
|
### ✨ Features
|
||||||
|
|
||||||
|
- **Android video can render on the device's own video surface.** Settings →
|
||||||
|
Video Playback → **Native Video** (experimental, off by default) hands
|
||||||
|
decoding to ExoPlayer, which draws into a surface composited *behind* a
|
||||||
|
transparent WebView, with the player controls layered on top of it.
|
||||||
|
|
||||||
|
The backend had reported "this platform has a native video surface" on Android
|
||||||
|
all along, but the frontend threw that answer away in two separate places, so
|
||||||
|
the path had never actually run. Both are lifted. The setting can only ever
|
||||||
|
*suppress* the backend's choice, never override it upward: turning it off
|
||||||
|
forces the web player even where native is available, and turning it on does
|
||||||
|
nothing on platforms whose backend never offered it — Linux cannot composite
|
||||||
|
behind its webview, so it stays on the web player either way.
|
||||||
|
|
||||||
|
Verified playing on a physical device. Still unverified: the mini-player
|
||||||
|
transition, audio-track switching on the native path, and whether hardware
|
||||||
|
decoding measurably improves battery or CPU — so the toggle stays off by
|
||||||
|
default. (UR-003, UR-004 → DR-150)
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
- **The video surface now reaches the screen at all.** The player built its
|
||||||
|
video surface, handed it to ExoPlayer, and then never added it to the view
|
||||||
|
hierarchy, because the Activity reference it needed was never supplied — so
|
||||||
|
native video would have decoded to a surface nobody could see, whatever else
|
||||||
|
was fixed. This also silently disabled picture-in-picture for video, which
|
||||||
|
gated on that same never-attached surface. (UR-003, UR-041 → DR-151)
|
||||||
|
|
||||||
|
- **Platform playback support is no longer guessed from the browser user
|
||||||
|
agent.** The frontend re-derived "does this platform decode audio natively" by
|
||||||
|
string-matching `navigator.userAgent` — a second copy of a decision the
|
||||||
|
backend already makes, free to drift out of step with the backends it was
|
||||||
|
describing. The backend now reports its own capabilities and the frontend
|
||||||
|
consumes them. (UR-003, UR-005 → DR-152)
|
||||||
|
|
||||||
|
### 🔧 Internal
|
||||||
|
|
||||||
|
- **The git tag is now the single source of truth for a release version.** The
|
||||||
|
version lived in four files that had to be edited in lockstep, and the release
|
||||||
|
workflow rewrote exactly one of them — so a tagged build produced an installer
|
||||||
|
named for the tag wrapped around package metadata naming the *previous*
|
||||||
|
release, and the Linux job, which had no version step at all, shipped whatever
|
||||||
|
happened to be committed. `scripts/set-version.sh` now writes all four from
|
||||||
|
one argument and every release job calls it with the tag. The Android
|
||||||
|
`versionCode` is derived in the same place, guarded by tests for the property
|
||||||
|
that actually matters: it must increase monotonically and stay above the value
|
||||||
|
already installed in the field, or Android silently refuses the update.
|
||||||
|
(DR-153)
|
||||||
|
|
||||||
|
## v0.4.8
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
- **Video with an undecodable soundtrack now transcodes instead of playing
|
||||||
|
silent.** Advertising a webview-shaped profile (v0.4.7) turned out not to be
|
||||||
|
enough: Jellyfin 10.11.5 enforces a direct-play profile's container and video
|
||||||
|
codec but ignores its audio codec, offering an E-AC-3 track for direct play
|
||||||
|
against a profile listing only AAC — and no `CodecProfile` or channel limit
|
||||||
|
changes that. The client now checks the track it would actually be served
|
||||||
|
against what its renderer can decode and forces the h264/AAC HLS transcode
|
||||||
|
when it cannot, rather than trusting the negotiation.
|
||||||
|
(UR-004 → DR-149)
|
||||||
|
|
||||||
|
## v0.4.7
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
- **Video plays with sound on devices that ship a Dolby decoder.** The audio
|
||||||
|
codec list sent to Jellyfin came from `MediaCodecList`, which describes
|
||||||
|
ExoPlayer — but video does not play through ExoPlayer: it renders in the
|
||||||
|
webview `<video>` element, which decodes far less. A phone whose vendor
|
||||||
|
licenses Dolby therefore advertised `ac3`/`eac3`, got a direct play, and
|
||||||
|
showed full picture with no audio, while a leaner device claimed neither
|
||||||
|
codec, received an AAC transcode, and played the same file correctly. The
|
||||||
|
video direct-play profile is now narrowed to what the webview can decode;
|
||||||
|
audio-only playback is genuinely the native player's and keeps the full list,
|
||||||
|
so music is not transcoded needlessly.
|
||||||
|
(UR-004 → DR-148)
|
||||||
|
|
||||||
|
## v0.4.1
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
- **The lockscreen pause works while a video's audio plays in the background.**
|
||||||
|
The handoff starts native audio and only then tears the WebView `<video>`
|
||||||
|
down — and that teardown fires a DOM `pause` the frontend reports like any
|
||||||
|
other, which left the controller believing webview media was still the
|
||||||
|
player. Transport stayed aimed at it: pressing pause on the lockscreen sent a
|
||||||
|
control command to a `<video>` that no longer existed while the native player
|
||||||
|
carried on, and the element's parting position report dragged the displayed
|
||||||
|
time backwards. A handoff is now tracked explicitly, so it hands transport to
|
||||||
|
the native backend and ignores what the dying element still reports. A pause
|
||||||
|
made from the lockscreen also survives the return to the app, instead of being
|
||||||
|
undone by the play state captured when the handoff began.
|
||||||
|
(UR-040, UR-005 → DR-052, DR-097)
|
||||||
|
|
||||||
|
## v0.4.0
|
||||||
|
|
||||||
|
### ✨ Features
|
||||||
|
|
||||||
|
- **Favourites, across libraries.** A `/library/favorites` page renders
|
||||||
|
favourites from every library with All / Movies / Shows / Music scope tabs,
|
||||||
|
reusing the standard grid so card shape still follows the media — a mixed All
|
||||||
|
tab reads as posters, squares and thumbnails side by side. Home carries
|
||||||
|
favourite rows below Recently Added, and a row with no items does not render
|
||||||
|
at all, so a fresh install shows no empty rows. Server favourite state is
|
||||||
|
mirrored into the local database as results are cached, so offline browsing
|
||||||
|
sees the same favourites as the server; a toggle made offline is never
|
||||||
|
overwritten by a stale server value before it has been pushed.
|
||||||
|
(UR-067, UR-069 → DR-113, DR-114, DR-115, DR-117, DR-118)
|
||||||
|
|
||||||
|
- **Search answers from the local index.** The instant leg read only downloaded
|
||||||
|
items, so with no downloads it returned nothing and every keystroke fell
|
||||||
|
through to a full `Recursive=true` server query. It now reads the whole synced
|
||||||
|
catalog through the same availability CTE `get_items` uses, gated on the same
|
||||||
|
`include_catalog_browse` flag, so search and browse cannot diverge. The index
|
||||||
|
also gained MusicArtist, Playlist and People — the very groups search sorts
|
||||||
|
results into. Re-indexing moved from a frontend startup call to a Rust
|
||||||
|
background task with a 6h TTL, so a long session no longer searches a stale
|
||||||
|
catalog. (UR-065 → DR-108, DR-110, DR-111)
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
- **Playback no longer restarts an episode at random on a flaky connection.**
|
||||||
|
Background audio-only playback of a video streams a progressive mp3 transcode
|
||||||
|
over plain HTTP, which is chunked and so declares no length: when the
|
||||||
|
connection dropped mid-episode, ExoPlayer saw end-of-input and reported
|
||||||
|
`STATE_ENDED`, indistinguishable from the real end. The app ran its
|
||||||
|
end-of-episode logic mid-episode and playback parked in `STATE_ENDED`, where
|
||||||
|
the next play intent from the lockscreen, notification or a Bluetooth
|
||||||
|
reconnect seeks an ended player to position 0 — surfacing as "the episode
|
||||||
|
randomly restarted". The item's runtime is now what decides: an end reported
|
||||||
|
well short of it re-opens the stream where it stopped. (UR-040 → DR-129)
|
||||||
|
|
||||||
|
- **A network hiccup no longer kills playback outright.** Music and video
|
||||||
|
declare a length, so a cut connection reaches them as an *error* rather than a
|
||||||
|
phantom end — and every error stopped the player. A recoverable error now gets
|
||||||
|
one bounded attempt at re-opening the stream where it stopped, with a growing
|
||||||
|
backoff, leaving the rest of the queue intact. On Linux, MPV additionally
|
||||||
|
reconnects inside the demuxer so ordinary blips never surface at all, and
|
||||||
|
`EndFile(ERROR)` — previously a bare log line that left playback halted while
|
||||||
|
the UI still showed "playing" — is now reported and recovered.
|
||||||
|
(UR-004, UR-040 → DR-129, DR-130)
|
||||||
|
|
||||||
|
- **The player no longer reads 0:00 as a track ends on Linux.** MPV exposes
|
||||||
|
`time-pos` and `duration` as properties of the *loaded* file, so at EOF it
|
||||||
|
unloads and both stop resolving — reporting zero at exactly the moment
|
||||||
|
end-of-file handling asks where playback reached. The last reading seen while
|
||||||
|
media was loaded is now kept and used as the fallback. (UR-005 → DR-130)
|
||||||
|
|
||||||
|
- **Server-side deletions propagate to the local catalog.** `DELETE FROM items`
|
||||||
|
existed nowhere, so items removed on the server lingered locally forever. A
|
||||||
|
post-crawl mark-and-sweep now removes them, scoped to crawled types, skipping
|
||||||
|
downloaded items, and refusing to run after a partial crawl. Separately,
|
||||||
|
`items_fts` grew a full duplicate index on every catalog pass; it is now a
|
||||||
|
real upsert, with a migration rebuilding existing indexes. (DR-110)
|
||||||
|
|
||||||
|
- **Android system bars and display cutout are handled correctly.** (UR-066)
|
||||||
|
|
||||||
## v0.2.0
|
## v0.2.0
|
||||||
|
|
||||||
### ✨ Features
|
### ✨ Features
|
||||||
|
|||||||
@@ -87,6 +87,22 @@ RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
|
|||||||
# Set NDK environment variable
|
# Set NDK environment variable
|
||||||
ENV NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION
|
ENV NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION
|
||||||
|
|
||||||
|
# Gradle distribution. `tauri android init` regenerates gen/android with a
|
||||||
|
# wrapper pointing at services.gradle.org, so every Android job would otherwise
|
||||||
|
# download ~130MB of Gradle at build time — slow, and a hard failure when the
|
||||||
|
# CDN hiccups ("Unexpected end of file from server"). Ship the distribution in
|
||||||
|
# the image instead; scripts/sync-android-sources.sh repoints the regenerated
|
||||||
|
# wrapper at this local copy. Keep GRADLE_VERSION in sync with the version
|
||||||
|
# Tauri's generated wrapper requests.
|
||||||
|
ENV GRADLE_VERSION=8.14.3 \
|
||||||
|
GRADLE_HOME=/opt/gradle/gradle-8.14.3
|
||||||
|
RUN mkdir -p /opt/gradle/dist && \
|
||||||
|
wget -q "https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" \
|
||||||
|
-O "/opt/gradle/dist/gradle-${GRADLE_VERSION}-bin.zip" && \
|
||||||
|
unzip -q "/opt/gradle/dist/gradle-${GRADLE_VERSION}-bin.zip" -d /opt/gradle && \
|
||||||
|
"$GRADLE_HOME/bin/gradle" --version
|
||||||
|
ENV PATH="$GRADLE_HOME/bin:$PATH"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Desktop packaging tools — kept in a trailing layer ON PURPOSE so that adding
|
# Desktop packaging tools — kept in a trailing layer ON PURPOSE so that adding
|
||||||
# or changing a packaging tool doesn't invalidate the expensive apt/rust/Android
|
# or changing a packaging tool doesn't invalidate the expensive apt/rust/Android
|
||||||
|
|||||||
+162
-11
@@ -75,6 +75,16 @@ For a narrative overview of the system design, see
|
|||||||
| UR-062 | Opening a TV series lands the viewer **where they are in it**, not at season 1: the series page scrolls the current season into view and highlights the current episode, and the hero button opens that episode (labelled `Resume S2E4` / `Play S1E1`). "Current" means the episode in progress, else the server's Next Up for that series, else the first unwatched episode, else the first — resolved by the backend so it also works offline. A season is **never a page of its own**: every route that names a season lands on the series with that season in view, so the episodes of all seasons are always one continuous scrollable list | High | Done |
|
| UR-062 | Opening a TV series lands the viewer **where they are in it**, not at season 1: the series page scrolls the current season into view and highlights the current episode, and the hero button opens that episode (labelled `Resume S2E4` / `Play S1E1`). "Current" means the episode in progress, else the server's Next Up for that series, else the first unwatched episode, else the first — resolved by the backend so it also works offline. A season is **never a page of its own**: every route that names a season lands on the series with that season in view, so the episodes of all seasons are always one continuous scrollable list | High | Done |
|
||||||
| UR-063 | Each video library is **one page**, not three. Browsing (hero, Continue Watching, Next Up, Recently Added, genre rows), the full title grid, and the genre browser are tabs of `/library/tv` and `/library/movies` rather than separate routes with inconsistent names (`/library/tv/shows` vs `/library/movies/all`, `/library/shows/genres` vs `/library/movies/genres`). The old routes redirect so existing links keep working | Medium | Done |
|
| UR-063 | Each video library is **one page**, not three. Browsing (hero, Continue Watching, Next Up, Recently Added, genre rows), the full title grid, and the genre browser are tabs of `/library/tv` and `/library/movies` rather than separate routes with inconsistent names (`/library/tv/shows` vs `/library/movies/all`, `/library/shows/genres` vs `/library/movies/genres`). The old routes redirect so existing links keep working | Medium | Done |
|
||||||
| UR-064 | Watch history can be **erased**, per series and per season, from the series page. Clearing marks every episode inside unwatched and clears resume positions, so the show returns to "never watched" and reopens on its premiere. It asks for confirmation first (it cannot be undone) and requires a connection to the server, since history cleared only locally would be undone by the next sync | Medium | Done |
|
| UR-064 | Watch history can be **erased**, per series and per season, from the series page. Clearing marks every episode inside unwatched and clears resume positions, so the show returns to "never watched" and reopens on its premiere. It asks for confirmation first (it cannot be undone) and requires a connection to the server, since history cleared only locally would be undone by the next sync | Medium | Done |
|
||||||
|
| UR-065 | Search answers from a **locally indexed copy of the library**, so results appear as fast as the device can query rather than at the speed of a round trip to the server, and the same results are found with the server unreachable. A background job keeps the index current — refreshing on a schedule rather than only at app start, dropping media removed from the server, and covering everything the result groups can show (including artists and people). The server is still queried in the background so media added since the last index still turns up, merged in without reordering what is already on screen | High | Implemented |
|
||||||
|
| UR-066 | The app's own chrome stays clear of the device's system chrome. On Android the bottom navigation sits above the navigation/gesture bar instead of underneath it, the header clears the status bar, and full-screen video and audio playback keep their controls inside the usable screen — clear of the gesture bar and, in landscape, of the display notch. This must hold across navigation modes (gesture and 3-button) and rotation, not only on the handsets it happened to be tested on | High | Done |
|
||||||
|
| UR-067 | Favourited media can be **found again**. A Favourites page lists everything favourited across all libraries, scoped by tabs (All / Movies / Shows / Music); the home screen carries favourite rows for movies, shows and music, hidden when a category is empty; and each library page can be filtered to favourites in place. Without this the like button writes to a store nothing reads | Medium | Done |
|
||||||
|
| UR-068 | Anything the app shows can be favourited where it is shown — from a movie, series, episode, album, artist or playlist page, and from any card in a grid or carousel — not only from the player while the item happens to be playing | Medium | Done |
|
||||||
|
| UR-069 | Favourite state agrees with the server in both directions. An item favourited in another Jellyfin client shows as favourited here without being touched, and an item favourited here while the server is unreachable reaches the server once it returns — without the user going back to the screen where they marked it | Medium | Done |
|
||||||
|
| UR-070 | Playback quality is the viewer's choice: the player offers the bitrates the server can produce for what is playing, and changing one resumes at the same point with the same audio and subtitle tracks. Because the chosen rendition can change at any moment, nothing that streams for playback is treated as a stored copy unless it happens to be byte-identical to the real file | Medium | Proposed |
|
||||||
|
| UR-071 | Media the viewer is watching can be **kept**, by a whole-file download that runs in the background independently of playback and at its own quality, so it is unaffected by bitrate changes. Where the streamed bytes already are that file (direct play), they are kept rather than fetched twice. A completed download is then played from disk rather than streamed again | Medium | Proposed |
|
||||||
|
| UR-073 | Watched state is something the viewer can **set**, not only something playback records. Any episode, season, series or movie can be marked watched — or unwatched again — from where it is shown, without sitting through it or erasing its history wholesale. Marking a season or series covers the episodes inside it, and works with the server unreachable | Medium | Done |
|
||||||
|
| UR-072 | Each page opens where a page should open. Moving to a new screen starts at the top of it, and going Back returns the viewer to the place they left — their position in a long library grid or home screen, not the top of it. A page never inherits the scroll position of the page before it | Medium | Done |
|
||||||
|
| UR-074 | Video streaming can be held to a **bandwidth budget the viewer sets**, rather than spent at whatever rate the server would otherwise send. A ceiling chosen once — from the source's own bitrate down to a rung that still plays on a poor connection — governs every video the app opens, live TV included, and survives a restart, so a metered connection is not quietly drained by the next thing played. A single video can be moved to a different ceiling from the player, resuming where it was, without disturbing that default | Medium | Done |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -115,6 +125,9 @@ External system integrations and platform-specific implementations.
|
|||||||
| IR-027 | Jellyfin `/System/Info/Public` reachability probe used as an offline→online recovery detector | API | UR-043 | Done |
|
| IR-027 | Jellyfin `/System/Info/Public` reachability probe used as an offline→online recovery detector | API | UR-043 | Done |
|
||||||
| IR-028 | Jellyfin/LMS SyncGroups API client (list, create, join, unsync, dissolve sync groups) | API | UR-046 | Done |
|
| IR-028 | Jellyfin/LMS SyncGroups API client (list, create, join, unsync, dissolve sync groups) | API | UR-046 | Done |
|
||||||
| IR-029 | Android `ConnectivityManager`/`NetworkCapabilities` transport probe with a `NetworkCallback` change subscription, surfaced to the frontend via the `AndroidNetworkType` JS bridge and the `jellytau-network-changed` WebView event (requires `ACCESS_NETWORK_STATE`) | Platform | UR-053 | Done (pending device verification) |
|
| IR-029 | Android `ConnectivityManager`/`NetworkCapabilities` transport probe with a `NetworkCallback` change subscription, surfaced to the frontend via the `AndroidNetworkType` JS bridge and the `jellytau-network-changed` WebView event (requires `ACCESS_NETWORK_STATE`) | Platform | UR-053 | Done (pending device verification) |
|
||||||
|
| IR-030 | Scheduled full-catalog crawl of every library (`Recursive=true`, paged) feeding the local index, driven by a Rust background task and the `ConnectivityMonitor` reconnect signal rather than by the frontend | Storage | UR-065 | Implemented |
|
||||||
|
| IR-031 | Android `WindowInsets` bridge: an `OnApplyWindowInsetsListener` on the decor view reports `systemBars() | displayCutout()` in CSS pixels, pushed into the WebView as `jt-inset` CSS custom properties plus a `jellytau-insets-changed` event, and pullable via the `AndroidInsets` JS bridge | Platform | UR-066 | Done (pending device verification) |
|
||||||
|
| IR-032 | Whole-file background download of the item being played, reusing the existing resumable download worker and the Range-capable `/Videos/{id}/stream.mp4` endpoint; plus per-platform read-through caching hooks (ExoPlayer `CacheDataSource`, mpv `stream-record`) for direct-play sessions only | Storage | UR-071 | Proposed |
|
||||||
|
|
||||||
### 2.2 Jellyfin API Requirements
|
### 2.2 Jellyfin API Requirements
|
||||||
|
|
||||||
@@ -154,6 +167,9 @@ API endpoints and data contracts required for Jellyfin integration.
|
|||||||
| JA-030 | Get person details and filmography | Persons | UR-036 | Done |
|
| JA-030 | Get person details and filmography | Persons | UR-036 | Done |
|
||||||
| JA-031 | Get items by person (actor/director filmography) | Items | UR-036 | Done |
|
| JA-031 | Get items by person (actor/director filmography) | Items | UR-036 | Done |
|
||||||
| JA-032 | Get audio-only stream URL for a video item (selected audio-stream index) | MediaInfo | UR-040 | Done |
|
| JA-032 | Get audio-only stream URL for a video item (selected audio-stream index) | MediaInfo | UR-040 | Done |
|
||||||
|
| JA-033 | Query favourite items (`Filters=IsFavorite`, recursive, scoped by item type) | Items | UR-067 | Done |
|
||||||
|
| JA-034 | Read `UserData` (favourite, played, resume position) from item responses | UserData | UR-069 | Done |
|
||||||
|
| JA-035 | Mark item played (`POST /Users/{userId}/PlayedItems/{itemId}`) | UserData | UR-025 | Done |
|
||||||
|
|
||||||
### 2.3 Development Requirements
|
### 2.3 Development Requirements
|
||||||
|
|
||||||
@@ -264,6 +280,64 @@ Internal architecture, components, and application logic.
|
|||||||
| DR-105 | Video library routes collapse to one per library. `/library/tv` and `/library/movies` render browse / all-titles / genres as in-page tabs driven by `?view=`, omitted for the default `browse` (the convention `searchRouteUrl` already uses for the `all` scope); `resolveLibraryView` is pure and unit-tested. The four legacy routes become redirect-only `+page.ts` loads rather than deletions, because `GenreTags` links to them and users have them in history; `resolveSearchScope` keeps its `/library/shows` branch for the same reason. The "Browse" tile grid at the bottom of both landing pages is removed — it was a second navigation affordance to the same destinations the carousels' "Show all" links already reach | UI | UR-063 | Done |
|
| DR-105 | Video library routes collapse to one per library. `/library/tv` and `/library/movies` render browse / all-titles / genres as in-page tabs driven by `?view=`, omitted for the default `browse` (the convention `searchRouteUrl` already uses for the `all` scope); `resolveLibraryView` is pure and unit-tested. The four legacy routes become redirect-only `+page.ts` loads rather than deletions, because `GenreTags` links to them and users have them in history; `resolveSearchScope` keeps its `/library/shows` branch for the same reason. The "Browse" tile grid at the bottom of both landing pages is removed — it was a second navigation affordance to the same destinations the carousels' "Show all" links already reach | UI | UR-063 | Done |
|
||||||
| DR-106 | Erasing watch history goes through the repository, not the local cache: `clear_watch_history(item_id)` maps to Jellyfin's `DELETE /Users/{userId}/PlayedItems/{itemId}`, which clears the played flag *and* zeroes the resume position, and which the server applies recursively to a folder — so one call handles a whole series or season. `OfflineRepository` returns `RepoError::Offline` rather than clearing locally, because history diverged only on the device would be silently undone by the next sync; the button disables itself while the server is unreachable. `ClearHistoryButton` is shared by the series hero and each `SeasonSection` header, confirms before acting (there is no undo), and reloads the page on success so the recomputed current episode — the premiere, for a fully cleared series — is what the viewer sees | Repository | UR-064 | Done |
|
| DR-106 | Erasing watch history goes through the repository, not the local cache: `clear_watch_history(item_id)` maps to Jellyfin's `DELETE /Users/{userId}/PlayedItems/{itemId}`, which clears the played flag *and* zeroes the resume position, and which the server applies recursively to a folder — so one call handles a whole series or season. `OfflineRepository` returns `RepoError::Offline` rather than clearing locally, because history diverged only on the device would be silently undone by the next sync; the button disables itself while the server is unreachable. `ClearHistoryButton` is shared by the series hero and each `SeasonSection` header, confirms before acting (there is no undo), and reloads the page on success so the recomputed current episode — the premiere, for a fully cleared series — is what the viewer sees | Repository | UR-064 | Done |
|
||||||
| DR-107 | Seasons on the series page are collapsible, and **only the current season is expanded** on load — the one holding the episode DR-101 resolved. A show with ten seasons otherwise renders every episode of every season at once, burying the one episode the viewer came for under hundreds of rows. Expansion state is per season and pure (`initialExpandedSeasons` in `seriesNavigation.ts`): the current season, or the first season when there is no current episode, so a never-watched show still opens on season 1 rather than fully collapsed. A `?episode=` deep link expands that episode's season too. Toggling is local and not persisted — it is a reading position, not a preference | UI | UR-062 | Done |
|
| DR-107 | Seasons on the series page are collapsible, and **only the current season is expanded** on load — the one holding the episode DR-101 resolved. A show with ten seasons otherwise renders every episode of every season at once, burying the one episode the viewer came for under hundreds of rows. Expansion state is per season and pure (`initialExpandedSeasons` in `seriesNavigation.ts`): the current season, or the first season when there is no current episode, so a never-watched show still opens on season 1 rather than fully collapsed. A `?episode=` deep link expands that episode's season too. Toggling is local and not persisted — it is a reading position, not a preference | UI | UR-062 | Done |
|
||||||
|
| DR-108 | The instant (cache) leg of `repository_search` searches the **synced catalog**, not just downloads. `OfflineRepository::search` replaces its `downloaded_items` CTE with the `available_items` CTE `get_items` already uses — the same downloads branches plus a `synced_at IS NOT NULL` branch gated on the same `include_catalog_browse()` flag — so search and browse cannot diverge on what is visible. Online (flag true) search reads the whole index and answers before any HTTP request completes; offline with "Show all server media" off (flag false) it stays downloads-only, unchanged. Requires no frontend change, since the flag is already set correctly for all three states. The `include_item_types` filter is switched from string interpolation to bound parameters, as `SearchOptions` is settable from the frontend and not only from `SearchScope` | Backend | UR-065 | Implemented |
|
||||||
|
| DR-109 | Index freshness is a Rust-owned policy, not a frontend startup call. A tokio task ticks every 30 min and runs a full pass when a repository is active, the server is reachable, and `last_catalog_sync` (already persisted to `app_settings`, previously read only for a UI hint) is older than `CATALOG_INDEX_TTL` (6 h); the `ConnectivityMonitor` reconnect signal re-evaluates the same condition immediately. An `AtomicBool` prevents concurrent passes, replacing `offlineCatalog.ts`'s `syncInProgress` — the frontend trigger is removed rather than left alongside, since two triggers with one guard each is how double-crawls happen. `RepositoryManager` gains an active-handle slot so the task has something to run against. Progress is emitted as the kebab-case `catalog-index-event` | Backend | UR-065 | Implemented |
|
||||||
|
| DR-110 | Index hygiene. `save_to_cache` switches from `INSERT OR REPLACE INTO items` to `ON CONFLICT(id) DO UPDATE`: REPLACE fires no `AFTER DELETE` trigger unless `recursive_triggers` is on (it is not — only `foreign_keys` and `journal_mode` are set), so `items_ad` never ran, and because `items.id` is a `TEXT PRIMARY KEY` each replacement also took a fresh rowid and appended a second `items_fts` entry — a duplicate index per sync, invisible in results but permanently degrading `MATCH`. The upsert preserves the rowid `items_fts` keys on and fires `items_au`; migration `021_rebuild_items_fts` clears orphans on existing installs. Separately, a post-crawl sweep deletes synced-but-not-downloaded rows a successful library crawl did not return, so media removed from the server stops being searchable; it skips items with completed downloads and skips any library whose crawl errored, because `items.parent_id` is `ON DELETE CASCADE` and a partial crawl would cascade away a whole series | Storage | UR-065 | Implemented |
|
||||||
|
| DR-111 | The index covers what the result groups render: `CATALOG_ITEM_TYPES` gains `MusicArtist` and `Playlist`, and migration `022_people_fts` adds a `people_fts` virtual table over the existing `people` table (which had no FTS, and is populated incidentally by item-detail fetches) with the same trigger pattern as `items_fts`. `OfflineRepository::search` UNIONs `people_fts` matches in as `Person` items when the resolved scope admits them — i.e. `SearchScope::All`, which expands to no filter (DR-063). Without this, the Artists and People groups UR-060 mandates can only ever be filled by the server leg | Storage | UR-065, UR-060 | Implemented |
|
||||||
|
| DR-112 | Safe-area insets come from **native**, not from `env()` alone. `env(safe-area-inset-*)` is 0px without `viewport-fit=cover` (missing from `app.html`, so every safe-area rule in the app was already a no-op), and even with it Android WebView maps only the *display cutout* — never the status bar or navigation bar. Since `enableEdgeToEdge()` plus `targetSdk 36` make edge-to-edge unconditional, the WebView always spans the system bars, so CSS could not learn about them by any route. `WindowInsetsBridge` reads the real insets and publishes `jt-inset` custom properties; `app.css` folds them with `env()` via `max()` into `--safe-*`, which is the only thing components may pad from. Ownership is exactly one element per edge: the app shell takes top/left/right, and BottomUi takes bottom wherever it renders (`shellReservesBottomInset` hands it back to the shell on routes with no bottom UI) so the padding sits inside BottomUi's surface box and the colour extends behind the gesture bar. The full-screen players inset their control layers only, leaving video and artwork edge-to-edge. The theme's `fitsSystemWindows=true` — which claimed the opposite and was overridden at runtime and ignored at this target SDK — is removed | UI | UR-066 | Done |
|
||||||
|
| DR-113 | `MediaItem.user_data` is populated from the server instead of being hardcoded `None`. `JellyfinItem` gains a `UserData` field (`#[serde(alias = "UserData")]` → the existing `UserData` type) and `to_media_item` maps it, so every list and detail response carries favourite/played/resume state. `UserData` is named explicitly in the `Fields=` list rather than relying on Jellyfin's default. Without this no card or detail page can render a favourite it did not itself set, and the mini player's per-track `storageGetPlaybackProgress` fetch is the only way to colour one heart | Repository | UR-069 | Done |
|
||||||
|
| DR-114 | Server favourite state is mirrored into the local `user_data` table by `OfflineRepository::save_to_cache` — the single choke point every cached server result passes through — so offline browsing and the offline Favourites page see the same favourites as the server. The upsert carries `pending_sync = 0` and is guarded by `WHERE user_data.pending_sync = 0`, which is the conflict rule: a toggle made offline is never overwritten by a stale server value before it has been pushed | Storage | UR-069 | Done |
|
||||||
|
| DR-115 | Cross-library favourites query: a `get_favorites(scope, options)` repository method plus the `repository_get_favorites` command. Online issues `Filters=IsFavorite&Recursive=true` with `IncludeItemTypes` expanded from `SearchScope::item_types()` in Rust (the frontend sends the opaque scope, never a type list — DR-063); offline reads `items ⨝ user_data (is_favorite = 1)` under the same `include_catalog_browse()` gate as browsing; hybrid races cache against server like `get_items` — saving server results through to the cache on a miss, so the favourites page does not re-query the server every visit and the DR-114 mirror is filled on a fresh install — and applies the DR-080 rule that an empty offline result is authoritative when the gate is off. The command falls back to this read when nothing is cached, rather than painting an empty state it will correct a round trip later. A separate method rather than `get_items` because favourites span libraries and `get_items` is `ParentId`-shaped | Repository | UR-067 | Done |
|
||||||
|
| DR-116 | `GetItemsOptions.favorites_only` filters an existing library listing in place — online by appending `Filters=IsFavorite`, offline by joining `user_data` into the existing `available_items` CTE so the downloads-only gate still applies. This is what backs the per-library favourites toggle, and composes with the genre and item-type filters already there | Repository | UR-067 | Done |
|
||||||
|
| DR-117 | The Favourites page (`/library/favorites`) renders favourites across libraries with All / Movies / Shows / Music scope tabs, reusing `LibraryViewTabs` + `LibraryGrid` + `MediaCard` so card shape still follows the media (§5A.1) and a mixed All tab reads as posters, squares and thumbnails side by side. Each tab sends a `SearchScope` value and nothing else. Reached from the library overview and from "See all" on the home rows | UI | UR-067 | Done |
|
||||||
|
| DR-118 | Home carries favourite rows for movies, shows and music, loaded via `repository_get_favorites` per scope and rendered below Recently Added. A row with no items does not render at all, so a fresh install shows no empty favourite rows | UI | UR-067 | Done |
|
||||||
|
| DR-119 | `FavoriteButton` is mounted wherever a whole item is shown — movie/series/episode detail heroes, album/artist/playlist headers, and as a `MediaCard` artwork overlay — and a `favorites` store holds in-session optimistic state so un-hearting on one surface updates every other without a refetch. Resolution order is `store override ?? item.userData?.isFavorite ?? false`. On a card the heart is its own button and stops propagation, so hearting never also opens, plays, or triggers the §5B.5 long-press; it is suppressed on server-only (greyed) cards | UI | UR-068 | Done |
|
||||||
|
| DR-120 | Favourite toggles made while offline reach the server. A Rust drain, triggered by the `ConnectivityMonitor` offline→online transition, pushes every `user_data` row with `pending_sync = 1` and clears the flag on success, leaving failures pending for the next transition. It lives in Rust rather than the frontend because a frontend drain dies with the component that started it. Both the drain and the hybrid background refresh emit the kebab-case `favorites-changed` event (`{ itemIds }`) so open views update — without it a favourite marked on another client appears only on the *second* visit to a page, since the cache-first read returns local rows and the server refresh is invisible to the frontend. Supersedes the unused `syncService.queueFavorite`, which is deleted rather than left as a second queue | Backend | UR-069 | Done |
|
||||||
|
| DR-121 | Player quality selector: Rust reports the bitrates available for the current media source and owns the quality→transcode-parameter mapping (the one `get_video_download_url` already holds — playback calls into it rather than restating it, or the two tables drift). Changing quality re-negotiates the stream URL and resumes at the current position with audio/subtitle selection preserved. On Linux, video re-negotiates *within* HLS: returning `stream.mp4` is the documented cause of transcoded playback never starting. The frontend renders the list and remembers the choice; it does not decide what the choice resolves to | UI | UR-070 | Proposed |
|
||||||
|
| DR-122 | The playback path is ephemeral. Streamed bytes are never persisted unless DR-124 rules them keepable, and any in-flight capture is abandoned — partial file deleted, never promoted — the moment the viewer changes quality, because a capture spanning a rendition change is a splice of two encodings rather than a playable file | Playback | UR-070 | Proposed |
|
||||||
|
| DR-123 | The download path is independent of playback: a whole-file fetch through the existing download manager at one canonical quality (default `original`, the direct static copy) over the Range-capable `/Videos/{id}/stream.mp4`, unaffected by bitrate changes and completing into an ordinary `downloads` row so offline browsing and `refresh_queue_local_sources` pick it up unchanged. Prerequisite: downloaded video is currently never played locally — `repository_get_video_stream_url` goes straight to the online repo and the player route calls it with no local check, so a completed video download is still streamed. Without that fix nothing in this spec is observable for video | Repository | UR-071 | In Progress |
|
||||||
|
| DR-124 | Streamed bytes are kept only where they *are* the download artifact — a direct-play session. Android uses ExoPlayer `SimpleCache`/`CacheDataSource` keyed by item **and** media-source id so renditions cannot collide, sharing the existing smart-cache storage budget rather than opening a second one over the same disk; Linux audio uses mpv `stream-record`, abandoned on seek because it is documented as intended for linear streams and seeking breaks the recording. Transcoded Linux video is **not** captured: HLS segments are not a file, and assembling one needs ffmpeg, which is not a dependency and which CI may not install at job time — DR-123 covers that case instead | Playback | UR-071 | Proposed |
|
||||||
|
| DR-125 | A capture is promoted to a completed `downloads` row only when it covers the whole resource; partials stay evictable cache. A new `downloads.source_rendition` column records the negotiated quality/container/codec (`NULL` for the existing paths, which are always `original`) so a captured transcode and a real download are distinguishable rows and an "upgrade to original" remains possible. A quality change never touches a file that already exists — not a permanent download, and not a completed temporary one, both of which stay valid copies of the rendition they hold. It invalidates only an **in-flight** capture or background download of cached media, which is abandoned and restarted at the newly chosen quality, because a capture spanning a rendition change is a splice of two encodings rather than a playable file | Storage | UR-071 | Proposed |
|
||||||
|
| DR-126 | Cache eviction only reclaims the *temporary* tier. `evict_lru_async` selected every completed download ordered by `completed_at ASC` with no `download_source` filter, so hitting the 10 GB storage limit deleted the **oldest** download — typically a film saved deliberately for offline — to make room for a newly precached track. It now evicts only `COALESCE(download_source, 'user') = 'auto'` rows; `COALESCE` rather than a bare equality because rows predating migration 012 can be NULL and unknown provenance must be treated as the user's, never as disposable. Freeing less than requested is the correct outcome when only user downloads remain — the caller reports "unable to free enough space" instead of silently deleting them | Storage | UR-071 | Done |
|
||||||
|
| DR-127 | A cache entry *is* a download with a shorter life: same `downloads` row and same file handling, distinguished by `download_source = 'auto'` plus an expiry, so there is one storage model rather than a cache and a download library that can disagree. Temporary rows are reclaimed on whichever comes first — the life limit elapsing, or eviction under space pressure (DR-126). Permanent (`'user'`) rows have no expiry. A temporary row can be promoted to permanent by the user choosing to keep it, which only clears the expiry and flips the source; the bytes never move | Storage | UR-071 | Done |
|
||||||
|
| DR-128 | Audio-only playback of *downloaded* media reads the local file rather than fetching an audio-only stream. No transcode is involved or wanted: the Linux backend already runs MPV with `video: no`, so handing it the downloaded video file decodes the audio track and ignores the video, and ExoPlayer disables its video renderer equivalently. Transcoding to a separate audio artifact would cost CPU and battery, need an encoder the project does not ship, and produce a second file to keep in step — for no gain over simply not decoding the video | Playback | UR-071 | Done |
|
||||||
|
| DR-129 | A stream that stops delivering is recovered, not treated as terminal. Two failure shapes, because the streams differ. (a) *Phantom end* — the background audio-only handoff uses a progressive mp3 transcode over plain HTTP, chunked and therefore length-less, so a dropped connection reaches the player as end-of-input and ExoPlayer reports `STATE_ENDED` indistinguishably from the real end. The item's runtime is the only thing that can tell them apart: an end reported more than a tolerance short of it (comparing the *absolute* position — handoff base plus the player's relative position) is a truncation. Left unhandled, playback parked in `STATE_ENDED` and the next play intent from the lockscreen, notification or a Bluetooth reconnect seeks an ended player to position 0 — the user-visible "the episode randomly restarted". (b) *Recoverable error* — music (`/Audio/{id}/stream?Static=true`) and video (`/Videos/{id}/master.m3u8`) declare their length, so the player detects the truncation itself and raises an error; the frontend's handler stopped playback outright, turning a hiccup into silence. Both resume the current item **in place** (never via `play_item`, which would replace the queue with a single item and lose the album), the error path after a per-attempt backoff. Seekable streams are re-prepared at the URL they already have and seeked; the length-less transcode, which cannot be seeked, has `StartTimeTicks` rewritten into its existing URL so the user's audio-track selection survives and recovery needs no network round-trip. Only `Remote` sources qualify — a local file cannot fail from the network. A shared budget of consecutive attempts at the same position, refilled whenever playback progresses, stops an unreachable server from looping | Playback | UR-040, UR-004 | Done |
|
||||||
|
| DR-130 | A backend's position and duration must survive the end of the file they describe. MPV exposes `time-pos`/`duration` as properties of the *loaded* file, so at EOF it unloads and both stop resolving — the accessors reported `0.0`/unknown at exactly the moment end-of-file handling asks where playback reached, and any position-versus-runtime check would have read every natural end as a truncation. The poll thread records the last reading and the accessors fall back to it. Linux resilience is layered on the same principle that the stream, not the player, is what failed: MPV is configured with ffmpeg reconnection (`stream-lavf-o`, `network-timeout`) so ordinary blips never surface, and `EndFile(ERROR)` — previously a bare log, which left playback halted while the UI still showed "playing" — is emitted as a *recoverable* error. Because MpvBackend is constructed before `PlayerController` exists, it cannot decide in-process like the Android JNI callback: the frontend echoes the error into `player_recover_stream`, which keeps the decision in Rust (the same shape as `PlaybackEnded` → `player_on_playback_ended`). Android reports errors it has already declined as *unrecoverable*, so the echo never asks twice | Playback | UR-004, UR-040 | Done |
|
||||||
|
| DR-131 | The offline mutation queue is drained. `sync_queue` had producers and no consumer: `PlaybackReporter::queue_for_sync` writes a row for every start/stop/mark-played that cannot reach the server, `sync_mark_processing`/`_completed`/`_failed` were registered commands with no callers, and no Rust task processed the table — so queued watch positions never reached Jellyfin and the offline banner's count only ever grew. A drain hangs off the same `connectivity:reconnected` transition as DR-120 (in Rust, because a drain started by a component dies with it) and replays rows oldest-first, so a stale start cannot move the server's resume position backwards after a later stop. `update_progress` replays as *stopped at N* rather than as progress — replaying a mid-playback report hours later would claim the item is still playing — and payloads are read in both dialects that exist in users' databases (`position_ticks` from Rust, camelCase `positionMs` from the frontend helper). A failed row stays queued for the next reconnect; after `MAX_SYNC_ATTEMPTS` it is `abandoned` and stops counting, because a row nothing can ever push is what turns the queue into a counter that only grows. An *unreachable* server is not counted as an attempt at all — the row goes back to `pending` untouched — so opening the app offline a few times cannot abandon good rows; only a server that answers and refuses spends the budget. The drain also runs once at startup, because a queue built in a previous session would otherwise sit untouched for a whole run whenever the server was reachable the entire time and no offline→online transition ever fired. Requires `MediaRepository::mark_played` (JA-035) — the previous stand-in reported a stop at `i64::MAX` | Backend | UR-025, UR-002 | Done |
|
||||||
|
| DR-132 | The pending-sync count is answerable. The offline banner's badge read "N pending sync(s)" and led nowhere, so it was taken for pending *transfers* and looked for on the Downloads page — which lists the `downloads` table and structurally cannot show `sync_queue` rows. The badge becomes a button opening the queue it counts: each row's operation, the item's title (resolved by a `LEFT JOIN items` in `sync_get_pending`, not a per-row frontend fetch), when it was queued, and the error of anything failing, plus a "Sync now" that runs the DR-131 drain on demand. The same list is a Settings section, because a row that keeps failing is still queued when the server is reachable and no banner is on screen. The drain emits `sync-queue-changed` so the badge updates on reconnect instead of lagging by up to one 10s poll | UI | UR-025 | Done |
|
||||||
|
| DR-133 | A downloaded file has exactly one on-disk path, and the row that names it is authoritative. `downloads.file_path` starts relative to the storage root, but the worker rewrites it to the absolute path it actually wrote when the transfer completes — so a *completed* row is already rooted. The video player's offline branch rooted it a second time, handing the asset protocol `/data/user/0/app//data/user/0/app/videos/x.mp4`; the webview reported `MEDIA_ERR_SRC_NOT_SUPPORTED` with `NETWORK_NO_SOURCE`, so every downloaded video failed to play while audio — which resolves the same column through Rust's `resolve_local_media_path`, without re-rooting — played fine. The join is absolute-aware (POSIX, Windows drive letters and UNC) so rows written before completion still resolve | Playback | UR-071 | Done |
|
||||||
|
| DR-134 | The webview can actually fetch the local files it is handed. `convertFileSrc` rewrites a path to `http://asset.localhost/…` unconditionally, but Tauri only answers that origin when the `protocol-asset` cargo feature is compiled in *and* `app.security.assetProtocol.enable` is set — neither was, so every such URL reached a protocol with no handler and the webview reported `NETWORK_NO_SOURCE`. This silently defeated both offline video (`<video src>`) and the cached-thumbnail path in `imageCache`, which fails soft to the server copy and so hid the breakage whenever the server was reachable. The scope is `$APPDATA/**` — the storage root under which the database, `downloads/` and the thumbnail cache all live — rather than an unrestricted grant, so the webview can read the app's own media and nothing else | Security | UR-071 | Done |
|
||||||
|
| DR-140 | An audio track is pinned only when the user picked one. Jellyfin's `MediaStream.Index` is global across every stream in a media source, so index 0 is the *video* stream on virtually all files — yet `AudioStreamIndex=0` was sent as "the first audio track" on the HLS transcode URL, the background audio-only handoff URL, the direct-play fallback URL, and the `PlaybackInfo` negotiation body. A server that honours the request literally then transcodes the video stream into the audio slot and the result plays as a picture with no sound; only servers that silently correct the index hid the bug, which is why it presented as "some videos have no audio". The parameter is now omitted whenever no track has been chosen, so the server resolves the source's `DefaultAudioStreamIndex`; an explicit selection from `player_switch_audio_track` is still carried through unchanged. On the `static=true` direct-play URL it is dropped outright — the original file is served untouched, so the parameter could only mislead | Playback | UR-004, UR-040 | Done |
|
||||||
|
| DR-147 | One search input per screen, and the URL is the search's single source of truth. The header bar rendered only under `/library/**` and merely *navigated* to `/search` (DR-063), so a desktop search handed the user to a screen whose input was a different element — the header box cleared itself and vanished, and the page's own box took over mid-word. That page then re-derived its input from `?q=` against `library.searchQuery` on every store write, so the next keystroke re-ran the effect and snapped the text back to the query the header had sent (and a scope chip back to the URL's scope); entering from the bottom-nav Search tab skipped it only because the effect early-returned on an empty query. The bar now renders on `/search` too (`showHeaderSearch`) and is the sole md+ input — the page's own input is `md:hidden` — and on that route it republishes the query into the URL with `replaceState`, so a whole session of typing costs one history entry. The page *consumes* that URL once per distinct value (`seedFromSearchUrl` against a non-reactive `applied` marker) instead of continuously reconciling it, and the scope chips publish through the same URL so the bar and the chips cannot disagree. Landing on `/search` with a seeded query focuses the bar and puts the caret at the end, because the box the user was typing in belonged to the unmounted route | UI | UR-049, UR-054 | Done |
|
||||||
|
| DR-142 | An episode has exactly **one** surface, and it is complete. Two divergent renderings existed: `EpisodeFocusView` (reached from Continue Watching, the series episode list, the TV landing page and Downloads — i.e. every real entry point) offered only Play and Favourite, while the bare `/library/<episodeId>` page nobody routed to carried the download button, the series/season breadcrumbs and the cast section. Opening an episode the normal way therefore silently lost the ability to download it. The Focus View is now the single surface and carries the full §5B.2 composition — hero action row `Play / Download / Favourite`, series name and `SxEy` badge as links back to the series and to that season's anchor, then genres → cast → similar shows *below* the episode strip, never above it (DR-062). `/library/<episodeId>` redirects into it (`episodeRedirectTarget`, the same rule seasons follow under DR-103), and an episode with no `seriesId` renders the same component series-less rather than falling back to a second, lesser page. The focused episode is fetched in full rather than reused from the season fan-out, because that is a *list* query and carries neither cast nor genres — the sections would have rendered empty. The strip hides itself when the episode has no siblings, a card that only shows the episode you are already on being noise | UI | UR-048, UR-058 | Done |
|
||||||
|
| DR-141 | The device profile states how many channels the audio route can actually voice. `MediaCodecList` answers "can this device *decode* 5.1", which is not the question that decides whether the user hears anything — a phone decodes an AC-3 5.1 track happily and still has two channels to play it out of. With no `MaxAudioChannels` in the profile, Jellyfin was free to direct-play the multichannel track, and the result is device dependent: a failed `AudioSink` configuration (silence) or dialogue folded into surround channels that go nowhere. media3's `AudioCapabilities.maxChannelCount` for the current route is reported over JNI alongside the codec lists, and bounds both the direct-play profile and the transcoding profiles, so the server downmixes rather than shipping channels the sink cannot take. Codecs are never removed from the profile — a device with genuine surround output keeps direct-playing it. A missing or zero reading means "route not yet established", not "no audio", and falls back to stereo, the one capability every sink has | Playback | UR-004 | Done |
|
||||||
|
| DR-145 | Video playback starts only once the app actually holds audio focus. Video manages focus by hand (`handleAudioFocus=false`, because ExoPlayer's automatic handling is reserved for the audio path), and the request's three outcomes were all treated as success: `AUDIOFOCUS_REQUEST_DELAYED` — which `setAcceptsDelayedFocusGain(true)` explicitly invites, and which means the system is *withholding our audio* until it calls back — and an outright `REQUEST_FAILED` were logged and then followed by `playWhenReady = true`. The picture rolled with no sound, indistinguishable to the user from a broken stream. Playback is now held when focus is not granted and started from the `AUDIOFOCUS_GAIN` callback; an explicit `play()` re-requests focus rather than resuming into a stream the system is still muting, guarded by a held-focus flag so repeated plays do not leak focus requests. A `LOSS` clears the pending flag, so an unrelated later `GAIN` cannot start playback the user never asked for | Playback | UR-004 | Done |
|
||||||
|
| DR-146 | The no-audio-track fallback picks a track the renderer can actually play. When ExoPlayer selected no audio track, the recovery forced group 0 / track 0 unconditionally — but the most likely reason nothing was selected is that this very track cannot be decoded on this device, so the override reinstated the silence it was meant to fix. It now scans the groups for the first `isTrackSupported` track and overrides to that, and clears `setTrackTypeDisabled(TRACK_TYPE_AUDIO)` because audio may equally have been off at the type level, which an override alone does not undo. When no group holds a supported track the condition is logged as an error — the server was expected to transcode — rather than leaving a silent video with no explanation in the log | Playback | UR-004 | Done |
|
||||||
|
| DR-148 | The video direct-play profile advertises only what the **webview** can decode. The audio codec list comes from `MediaCodecList`, which describes ExoPlayer — but video does not play through ExoPlayer on either platform: Android force-renders every video in the webview `<video>` element (the interim override in `VideoPlayer.svelte`, because the native SurfaceView sits behind an opaque webview) and Linux always has. Chromium and WebKit decode a far narrower set than the platform does, and the gap is widest on devices whose vendor licenses Dolby: a phone shipping `/vendor/etc/media_codecs_dolby_audio.xml` reports `ac3,eac3`, so Jellyfin direct-played an E-AC-3 track with `static=true` and the webview built a video decoder and no audio decoder at all — full picture, no sound. The defect is triggered by *capability*, not the lack of it, which is why it reproduced on one Motorola while a Fairphone and an Honor tablet played the same file on the same build: a device without the Dolby decoder never claims the codec, so the server transcodes to AAC and it plays. `video_audio_codecs` narrows the platform list to the webview-decodable set (`aac,mp3,opus,vorbis,flac`) for the video direct-play profile *only* — the audio-only profile keeps the full list, since that playback really is the native player's and narrowing it would transcode music that plays perfectly well. A list with nothing decodable still claims `aac` rather than going out empty, because a profile that claims nothing invites the server to give up instead of transcoding. The video codec list is deliberately untouched: HEVC direct-plays through the webview correctly, so the constraint is specific to audio | Playback | UR-004 | Done |
|
||||||
|
| DR-149 | The client decides whether its own renderer can decode the audio, rather than trusting the server's negotiation. Advertising a webview-shaped profile (DR-148) turned out to be necessary but not sufficient: Jellyfin 10.11.5 enforces a `DirectPlayProfile`'s `Container` and `VideoCodec` — excluding either returns `SupportsDirectPlay: false` with `TranscodeReasons=ContainerNotSupported` / `VideoCodecNotSupported` — but **ignores its `AudioCodec`**, offering an E-AC-3 track for direct play against a profile listing only `aac,flac,mp3,opus,vorbis`. Neither a `VideoAudio` `CodecProfile` forbidding the codec nor a `MaxAudioChannels: 2` against a 6-channel track changes the answer, so no profile the client can send fixes it and the picture plays silent. The negotiated source's audio is therefore checked locally against what the webview decodes, and an undecodable track forces the existing h264/aac HLS transcode URL regardless of the server saying direct play is fine — `direct_play` and `needs_transcoding` are corrected to match, so the frontend and the reporting path agree with the URL actually used. The track judged is the one the server would serve: the default, or the first when nothing is marked default, since a supported track further down the list is not the one that plays. A source with no audio streams, or a stream whose codec the server did not name, is left alone — forcing a transcode on a guess spends server CPU on files that already play | Playback | UR-004 | Done |
|
||||||
|
| DR-150 | Android video renders on the native ExoPlayer surface behind a transparent WebView, behind the `experimentalNativeVideo` opt-in. Rust already reported `use_html5_element: false` on Android, but two frontend overrides discarded it — `createAdapter()` hardcoded `"html5"`, and `VideoPlayer.svelte` forced `useHtml5Element = true` and stopped the native backend `player_play_item` had just started. The flag is a **suppressor, never a promoter**: off forces HTML5 even where Rust says native, so an in-progress spike cannot ship as the default, but it can never select native where Rust reported HTML5 (Linux cannot composite behind WebKitGTK, so promoting there is a black screen). Compositing requires clearing two independent opaque layers, and clearing only one leaves audio over a black picture — the WebView widget background and window drawable from Kotlin (`AndroidVideoSurface.setTransparent`), and the page's `html`/`body` and app-shell background from CSS (`data-native-video`). Transparency is declared in `tauri.android.conf.json` rather than the base config, because a transparent window on Linux has nothing behind it, and is toggled per playback session rather than set once, because a permanently transparent window shows the launcher through the rest of the app | Playback | UR-003, UR-004 | Done (behind `experimentalNativeVideo`, default off) |
|
||||||
|
| DR-151 | The player's video SurfaceView actually reaches the view hierarchy. `JellyTauPlayer.setActivity()` had zero callers, so `currentActivity` was always null and `autoAttachSurface()` returned at "Cannot attach surface - no Activity reference". The surface was created and handed to ExoPlayer but never added to the content view, so native video decoded to a surface that was never on screen — independent of any webview transparency. `MainActivity.onCreate` now supplies the reference, which also revives PiP on the video path: `canEnterPip()` gates on `isVideoSurfaceAttached()`, which had been permanently false | Playback | UR-003, UR-041 | Done |
|
||||||
|
| DR-152 | Platform playback facilities are reported by Rust, not sniffed from the user agent. `webviewAudio.ts` re-derived "does this platform have a native audio backend" by matching `navigator.userAgent` against `android`/`linux` — a second copy of the `cfg!` gate the backends are compiled under, free to drift from it. `player_get_capabilities` now returns `usesWebviewAudio` and `supportsNativeVideo` from the same cfg gates, and the frontend consumes them; the settings toggle for native video is hidden entirely where the platform cannot support it | Player | UR-003, UR-005 | Done |
|
||||||
|
| DR-153 | The git tag is the single source of truth for a release version. The version lived in four files (`package.json`, `tauri.conf.json`, `Cargo.toml`, `Cargo.lock`) that had to be hand-edited in lockstep, and CI's release job rewrote exactly one of them — so a tagged build produced an installer named for the tag wrapped around package metadata naming the previous release, while the Linux job had no version step at all and shipped whatever was committed. `scripts/set-version.sh` writes all four from one argument and is the only thing that does; every release job calls it with the tag. The Android `versionCode` is derived in the same place as `1000 + major*10000 + minor*100 + patch`, which is monotonic in semver order and clears the 1000 floor already installed in the field — a lower code than the installed one makes Android refuse the update. A prerelease suffix is stripped before that arithmetic, which would otherwise abort the script, and a non-tag ref (CI passes `${GITHUB_REF#refs/tags/}` unconditionally) falls back to `git describe` rather than failing a branch build | Build | - | Done |
|
||||||
|
| DR-154 | A watch position that cannot reach the server is queued, not dropped. `sync_queue` and its drain (DR-131) were built, tested and running, but the stop-report path never fed them: `HybridRepository::report_playback_stopped` is a bare pass-through to the online repository ("Playback reporting goes directly to server"), and on failure the error surfaced to a frontend `catch` whose own comment read "Server error - could queue, but for now just log". Both producers that *would* have queued it — `PlaybackReporter::queue_for_sync` in Rust and `syncService.queuePlaybackProgress` on the frontend — have no callers on the playback path, so closing a video while the server was unreachable lost the resume point outright even though `user_data.pending_sync` was dutifully set to 1 and nothing ever drains that flag for positions (unlike favourites, DR-120). The command layer now enqueues a `report_playback_stopped` row whenever the push fails, which the existing drain already knows how to parse and replay. The pending row for an item is **superseded in place** rather than appended to: progress is reported every 10s, so a server that stays down would otherwise add a row per tick, all of them obsoleted by the newest — the unbounded queue DR-131 exists to prevent. Only `pending`/`failed` rows are superseded, because an `abandoned` row has been given up on and reviving it would restore that same growing counter. Queueing is best-effort and never fails the command: the local position is already saved, so a failed *queue* write must not be reported as a lost position | Backend | UR-025, UR-002 | Done |
|
||||||
|
| DR-155 | A watch position set on another device reaches this one. The resume check reads the local `user_data` row and nothing else, but `mirror_user_data` — the only path by which server `UserData` lands in that table — mirrored `is_favorite` alone, and returned early whenever that field was absent, which is exactly the shape of an ordinary watched episode. So `playback_position_ticks` was write-only from this device's perspective: watch 40 minutes in a browser, open JellyTau, and it resumed from whatever *this* device last saw or offered no resume at all — the same user-visible symptom as DR-150's Android bug, from an unrelated cause, which is why resume read as broadly flaky. The mirror now carries the position alongside the favourite flag under the same `pending_sync = 0` conflict rule, so a local position still waiting to be pushed is never pulled *backwards* by a server that has not yet heard where we got to; `COALESCE(excluded.x, user_data.x)` means a field the server omitted keeps its stored value rather than being nulled, and a row with neither field is still skipped rather than fabricated as zeroes. Mirroring alone was not sufficient: `get_item` — the call the player route makes — returned the cached copy on a hit and never consulted the server, so for an already-cached item the mirror never ran. It now refreshes in the background on a cache hit (`race_with_refresh`, the reusable form of what `get_items` already did inline), which is why browsing a season picked up other devices' state while opening the episode directly did not. The refreshed value lands for the next read, the cache-first race still answering immediately | Backend | UR-025, UR-002 | Done |
|
||||||
|
| DR-156 | A page no longer inherits the previous page's scroll position. The shell keeps its scrollers alive across navigation by design — the root layout, the home page and the library layout each own a `flex-1 overflow-y-auto` box that outlives the route inside it, which is what lets `BottomUi` be a flex sibling rather than a measured overlay — but the element therefore never remounts and its `scrollTop` survives the route change. SvelteKit's own scroll restoration could not help: it saves and restores `window` scroll, and in this app the window never scrolls at all, so there was no scroll handling of any kind. The symptom was that opening an item from half-way down a library grid dropped the viewer half-way down the detail page, and returning to the grid landed at the top of it — exactly backwards. `ScrollMemory` (pure, one instance per container, keyed on path + query so a genre-filtered grid keeps its own place) records the offset a route is left at in `beforeNavigate` and decides in `afterNavigate`: `link`/`goto`/`form` reset to the top, `popstate` restores that route's saved offset, and the initial `enter` is left alone. Deciding does not consume the offset, so a route returned to more than once restores each time. Applied via the `scrollContainer` action on all three scrollers | UI | UR-072 | Done |
|
||||||
|
| DR-160 | Picture-in-picture works on the path that actually plays video. PiP shrinks the whole *Activity*, so `canEnterPip` demanded a native ExoPlayer `SurfaceView` be attached and rendering — `isPlayingVideo() && getSurfaceView() != null && isVideoSurfaceAttached()`. But the native path sits behind `experimentalNativeVideo`, which defaulted to **off**, so in the shipping configuration video played in the WebView's `<video>` element and all three conditions were false. `enterPip` bailed with "Not entering PiP: no local video playing" every single time: the button was offered (gated only on OS capability) and could not work, however it was pressed. The manager now accepts either surface. The frontend reports the element through `AndroidPictureInPicture.setHtml5VideoState(active, width, height, playing)` — intrinsic size because the PiP window's aspect ratio came from the letterboxed surface's measured bounds, which do not exist here, and play state because `ExoPlayer.isPlaying` is false on this path and the PiP play/pause action would be frozen on "Play" mid-playback. Two behaviours invert when the WebView *is* the video: it must stay visible in PiP rather than be hidden (`hideWebView` is now gated on the native path — hiding it would leave an empty black window), and the play/pause `RemoteAction` has to reach the element, so the receiver dispatches `jellytau-pip-play`/`jellytau-pip-pause` DOM events instead of driving ExoPlayer. `jellytau-pip-entered`/`-exited` let the player strip its own chrome, since controls, title and gradients would otherwise be rendered into a window a couple of inches wide. The `<video>` is deregistered on teardown so PiP is never offered over a video that has gone | UI | UR-041 | Done (pending device verification) |
|
||||||
|
| DR-167 | Each downloaded library shows only its own media. Cached items carry no link back to their library — `library_id` and `parent_id` are NULL on every row ([[offline-libraries-never-cached]]) — so `get_downloaded_items` matched the library branch with `EXISTS (SELECT 1 FROM libraries l WHERE l.id = ?)`, which asserts only that the requested library *exists* and never constrains the item to it. Opening any downloaded library therefore listed every downloaded top-level item on the server: films under Music, albums under TV. The sibling query that decides which libraries *appear* already carried the right rule — a `collection_type` ↔ `item_type` mapping — so the two disagreed about the same question. That mapping is now the named constant `LIBRARY_HOLDS_ITEM`, used by both, and a library of unknown collection type still keeps everything rather than being emptied by a rule that cannot classify it. The taxonomy stays in Rust, never the frontend | Downloads | UR-055 | Done |
|
||||||
|
| DR-168 | Pause and resume actually stop and restart the bytes. `pause_download` wrote `status = 'paused'` and did nothing else, and no cancellation existed anywhere in the download stack — no token, no flag, no abort — so the streaming task ran on, kept writing, and overwrote the row with `completed`/`failed` when it finished: the row flicked to "paused" and undid itself. `resume_download` had the mirror defect, flipping the row to `pending` without calling `pump_download_queue`; the pump runs when something calls it rather than polling, so a resumed download sat untouched until an unrelated event happened to pump the queue. A per-download stop flag (`download::stop`) is the missing half — a module-level registry because the two sides never meet, the command holding Tauri state and the worker running detached in `async_runtime::spawn`. The worker reads it between chunks and on retry (so a pause is not swallowed by a 45-second backoff), flushes, and returns `Stopped`, which is deliberately **not** retryable and **not** recorded as a failure: the `.part` file is left intact because that is exactly what the resume's Range request continues from. Registering returns a *fresh* flag, or a resumed download would inherit the pause that stopped it and halt instantly. Cancel and `clear_stale_downloads` signal it too, so neither deletes a file still being written | Downloads | UR-055 | Done |
|
||||||
|
| DR-169 | Partial files are actually reaped. The worker named its sidecar with `Path::with_extension("part")`, which *replaces* the extension — `movie.mp4` became `movie.part` — while every cleanup path deleted `"{file_path}.part"`, i.e. `movie.mp4.part`. The two never matched, so the partial file of every cancelled or failed download stayed on disk indefinitely, invisible to the disk-usage totals because no `downloads` row pointed at it. `partial_path` appends instead, is the single definition both the writer and the cleaners use, and incidentally removes a collision the old form had, where `movie.mp4` and `movie.mkv` mapped to one `movie.part` | Downloads | UR-055 | Done |
|
||||||
|
| DR-170 | Downloads at a chosen bitrate are no longer corrupted by their own retries. Only the `original` preset asks for `Static=true`; every other rung requests a **transcode**, which Jellyfin serves chunked, with no `Content-Length`, and cannot byte-seek — so it ignores `Range` and answers `200` with the whole stream from the beginning rather than `206` with the requested tail. The worker sent the Range header whenever a `.part` existed and appended the body unconditionally, so each retry and each resume concatenated a fresh copy of the entire transcode onto the bytes already on disk: the file grew past its real size and would not play, which is why "downloads for different bitrates" stayed broken after the `videoBitRate` casing fix (DR-adc460f3) corrected the *request*. `resume_offset` makes the response decide — append only on a `206`, otherwise truncate and take the stream from the top — and the total size is computed from that offset rather than from a partial length the server never agreed to | Downloads | UR-071 | Done |
|
||||||
|
| DR-171 | A downloaded video keeps audio the device can actually decode. `original` quality asked for `Static=true`, which hands back the source file byte-for-byte — E-AC-3/AC-3/DTS/TrueHD track included — and video is rendered on both platforms by the webview `<video>` element, which decodes none of them. Streaming already knew this: DR-149 judges the track the server would serve against `WEBVIEW_AUDIO_CODECS` and forces a transcode over Jellyfin's own direct-play offer, because 10.11.5 honours a `DirectPlayProfile`'s container and video codec but ignores its audio codec. The download path never consulted that policy, so the *same film* had sound when streamed and played as picture in silence once downloaded — and offline a download is the only source a video has, so there was no working path left to fall back to. The rule is now one rule: `served_audio_codec` picks the track the server will serve (the default, or the first when none is marked) and both callers judge it, the streaming verdict staying a bool and the download path needing the codec itself so it can say what to re-encode. Only the audio is re-encoded — `allowVideoStreamCopy=true` keeps an h264 source's picture byte-for-byte and no bitrate or resolution cap is added, so `original` still means original quality; a source the webview could not have rendered anyway (HEVC) becomes h264 as a side effect, which is the only form of it that would have played. The decision is per item rather than blanket because the transcode costs the byte-range resumability `Static=true` gives the download worker (see DR-170 for what a chunked, length-less response does to a resume), so a file whose audio already plays keeps the direct copy. An unknown codec — item not fetchable, or the server named none — changes nothing: the policy only ever *adds* a transcode, so it cannot make a working download worse. The codec set judged against is the **webview's**, not the platform's, even though DR-161 made ExoPlayer the Android default: `experimentalNativeVideo` is a user setting, a downloaded file outlives whatever it was set to when the file arrived, and the narrow list is the only one that holds on both sides of it — at the cost of a Dolby-licensed device re-encoding a track its ExoPlayer could have played. `resolve_video_download_url` is the single entrance for all three resolution sites (the frontend's per-item command, the bulk series/season enqueue, and the offline-queued resume), since the pure builder cannot look a codec up and a caller that forgets to is exactly how the silent downloads shipped. **Files already downloaded stay silent** — the bytes on disk are the wrong bytes and only a re-download replaces them | Downloads | UR-071, UR-004 | Done |
|
||||||
|
| DR-162 | Video streams are opened against a **bandwidth ceiling the user chose**, instead of a fixed allowance nobody could change. Every video URL carried `MaxStreamingBitrate=20000000`/`VideoBitrate=18000000`, `PlaybackInfo` negotiated at 20 Mbps, and the device profile advertised `999999999` — so on a metered or slow connection the only lever was not watching. `StreamingQuality` is a ladder of ceilings (Original, 20/10/8/4/2/1 Mbps, 720 kbps) in which a step is not a label but a bundle of transcode parameters: the total ceiling, the audio share of it, and the resolution that budget can carry. It lives in Rust because those numbers are Jellyfin encoding vocabulary — the frontend names a variant and reads labels back over `player_get_streaming_qualities`, the same arrangement as the EQ preset curves. The video bitrate is the total *minus* the audio share, so the two together honour the cap rather than overshooting it by the size of the audio track, and `MaxHeight` falls with the ladder so a small budget is not spent on pixels it cannot afford. The cap has to reach the **negotiation**, not only the transcode URL: `max_static_bitrate` in the device profile is what makes the server refuse to direct-play a source fatter than the ceiling, and without it a 30 Mbps remux is handed over untouched and every URL parameter downstream is moot — which is why it is applied at all four places that decide bandwidth (the HLS builder, `PlaybackInfo`, `open_live_stream`, and the background-audio handoff, which takes the lower of the cap and its own 384 kbps). The ceiling is process-wide rather than a field on `OnlineRepository`, mirroring `INCLUDE_CATALOG_BROWSE`: it is a preference about *this device's connection*, it must survive a repository rebuilt on re-login, and every builder plus the negotiation have to agree on it or the cap leaks. Settings owns the durable default and is the only writer to `app_settings` — persisted unlike the rest of `VideoSettings`, because a limit set for a metered connection that silently reverts to uncapped on the next launch spends the user's data with no changed setting to show for it — and it is restored at startup from the async runtime, defaulting to uncapped if the read fails so a database problem degrades to the old behaviour rather than to an arbitrary limit. The in-player menu is the per-video override: a cap is a property of the stream the server is producing, so it cannot be applied to one already in flight — `player_set_stream_quality` re-opens the stream at the new quality and resumes at the current position, reloading a native backend itself and handing HTML5 a URL for the same `reloadSource` primitive the audio-track switch uses, so no strategy branch lives in the UI. It deliberately does not persist. This gives UR-070 its resume-at-the-same-point mechanism; the server-offered per-item rendition list that requirement also asks for remains proposed | Playback | UR-074, UR-070 | Done |
|
||||||
|
| DR-161 | Native video is the default, so picture-in-picture has a real surface. DR-160 makes PiP work on the HTML5 path, but that path can only ever shrink the *UI* into the PiP window; showing the video itself needs the SurfaceView behind the WebView, which is what `experimentalNativeVideo` gates. The flag now defaults to on when the user has never chosen, with an explicit stored choice still winning in both directions so anyone who turned it off keeps it off. This is a deliberate acceptance of risk: the flag existed because the native path was an unfinished spike, and `VideoPlayer.scrubRegression.test.ts` documents its history — a native init that flipped to HTML5 mid-lifecycle and left seeks going down one path while ExoPlayer played on another. Those tests pin the **flag-off** interim override (native response overridden to HTML5, backend stopped once), which the default no longer selects, so they now mock the flag off rather than inherit it: they still guard that path, but they no longer describe what ships. The native scrub/seek path is consequently not covered by the suite and needs device verification | UI | UR-041, UR-003 | Needs device verification |
|
||||||
|
| DR-159 | The background-audio handoff stops leaking its relative timeline. The handoff plays the episode as a *relative* stream — the audio-only URL is built with `StartTimeTicks` = the position the screen was locked at, so ExoPlayer's zero is the handoff point — and `background_audio_base` holds the offset that turns one back into a real position. The base was a **display-only** correction, applied in exactly two places (the lockscreen scrubber and the internal truncation maths) while every other consumer worked in the relative timeline treating the number as absolute. Each crossing threw away exactly `base` seconds, which is why the jump-back distance varied with where the screen was locked and read as random. Three crossings were live: progress reporting to Jellyfin sent the relative position every 30s, so the server was told `real − base` — and since DR-155 now mirrors the server's position back and refreshes on a cache hit, that regressed value returned as the resume point (lock at 40 min, listen to 90, reopen at 50); lockscreen seeks went out absolute and came back relative, against a chunked length-less transcode that cannot honour a seek at all, so a clamped seek landed at stream zero; and media3's own `seekToDefaultPosition`/`seekBack`/`seekForward` bypassed the `ForwardingPlayer` wrapper entirely, reaching the real ExoPlayer — `Util.handlePlayButtonAction` seeking an ended player to the relative zero being the same mechanism as DR-129's truncation bug through a different door. The fix converts **once, at the boundary**: `JellyTauPlayer`'s position tick adds the base (and shifts the duration with it, since the stream's own length is only what remains) before either `nativeOnPositionUpdate` or the lockscreen sees it, so position updates, progress reports, the frontend and the truncation check all speak the episode's timeline and none needs to know a handoff happened. The base is consequently *removed* from `claim_stream_resume`, `truncated_stream_resume_position` and `player_exit_background_audio`, where adding it now double-counts, and the lockscreen's `positionOffsetMs` addition goes with it (the field remains, read-only, as the tick's input). Inbound seeks go the other way: `seek_absolute` is the new boundary for every outside seek, re-opening the stream at the requested position via `resume_stream_at` when a handoff is active — which is what `onSeekTo` had claimed for months in a comment describing code that did not exist — and an ordinary seek otherwise. `seekToDefaultPosition` is swallowed rather than forwarded, since Rust already owns what "play after the stream ended" means and the `play()` that follows reaches it. Exit reads the position *before* clearing either base, or a tick landing in between hands back a relative one | Player | UR-040, UR-005, UR-025 | Done (pending device verification) |
|
||||||
|
| DR-158 | A watched toggle, on the episode row, the season header, the series and movie hero, and the Episode Focus View. Both halves of the backend already existed and neither had a caller: `mark_played` (`POST /PlayedItems`) was reachable only from the sync drain replaying rows the *reporter* had queued, and `clear_watch_history` (`DELETE /PlayedItems`) only from the destructive "erase this series' history" button — so the sole way to mark something watched was to play it. Jellyfin applies both recursively over a season or series, so the container case needs no client-side fan-out *online*. Offline it does: `storage_set_watched` writes the item **and its descendants** (drawn from `items` by `parent_id`/`album_id`/`season_id`/`series_id`, so an uncached id selects nothing and the statement no-ops instead of raising a foreign-key error), because otherwise marking a season watched with no server would tick the season and leave every episode inside it unwatched. It is deliberately separate from `storage_mark_played`, which stays the single-item "this finished playing" path that increments `play_count`. Un-marking clears the resume position as well as the flag, matching the server. `QueuedOp::MarkUnplayed` gives the queue the missing direction — pushing as `clear_watch_history` — so the toggle works offline both ways rather than only one; without it un-marking would have been the half that needed a connection. The button is an everyday toggle, so unlike `ClearHistoryButton` it does not confirm, and it holds an optimistic state because the caller's `watched` prop only catches up after a reload (a season means a round trip, during which the button would otherwise appear to ignore the tap) | UI | UR-073 | Done |
|
||||||
|
| DR-157 | Full-screen video on Android actually goes full screen. `toggleFullscreen` called `document.documentElement.requestFullscreen()` and nothing else, which inside an Android WebView does not touch the Activity window — it expands the element within a viewport that already spans the whole screen, because `enableEdgeToEdge()` is called in `onCreate` and SDK 36 ignores the opt-out. So the control did nothing visible while the status bar and navigation/gesture bar stayed painted over the video, and (unlike DR-112's chrome-clearance work, which is about *reserving* space for the bars) here the bars should not be there at all. `ImmersiveModeBridge` hides them via `WindowInsetsControllerCompat` with `BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE`, so an edge swipe brings them back transiently over the video instead of resizing the window mid-playback, and the system's own gestures stay reachable. Exposed as the `AndroidImmersive` bridge and posted to the main thread, since `@JavascriptInterface` methods arrive on a WebView binder thread. `requestFullscreen()` is kept for the platforms where it does work, but its rejection is caught rather than allowed to abort the immersive call. Restoring is wired to three paths, not one: leaving fullscreen, Escape (which previously called `document.exitFullscreen()` directly, bypassing the flag and the bars), and `onDestroy` — the bars belong to the Activity, so a player torn down while immersive would strand every screen behind it without them. The `--jt-inset-*` properties need no special handling: hiding the bars fires the decor view's inset listener with zeroes and `WindowInsetsBridge` republishes them | UI | UR-066 | Done |
|
||||||
|
| DR-143 | Flipping the offline downloaded-only gate actually re-queries the listing. The gate (DR-078) is a process-wide flag in Rust consulted only *while a query runs*, but no library surface re-queried when its inputs changed: `useServerReachabilityReload` fires only on the offline → **online** transition, and `GenericMediaListPage`, `GenericGenreBrowser` and the favourites page never even called its `checkServerReachability`. So going offline left the full server catalog on screen under a now-closed gate, and toggling "Show all server media" only greyed cards — `MediaCard.isServerOnly` is a pure frontend derivation that updates instantly — without adding or removing a single row. The filter therefore read as "shows everything until I filter, then greys some of it" while the backend gate was correct and simply never exercised. `catalogFilterVersion` is the refetch signal: `pushCatalogVisibility` now awaits `set_show_server_catalog` and bumps the version only **after** the backend accepts the new flag, since a reload racing the push would re-query under the old gate and undo itself. A failed push clears `lastIncludeCatalog` instead of latching it, so the next identical transition is retried rather than skipped as a no-op and left permanently disagreeing with the backend. `useOfflineFilterReload` subscribes pages to that signal, skipping the value they already loaded under; it is wired into both generic list components and the movies/music/tv/favourites landing pages and the `/library/[id]` detail page | UI | UR-052 | Done |
|
||||||
|
| DR-135 | A download's media type comes from the item, not a default. `download_item` — the path a media card uses to queue an item while offline — never records `media_type`, and the reconnect resolver read that NULL as `'audio'`, so a **movie** queued from a card had its URL resolved by `get_audio_stream_url`. The file that landed on disk was an audio-only transcode, which is why a "downloaded" film could never play offline no matter how the path or protocol was fixed. The resolver now falls back to the item's own `item_type` (`VIDEO_ITEM_TYPES` in Rust, so the frontend never learns which types are video) and only defaults to audio when the item is not cached locally. An explicit `media_type` on the row still wins | Downloads | UR-071, UR-052 | Done |
|
||||||
|
| DR-136 | Rows already downloaded under the audio default are repaired, not just prevented. They are identifiable after the fact — no `media_type`, but a video item — so on reconnect they are reset to `pending` with their audio URL cleared and re-resolved by DR-135's corrected logic, overwriting the audio file in place. Without this the fix is invisible to anyone who had already queued a film: the row still reads "downloaded" and still fails to play. Rows carrying an explicit `media_type` and genuine audio downloads are left untouched | Downloads | UR-071 | Done |
|
||||||
|
| DR-137 | Local media is served to the player over a loopback HTTP server, not the asset protocol. Tauri's `asset` protocol answers a request carrying no `Range` header by reading the whole file into memory, and only advertises `Accept-Ranges: bytes` from *inside* its range branch — so the first request never learns ranges exist and a multi-gigabyte body is attempted instead. Chromium abandoned it with `PIPELINE_ERROR_READ` after ~31s, which reached the user as "downloaded video does not play offline". Real HTTP on `127.0.0.1` is chosen over a custom URI scheme deliberately: range support becomes a property of the transport rather than depending on whether a platform's webview forwards `Range` to a custom scheme. No response ever exceeds a 4 MiB chunk and bodies stream from the file handle, so memory is bounded regardless of file size. Because **loopback is shared between apps on Android**, the server binds `127.0.0.1` only and every URL carries a random per-session token; paths are additionally confined to the app data directory, so a leaked URL cannot read outside it. This is stage 1 of making the server the single media origin — remote passthrough and download-while-watching are deliberately out of scope here | Playback | UR-071 | Done |
|
||||||
|
| DR-138 | Loopback is exempted from Android's cleartext ban, and nothing else is. Release builds set `usesCleartextTraffic="false"`, so the webview's request to the local media server (DR-137) was rejected by network security policy before any I/O — `<video>` failed in the same millisecond as `loadstart`, with `NETWORK_NO_SOURCE` and no server-side log at all, which is why it looked identical to a missing file. A `network-security-config` resource permits cleartext for `127.0.0.1` only and keeps `base-config cleartextTrafficPermitted="false"`, so a remote server must still be HTTPS; this is deliberately not a blanket opt-in. The manifest attribute is ignored once the config is present, so the config is the single authority. `sync-android-sources.sh` also had to learn to copy `res/xml`, which it skipped — the manifest references the resource, so a missed copy fails the resource link rather than degrading quietly | Security | UR-071 | Done |
|
||||||
| DR-093 | Traceability coverage gate derives its requirement denominators from `requirements.md` at run time rather than hardcoded literals: `countDefinedRequirements` counts an ID only where it leads a markdown table row (ignoring the "Traces To" column and prose) and deduplicates IDs listed both in the definition tables and in the §3 traceability matrix; `computeCoverage` reports the *intersection* of traced and defined IDs so an ID traced in code but absent from `requirements.md` is surfaced as `orphaned` instead of inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. CI and `bun run traces:coverage` share this computation and fail on both a sub-threshold and an impossible >100% result | Tooling | - | Done |
|
| DR-093 | Traceability coverage gate derives its requirement denominators from `requirements.md` at run time rather than hardcoded literals: `countDefinedRequirements` counts an ID only where it leads a markdown table row (ignoring the "Traces To" column and prose) and deduplicates IDs listed both in the definition tables and in the §3 traceability matrix; `computeCoverage` reports the *intersection* of traced and defined IDs so an ID traced in code but absent from `requirements.md` is surfaced as `orphaned` instead of inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. CI and `bun run traces:coverage` share this computation and fail on both a sub-threshold and an impossible >100% result | Tooling | - | Done |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -277,7 +351,7 @@ Internal architecture, components, and application logic.
|
|||||||
| 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 |
|
||||||
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010 |
|
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010 |
|
||||||
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006 |
|
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171 |
|
||||||
| UR-005 | - | DR-001, DR-005, DR-009 |
|
| UR-005 | - | DR-001, DR-005, DR-009 |
|
||||||
| UR-006 | IR-005, IR-006, IR-007, IR-008 | - |
|
| UR-006 | IR-005, IR-006, IR-007, IR-008 | - |
|
||||||
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
|
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
|
||||||
@@ -298,7 +372,7 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-022 | IR-017 | DR-025 |
|
| UR-022 | IR-017 | DR-025 |
|
||||||
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049 |
|
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049 |
|
||||||
| UR-024 | IR-010 | DR-027 |
|
| UR-024 | IR-010 | DR-027 |
|
||||||
| UR-025 | IR-015 | DR-028 |
|
| UR-025 | IR-015 | DR-028, DR-131, DR-132 |
|
||||||
| UR-026 | - | DR-029, DR-048, DR-050 |
|
| UR-026 | - | DR-029, DR-048, DR-050 |
|
||||||
| UR-027 | IR-020 | DR-030 |
|
| UR-027 | IR-020 | DR-030 |
|
||||||
| UR-028 | - | DR-031 |
|
| UR-028 | - | DR-031 |
|
||||||
@@ -313,30 +387,40 @@ 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 |
|
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159 |
|
||||||
| UR-041 | IR-026 | DR-053 |
|
| UR-041 | IR-026 | DR-053, DR-160, DR-161 |
|
||||||
| 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 |
|
||||||
| 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 |
|
||||||
| UR-047 | IR-013 | DR-060 |
|
| UR-047 | IR-013 | DR-060 |
|
||||||
| UR-048 | - | DR-061, DR-062 |
|
| UR-048 | - | DR-061, DR-062, DR-142 |
|
||||||
| UR-049 | IR-010 | DR-063, DR-064, DR-065 |
|
| 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 |
|
| UR-052 | IR-027 | DR-078, DR-079, DR-080, DR-143 |
|
||||||
| UR-053 | IR-029 | DR-074 |
|
| UR-053 | IR-029 | DR-074 |
|
||||||
| UR-054 | - | DR-075, DR-076, DR-077 |
|
| UR-054 | - | DR-075, DR-076, DR-077, DR-147 |
|
||||||
| UR-055 | - | DR-081, DR-082, DR-083, DR-084 |
|
| UR-055 | - | DR-081, DR-082, DR-083, DR-084, DR-167, DR-168, DR-169 |
|
||||||
| UR-056 | - | DR-085 |
|
| UR-056 | - | DR-085 |
|
||||||
| UR-057 | - | DR-086 |
|
| UR-057 | - | DR-086 |
|
||||||
| UR-058 | - | DR-087 |
|
| UR-058 | - | DR-087, DR-142 |
|
||||||
| UR-060 | - | DR-090, DR-091 |
|
| 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 |
|
||||||
| 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-066 | IR-031 | DR-112, DR-157 |
|
||||||
|
| UR-067 | - | DR-115, DR-116, DR-117, DR-118 |
|
||||||
|
| UR-068 | - | DR-119 |
|
||||||
|
| UR-069 | - | DR-113, DR-114, DR-120 |
|
||||||
|
| 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 |
|
||||||
|
| UR-072 | - | DR-156 |
|
||||||
|
| UR-073 | - | DR-158 |
|
||||||
|
| UR-074 | - | DR-162 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -436,6 +520,73 @@ Internal architecture, components, and application logic.
|
|||||||
| UT-091 | Transport intents (play/pause/toggle) reach the backend even while a video adapter is registered, and never call the adapter's own `play`/`pause`/`toggle` — the webview must not decide play-vs-pause from the DOM | DR-097 | Done |
|
| UT-091 | Transport intents (play/pause/toggle) reach the backend even while a video adapter is registered, and never call the adapter's own `play`/`pause`/`toggle` — the webview must not decide play-vs-pause from the DOM | DR-097 | Done |
|
||||||
| UT-092 | `shouldReuseActivePlayback` reuses backend playback for an already-loaded audio track but never for video, and never when an explicit start position or a next-episode restart was requested | DR-100 | Done |
|
| UT-092 | `shouldReuseActivePlayback` reuses backend playback for an already-loaded audio track but never for video, and never when an explicit start position or a next-episode restart was requested | DR-100 | Done |
|
||||||
| UT-093 | `resolvePlayerSurface` returns `video` only with a stream URL, `pending` for video whose stream URL is still missing (never `audio`), and `audio` for audio content | DR-100 | Done |
|
| UT-093 | `resolvePlayerSurface` returns `video` only with a stream URL, `pending` for video whose stream URL is still missing (never `audio`), and `audio` for audio content | DR-100 | Done |
|
||||||
|
| UT-094 | `parseNativeInsets` accepts the bridge's JSON or a decoded object, and coerces missing/negative/non-finite edges to 0 rather than emitting `NaNpx` (which would invalidate the whole padding declaration) | DR-112 | Done |
|
||||||
|
| UT-095 | `safeAreaCssVars`/`applySafeAreaInsets` emit px-suffixed `jt-inset` custom properties for all four edges | DR-112 | Done |
|
||||||
|
| UT-096 | `readNativeInsets` returns null with no bridge and survives a stale WebView proxy (missing or throwing `get`) instead of throwing out of layout init | IR-031, DR-112 | Done |
|
||||||
|
| UT-097 | `initSafeArea` primes the document on start, re-applies on `jellytau-insets-changed` (rotation, nav-mode switch), unsubscribes on teardown, and writes nothing without a bridge so `env()` still wins on iOS/desktop | IR-031, DR-112 | Done |
|
||||||
|
| UT-098 | `shellReservesBottomInset` gives the bottom inset to BottomUi wherever one renders and to the app shell only on routes without one, so the gesture bar is never ignored nor double-padded | DR-112 | Done |
|
||||||
|
| UT-099 | A Jellyfin item payload carrying `UserData.IsFavorite` maps to `MediaItem.user_data.is_favorite` | DR-113, JA-034 | Done |
|
||||||
|
| UT-100 | `OnlineRepository::get_favorites` builds `Filters=IsFavorite` + `Recursive=true` + the scope's `IncludeItemTypes`, and omits the type filter entirely for `SearchScope::All` | DR-115, JA-033 | Done |
|
||||||
|
| UT-101 | `OfflineRepository::get_favorites` returns only `is_favorite = 1` rows, honours the scope type filter, and stays downloads-only when the catalog-browse gate is off | DR-115 | Done |
|
||||||
|
| UT-102 | The `save_to_cache` favourite mirror does not overwrite a row with `pending_sync = 1` | DR-114 | Done |
|
||||||
|
| UT-103 | The reconnect drain pushes pending favourites, clears `pending_sync`, and leaves failed rows pending | DR-120 | Done |
|
||||||
|
| UT-104 | `get_items` with `favorites_only` filters online (endpoint) and offline (SQL) | DR-116 | Done |
|
||||||
|
| UT-105 | `favorites` store precedence: override beats `userData.isFavorite` beats `false` | DR-119 | Done |
|
||||||
|
| UT-106 | Un-favouriting removes an item from a favourites listing view | DR-117, DR-119 | Done |
|
||||||
|
| UT-107 | The hybrid background refresh emits `favorites-changed` only for ids whose favourite state actually flipped | DR-120 | Done |
|
||||||
|
| UT-109 | Search covers synced-but-not-downloaded items when catalog browse is on, and stays downloads-only when off | DR-108 | Done |
|
||||||
|
| UT-110 | Search item-type filter is bound, not interpolated: a quote-bearing type neither errors nor widens results | DR-108 | Done |
|
||||||
|
| UT-111 | FTS prefix queries quote each token, so apostrophes/hyphens/slashes are data; empty or punctuation-only input returns no rows rather than erroring | DR-108 | Done |
|
||||||
|
| UT-112 | Repeated catalog passes leave one `items_fts` entry per item, not one per pass | DR-110 | Done |
|
||||||
|
| UT-113 | The stale-catalog sweep removes vanished synced rows, keeps downloaded ones, keeps uncrawled types, and stays scoped to one server | DR-110 | Done |
|
||||||
|
| UT-114 | Cached people are reachable from unscoped search and excluded from scoped search | DR-111 | Done |
|
||||||
|
| UT-115 | Re-index staleness policy: never-indexed and unparseable timestamps are due, fresh ones are not, future ones are not | DR-109 | Done |
|
||||||
|
| UT-116 | `resolve_local_media_path` returns a completed download's file, and `None` for an in-progress download, a row whose file has been deleted, or an unknown item | DR-123 | Done |
|
||||||
|
| UT-118 | `resolveVideoSource` prefers a downloaded file, never marks a local file as needing transcoding, and falls back to streaming for a blank path | DR-123 | Done |
|
||||||
|
| UT-119 | The audio-only handoff picks a downloaded file over the audio-only stream URL, preserving the Jellyfin id for progress sync | DR-128 | Done |
|
||||||
|
| UT-120 | Expiry reclaim takes only expired temporary entries: derived from `completed_at`+TTL, honouring an `expires_at` override, never a user download, and disabled by a zero TTL | DR-127 | Done |
|
||||||
|
| UT-108 | LRU eviction reclaims only `'auto'` downloads and never a user's own, even when the user's is the oldest | DR-126 | Done |
|
||||||
|
| UT-117 | A background audio-only stream cut short resumes where it died instead of ending the episode; a real end still advances; the absolute position is compared against the runtime; retries at a stuck position give up. A recoverable error resumes music and video too, with growing backoff, leaving the rest of the queue intact and the seekable stream's URL untouched; local and DirectUrl sources are excluded | DR-129 | Done |
|
||||||
|
| UT-124 | `downloadedFilePath` leaves a completed download's absolute path alone (POSIX and Windows) and only roots one that is still relative | DR-133 | Done |
|
||||||
|
| UT-125 | A NULL `media_type` resolves from the item type — Movie and Episode as video, a track as audio — an uncached item still defaults to audio, and an explicit `media_type` overrides the item | DR-135 | Done |
|
||||||
|
| UT-126 | Requeueing takes only video rows downloaded under the audio default, clearing their URL, and leaves correctly-typed video rows and real audio downloads alone | DR-136 | Done |
|
||||||
|
| UT-127 | The media server bounds and confines every response: a range-less request yields one chunk rather than the whole file, no range exceeds the chunk cap, explicit/open-ended/suffix ranges resolve correctly, a range past the end is unsatisfiable rather than clamped, a malformed header falls back to the first chunk, path traversal and unrelated absolute paths are refused, a wrong or absent token is rejected, and content type comes from the extension then the magic bytes | DR-137 | Done |
|
||||||
|
| UT-121 | An EOF reads as the last observed timestamp, not zero: live readings win while the file is loaded, a not-yet-established duration is not recorded as a real zero, a seek updates the position before the next poll, and loading a new file clears the previous one's | DR-130 | Done |
|
||||||
|
| UT-122 | The sync-queue drain pushes queued playback reports oldest-first, defers failures for the next reconnect, abandons a row after `MAX_SYNC_ATTEMPTS`, ignores other users' rows, and parses both payload dialects | DR-131 | Done |
|
||||||
|
| UT-123 | Pending-sync rows describe themselves: every queueable operation has a label, an unknown one still renders, the item title falls back to its id, and rows list oldest-first | DR-132 | Done |
|
||||||
|
| UT-130 | Video and background-audio stream URLs omit `AudioStreamIndex` when no track was chosen, and carry the exact index when one was | DR-140 | Done |
|
||||||
|
| UT-131 | The Episode Focus View hero offers a download control | DR-142 | Done |
|
||||||
|
| UT-132 | The series name links to the series and the `SxEy` badge to that season's anchor | DR-142 | Done |
|
||||||
|
| UT-133 | Cast renders below the "More Episodes" strip, never above it | DR-062, DR-142 | Done |
|
||||||
|
| UT-134 | The episode strip is hidden when the episode has no siblings | DR-142 | Done |
|
||||||
|
| UT-135 | An episode with no `seriesId` still renders the Focus View, with title, Play and download | DR-142 | Done |
|
||||||
|
| UT-136 | `episodeRedirectTarget` sends a bare episode page into its series' Focus View, and returns null with no series | DR-142 | Done |
|
||||||
|
| UT-137 | Going offline with the toggle off pushes the closed gate and bumps `catalogFilterVersion` | DR-143 | Done |
|
||||||
|
| UT-138 | The version bumps only after `set_show_server_catalog` resolves, never before | DR-143 | Done |
|
||||||
|
| UT-139 | A failed visibility push is retried on the next identical transition rather than latched | DR-143 | Done |
|
||||||
|
| UT-140 | `useOfflineFilterReload` skips the value a page already loaded under and reloads on each later change | DR-143 | Done |
|
||||||
|
| UT-141 | The advertised channel cap: an unknown or zero reading falls back to stereo, a real route keeps its channels, an absurd driver reading is capped at 7.1, and mono is taken at its word | DR-141 | Done |
|
||||||
|
| UT-148 | Forcing a transcode from the client: an undecodable default track forces one, a decodable track does not, the default track decides rather than the first, the first decides when nothing is marked default, and neither an audio-less source nor an unnamed codec is second-guessed | DR-149 | Done |
|
||||||
|
| UT-149 | `createAdapter` returns the native adapter only when Rust reports native AND `experimentalNativeVideo` is on; the flag off forces HTML5 even when Rust says native, and the flag on never promotes a platform Rust reported as HTML5 | DR-150 | Done |
|
||||||
|
| UT-150 | `set-version.sh` stamps all four manifests without touching dependency versions, and the Android versionCode is monotonic across an upgrade sequence, clears the 1000 floor, and survives a prerelease suffix | DR-153 | Done |
|
||||||
|
| UT-151 | An unreportable stop lands in the queue and is pushed by the existing drain; re-queueing the same item supersedes the earlier position rather than adding a row, distinct items keep their own positions, and an abandoned row is not revived by a later report | DR-154 | Done |
|
||||||
|
| UT-152 | Caching a server result mirrors its watch position locally — including for an item carrying a position but no favourite flag — without inventing a row for an item the server reported no user data for, and without pulling a still-unsynced local position backwards | DR-155 | Done |
|
||||||
|
| UT-162 | Each downloaded library lists only its own media: the music library shows the album and neither the film nor the series, the movie library only the film, the TV library only the series | DR-163 | Done |
|
||||||
|
| UT-163 | `partial_path` appends rather than replacing the extension, so it matches what the cleanup paths delete, keeps two sources for one title apart, and still produces a sidecar for an extension-less target | DR-165 | Done |
|
||||||
|
| UT-164 | `resume_offset` appends only when the server answered `206`; a `200` after a Range request restarts the file, because that body is the whole stream | DR-166 | Done |
|
||||||
|
| UT-165 | A registered download starts unflagged, `signal` sets the flag its worker reads, signalling an unregistered id reports not-in-flight, `clear` forgets it, and re-registering drops a previous stop so a resumed download does not halt instantly | DR-164 | Done |
|
||||||
|
| UT-166 | `original` quality re-encodes audio the webview cannot decode (E-AC-3/AC-3/DTS/TrueHD) to AAC without capping bitrate or resolution, keeps the `Static=true` direct copy for audio that plays here (AAC/MP3/Opus/Vorbis/FLAC) and for an unknown codec, leaves the explicit quality presets untouched, and picks the served track by the same default-or-first rule the streaming verdict uses | DR-171 | Done |
|
||||||
|
| UT-155 | A seek during a background-audio handoff re-opens the stream at the requested absolute position (`StartTimeTicks`) and rebases the handoff to it, while a seek outside a handoff stays an ordinary seek and invents no base | DR-159 | Done |
|
||||||
|
| UT-154 | `mark_unplayed` parses to `QueuedOp::MarkUnplayed` and is rejected without an item id, and a queued un-mark drains to the server as `clear_watch_history` | DR-158 | Done |
|
||||||
|
| UT-156 | A capped step reaches the transcode URL as all four of its parts (total ceiling, the video/audio split summing to the cap, and a `MaxHeight`), the uncapped default keeps the historical 20/18 Mbps allowance and constrains no resolution, and the background-audio handoff takes the lower of the cap and its own 384 kbps | DR-162 | Done |
|
||||||
|
| UT-157 | The quality ladder is internally consistent — video + audio equals the cap at every step, audio never consumes the budget, only `Original` is uncapped — descends in bitrate, resolution and audio share together, and round-trips through the serde token it is persisted as | DR-162 | Done |
|
||||||
|
| UT-153 | Scroll handling per navigation kind: a forward move always lands at the top even when the previous page was scrolled and even when the target was visited before, Back restores that route's own saved offset (and the top when it has none), offsets are kept per route rather than shared, a repeated Back still restores, and the initial load leaves the container alone | DR-156 | Done |
|
||||||
|
| UT-142 | The audio codecs offered for video direct play: a Dolby device's real `MediaCodecList` output drops `ac3`/`eac3`, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims `aac`, and stray spacing or casing does not decide whether the user gets sound | DR-148 | Done |
|
||||||
|
| UT-143 | Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to "Off", and a server-default track is never auto-selected | UR-020, DR-023 | Done |
|
||||||
|
| UT-144 | VideoPlayer actually renders `<track kind="subtitles">` children carrying `data-stream-index`, with no `default` attribute and no async `getSubtitleUrl()` bound to `src` | UR-020, DR-023 | Done |
|
||||||
|
| UT-145 | The frontend's subtitle payload survives the IPC hop: a camelCase `PlayItemRequest` carrying `subtitles` deserializes, `create_media_item` lands them on `MediaItem.subtitles` in the order sent, and a request without the field still defaults to empty | UR-020, IR-016 | Done |
|
||||||
|
| UT-146 | The subtitle JSON serialized across the JNI boundary uses the keys `JellyTauPlayer.load()` reads — `url`, `language`, `label` and `mime_type`, never `mimeType` | UR-020, IR-016, JA-008 | Done |
|
||||||
|
| UT-147 | The native subtitle payload and the track-selection index come from the same resolved list: the wire shape keeps `mime_type` and stream order, `playerPlayItem` actually sends it, and the index is a position in the sent list (so a track whose URL failed to resolve cannot shift the others) rather than the menu's row number | UR-020, IR-016 | Done |
|
||||||
|
|
||||||
### Integration Tests
|
### Integration Tests
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
# Spec: Android native video — transparent-webview spike
|
# Spec: Android native video — transparent-webview spike
|
||||||
|
|
||||||
**Status:** Proposed (spike — timeboxed, may conclude "not viable")
|
**Status:** Spike succeeded — native video confirmed working on a physical
|
||||||
**Requirements:** IR-004, UR-003, UR-004 → DR-001, DR-023, DR-024
|
device (2026-08-11) with `experimentalNativeVideo` on. Shipped behind that flag,
|
||||||
|
default off. Branch `feat/android-native-video`.
|
||||||
|
|
||||||
|
**The spike's central question is answered: yes.** A `SurfaceView` *can* be
|
||||||
|
composited behind a transparent Tauri WebView on Android. Nothing upstream
|
||||||
|
blocked it and nothing upstream demonstrated it — this is, as far as the issue
|
||||||
|
trackers show, the first working instance. The remaining flag is about test
|
||||||
|
coverage and the unverified cases below, not about viability.
|
||||||
|
**Requirements:** IR-004, UR-003, UR-004, UR-041 → DR-001, DR-004, DR-150, DR-151, DR-152
|
||||||
|
**Note:** the original draft cited DR-023/DR-024 here. Those are the *subtitle*
|
||||||
|
and *audio-track selection UI* requirements — unrelated to this work. The IDs
|
||||||
|
actually implemented are DR-150 (native rendering behind the flag), DR-151 (the
|
||||||
|
severed SurfaceView attach chain) and DR-152 (capabilities reported by Rust).
|
||||||
**UX spec:** n/a — no intended visual change; the video surface must land exactly where the `<video>` element is today
|
**UX spec:** n/a — no intended visual change; the video surface must land exactly where the `<video>` element is today
|
||||||
**Supersedes / revises:** acts on finding 2 of [playback-backend-unification.md](playback-backend-unification.md)
|
**Supersedes / revises:** acts on finding 2 of [playback-backend-unification.md](playback-backend-unification.md)
|
||||||
|
|
||||||
@@ -109,6 +121,46 @@ in here rather than leaving a second, subtler copy of the bug behind. If
|
|||||||
`get_player_status` does not currently expose enough to cover the audio case, add
|
`get_player_status` does not currently expose enough to cover the audio case, add
|
||||||
the field — that is backend work, and correct.
|
the field — that is backend work, and correct.
|
||||||
|
|
||||||
|
### Implementation findings (2026-08-11)
|
||||||
|
|
||||||
|
Two blockers existed that this spec did not anticipate. Both were in code the
|
||||||
|
spec assumed was merely *unreachable*; it was also *broken*.
|
||||||
|
|
||||||
|
**1. The Kotlin attach chain was severed.** `JellyTauPlayer.setActivity()` had
|
||||||
|
**zero callers** anywhere in the tree. `currentActivity` was therefore always
|
||||||
|
null, so `autoAttachSurface()` logged "Cannot attach surface - no Activity
|
||||||
|
reference" and returned. The `SurfaceView` was created and wired to ExoPlayer but
|
||||||
|
never added to the view hierarchy — video would have decoded to a surface that
|
||||||
|
was never on screen, *regardless* of webview transparency. Fixed by calling
|
||||||
|
`JellyTauPlayer.setActivity(this)` from `MainActivity.onCreate`.
|
||||||
|
|
||||||
|
Note the knock-on: `PictureInPictureManager.canEnterPip()` gates on
|
||||||
|
`VideoOverlayManager.isVideoSurfaceAttached()`, which was permanently false. PiP
|
||||||
|
on the video path was dead for the same reason.
|
||||||
|
|
||||||
|
**2. `createAdapter()` was not the real gate.** It is never called by production
|
||||||
|
code — `VideoPlayer.svelte` constructs `Html5PlayerAdapter` directly. The actual
|
||||||
|
override was `VideoPlayer.svelte`'s INTERIM block, which read Rust's
|
||||||
|
`useHtml5Element`, forced it to `true`, and called `playerStop()` to kill the
|
||||||
|
native backend `player_play_item` had just started. Both sites are now fixed;
|
||||||
|
`VideoPlayer.svelte` routes through `createAdapter()` so there is one gate.
|
||||||
|
|
||||||
|
**Transparency needs two independent layers cleared,** not one. The spec's
|
||||||
|
Phase 1 named only `html, body`. Clearing just the page leaves the WebView
|
||||||
|
widget's own background opaque, which is a black screen with audio — the exact
|
||||||
|
symptom the INTERIM comment described as "native surface not visible". Both are
|
||||||
|
now toggled together by `$lib/utils/videoSurface.ts`:
|
||||||
|
|
||||||
|
| Layer | Cleared by | Reachable from |
|
||||||
|
|-------|-----------|----------------|
|
||||||
|
| WebView widget background + window drawable | `AndroidVideoSurface.setTransparent()` (MainActivity) | Kotlin only |
|
||||||
|
| `html`/`body` + app-shell `--color-background` | `data-native-video` attribute → app.css | CSS only |
|
||||||
|
|
||||||
|
Transparency is scoped to `tauri.android.conf.json` rather than the base config:
|
||||||
|
a transparent window on Linux is a regression, since nothing renders behind it.
|
||||||
|
It is also toggled per-session rather than set once — a permanently transparent
|
||||||
|
window shows the launcher through the rest of the app.
|
||||||
|
|
||||||
### Phase 3 — surface positioning
|
### Phase 3 — surface positioning
|
||||||
|
|
||||||
The hard part, and where this most likely fails. The webview's `<video>` element
|
The hard part, and where this most likely fails. The webview's `<video>` element
|
||||||
@@ -124,6 +176,32 @@ the video is effectively fullscreen on Android, which it is in the player route.
|
|||||||
rotation or the mini-player transition without visible artefacts, the spike fails
|
rotation or the mini-player transition without visible artefacts, the spike fails
|
||||||
and we keep HTML5. Do not ship a janky native path for a codec win.
|
and we keep HTML5. Do not ship a janky native path for a codec win.
|
||||||
|
|
||||||
|
**Update: no rect plumbing was needed.** The premise — that the surface must be
|
||||||
|
positioned to match a laid-out `<video>` box — does not hold on the player route,
|
||||||
|
where video is fullscreen. `VideoOverlayManager` adds the SurfaceView at index 0
|
||||||
|
of `android.R.id.content` with `MATCH_PARENT`, and `fitSurfaceToScreen()`
|
||||||
|
(`JellyTauPlayer.kt`) already letterboxes/pillarboxes to the real video aspect
|
||||||
|
ratio and re-centres via a `Gravity.CENTER` `FrameLayout.LayoutParams`. Rotation
|
||||||
|
is handled by an `OnLayoutChangeListener` that re-fits on any bounds change. The
|
||||||
|
frontend's native branch is a bare `flex-1` box, so there is no rect to report
|
||||||
|
and nothing to keep in sync.
|
||||||
|
|
||||||
|
Fullscreen playback is confirmed working on device. But this reasoning rests
|
||||||
|
entirely on the fullscreen assumption, so **the mini-player transition is the
|
||||||
|
known gap** — it is the one case where the surface is *not* fullscreen, and
|
||||||
|
therefore the one case where the "no rect plumbing needed" conclusion could
|
||||||
|
still turn out to be wrong. If artefacts appear there, the fix is the rect
|
||||||
|
reporting this section originally proposed, scoped to that transition alone.
|
||||||
|
|
||||||
|
### A trap for the next implementer
|
||||||
|
|
||||||
|
There is a **stale duplicate player** at
|
||||||
|
`src-tauri/android/app/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt`
|
||||||
|
(only commit: `cfddc1e` "First working POC"). No `sourceSets` entry points at it,
|
||||||
|
so it is not compiled — but edits made there silently do nothing. The canonical
|
||||||
|
tree is `src-tauri/android/src`, synced into `gen/` by
|
||||||
|
`scripts/sync-android-sources.sh`.
|
||||||
|
|
||||||
### What we gain if it works
|
### What we gain if it works
|
||||||
|
|
||||||
- **Hardware decode via MediaCodec** — `CodecDetector.kt` already reports
|
- **Hardware decode via MediaCodec** — `CodecDetector.kt` already reports
|
||||||
@@ -145,12 +223,13 @@ and we keep HTML5. Do not ship a janky native path for a codec win.
|
|||||||
The spike is **complete** when one of these is true:
|
The spike is **complete** when one of these is true:
|
||||||
|
|
||||||
**Success path**
|
**Success path**
|
||||||
- [ ] Transparent WebView confirmed working on a physical device.
|
- [x] Transparent WebView confirmed working on a physical device (reported by the maintainer; the config that enables it is now committed in `tauri.android.conf.json`).
|
||||||
- [ ] `experimentalNativeVideo` off → behaviour byte-identical to today.
|
- [x] `experimentalNativeVideo` off → behaviour byte-identical to today. Guarded by `adapterSelection.test.ts`, which asserts the flag-off case forces HTML5 even when Rust reports native.
|
||||||
- [ ] `webviewAudio.ts` no longer inspects `navigator.userAgent`; the platform's audio backend is read from Rust.
|
- [x] `webviewAudio.ts` no longer inspects `navigator.userAgent`; the platform's audio backend is read from Rust (`player_get_capabilities` → `usesWebviewAudio`).
|
||||||
- [ ] `experimentalNativeVideo` on → video plays via ExoPlayer/MediaCodec, correctly positioned, with working seek, audio-track switch, and subtitle selection through the existing `PlayerAdapter` contract.
|
- [x] `experimentalNativeVideo` on → video plays via ExoPlayer, correctly positioned, on a physical device (2026-08-11). The surface reaches the hierarchy and is visible through the transparent WebView — the whole point of the spike.
|
||||||
- [ ] No artefacts on rotation, background/foreground, or mini-player transition.
|
- [ ] Seek, audio-track switch and subtitle selection exercised through `NativePlayerAdapter`. Playback is confirmed; these individual controls are not yet each verified on the native path.
|
||||||
- [ ] `adb shell dumpsys media.metrics` (or logcat) confirms a hardware decoder is in use.
|
- [ ] No artefacts on rotation, background/foreground, or **mini-player transition** — the last is the one case the fullscreen assumption does not cover, so it is the likeliest place to find a problem.
|
||||||
|
- [ ] `adb shell dumpsys media.metrics` (or logcat) confirms a hardware decoder is in use. Plausible but unmeasured — do not claim the MediaCodec win until this is read.
|
||||||
- [ ] Measured battery/thermal or CPU improvement over the HTML5 path on the same clip.
|
- [ ] Measured battery/thermal or CPU improvement over the HTML5 path on the same clip.
|
||||||
|
|
||||||
**Failure path**
|
**Failure path**
|
||||||
@@ -159,8 +238,15 @@ The spike is **complete** when one of these is true:
|
|||||||
- [ ] `nativeAdapter.ts:11-14` no longer cites tauri#10152.
|
- [ ] `nativeAdapter.ts:11-14` no longer cites tauri#10152.
|
||||||
|
|
||||||
Either way:
|
Either way:
|
||||||
- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass.
|
- [x] `bun run check` (0 errors), `bun run test` (892 passed), `bun run check:boundary` pass.
|
||||||
- [ ] `cargo fmt` / `cargo clippy` clean; `bun run test:rust` passes.
|
- [x] `cargo fmt` / `cargo clippy` clean (no new warnings); `cargo test` passes (603 lib + 7 doc).
|
||||||
|
|
||||||
|
> Note: this environment has no host WebKitGTK dev packages, no Android SDK and
|
||||||
|
> no `bun`, so all of the above were run inside the CI builder image
|
||||||
|
> (`gitea.tourolle.paris/dtourolle/jellytau-builder:latest`). On Fedora the bind
|
||||||
|
> mount needs `:z` for SELinux, and `scripts/build-android.sh` hardcodes
|
||||||
|
> `ANDROID_HOME="$HOME/Android/Sdk"`, so the image's SDK at `/opt/android-sdk`
|
||||||
|
> must be symlinked there rather than passed by env var.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
@@ -179,7 +265,7 @@ native adapter), write the failing test first.
|
|||||||
|
|
||||||
## TRACES
|
## TRACES
|
||||||
|
|
||||||
- `createAdapter` → `// TRACES: UR-003, UR-004 | DR-023, DR-024`
|
- `createAdapter` → `// TRACES: UR-003, UR-004 | DR-004, DR-150 | UT-149`
|
||||||
- Adapter-selection tests → `UT-xxx`
|
- Adapter-selection tests → `UT-xxx`
|
||||||
- No new requirement IDs; this spike either satisfies existing IR-004 expectations or documents why it cannot.
|
- No new requirement IDs; this spike either satisfies existing IR-004 expectations or documents why it cannot.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,334 @@
|
|||||||
|
# Spec: Locally-indexed search
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
**Requirements:** UR-065 → DR-108, DR-109, DR-110, DR-111; IR-030
|
||||||
|
**UX spec:** [ux-flows.md §6.1](../ux-flows.md) (search surface is unchanged)
|
||||||
|
**Revises:** [scoped-search.md](scoped-search.md) and
|
||||||
|
[scoped-search-boundary.md](scoped-search-boundary.md) — scope semantics are
|
||||||
|
untouched; this changes only *which corpus* the cache leg searches.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Search stops depending on a per-keystroke round trip to Jellyfin. The local
|
||||||
|
SQLite catalog — which is already synced and already FTS5-indexed — becomes the
|
||||||
|
corpus the instant leg of search reads, so results appear as fast as SQLite can
|
||||||
|
answer, online or offline. A background indexer keeps that catalog fresh on a
|
||||||
|
schedule instead of only at app start, prunes content deleted on the server, and
|
||||||
|
covers the item types search groups results by. The server query stays, demoted
|
||||||
|
to a background reconciliation that merges in late results for anything indexed
|
||||||
|
since the last pass.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
The pieces are already built and simply not wired together:
|
||||||
|
|
||||||
|
- [`sync_full_catalog`](../../src-tauri/src/commands/catalog.rs) already walks
|
||||||
|
every library `Recursive=true` and persists items with `synced_at`.
|
||||||
|
- `items_fts` (schema.rs migration 001) already indexes `name`, `overview`,
|
||||||
|
`album_name`, `album_artist`, `artists`, `series_name` with keep-in-sync
|
||||||
|
triggers.
|
||||||
|
- `repository_search` is already two-phase — synchronous cache result, then a
|
||||||
|
spawned server query merged in via the `search-event`.
|
||||||
|
|
||||||
|
What breaks the chain is that the cache leg is hard-restricted to *downloaded*
|
||||||
|
items. `OfflineRepository::search` wraps its FTS query in a `downloaded_items`
|
||||||
|
CTE requiring `d.status = 'completed'`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
FROM items i
|
||||||
|
JOIN items_fts fts ON fts.rowid = i.rowid
|
||||||
|
INNER JOIN downloaded_items di ON i.id = di.id
|
||||||
|
WHERE i.server_id = ? AND items_fts MATCH ?
|
||||||
|
```
|
||||||
|
|
||||||
|
So for a user with no downloads, phase 1 returns nothing on every query, and
|
||||||
|
every debounced keystroke falls through to a full `Recursive=true` server
|
||||||
|
request with `Limit=10000`. The populated local index is never read.
|
||||||
|
|
||||||
|
`get_items` does not have this problem — it gates a third `synced_at IS NOT NULL`
|
||||||
|
branch on `include_catalog_browse()` (offline.rs, the "Show all server media"
|
||||||
|
toggle). The asymmetry is the bug: **offline you can already browse the whole
|
||||||
|
catalog but cannot search it.**
|
||||||
|
|
||||||
|
Three further defects found while confirming the above:
|
||||||
|
|
||||||
|
1. **The FTS index grows without bound.** `save_to_cache` uses
|
||||||
|
`INSERT OR REPLACE INTO items`, but `recursive_triggers` is never enabled
|
||||||
|
(`storage/mod.rs` sets only `foreign_keys` and `journal_mode`). SQLite fires
|
||||||
|
`AFTER DELETE` triggers on a REPLACE *only* with recursive triggers on — so
|
||||||
|
`items_ad` never runs, the old FTS row is orphaned, and because `items.id` is
|
||||||
|
a `TEXT PRIMARY KEY` the replacement row takes a **new rowid** and inserts a
|
||||||
|
second FTS entry. Every sync appends a duplicate index. Results stay correct
|
||||||
|
(the `INNER JOIN … ON fts.rowid = i.rowid` hides orphans, and no rowid is ever
|
||||||
|
reused because nothing is deleted) but `MATCH` degrades permanently.
|
||||||
|
2. **Server-side deletions never propagate.** There is no `DELETE FROM items`
|
||||||
|
anywhere in the codebase. The local catalog is append-only, so media removed
|
||||||
|
from the server would stay searchable forever — tolerable when the cache was
|
||||||
|
only a browse accelerator, not acceptable when it is the search corpus.
|
||||||
|
3. **The index omits types search groups by.** `CATALOG_ITEM_TYPES` is
|
||||||
|
`MusicAlbum, Movie, Series, Season, Episode, Audio, BoxSet` — no
|
||||||
|
`MusicArtist`, no `Playlist`, and People live in a separate `people` table
|
||||||
|
with no FTS at all. UR-060 mandates Artists and People result groups, so today
|
||||||
|
those can *only* come from the server.
|
||||||
|
|
||||||
|
## Layer assignment
|
||||||
|
|
||||||
|
| Logic / responsibility | Layer | Why it belongs there |
|
||||||
|
|------------------------|-------|----------------------|
|
||||||
|
| Which corpus search reads (downloads-only vs full synced catalog) | **Rust** | Sync/availability policy over domain data. Changes if Jellyfin's API or the offline rules change, not if the UI is redesigned. Reuses the existing `include_catalog_browse()` flag so search and browse cannot diverge again. |
|
||||||
|
| Index freshness policy — TTL, when a re-index is due, skip-while-offline | **Rust** | Explicitly named as domain policy in [SPEC-REVIEW-CHECKLIST.md](SPEC-REVIEW-CHECKLIST.md) ("reachability/sync policy"). It is currently frontend-driven in `offlineCatalog.ts`; this spec moves it. |
|
||||||
|
| Which Jellyfin item types get indexed (`CATALOG_ITEM_TYPES`) | **Rust** | Textbook domain taxonomy — a category→item-type set. Must never appear in `src/`. |
|
||||||
|
| Reconciling a crawl against local rows (what to prune) | **Rust** | Operates on domain data and depends on crawl completeness semantics. |
|
||||||
|
| FTS query construction, ranking, scope→type expansion | **Rust** | Already there (`search_rank.rs`, `SearchScope::item_types()`); unchanged by this spec. |
|
||||||
|
| Rendering a "catalog last indexed N ago" hint and any re-index button | **Frontend** | Pure presentation of a backend-supplied timestamp. |
|
||||||
|
| Debounce interval, result group order, scope chips | **Frontend** | Input handling and view preference; changes only if the UI is redesigned. |
|
||||||
|
|
||||||
|
Borderline call, recorded: the **TTL value itself** (how many hours before a
|
||||||
|
re-index is due) could be argued as a user preference and therefore frontend. It
|
||||||
|
is placed in Rust because the frontend must not be able to decide *whether the
|
||||||
|
cache is authoritative* — that is the same class of decision as
|
||||||
|
`include_catalog_browse`, which already lives in Rust. If the TTL later becomes
|
||||||
|
user-configurable it stays a Rust-owned setting the frontend edits through a
|
||||||
|
command, not a frontend constant. Borderline defaults to Rust.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### 1. Search the full synced catalog (DR-108)
|
||||||
|
|
||||||
|
`OfflineRepository::search` mirrors `get_items` exactly: rename the CTE to
|
||||||
|
`available_items` and add the same third branch, gated on the same flag.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let catalog_branch = if include_catalog_browse() {
|
||||||
|
"UNION
|
||||||
|
|
||||||
|
-- Synced catalog: fast online search, or the offline 'Show all
|
||||||
|
-- server media' view. Mirrors get_items; see set_include_catalog_browse.
|
||||||
|
SELECT DISTINCT i.id
|
||||||
|
FROM items i
|
||||||
|
WHERE i.synced_at IS NOT NULL"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
No new IPC surface and no frontend change: `set_include_catalog_browse` is
|
||||||
|
already called with `true` when online or when the offline toggle is on, and
|
||||||
|
`false` only when offline with the toggle off. Search inherits the correct
|
||||||
|
behaviour in all three states, and the "search is restricted to downloads" case
|
||||||
|
survives for users who deliberately asked for downloads-only.
|
||||||
|
|
||||||
|
Also fix, in the same function, the `type_filter` built by **string
|
||||||
|
interpolation** of `include_item_types` rather than bound parameters. It is
|
||||||
|
currently safe only because callers pass `SearchScope`-derived values, but
|
||||||
|
`SearchOptions.include_item_types` is settable directly from the frontend (as
|
||||||
|
`GenericMediaListPage` does). Bind the values.
|
||||||
|
|
||||||
|
Phase 2 (the server query) is unchanged and still merges via `search-event`, so
|
||||||
|
content added to the server since the last index still surfaces — just late
|
||||||
|
rather than first.
|
||||||
|
|
||||||
|
### 2. Scheduled background indexer (DR-109, IR-030)
|
||||||
|
|
||||||
|
A Rust-owned task replaces the frontend's startup-only trigger.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// How long a full-catalog index stays fresh before a re-index is due.
|
||||||
|
const CATALOG_INDEX_TTL: Duration = Duration::from_secs(6 * 60 * 60);
|
||||||
|
```
|
||||||
|
|
||||||
|
Behaviour:
|
||||||
|
|
||||||
|
- On app setup, spawn a tokio task that ticks every 30 min.
|
||||||
|
- Each tick: if a repository is active **and** the server is reachable **and**
|
||||||
|
`now - last_catalog_sync > CATALOG_INDEX_TTL`, run a full index pass.
|
||||||
|
- On the existing `ConnectivityMonitor` reconnect signal, evaluate the same
|
||||||
|
staleness condition immediately rather than waiting for the next tick.
|
||||||
|
- Never run two passes concurrently (the existing `syncInProgress` guard moves
|
||||||
|
into Rust as an `AtomicBool`).
|
||||||
|
|
||||||
|
`last_catalog_sync` is already written to `app_settings` by `sync_full_catalog`
|
||||||
|
and is currently read only for a UI hint; this makes it load-bearing.
|
||||||
|
|
||||||
|
`RepositoryManager` (`commands/repository.rs`) is a `HashMap<String, …>` with no
|
||||||
|
notion of an active handle, so the task has nothing to run against. Add:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct RepositoryManager {
|
||||||
|
repositories: Arc<Mutex<HashMap<String, Arc<HybridRepository>>>>,
|
||||||
|
active: Arc<Mutex<Option<String>>>, // set in create(), cleared in destroy()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Progress is reported with a **kebab-case** event (per the project convention):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// event name: "catalog-index-event"
|
||||||
|
#[derive(specta::Type, Serialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CatalogIndexEvent {
|
||||||
|
pub state: CatalogIndexState, // #[serde(tag = "type")] Idle | Running | Complete | Failed
|
||||||
|
pub libraries_done: usize,
|
||||||
|
pub libraries_total: usize,
|
||||||
|
pub items_indexed: usize,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`sync_full_catalog` stays a command so the UI can still force a pass; it and the
|
||||||
|
scheduler share one internal `run_index_pass()`.
|
||||||
|
|
||||||
|
### 3. Index hygiene — no orphans, and deletions propagate (DR-110)
|
||||||
|
|
||||||
|
**Orphan growth.** Replace `INSERT OR REPLACE INTO items (…)` in `save_to_cache`
|
||||||
|
with a true upsert:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO items (id, server_id, …) VALUES (…)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
name = excluded.name, overview = excluded.overview, …,
|
||||||
|
synced_at = excluded.synced_at
|
||||||
|
```
|
||||||
|
|
||||||
|
This preserves the rowid (which `items_fts` keys on via `content_rowid`) and
|
||||||
|
fires `items_au` instead of silently orphaning a row. Preferred over
|
||||||
|
`PRAGMA recursive_triggers = ON` because it also stops the rowid churn, and the
|
||||||
|
three FTS triggers are the only triggers in the schema so nothing else depends
|
||||||
|
on REPLACE semantics.
|
||||||
|
|
||||||
|
A new migration `021_rebuild_items_fts` clears the orphans already accumulated on
|
||||||
|
existing installs:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO items_fts(items_fts) VALUES('rebuild');
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deletions.** After a library crawls *successfully and completely*, reconcile:
|
||||||
|
delete local rows for that library whose `id` was not seen in the crawl. Two
|
||||||
|
constraints the implementation must respect:
|
||||||
|
|
||||||
|
- Skip any item with a completed download — the user has the file; removing the
|
||||||
|
row would orphan it. Prune only synced-but-not-downloaded rows.
|
||||||
|
- Only sweep libraries whose crawl succeeded. `sync_full_catalog` is
|
||||||
|
deliberately best-effort per library, and `items.parent_id` is
|
||||||
|
`ON DELETE CASCADE` — sweeping on a partial crawl would cascade a whole series
|
||||||
|
away because one request timed out.
|
||||||
|
|
||||||
|
### 4. Index the types search groups by (DR-111)
|
||||||
|
|
||||||
|
Add `MusicArtist` and `Playlist` to `CATALOG_ITEM_TYPES`.
|
||||||
|
|
||||||
|
People need a different mechanism: they live in `people` (`id`, `server_id`,
|
||||||
|
`name`, `overview`, `primary_image_tag`, `synced_at`), populated incidentally by
|
||||||
|
item-detail fetches, with no FTS table. Migration `022_people_fts` adds one
|
||||||
|
mirroring the `items_fts` pattern:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS people_fts USING fts5(
|
||||||
|
name, overview, content='people', content_rowid='rowid'
|
||||||
|
);
|
||||||
|
-- plus people_ai / people_ad / people_au triggers
|
||||||
|
```
|
||||||
|
|
||||||
|
`OfflineRepository::search` UNIONs `people_fts` matches into its result set as
|
||||||
|
`Person`-typed items when the resolved scope permits them (i.e. when
|
||||||
|
`include_item_types` is `None` — `SearchScope::All`). `search_rank.rs` already
|
||||||
|
handles `MediaKind::Person`, so ranking needs no change.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- **Incremental indexing** (e.g. Jellyfin's `MinDateLastSaved`). A full crawl is
|
||||||
|
what makes the deletion sweep in §3 sound — it yields the authoritative id set
|
||||||
|
per library. An incremental pass cannot detect deletions, so it would need a
|
||||||
|
separate reconciliation strategy. Worth revisiting if full crawls prove too
|
||||||
|
slow on large libraries; measure first.
|
||||||
|
- **Changing search UX** — scope chips, group order, the debounce, and the
|
||||||
|
`/search` route are untouched.
|
||||||
|
- **Removing the server leg.** Phase 2 stays.
|
||||||
|
- The two dead search implementations (`storage_search_items` in
|
||||||
|
`commands/storage/mod.rs`, `offline_search` in `commands/offline.rs`) — both
|
||||||
|
registered in `lib.rs` and exported to `bindings.ts`, neither called from the
|
||||||
|
frontend. Deleting them is correct but is cleanup, not this feature; file
|
||||||
|
separately so this spec's diff stays reviewable.
|
||||||
|
- `GenericMediaListPage` passing raw `includeItemTypes` and re-implementing the
|
||||||
|
store's request-id/event protocol. A real boundary smell, tracked separately.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] With a synced catalog and **zero downloads**, typing a query returns
|
||||||
|
results from the local index before any server request completes.
|
||||||
|
- [ ] Offline with "Show all server media" **on**, search returns the full
|
||||||
|
catalog (non-downloaded entries greyed out, matching browse).
|
||||||
|
- [ ] Offline with the toggle **off**, search returns downloaded media only —
|
||||||
|
the behaviour that exists today.
|
||||||
|
- [ ] Re-running a full index pass N times does not grow `items_fts` row count
|
||||||
|
beyond the `items` row count.
|
||||||
|
- [ ] An item deleted server-side disappears from local search after one index
|
||||||
|
pass; a **downloaded** item deleted server-side does not.
|
||||||
|
- [ ] A library that fails mid-crawl prunes nothing.
|
||||||
|
- [ ] Searching an artist or actor name returns results with the server
|
||||||
|
unreachable.
|
||||||
|
- [ ] `bun run check` and `bun run test` pass.
|
||||||
|
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
||||||
|
- [ ] `bun run check:boundary` passes.
|
||||||
|
- [ ] New requirement-implementing code carries `// TRACES:` comments.
|
||||||
|
- [ ] `bindings.ts` regenerated (new `CatalogIndexEvent` type).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Per CLAUDE.md, each defect gets a **failing test first**.
|
||||||
|
|
||||||
|
Rust (`cargo test`), against an in-memory DB seeded with synced-but-not-
|
||||||
|
downloaded items:
|
||||||
|
|
||||||
|
- `search` returns synced items when `include_catalog_browse()` is true, and
|
||||||
|
only downloaded items when false. *Fails today* — the current CTE returns
|
||||||
|
empty in the first case.
|
||||||
|
- Upserting the same item twice leaves exactly one `items_fts` row. *Fails
|
||||||
|
today.*
|
||||||
|
- The sweep removes a vanished synced item, retains a vanished downloaded item,
|
||||||
|
and no-ops for a library whose crawl errored.
|
||||||
|
- `type_filter` binds parameters — a type string containing a quote does not
|
||||||
|
alter the query.
|
||||||
|
- Staleness: a `last_catalog_sync` inside the TTL does not trigger a pass; one
|
||||||
|
outside it does; offline never does.
|
||||||
|
- `people_fts` matches surface as `Person` items under `SearchScope::All` and
|
||||||
|
are excluded under `Music`/`Movies`/`Tv`.
|
||||||
|
|
||||||
|
Frontend (`vitest`): the catalog-index event maps to the staleness hint; no
|
||||||
|
change to the search store's request-id/stale-response handling, which stays
|
||||||
|
covered by its existing tests.
|
||||||
|
|
||||||
|
## TRACES
|
||||||
|
|
||||||
|
| Piece | Tag |
|
||||||
|
|---|---|
|
||||||
|
| `OfflineRepository::search` availability CTE | `// TRACES: UR-065 \| DR-108` |
|
||||||
|
| Background indexer task + scheduling | `// TRACES: UR-065 \| DR-109, IR-030` |
|
||||||
|
| `save_to_cache` upsert + FTS rebuild migration | `// TRACES: UR-065 \| DR-110` |
|
||||||
|
| Deletion reconciliation | `// TRACES: UR-065 \| DR-110` |
|
||||||
|
| `CATALOG_ITEM_TYPES` widening + `people_fts` | `// TRACES: UR-065, UR-060 \| DR-111` |
|
||||||
|
|
||||||
|
## Notes for the implementer
|
||||||
|
|
||||||
|
- **A parallel Claude session may be active in this repo.** Run `git diff`
|
||||||
|
before "repairing" changes you did not make (CLAUDE.md gotchas).
|
||||||
|
- The frontend's `offlineCatalog.ts` startup trigger should be **removed**, not
|
||||||
|
left alongside the Rust scheduler — two independent triggers with one
|
||||||
|
`syncInProgress` guard each is how double-crawls happen.
|
||||||
|
- `downloads` has a relaxed FK to `items` (migration 005). Verify the deletion
|
||||||
|
sweep's interaction with it before enabling the sweep, and check whether
|
||||||
|
`parent_id`'s `ON DELETE CASCADE` reaches further than intended.
|
||||||
|
- The existing 100 ms `cache_with_timeout` in `hybrid.rs` returns *empty* on
|
||||||
|
timeout rather than erroring. Once the cache leg is the primary path, that
|
||||||
|
budget may need raising — an FTS query over a large catalog on cold page cache
|
||||||
|
can exceed it, and the failure mode is a silently empty result.
|
||||||
|
- Keep `SearchScope` semantics as-is: `All => None` (no filter), deliberately
|
||||||
|
not a union, so People and folders are not filtered out (DR-063).
|
||||||
|
- Noted but deliberately not fixed here: `pushCatalogVisibility` in
|
||||||
|
`offlineCatalog.ts` derives the flag as `connected || showCatalog` — the
|
||||||
|
frontend computing an availability *policy*, even though the flag itself is
|
||||||
|
Rust-stored. DR-108 depends on that derivation being correct and it is, so
|
||||||
|
this spec leaves it alone. Once DR-109 has moved sync policy into Rust, the
|
||||||
|
derivation belongs there too, with the frontend pushing only the raw user
|
||||||
|
toggle. Folding it into this change would enlarge the diff for no behavioural
|
||||||
|
gain — but do not add *new* policy on the frontend side of that line.
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
# Spec: Favourites — marking, browsing, and sync
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
**Requirements:** UR-067, UR-068, UR-069 → DR-113 … DR-120; JA-033, JA-034
|
||||||
|
(allocated in [requirements.md](../requirements.md); tests UT-099 … UT-107).
|
||||||
|
Note: UR-066/DR-112/IR-031 were claimed by the concurrent safe-area work while
|
||||||
|
this spec was being written, so the ids here start one higher than first drafted.
|
||||||
|
Existing: UR-017 → DR-021, JA-017, JA-018 (the toggle itself, already built).
|
||||||
|
**UX spec:** [ux-flows.md](../ux-flows.md) §3.2 (full-player favourite), §5.2
|
||||||
|
(album detail favourite), §5B.3 (movie detail hero: *Play / Download / Favorite*)
|
||||||
|
— all three already specify favourite affordances that **do not exist in the
|
||||||
|
build**. This spec closes those, and adds a new §5C for the Favourites browse
|
||||||
|
surface.
|
||||||
|
**Supersedes / revises:** nothing.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
JellyTau can favourite an item but can never show you what you favourited. The
|
||||||
|
heart is mounted in exactly one place (the mini player), no query anywhere asks
|
||||||
|
Jellyfin or the local database for favourites, and favourites marked on any other
|
||||||
|
client are invisible here. This spec adds the read side (a Favourites page, home
|
||||||
|
carousels, an in-library filter), puts the heart on detail pages and media cards,
|
||||||
|
teaches the backend to ingest server-side favourite state, and drains favourite
|
||||||
|
toggles made while offline.
|
||||||
|
|
||||||
|
## Background: what exists today
|
||||||
|
|
||||||
|
Verified in code, 2026-08-04. The **write** path is real and mostly correct; the
|
||||||
|
**read** path does not exist at all.
|
||||||
|
|
||||||
|
1. **Toggling works, from one place only.**
|
||||||
|
[FavoriteButton.svelte](../../src/lib/components/FavoriteButton.svelte) is
|
||||||
|
mounted solely in
|
||||||
|
[MiniPlayer.svelte:381](../../src/lib/components/player/MiniPlayer.svelte#L381).
|
||||||
|
Nothing else in `src/` renders it — so the only favouritable item in the app
|
||||||
|
is the one currently playing.
|
||||||
|
|
||||||
|
2. **The toggle's plumbing is sound.**
|
||||||
|
[favorites.ts](../../src/lib/services/favorites.ts) writes local first
|
||||||
|
(`storage_toggle_favorite`,
|
||||||
|
[storage/mod.rs:908](../../src-tauri/src/commands/storage/mod.rs#L908) — sets
|
||||||
|
`user_data.is_favorite` + `pending_sync = 1`), then POST/DELETEs
|
||||||
|
`/Users/{uid}/FavoriteItems/{id}`
|
||||||
|
([online.rs:1652](../../src-tauri/src/repository/online.rs#L1652)) only when
|
||||||
|
connected. Leave this design intact.
|
||||||
|
|
||||||
|
3. **`MediaItem.user_data` is always `None` from the server.**
|
||||||
|
`JellyfinItem` has no `UserData` field, and `to_media_item` hardcodes
|
||||||
|
[`user_data: None`](../../src-tauri/src/repository/online.rs#L656) with the
|
||||||
|
comment *"User data not included in basic item responses"*. The only
|
||||||
|
populated `user_data` in the app comes from
|
||||||
|
[series_progress.rs](../../src-tauri/src/repository/series_progress.rs#L244)
|
||||||
|
and the local read in
|
||||||
|
[offline.rs:115](../../src-tauri/src/repository/offline.rs#L115). **Nothing
|
||||||
|
ingests server favourite state**, which is why the mini player has to fetch
|
||||||
|
`storageGetPlaybackProgress` per track to colour one heart
|
||||||
|
([MiniPlayer.svelte:75-93](../../src/lib/components/player/MiniPlayer.svelte#L75-L93)).
|
||||||
|
|
||||||
|
4. **No favourites query exists.**
|
||||||
|
[`GetItemsOptions`](../../src-tauri/src/repository/types.rs#L278) has no
|
||||||
|
favourites field; `Filters=IsFavorite` appears nowhere; no SQL selects
|
||||||
|
`is_favorite = 1`; there is no `/library/favorites` route and no favourites
|
||||||
|
carousel in [home.ts](../../src/lib/stores/home.ts).
|
||||||
|
|
||||||
|
5. **Offline favourites are silently lossy.** Offline `mark_favorite` /
|
||||||
|
`unmark_favorite` are no-ops
|
||||||
|
([offline.rs:1620](../../src-tauri/src/repository/offline.rs#L1620)), so an
|
||||||
|
offline toggle survives only as a local row with `pending_sync = 1` — and
|
||||||
|
**nothing ever drains that flag**. `syncService.queueFavorite`
|
||||||
|
([syncService.ts:91](../../src/lib/services/syncService.ts#L91)) exists with
|
||||||
|
no callers.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
Favouriting is a promise: the app takes the input and shows a "Added to
|
||||||
|
favorites" toast, then discards it as far as the user can tell. Three of the UX
|
||||||
|
flows already specify favourite buttons that were never built, and the one that
|
||||||
|
was built (mini player) writes to a store nothing reads. Either the feature gets
|
||||||
|
its read side or the heart should be removed — this spec takes the first option.
|
||||||
|
|
||||||
|
## Layer assignment
|
||||||
|
|
||||||
|
| Logic / responsibility | Layer | Why it belongs there |
|
||||||
|
|------------------------|-------|----------------------|
|
||||||
|
| Favourites **scope** → set of Jellyfin item types | Rust | Domain taxonomy. Changes when Jellyfin adds/renames a type, never when the UI is redesigned. Reuses the canonical `SearchScope::item_types()` ([types.rs:324](../../src-tauri/src/repository/types.rs#L324)) — the exact leak class of [scoped-search-boundary.md](scoped-search-boundary.md). |
|
||||||
|
| Cross-library favourites query (`Filters=IsFavorite`, `Recursive`, paging, sort field) | Rust | Query shaping against the Jellyfin API is domain logic; the endpoint's contract changes with the server, not the UI. |
|
||||||
|
| Offline favourites SQL (join `user_data`, downloaded/catalog gating) | Rust | Storage + domain. Must obey the existing catalog-browse gate (DR-080) which the frontend cannot see. |
|
||||||
|
| Deserialising Jellyfin `UserData` into `MediaItem.user_data` | Rust | Provider payload mapping. |
|
||||||
|
| Mirroring server favourite state into the local `user_data` table | Rust | Cache/sync policy. |
|
||||||
|
| Conflict rule: a local row with `pending_sync = 1` beats the server value | Rust | Business rule about which write wins; nothing to do with rendering. |
|
||||||
|
| Draining pending favourite toggles on reconnect | Rust | Sync policy, and it must run whether or not any view is mounted — a frontend-driven drain dies with the component. Consistent with *reachability from real traffic* (DR-055). |
|
||||||
|
| Which surfaces show favourites, tab order, row placement on home | Frontend | Pure presentation; changes only if the UI is redesigned. |
|
||||||
|
| Heart placement, animation, toast, haptics, empty-state copy | Frontend | Presentation. |
|
||||||
|
| In-session optimistic heart state shared across views | Frontend | View state, not persisted truth; the durable write already goes to Rust. |
|
||||||
|
|
||||||
|
**Borderline, and the tie-breaker used:** *which* scopes appear as tabs (All /
|
||||||
|
Movies / Shows / Music) is a presentation choice — the frontend picks which
|
||||||
|
`SearchScope` values to offer. What each scope *means* is Rust's. The frontend
|
||||||
|
sends the enum value and never names an item type in connection with favourites.
|
||||||
|
Single-type pages (`itemType: "Movie"` on the Movies list page) stay as they are;
|
||||||
|
this rule targets category taxonomy, not every mention of a type.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### 1. Server user data reaches `MediaItem` (Rust, DR-113, JA-034)
|
||||||
|
|
||||||
|
Jellyfin returns `UserData` on `/Users/{uid}/Items*` responses. Add the field to
|
||||||
|
`JellyfinItem` and map it in `to_media_item`, replacing the hardcoded `None`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// in JellyfinItem
|
||||||
|
#[serde(alias = "UserData")]
|
||||||
|
pub user_data: Option<JellyfinUserData>,
|
||||||
|
```
|
||||||
|
|
||||||
|
`JellyfinUserData` deserialises `IsFavorite`, `Played`, `PlaybackPositionTicks`,
|
||||||
|
`PlayCount`, `LastPlayedDate` into the existing
|
||||||
|
[`UserData`](../../src-tauri/src/repository/types.rs#L44) type (which already
|
||||||
|
carries `is_favorite` and already serialises camelCase, so `bindings.ts` needs no
|
||||||
|
new type — only regeneration). Add `UserData` to the `Fields=` list in `get_items`
|
||||||
|
/ `get_item` so the shape is explicit rather than relying on the default.
|
||||||
|
|
||||||
|
Wire shape, unchanged from today's `UserData`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
item.userData?.isFavorite // boolean | null | undefined
|
||||||
|
```
|
||||||
|
|
||||||
|
Delete the now-false `// User data not included in basic item responses` comment.
|
||||||
|
|
||||||
|
### 2. Local mirror of server favourites (Rust, DR-114)
|
||||||
|
|
||||||
|
Choke point: `save_to_cache(parent_id, &items)` in
|
||||||
|
[offline.rs](../../src-tauri/src/repository/offline.rs) — every server result
|
||||||
|
that gets cached (including via
|
||||||
|
[`cache_items_from_server`](../../src-tauri/src/repository/hybrid.rs#L124) and
|
||||||
|
the background cache refresh) passes through it.
|
||||||
|
|
||||||
|
For each item carrying `user_data.is_favorite`, upsert:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO user_data (user_id, item_id, is_favorite, synced_at, pending_sync)
|
||||||
|
VALUES (?, ?, ?, ?, 0)
|
||||||
|
ON CONFLICT(user_id, item_id) DO UPDATE SET
|
||||||
|
is_favorite = excluded.is_favorite,
|
||||||
|
synced_at = excluded.synced_at
|
||||||
|
WHERE user_data.pending_sync = 0; -- local unsynced change wins
|
||||||
|
```
|
||||||
|
|
||||||
|
The `WHERE` on the conflict clause is the whole conflict rule: a toggle made
|
||||||
|
offline is never overwritten by a stale server value before it has been pushed.
|
||||||
|
|
||||||
|
### 3. Favourites queries (Rust, DR-115, DR-116, JA-033)
|
||||||
|
|
||||||
|
**(a) In-library filter** — one new field on `GetItemsOptions`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub favorites_only: Option<bool>,
|
||||||
|
```
|
||||||
|
|
||||||
|
- online `get_items`: append `&Filters=IsFavorite` when true.
|
||||||
|
- offline `get_items`: add `INNER JOIN user_data ud ON ud.item_id = i.id AND ud.user_id = ? AND ud.is_favorite = 1`, composed with the existing `available_items` CTE so the downloads-only gate still applies.
|
||||||
|
|
||||||
|
Frontend sends `{ favoritesOnly: true }` (camelCase — nested struct field, needs
|
||||||
|
the existing `#[serde(rename_all = "camelCase")]` on `GetItemsOptions`, already
|
||||||
|
present).
|
||||||
|
|
||||||
|
**(b) Cross-library favourites** — a new trait method, because favourites span
|
||||||
|
libraries and `get_items` is `ParentId`-shaped:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// TRACES: UR-067 | DR-115 | JA-033
|
||||||
|
async fn get_favorites(
|
||||||
|
&self,
|
||||||
|
scope: SearchScope,
|
||||||
|
options: Option<GetItemsOptions>,
|
||||||
|
) -> Result<SearchResult, RepoError>;
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn repository_get_favorites(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
scope: SearchScope,
|
||||||
|
options: Option<GetItemsOptions>,
|
||||||
|
) -> Result<SearchResult, String>
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend call (command name matches the Rust fn exactly; top-level params
|
||||||
|
auto-camelCase; `SearchScope` is `#[serde(rename_all = "camelCase")]` so the wire
|
||||||
|
values are `"all" | "music" | "movies" | "tv"`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await commands.repositoryGetFavorites(handle, "movies", { limit: 100 });
|
||||||
|
```
|
||||||
|
|
||||||
|
- **online**: `/Users/{uid}/Items?Filters=IsFavorite&Recursive=true&SortBy=SortName&SortOrder=Ascending` + `&IncludeItemTypes=…` from `scope.item_types()` (omit entirely on `None`, per that function's contract) + the standard `Fields=`.
|
||||||
|
- **offline**: `items ⨝ user_data (is_favorite = 1)`, type filter from the same `scope.item_types()`, honouring `include_catalog_browse()`.
|
||||||
|
- **hybrid**: same cache-first race as `get_items`, **including the DR-080 rule** — with the catalog-browse gate off, an empty offline result is authoritative and must not fall through to the server. Getting this wrong reproduces Defect B from [offline-downloaded-only-filter.md](offline-downloaded-only-filter.md).
|
||||||
|
|
||||||
|
**The cache-first result arrives stale, and there is no second payload.** On a
|
||||||
|
cache hit, `hybrid::get_items` returns the local rows and refreshes the cache in
|
||||||
|
a background task whose result the frontend never sees — fine for a library
|
||||||
|
listing that changes daily, wrong for favourites, where the *point* is that
|
||||||
|
another client just changed something. Favourites is the second read path (after
|
||||||
|
search) that needs the deferred update, so the background refresh in
|
||||||
|
`get_favorites` must emit the same `favorites-changed` event as §4 when the
|
||||||
|
server's favourite set differs from what was returned:
|
||||||
|
|
||||||
|
```
|
||||||
|
favorites-changed → { itemIds: string[] } // union of ids whose is_favorite flipped
|
||||||
|
```
|
||||||
|
|
||||||
|
Both producers (background refresh, reconnect drain) emit the identical payload,
|
||||||
|
and the frontend has one handler that refreshes the `favorites` store. Without
|
||||||
|
this, a favourite marked on another client appears in JellyTau only on the
|
||||||
|
*second* visit to the page.
|
||||||
|
|
||||||
|
### 4. Draining offline toggles (Rust, DR-120)
|
||||||
|
|
||||||
|
On the offline→online transition already detected by `ConnectivityMonitor`,
|
||||||
|
select `user_data WHERE pending_sync = 1 AND is_favorite IS NOT NULL`, POST or
|
||||||
|
DELETE `/Users/{uid}/FavoriteItems/{id}` per row, then set `pending_sync = 0` and
|
||||||
|
`synced_at`. Failures leave the row pending for the next transition.
|
||||||
|
|
||||||
|
Emit a kebab-case event when anything changed, so open views refresh without
|
||||||
|
polling:
|
||||||
|
|
||||||
|
```
|
||||||
|
favorites-changed → { itemIds: string[] }
|
||||||
|
```
|
||||||
|
|
||||||
|
`syncService.queueFavorite` is dead code once this lands — delete it or point it
|
||||||
|
at the backend drain; do not leave two competing queues.
|
||||||
|
|
||||||
|
### 5. Frontend surfaces (DR-117, DR-118, DR-119)
|
||||||
|
|
||||||
|
**Favourites page** — new route `/library/favorites`:
|
||||||
|
- Scope tabs *All / Movies / Shows / Music* via the existing `LibraryViewTabs`; each tab sends a `SearchScope` value, nothing more.
|
||||||
|
- Renders through `LibraryGrid` + `MediaCard` (tracklist for Music→tracks if the tab is later split; not in this pass).
|
||||||
|
- Entry points: a card on the library overview ([library/+page.svelte](../../src/routes/library/+page.svelte)) and "See all" on the home rows.
|
||||||
|
- Empty state per tab: "Nothing favourited yet — tap the heart on anything you like."
|
||||||
|
|
||||||
|
**Home carousels** — `favoriteMovies`, `favoriteShows`, `favoriteMusic` added to
|
||||||
|
[home.ts](../../src/lib/stores/home.ts), each `repositoryGetFavorites(scope, { limit: 20 })`,
|
||||||
|
rendered after *Recently Added* and **only when non-empty** (no empty rows on a
|
||||||
|
fresh install).
|
||||||
|
|
||||||
|
**In-library filter** — a favourites toggle in the header of
|
||||||
|
[GenericMediaListPage](../../src/lib/components/library/GenericMediaListPage.svelte)
|
||||||
|
and the Movies/TV landing pages, passing `favoritesOnly: true` into the existing
|
||||||
|
`repo.getItems(...)` options. Session-scoped state; not persisted (a persisted
|
||||||
|
filter that hides most of a library is a support call waiting to happen).
|
||||||
|
|
||||||
|
**Hearts** — mount `FavoriteButton`:
|
||||||
|
- Movie / series detail hero button row, beside the download buttons ([library/[id]/+page.svelte:528-553](../../src/routes/library/%5Bid%5D/+page.svelte#L528-L553)) — closes ux-flows §5B.3.
|
||||||
|
- `EpisodeFocusView`, `ArtistDetailView`, `PlaylistDetailView`, album detail — closes ux-flows §5.2.
|
||||||
|
- `MediaCard` artwork overlay (top-right). Suppressed on `isServerOnly` cards, and must not fight the existing long-press/scroll-guard handlers ([MediaCard.svelte:60-70](../../src/lib/components/library/MediaCard.svelte#L60-L70)) — the heart is its own button and stops propagation.
|
||||||
|
|
||||||
|
**Shared optimistic state** — a small `favorites` store (`Map<string, boolean>`
|
||||||
|
overlay + `favorites.set(id, value)`), so un-hearting an item on the Favourites
|
||||||
|
page removes it from the grid and from any home row without a refetch, and a
|
||||||
|
heart tapped on a card is reflected on the detail page. Resolution order:
|
||||||
|
|
||||||
|
```
|
||||||
|
favorites store override ?? item.userData?.isFavorite ?? false
|
||||||
|
```
|
||||||
|
|
||||||
|
`toggleFavorite()` updates the store alongside its existing local + server
|
||||||
|
writes; the `favorites-changed` event refreshes it. This removes the mini
|
||||||
|
player's per-track `storageGetPlaybackProgress` fetch once items carry
|
||||||
|
`userData`.
|
||||||
|
|
||||||
|
### 6. Offline behaviour
|
||||||
|
|
||||||
|
Toggling offline keeps working exactly as now (local write + `pending_sync`), and
|
||||||
|
now actually reaches the server on reconnect (§4). The Favourites page offline
|
||||||
|
shows favourites among downloaded/cached items, subject to the existing
|
||||||
|
catalog-browse gate. The offline repo's no-op `mark_favorite`/`unmark_favorite`
|
||||||
|
stay no-ops — the local write plus the drain is the offline path.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Favouriting people, genres, or collections; favourite **playlists** are included only insofar as they fall under the Music scope.
|
||||||
|
- Sorting by "date favourited" — Jellyfin does not expose it. Favourites sort by name.
|
||||||
|
- A dedicated bottom-nav tab for favourites (reachable from library overview + home).
|
||||||
|
- Building a playlist or download batch from favourites.
|
||||||
|
- Reconciling favourites for items that no longer exist on the server.
|
||||||
|
- Splitting the Music tab into albums/artists/tracks sub-tabs.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Favouriting is possible from movie, series, episode, album, artist and playlist detail pages, and from media cards in any grid.
|
||||||
|
- [ ] A favourite marked in another Jellyfin client shows a filled heart in JellyTau without toggling it here.
|
||||||
|
- [ ] `/library/favorites` lists favourites across libraries, filtered by the All/Movies/Shows/Music tabs.
|
||||||
|
- [ ] Home shows favourite rows for movies, shows and music, and shows no row when a category has none.
|
||||||
|
- [ ] Movies/TV/Music list pages can be filtered to favourites only.
|
||||||
|
- [ ] Un-hearting an item on one surface updates the others without a manual refresh.
|
||||||
|
- [ ] A favourite toggled while offline reaches the server after reconnect (verified against a real server or a fake repository).
|
||||||
|
- [ ] Offline, the Favourites page respects the "Show all server media" gate — with it off, an empty result stays empty and does not fall through to the server.
|
||||||
|
- [ ] No item-type set appears in `src/` in connection with favourites; the frontend sends `SearchScope` only.
|
||||||
|
- [ ] `bun run check` and `bun run test` pass.
|
||||||
|
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
||||||
|
- [ ] `bun run check:boundary` passes (necessary, not sufficient — see CLAUDE.md).
|
||||||
|
- [ ] New requirement-implementing code carries `// TRACES:` comments.
|
||||||
|
- [ ] `bindings.ts` regenerated from Rust, not hand-edited.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
**🔴 §4 (the pending-sync drain) is a bug fix — failing test first.** Write a
|
||||||
|
test that toggles a favourite with the repository offline, transitions to online,
|
||||||
|
and asserts the server call happened; watch it fail before writing the drain.
|
||||||
|
|
||||||
|
Rust (`cd src-tauri && cargo test`):
|
||||||
|
|
||||||
|
| Test | Covers |
|
||||||
|
|------|--------|
|
||||||
|
| UT-099 | A Jellyfin item JSON fixture with `UserData.IsFavorite: true` maps to `MediaItem.user_data.is_favorite == Some(true)` |
|
||||||
|
| UT-100 | `online::get_favorites` builds an endpoint with `Filters=IsFavorite`, `Recursive=true`, and the scope's `IncludeItemTypes`; `SearchScope::All` omits the type filter entirely |
|
||||||
|
| UT-101 | `offline::get_favorites` returns only `is_favorite = 1` rows, respects the scope type filter, and returns nothing extra when the catalog-browse gate is off |
|
||||||
|
| UT-102 | `save_to_cache` mirror does **not** overwrite a row with `pending_sync = 1` |
|
||||||
|
| UT-103 | Drain pushes pending rows, clears `pending_sync`, sets `synced_at`, and leaves failed rows pending |
|
||||||
|
| UT-104 | `get_items` with `favorites_only: true` filters both online (endpoint) and offline (SQL) |
|
||||||
|
| UT-107 | The background refresh in `hybrid::get_favorites` emits `favorites-changed` with the flipped ids, and emits nothing when the server set matches the cache |
|
||||||
|
|
||||||
|
Frontend (`bun run test`):
|
||||||
|
|
||||||
|
| Test | Covers |
|
||||||
|
|------|--------|
|
||||||
|
| UT-105 | `favorites` store override precedence: store value beats `userData.isFavorite` beats `false` |
|
||||||
|
| UT-106 | Un-hearting removes the item from a favourites list view (pure logic extracted to a `.ts` module, per the TrackList/episodeStrip pattern) |
|
||||||
|
| IT-0xx | `repositoryGetFavorites` param naming — add to [tauriIntegration.test.ts](../../src/lib/utils/tauriIntegration.test.ts): camelCase top-level params, scope serialised as `"movies"` etc. |
|
||||||
|
|
||||||
|
Any component logic worth testing gets extracted into a plain `.ts` module first
|
||||||
|
(`favoritesView.ts`), rather than tested through the component.
|
||||||
|
|
||||||
|
## TRACES
|
||||||
|
|
||||||
|
| Piece | Tag |
|
||||||
|
|-------|-----|
|
||||||
|
| `JellyfinUserData` + `to_media_item` mapping | `// TRACES: UR-069 \| DR-113, JA-034 \| UT-099` |
|
||||||
|
| `save_to_cache` user_data mirror | `// TRACES: UR-069 \| DR-114 \| UT-102` |
|
||||||
|
| `get_favorites` (trait, online, offline, hybrid) + command | `// TRACES: UR-067 \| DR-115, JA-033 \| UT-100, UT-101` |
|
||||||
|
| `GetItemsOptions.favorites_only` handling | `// TRACES: UR-067 \| DR-116 \| UT-104` |
|
||||||
|
| `/library/favorites` route + tabs | `// TRACES: UR-067 \| DR-117` |
|
||||||
|
| Home favourite carousels | `// TRACES: UR-067 \| DR-118` |
|
||||||
|
| `FavoriteButton` mounts + `favorites` store | `// TRACES: UR-068 \| DR-119 \| UT-105, UT-106` |
|
||||||
|
| Pending-favourite drain + `favorites-changed` | `// TRACES: UR-069 \| DR-120 \| UT-103` |
|
||||||
|
|
||||||
|
New requirement rows to add to [requirements.md](../requirements.md):
|
||||||
|
|
||||||
|
- **UR-067** — Browse favourited media across libraries (page, home rows, in-library filter).
|
||||||
|
- **UR-068** — Mark/unmark favourites from browse and detail surfaces, not only the player.
|
||||||
|
- **UR-069** — Favourite state stays consistent with the server in both directions.
|
||||||
|
- **DR-113 … DR-120** — as tabled above.
|
||||||
|
- **JA-033** — Query favourite items (`Filters=IsFavorite`).
|
||||||
|
- **JA-034** — Read `UserData` from item responses.
|
||||||
|
|
||||||
|
## Implementation notes (as built)
|
||||||
|
|
||||||
|
Two things landed differently from the design above, both forced by where the
|
||||||
|
`AppHandle` lives:
|
||||||
|
|
||||||
|
1. **The `favorites-changed` event is emitted from the command layer, not the
|
||||||
|
repository.** `HybridRepository` has no `AppHandle` — the same reason
|
||||||
|
`search-event` is emitted from `repository_search`. `repository_get_favorites`
|
||||||
|
therefore does the two-phase read itself (cache leg returned, server leg
|
||||||
|
spawned) and diffs the two id sets via `changed_favorite_ids`, which is
|
||||||
|
extracted and unit-tested (UT-107) rather than buried in the spawn.
|
||||||
|
2. **The drain hooks the existing `connectivity:reconnected` event** via
|
||||||
|
`app.listen` in `commands/favorites.rs`, rather than reaching into
|
||||||
|
`ConnectivityMonitor` (which knows nothing about repositories). It drains
|
||||||
|
through a narrow `FavoriteSink` trait so it can be tested against a recording
|
||||||
|
double instead of a forty-method `MediaRepository` mock.
|
||||||
|
|
||||||
|
3. **The command falls back to `HybridRepository::get_favorites` when nothing is
|
||||||
|
cached.** The two-phase read alone paints "Nothing favourited yet" on a fresh
|
||||||
|
install and corrects it a server round trip later, which is a wrong answer
|
||||||
|
shown to the user. An empty cache leg therefore defers to the repository's
|
||||||
|
own cache-first-then-server read. That read was also fixed to *save through*
|
||||||
|
on a server hit — without it the page re-queried the server on every visit
|
||||||
|
and the DR-114 mirror was never filled by this path.
|
||||||
|
|
||||||
|
Also as built: `DatabaseService` is not object-safe (generic methods), so the
|
||||||
|
drain takes `Arc<RusqliteService>` like the rest of the storage code, and
|
||||||
|
`get_items`' endpoint construction was extracted to `build_get_items_endpoint`
|
||||||
|
so the `favorites_only` filter could be asserted without an HTTP server.
|
||||||
|
|
||||||
|
**Not built:** the full-player heart. ux-flows §3.2 lists one among the full
|
||||||
|
player's secondary controls and it remains unbuilt — recorded as a known
|
||||||
|
deviation in ux-flows §5C.5 rather than silently dropped.
|
||||||
|
|
||||||
|
## Notes for the implementer
|
||||||
|
|
||||||
|
- A parallel Claude session may be active in this repo — `git diff` before "repairing" unexpected changes.
|
||||||
|
- Do **not** try to reuse `get_items` with an empty `ParentId` for cross-library favourites; that endpoint is built as `?ParentId={}` ([online.rs:731](../../src-tauri/src/repository/online.rs#L731)) and an empty value is not a reliable "all libraries" request. Use `get_favorites`.
|
||||||
|
- `SearchScope` is reused rather than a new `FavoritesScope` so there is one taxonomy expansion in the codebase, not two that can drift. If the name grates once favourites ship, rename the type across search + favourites in one commit — don't fork it.
|
||||||
|
- `SearchScope::All` returns `None` from `item_types()` **on purpose**; callers must omit `IncludeItemTypes` entirely rather than sending a union (see the doc comment at [types.rs:316](../../src-tauri/src/repository/types.rs#L316)).
|
||||||
|
- Ship order that keeps each step demonstrable: §1+§2 (state becomes visible) → §5 hearts (marking becomes possible) → §3+§5 browse surfaces (finding becomes possible) → §4 drain.
|
||||||
|
- Regenerate `bindings.ts` after the Rust types change; never hand-edit it.
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
# Spec: Two-path media — selectable playback bitrate, independent whole-file download
|
||||||
|
|
||||||
|
**Status:** Proposed
|
||||||
|
**Requirements:** UR-070, UR-071 → DR-121, DR-122, DR-123, DR-124, DR-125; IR-032
|
||||||
|
**UX spec:** player quality selector — needs a `ux-flows.md` section before build
|
||||||
|
**Related:** [catalog-index-search.md](catalog-index-search.md),
|
||||||
|
[downloads-as-offline-library.md](downloads-as-offline-library.md)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Two things that are today tangled become explicitly separate:
|
||||||
|
|
||||||
|
- **The playback path** streams at a bitrate the viewer can change from the
|
||||||
|
player. It is ephemeral and its rendition is volatile.
|
||||||
|
- **The download path** fetches the whole file at one canonical quality, in the
|
||||||
|
background, independently of whatever playback is doing.
|
||||||
|
|
||||||
|
Bytes fetched for playback are kept **only** when the playback rendition happens
|
||||||
|
to be the same artifact the download path would produce — i.e. direct play.
|
||||||
|
Otherwise playback bytes are discarded and the download path does its own fetch.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
The appealing version of this — "stream and download at once, switch when enough
|
||||||
|
has arrived" — breaks the moment the viewer can change bitrate. A capture taken
|
||||||
|
while the rendition changes underneath it is a splice of two encodings: not a
|
||||||
|
playable file, and not something that can be honestly recorded as a download.
|
||||||
|
Once bitrate is selectable, one stream cannot serve both jobs.
|
||||||
|
|
||||||
|
Separating the paths also removes the thing that made the original idea
|
||||||
|
expensive: there is no mid-playback source swap to engineer, because the download
|
||||||
|
never has to take over the live session. It lands on disk and is used at the next
|
||||||
|
natural boundary — next episode, or next time the item is played.
|
||||||
|
|
||||||
|
What exists already and is *not* this: `SmartCache` predictively downloads *other*
|
||||||
|
items, `player_preload_upcoming` warms the next one, and
|
||||||
|
`refresh_queue_local_sources` swaps queue entries to local at boundaries. All of
|
||||||
|
it concerns items you are not currently playing.
|
||||||
|
|
||||||
|
## Layer assignment
|
||||||
|
|
||||||
|
| Logic / responsibility | Layer | Why it belongs there |
|
||||||
|
|------------------------|-------|----------------------|
|
||||||
|
| Available bitrate options for an item | **Rust** | Derived from Jellyfin's media sources and playback-info negotiation; changes with the API. |
|
||||||
|
| Mapping a chosen bitrate to transcode parameters | **Rust** | Domain vocabulary. `get_video_download_url` already owns the quality→params mapping; playback must reuse it, not restate it. |
|
||||||
|
| Deciding whether playback bytes are keepable (direct play vs transcode) | **Rust** | Depends on the negotiated session. |
|
||||||
|
| Canonical download quality | **Rust** | Policy over domain data. |
|
||||||
|
| Cache eviction, storage budget, sparse-range bookkeeping | **Rust** | Storage policy. |
|
||||||
|
| Promotion to a `downloads` row, and what invalidates a cache entry | **Rust** | Domain state. |
|
||||||
|
| Rendering the quality selector; remembering the last choice | **Frontend** | Presentation and a view preference. The *list* comes from Rust. |
|
||||||
|
| WiFi-only / opt-in toggles | **Frontend collects, Rust enforces** | The control is UI; the gate must hold even if the UI never calls. |
|
||||||
|
|
||||||
|
Borderline, recorded: the **default** playback bitrate could look like a user
|
||||||
|
preference (frontend). It goes to Rust because it must be reconcilable with what
|
||||||
|
the server can actually produce for a given media source — a preference the
|
||||||
|
backend has to validate is not a preference the frontend can own alone. The
|
||||||
|
frontend stores the user's *choice*; Rust decides what that choice resolves to.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### DR-121 — Bitrate selection in the player
|
||||||
|
|
||||||
|
The player exposes the qualities Rust reports for the current item. Changing it
|
||||||
|
re-negotiates the stream URL at the new quality and resumes at the current
|
||||||
|
position. This is a deliberate, user-initiated interruption — a brief rebuffer is
|
||||||
|
expected and acceptable, unlike the involuntary swap the earlier design would
|
||||||
|
have needed.
|
||||||
|
|
||||||
|
Constraints that must not be broken:
|
||||||
|
|
||||||
|
- On Linux, video playback must keep using the HLS `master.m3u8` URL. CLAUDE.md
|
||||||
|
records that returning `stream.mp4` means transcoded playback never starts.
|
||||||
|
A quality change re-negotiates *within* HLS.
|
||||||
|
- The quality→transcode-parameter mapping already exists in
|
||||||
|
`get_video_download_url` ([online.rs:1702-1717](../../src-tauri/src/repository/online.rs#L1702-L1717)).
|
||||||
|
Playback must call into the same mapping. Two copies of that table will drift.
|
||||||
|
- Track selection (audio/subtitle) already survives a stream re-negotiation
|
||||||
|
elsewhere in the player; a quality change must preserve it too.
|
||||||
|
|
||||||
|
### DR-122 — The playback path is ephemeral
|
||||||
|
|
||||||
|
Playback bytes are not persisted unless DR-124 says they are keepable. No partial
|
||||||
|
capture is ever retained across a quality change: on change, any in-flight capture
|
||||||
|
for that session is abandoned and its partial file deleted.
|
||||||
|
|
||||||
|
### DR-123 — The download path is independent
|
||||||
|
|
||||||
|
Downloading the whole file is a separate operation through the existing download
|
||||||
|
manager, at one canonical quality (default `original`, the direct static copy),
|
||||||
|
using `/Videos/{id}/stream.mp4` — progressive and Range-capable, which is what
|
||||||
|
the resumable download worker relies on. It is unaffected by what playback is
|
||||||
|
doing, and playback is unaffected by it.
|
||||||
|
|
||||||
|
Once complete it becomes an ordinary download row, so everything already built on
|
||||||
|
top of downloads — offline browsing, `refresh_queue_local_sources`, the Downloads
|
||||||
|
page — picks it up with no further work.
|
||||||
|
|
||||||
|
**Prerequisite:** downloaded *video* is currently never played locally.
|
||||||
|
`repository_get_video_stream_url` goes straight to the online repo and
|
||||||
|
[player/[id]/+page.svelte:316](../../src/routes/player/[id]/+page.svelte#L316)
|
||||||
|
calls it with no local check — so a completed video download is still streamed.
|
||||||
|
This must be fixed or the whole feature is invisible for video.
|
||||||
|
|
||||||
|
### DR-124 — Keep playback bytes only when they *are* the download
|
||||||
|
|
||||||
|
Capture is enabled only where the played bytes and the canonical download artifact
|
||||||
|
are the same thing — a **direct-play** session. Then:
|
||||||
|
|
||||||
|
| Path | Mechanism |
|
||||||
|
|---|---|
|
||||||
|
| Android / ExoPlayer | `SimpleCache` + `CacheDataSource`, keyed by item id **and** media-source id so renditions never collide. LRU evictor sharing the existing smart-cache budget — not a second budget over the same disk. |
|
||||||
|
| Linux audio / MPV | `stream-record`, set through the existing `set_property` plumbing. |
|
||||||
|
| Linux video (HLS transcode) | **Not captured.** Segments are not a file; assembling one needs ffmpeg, which is not a dependency and which CI is forbidden from installing at job time. The download path (DR-123) covers this case instead. |
|
||||||
|
|
||||||
|
Two abandonment rules, both of which must delete the partial rather than promote
|
||||||
|
it:
|
||||||
|
|
||||||
|
- **Seek during an mpv capture.** `stream-record` is documented as intended for
|
||||||
|
linear streams; seeking breaks the recording. Straight-through listening
|
||||||
|
captures, scrubbing does not.
|
||||||
|
- **Any quality change** (DR-122).
|
||||||
|
|
||||||
|
### DR-125 — Promotion, rendition, and invalidation
|
||||||
|
|
||||||
|
A capture is promoted to a `downloads` row (`status = 'completed'`) only when it
|
||||||
|
covers the whole resource. Partial captures stay cache and remain evictable.
|
||||||
|
|
||||||
|
A new `downloads.source_rendition` column records the negotiated
|
||||||
|
quality/container/codec of whatever produced the bytes; `NULL` for rows fetched by
|
||||||
|
the existing paths, which are always `original`. This is what makes an "upgrade to
|
||||||
|
original" action possible later, and what stops a 720p capture and a 4K download
|
||||||
|
being indistinguishable rows.
|
||||||
|
|
||||||
|
**Invalidation.** A quality change never touches a file that already exists —
|
||||||
|
neither a permanent download nor a completed temporary one. Both remain valid
|
||||||
|
copies of the rendition they hold, and deleting either would throw away bytes
|
||||||
|
already paid for.
|
||||||
|
|
||||||
|
What a quality change *does* invalidate is an **in-flight** capture or background
|
||||||
|
download of cached media: it is abandoned and restarted at the newly chosen
|
||||||
|
quality, because a capture spanning a rendition change is a splice of two
|
||||||
|
encodings rather than a playable file (DR-122).
|
||||||
|
|
||||||
|
So the rule is about *ongoing* work, not stored files. Nothing in this spec
|
||||||
|
deletes user data.
|
||||||
|
|
||||||
|
### Gating
|
||||||
|
|
||||||
|
Capture and background download obey the existing WiFi-only gate and storage
|
||||||
|
budget, and are off unless opted in. Enforcement is in Rust.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- **Mid-playback switch onto a completing download.** Two independent paths make
|
||||||
|
it unnecessary; the download is used from the next boundary.
|
||||||
|
- **Backfilling the unplayed remainder of a capture.** Watch 40 minutes and you
|
||||||
|
have 40 minutes; completing it needs sparse-range bookkeeping and a resumable
|
||||||
|
tail fetch. The DR-123 download path already produces a complete file, which is
|
||||||
|
the reason this can wait.
|
||||||
|
- **Bundling ffmpeg** to make transcoded video capturable. Real option, large
|
||||||
|
packaging decision, its own proposal.
|
||||||
|
- **Routing Linux video playback through `stream.mp4`.** Regresses a documented,
|
||||||
|
hard-won fix.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] The player offers the qualities Rust reports, and changing one resumes at
|
||||||
|
the same position with audio/subtitle selection preserved.
|
||||||
|
- [ ] A quality change abandons any in-flight capture and leaves no partial file.
|
||||||
|
- [ ] A quality change never deletes a `downloads` row.
|
||||||
|
- [ ] A completed background download of a video is *played from disk* on the next
|
||||||
|
play (the DR-123 prerequisite).
|
||||||
|
- [ ] A direct-play session played start-to-finish leaves a complete local file
|
||||||
|
with no second fetch; replaying it fetches no media bytes.
|
||||||
|
- [ ] Seeking during an mpv capture abandons it; no truncated file is promoted.
|
||||||
|
- [ ] A transcoded Linux video session is never captured, and never partially
|
||||||
|
promoted.
|
||||||
|
- [ ] Promoted rows record their rendition; existing paths still record
|
||||||
|
`NULL`/`original`.
|
||||||
|
- [ ] Gates hold with the setting off *and* with the frontend never sending it.
|
||||||
|
- [ ] Eviction cannot delete bytes backing a promoted download row.
|
||||||
|
- [ ] `bun run check`, `bun run test`, `cargo fmt`, `cargo clippy`,
|
||||||
|
`bun run test:rust`, `bun run check:boundary` pass; `bindings.ts`
|
||||||
|
regenerated if Rust types changed.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Rust, table-driven and pure where possible: quality→params resolution shared with
|
||||||
|
the download path; keepability (direct play vs transcode vs gate off); promotion
|
||||||
|
(complete → promoted, partial → not, seek-abandoned → not, quality-changed → not);
|
||||||
|
invalidation (evicts cache, never a download row); rendition round-trip.
|
||||||
|
|
||||||
|
Android: instrumented — a played direct-play item yields cache entries, and a
|
||||||
|
replay issues no media network request.
|
||||||
|
|
||||||
|
Frontend: the quality list renders from backend data with no item-type or
|
||||||
|
codec taxonomy in `src/`; the selector's remembered choice is a view preference.
|
||||||
|
|
||||||
|
## TRACES
|
||||||
|
|
||||||
|
| Piece | Tag |
|
||||||
|
|---|---|
|
||||||
|
| Quality selector + re-negotiation | `// TRACES: UR-070 \| DR-121` |
|
||||||
|
| Ephemeral playback / capture abandonment | `// TRACES: UR-070 \| DR-122` |
|
||||||
|
| Independent whole-file download + local video playback fix | `// TRACES: UR-071 \| DR-123, IR-032` |
|
||||||
|
| ExoPlayer cache / mpv stream-record / keepability | `// TRACES: UR-071 \| DR-124` |
|
||||||
|
| Promotion, `source_rendition`, invalidation | `// TRACES: UR-071 \| DR-125` |
|
||||||
|
|
||||||
|
## Notes for the implementer
|
||||||
|
|
||||||
|
- **A parallel Claude session is active in this repo.** `git diff` before
|
||||||
|
"repairing" anything you did not write.
|
||||||
|
- Do not duplicate the quality→transcode-parameter table. Call the existing one.
|
||||||
|
- Reuse the smart-cache storage budget; two budgets over one disk is how devices
|
||||||
|
fill up.
|
||||||
|
- The `downloads` FK to `items` is relaxed (migration 005) — exercise promotion
|
||||||
|
for an item that was never cached.
|
||||||
|
- Build DR-123's local-playback fix first. Without it nothing in this spec is
|
||||||
|
observable for video.
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# Spec: streaming bitrate cap
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
**Requirements:** UR-074 → DR-162 (partially serves UR-070)
|
||||||
|
**UX spec:** n/a — the controls reuse existing patterns (Settings → Video Playback, and the player's track menus).
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The viewer picks a bandwidth ceiling for video — from `Original` (no client
|
||||||
|
limit) down to 720 kbps — and every video the app opens is fetched within it,
|
||||||
|
live TV included. The choice is made once in Settings and persists across
|
||||||
|
restarts; a single video can be moved to another ceiling from the player, which
|
||||||
|
re-opens the stream and resumes where it was without changing the saved default.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
Every video URL the app built carried a fixed allowance —
|
||||||
|
`MaxStreamingBitrate=20000000`, `VideoBitrate=18000000` — the `PlaybackInfo`
|
||||||
|
negotiation asked for 20 Mbps, and the device profile advertised
|
||||||
|
`999999999`, which invites the server to direct-play a source of any size. On a
|
||||||
|
metered or slow connection there was no lever at all short of not watching.
|
||||||
|
|
||||||
|
The related UR-070 asks for something adjacent but different: a list of the
|
||||||
|
renditions *the server can produce for this item*. That needs per-item
|
||||||
|
`MediaSources` negotiation and is still proposed. What was missing first is
|
||||||
|
cruder and more valuable: a device-wide budget that holds regardless of what is
|
||||||
|
playing.
|
||||||
|
|
||||||
|
## Layer assignment
|
||||||
|
|
||||||
|
| Logic / responsibility | Layer | Why it belongs there |
|
||||||
|
|------------------------|-------|----------------------|
|
||||||
|
| What a quality step *is* — total ceiling, audio share, resolution cap | Rust | Jellyfin encoding vocabulary. It changes if Jellyfin's transcoder or parameter binding changes, not if the UI is redesigned. Exactly the shape of `EqPreset::gains()`. |
|
||||||
|
| Splitting the ceiling between video and audio | Rust | A domain rule about what the server is being asked to produce; getting it wrong overshoots the user's cap. |
|
||||||
|
| Choosing `MaxHeight` for a bitrate | Rust | An encoding judgement (how many pixels a budget can carry), not a display preference. |
|
||||||
|
| Where the cap is applied (URL builders, `PlaybackInfo`, live TV, audio handoff) | Rust | All four are backend concerns, and the frontend must not have to know that a cap has more than one enforcement point. |
|
||||||
|
| Whether a mid-playback change needs a stream reload, and performing it | Rust | Same decision the audio-track switch already delegates: the backend knows the playback mode and owns the queue. |
|
||||||
|
| Persisting the default | Rust | Application state in `app_settings`, alongside every other durable setting. |
|
||||||
|
| Rendering the picker, menu placement, which control is highlighted | Frontend | Pure presentation. |
|
||||||
|
|
||||||
|
The frontend holds one string — the serde token for the chosen variant — and
|
||||||
|
labels/details it received from Rust. It never encodes a bitrate, a resolution
|
||||||
|
or a parameter name.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
`StreamingQuality` (`src-tauri/src/settings.rs`) is the ladder: `Original`,
|
||||||
|
`Mbps20`, `Mbps10`, `Mbps8`, `Mbps4`, `Mbps2`, `Mbps1`, `Kbps720`, serialised
|
||||||
|
camelCase (`"mbps10"`). Each step answers `max_bitrate()`, `audio_bitrate()`,
|
||||||
|
`video_bitrate()` (= total − audio), `max_height()`, `label()`, `detail()`.
|
||||||
|
|
||||||
|
The active ceiling is a process-wide `RwLock<StreamingQuality>` in
|
||||||
|
`repository/online.rs`, read by every builder. Process-wide rather than a field
|
||||||
|
on `OnlineRepository` because it is a preference about *this device's
|
||||||
|
connection*: it must survive a repository rebuilt on re-login, and the URL
|
||||||
|
builders and the negotiation have to agree on it or the cap leaks. This mirrors
|
||||||
|
`offline::INCLUDE_CATALOG_BROWSE`.
|
||||||
|
|
||||||
|
Enforcement points — all four are required:
|
||||||
|
|
||||||
|
| Point | What the cap sets |
|
||||||
|
|-------|-------------------|
|
||||||
|
| `get_video_stream_url` (HLS transcode) | `MaxStreamingBitrate`, `VideoBitrate`, `AudioBitrate`, `MaxHeight` |
|
||||||
|
| `get_playback_info` | request `MaxStreamingBitrate`, and the device profile's `MaxStreamingBitrate`/`MaxStaticBitrate` |
|
||||||
|
| `open_live_stream` | `MaxStreamingBitrate` |
|
||||||
|
| `build_audio_only_stream_url_for_video` | `min(cap audio, 384 kbps)` |
|
||||||
|
|
||||||
|
The negotiation is the one that matters most. `MaxStaticBitrate` is what makes
|
||||||
|
the server refuse to *direct play* a source fatter than the ceiling; without it
|
||||||
|
a 30 Mbps remux is served untouched and no URL parameter downstream can reduce
|
||||||
|
it.
|
||||||
|
|
||||||
|
IPC:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
player_get_streaming_qualities() -> Vec<(StreamingQuality, String, String)> // variant, label, detail
|
||||||
|
player_set_stream_quality(repository_handle, quality, use_html5,
|
||||||
|
current_position, media_source_id, audio_stream_index)
|
||||||
|
-> StreamQualityResponse // #[serde(tag = "strategy")]: native | reloadStream
|
||||||
|
```
|
||||||
|
|
||||||
|
`VideoSettings` gains `streaming_quality` (`#[serde(default)]`, so settings
|
||||||
|
persisted before the field existed load as uncapped).
|
||||||
|
`player_set_video_settings` applies it and writes it to `app_settings`;
|
||||||
|
`restore_streaming_quality` reads it back in the Tauri `setup` hook via
|
||||||
|
`tauri::async_runtime::spawn`, defaulting to uncapped if anything fails.
|
||||||
|
|
||||||
|
`StreamQualityResponse` keeps its Rust field names on the wire (`new_url`) —
|
||||||
|
tauri-specta only camelCases the `strategy` tag. The facade
|
||||||
|
(`playerController.setStreamQuality`) dispatches `reloadSource` for
|
||||||
|
`reloadStream` and does nothing for `native`, because the backend has already
|
||||||
|
reloaded itself.
|
||||||
|
|
||||||
|
Mid-playback the change applies to the current video **and** becomes the process
|
||||||
|
ceiling for what follows, but it is not persisted: the in-player menu is a "this
|
||||||
|
film, this connection" control and Settings owns the durable default.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Per-item rendition lists from the server's `MediaSources` (UR-070's other half).
|
||||||
|
- Connection-aware caps (separate WiFi/cellular ceilings). One cap, all connections.
|
||||||
|
- Adaptive/automatic selection from measured throughput.
|
||||||
|
- Download quality, which already has its own preset vocabulary (UR-071/DR-123).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] `bun run check` passes.
|
||||||
|
- [x] `cargo fmt` clean, `cargo clippy` clean, Rust tests pass.
|
||||||
|
- [x] `bun run test` passes.
|
||||||
|
- [x] `bun run check:boundary` passes — no bitrate/resolution numbers in `src/`.
|
||||||
|
- [x] New code carries `// TRACES:` comments.
|
||||||
|
- [x] `bindings.ts` regenerated from Rust.
|
||||||
|
- [x] A capped step changes what the URL asks for; the uncapped default is byte-identical to the previous behaviour.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Rust (`cargo test`):
|
||||||
|
|
||||||
|
- `test_video_stream_url_applies_bitrate_cap` — all four parameters at `Mbps2`.
|
||||||
|
- `test_video_stream_url_uncapped_keeps_legacy_allowance` — `Original` is unchanged and adds no `MaxHeight`.
|
||||||
|
- `test_audio_only_stream_url_takes_the_lower_of_cap_and_default`.
|
||||||
|
- `test_streaming_quality_budget_is_internally_consistent`, `..._ladder_descends`, `..._round_trips_through_json`.
|
||||||
|
|
||||||
|
The ceiling is process-wide, so tests that depend on it serialise on a guard
|
||||||
|
(`QualityFixture`) that restores `Original` on drop — including the two
|
||||||
|
pre-existing stream-URL tests, which would otherwise see another test's cap.
|
||||||
|
|
||||||
|
`get_playback_info` and `open_live_stream` need a live server and are not unit
|
||||||
|
tested; their behaviour is the enum's `max_bitrate()`, which is.
|
||||||
|
|
||||||
|
## TRACES
|
||||||
|
|
||||||
|
- `StreamingQuality`, `VideoSettings.streaming_quality` — `UR-074 | DR-162`
|
||||||
|
- URL builders / negotiation / live TV — `UR-004, UR-074 | DR-140, DR-162`
|
||||||
|
- Audio-only handoff — `UR-040, UR-074 | DR-162`
|
||||||
|
- Commands, facade, Settings UI, player menu — `UR-074 | DR-162`
|
||||||
|
- Tests — `UT-156`, `UT-157`
|
||||||
|
|
||||||
|
## Notes for the implementer
|
||||||
|
|
||||||
|
- `videoBitRate` with a capital R is the *download* endpoint's binding quirk
|
||||||
|
(DR-123). The streaming endpoint used here binds `VideoBitrate`/
|
||||||
|
`MaxStreamingBitrate` as spelled above — do not "correct" one to the other.
|
||||||
|
- A parallel Claude session may be active in this repo; `git diff` before
|
||||||
|
repairing unexpected changes. DR-160/161 were claimed by such a session while
|
||||||
|
this feature was in flight, which is why it is DR-162.
|
||||||
+5616
-2027
File diff suppressed because it is too large
Load Diff
+126
-2
@@ -634,7 +634,7 @@ episode strip.
|
|||||||
│ │ S2E4 • 48m • ★8.1 │ │
|
│ │ S2E4 • 48m • ★8.1 │ │
|
||||||
│ │ Overview… │ │
|
│ │ Overview… │ │
|
||||||
│ │ ▓▓▓▓▓░░░░░ 32m left │ │
|
│ │ ▓▓▓▓▓░░░░░ 32m left │ │
|
||||||
│ │ [▶ Play] │ │
|
│ │ [▶ Play] [⬇] [♡] │ │
|
||||||
│ └───────────────────────────────────────────┘ │
|
│ └───────────────────────────────────────────┘ │
|
||||||
│ │
|
│ │
|
||||||
│ More Episodes │ ← 2. EPISODE STRIP
|
│ More Episodes │ ← 2. EPISODE STRIP
|
||||||
@@ -688,7 +688,7 @@ A movie has no continuation set, so cast follows the hero directly.
|
|||||||
### 5B.4 Series detail — section order
|
### 5B.4 Series detail — section order
|
||||||
|
|
||||||
```
|
```
|
||||||
Hero (poster, title, metadata, Resume SxEy / Download / Clear history)
|
Hero (poster, title, metadata, Resume SxEy / Download / Favorite / Clear history)
|
||||||
→ Crew links
|
→ Crew links
|
||||||
→ Genre tags
|
→ Genre tags
|
||||||
→ Seasons (collapsible; only the current season expanded)
|
→ Seasons (collapsible; only the current season expanded)
|
||||||
@@ -757,6 +757,130 @@ opt-in. Grids and other surfaces keep tap-to-open with no long-press.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 5C. Favourites
|
||||||
|
|
||||||
|
Favouriting is a two-sided promise: the heart takes the input, and the app must
|
||||||
|
be able to give it back. This section covers both sides — where you can mark a
|
||||||
|
favourite, and where marked favourites resurface.
|
||||||
|
|
||||||
|
See [specs/favorites-browsing.md](specs/favorites-browsing.md) for the layer
|
||||||
|
assignment and wire shapes.
|
||||||
|
|
||||||
|
### 5C.1 The heart appears wherever an item does
|
||||||
|
|
||||||
|
A favourite is a property of an *item*, so the affordance follows the item
|
||||||
|
rather than living on one privileged screen. Any surface that shows a whole
|
||||||
|
item shows its heart.
|
||||||
|
|
||||||
|
| Surface | Heart position | Notes |
|
||||||
|
|---------|----------------|-------|
|
||||||
|
| Movie / Series detail hero | In the button row, after Play and Download | §5B.3, §5B.4 |
|
||||||
|
| Episode Focus View hero | Same row as Play / Download | §5B.2 |
|
||||||
|
| Album, Artist, Playlist detail | In the header button row | §5.2 |
|
||||||
|
| Media card (any grid or carousel) | Top-right overlay on the artwork | Hidden on server-only (greyed) cards |
|
||||||
|
| Mini player | Right of the track metadata | Existing behaviour, unchanged |
|
||||||
|
| Full player | Secondary controls row | §3.2 — **not yet built**, see §5C.5 |
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- **The heart never competes with the card.** On a media card it is its own
|
||||||
|
button and swallows the tap, so hearting an item never also opens or plays
|
||||||
|
it, and never triggers the §5B.5 long-press.
|
||||||
|
- **State is shown, not guessed.** A filled heart means the *server* considers
|
||||||
|
the item a favourite (or you just tapped it). An item favourited in Jellyfin
|
||||||
|
Web, on another device, or by another client renders filled here without
|
||||||
|
being touched in JellyTau.
|
||||||
|
- **Feedback is immediate.** The heart fills on tap and a toast confirms;
|
||||||
|
neither waits for the server round-trip.
|
||||||
|
|
||||||
|
### 5C.2 Three ways back to what you favourited
|
||||||
|
|
||||||
|
Favourites are not one destination — they are a lens, and the right surface
|
||||||
|
depends on whether the user is *browsing*, *deciding*, or *hunting*.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
User[User wants their favourites] --> How{Intent}
|
||||||
|
|
||||||
|
How -->|Passive: show me something| Home[Home carousels<br/>Favourite Movies / Shows / Music]
|
||||||
|
How -->|Deliberate: my whole collection| Page[Favourites page<br/>/library/favorites]
|
||||||
|
How -->|Narrowing: within this library| Filter[Favourites filter<br/>on a library page]
|
||||||
|
|
||||||
|
Home -->|See all| Page
|
||||||
|
Page --> Detail[Item detail page]
|
||||||
|
Filter --> Detail
|
||||||
|
```
|
||||||
|
|
||||||
|
**Home carousels.** Rows for favourite movies, shows and music sit below
|
||||||
|
*Recently Added*. A row with nothing in it **does not render** — a fresh install
|
||||||
|
shows no empty favourite rows. Each row ends with *See all*, landing on the
|
||||||
|
matching tab of the Favourites page.
|
||||||
|
|
||||||
|
**The Favourites page** (`/library/favorites`) is the complete collection,
|
||||||
|
scoped by tabs:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ [←] Favourites │
|
||||||
|
│ ┌─────┬────────┬───────┬───────┐ │
|
||||||
|
│ │ All │ Movies │ Shows │ Music │ ← scope tabs │
|
||||||
|
│ └─────┴────────┴───────┴───────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌────┐┌────┐┌────┐┌────┐┌────┐ │
|
||||||
|
│ │ ♥ ││ ♥ ││ ♥ ││ ♥ ││ ♥ │ grid/list │
|
||||||
|
│ └────┘└────┘└────┘└────┘└────┘ per §5A │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- Cards obey §5A in full — shape follows the media, so a mixed *All* tab reads
|
||||||
|
as posters, squares and thumbnails side by side rather than one forced shape.
|
||||||
|
- Reached from a card on the library overview (`/library`) and from *See all*
|
||||||
|
on any home favourites row.
|
||||||
|
- Sorted by name. Jellyfin does not record *when* an item was favourited, so
|
||||||
|
"recently favourited" is not offerable — see §5C.5.
|
||||||
|
- Empty state, per tab: *"Nothing favourited yet — tap the heart on anything
|
||||||
|
you like."*
|
||||||
|
|
||||||
|
**The in-library filter** is for narrowing where the user already is: a
|
||||||
|
favourites toggle in the header of the Movies, TV and Music browse pages,
|
||||||
|
filtering the current list in place. It is **session-scoped and not persisted** —
|
||||||
|
a sticky filter that silently hides most of a library reads as data loss on the
|
||||||
|
next launch.
|
||||||
|
|
||||||
|
### 5C.3 Removing a favourite removes it everywhere, at once
|
||||||
|
|
||||||
|
Un-hearting an item on the Favourites page removes its card from the grid
|
||||||
|
immediately; the same item disappears from the home rows and shows an empty
|
||||||
|
heart on its detail page without a manual refresh. The reverse holds for
|
||||||
|
favouriting. There is no confirmation prompt — the action is one tap to undo.
|
||||||
|
|
||||||
|
### 5C.4 Offline
|
||||||
|
|
||||||
|
- **Marking works offline.** The heart fills, the toast confirms, and the change
|
||||||
|
is held locally.
|
||||||
|
- **It reaches the server on reconnect**, without the user returning to the
|
||||||
|
screen where they made it.
|
||||||
|
- **Browsing offline shows favourites among media on the device**, subject to
|
||||||
|
the same "Show all server media" gate as every other browse surface (§7.2) —
|
||||||
|
with the gate off, an empty Favourites tab means *nothing favourited is
|
||||||
|
downloaded*, and the page does not quietly fall back to the server catalog.
|
||||||
|
|
||||||
|
### 5C.5 Known deviations
|
||||||
|
|
||||||
|
- **The full player has no heart.** §3.2 and §3.3 list a Favorite button among
|
||||||
|
the full player's secondary controls; it was never built, and this pass does
|
||||||
|
not add it. The mini player heart above it is the only in-player affordance.
|
||||||
|
*(UR-067)*
|
||||||
|
- **No "recently favourited" sort.** Jellyfin's API does not expose a favourite
|
||||||
|
timestamp, so favourites can only be ordered by name. Recording the
|
||||||
|
timestamp locally at toggle time would order *this device's* favourites only,
|
||||||
|
which is worse than a consistent name sort.
|
||||||
|
- **Music is one tab, not three.** The Music scope mixes albums, artists and
|
||||||
|
tracks in a single grid rather than offering sub-tabs. Acceptable while
|
||||||
|
favourite counts are small; revisit if the tab becomes unscannable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 6. Search Flow
|
## 6. Search Flow
|
||||||
|
|
||||||
Search is **context-scoped**: what you are looking at when you start a search
|
Search is **context-scoped**: what you are looking at when you start a search
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jellytau",
|
"name": "jellytau",
|
||||||
"version": "0.3.0",
|
"version": "0.5.3",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
|
|||||||
@@ -173,10 +173,10 @@ describe("live requirements.md", () => {
|
|||||||
);
|
);
|
||||||
const defined = countDefinedRequirements(md);
|
const defined = countDefinedRequirements(md);
|
||||||
|
|
||||||
expect(defined.UR).toBe(64);
|
expect(defined.UR).toBe(74);
|
||||||
expect(defined.IR).toBe(29);
|
expect(defined.IR).toBe(32);
|
||||||
expect(defined.DR).toBe(104);
|
expect(defined.DR).toBe(162);
|
||||||
expect(defined.JA).toBe(32);
|
expect(defined.JA).toBe(35);
|
||||||
expect(defined.total).toBe(229);
|
expect(defined.total).toBe(303);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Executable
+119
@@ -0,0 +1,119 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Stamp the release version into every file that carries it.
|
||||||
|
#
|
||||||
|
# The git tag is the single source of truth for a release version. The versions
|
||||||
|
# committed in package.json / tauri.conf.json / Cargo.toml are a placeholder for
|
||||||
|
# dev builds; a tagged build overwrites all of them from the tag so they cannot
|
||||||
|
# disagree with each other or with the tag.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/set-version.sh 0.5.0 # explicit
|
||||||
|
# JELLYTAU_VERSION=0.5.0 ./scripts/set-version.sh
|
||||||
|
# ./scripts/set-version.sh # derive from git describe (dev builds)
|
||||||
|
#
|
||||||
|
# Accepts the version with or without a leading "v".
|
||||||
|
#
|
||||||
|
# Why a script and not four sed lines in CI: the version lived in four files and
|
||||||
|
# CI only ever rewrote one of them (tauri.conf.json), so a tagged release shipped
|
||||||
|
# a matching installer name and mismatched package metadata. Keeping the write in
|
||||||
|
# one place is what makes "the tag is authoritative" actually true.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
VERSION="${1:-${JELLYTAU_VERSION:-}}"
|
||||||
|
|
||||||
|
# CI passes "${GITHUB_REF#refs/tags/}" unconditionally, which on an untagged
|
||||||
|
# build is still a full ref ("refs/heads/master"). Treat anything that is not a
|
||||||
|
# bare version as "no version given" and fall through to git describe, so a
|
||||||
|
# branch build gets a sane dev version instead of failing the job.
|
||||||
|
case "$VERSION" in
|
||||||
|
refs/*) VERSION="" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -z "$VERSION" ]; then
|
||||||
|
# No explicit version: derive from the most recent tag. Dev builds land on
|
||||||
|
# something like 0.5.0 (exact tag) or 0.5.0-3-gabc1234 (ahead of the tag).
|
||||||
|
VERSION="$(git describe --tags --always --match 'v*' 2>/dev/null || echo "0.0.0")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Tags are written v0.5.0; the files carry a bare semver.
|
||||||
|
VERSION="${VERSION#v}"
|
||||||
|
|
||||||
|
# Validate before writing anything — a malformed version silently propagated
|
||||||
|
# into four files is far worse than a failed script.
|
||||||
|
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$'; then
|
||||||
|
echo "❌ Not a valid semver: '$VERSION'" >&2
|
||||||
|
echo " Expected MAJOR.MINOR.PATCH with an optional -prerelease/+build suffix." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "📌 Setting version to $VERSION"
|
||||||
|
|
||||||
|
# --- The three committed manifests -----------------------------------------
|
||||||
|
# Anchored to the first "version" key so a dependency's version is never hit.
|
||||||
|
|
||||||
|
# package.json — the top-level "version", which sits in the first few lines.
|
||||||
|
perl -0pi -e 's/("version"\s*:\s*)"[^"]*"/$1"'"$VERSION"'"/' package.json
|
||||||
|
|
||||||
|
# tauri.conf.json — likewise; this is the one the bundler reads for installer
|
||||||
|
# names, and the one CI used to patch alone.
|
||||||
|
perl -0pi -e 's/("version"\s*:\s*)"[^"]*"/$1"'"$VERSION"'"/' src-tauri/tauri.conf.json
|
||||||
|
|
||||||
|
# Cargo.toml — only the [package] version, never a dependency's. Restricted to
|
||||||
|
# the first occurrence of a line-anchored `version = "..."`.
|
||||||
|
perl -0pi -e 's/^(version\s*=\s*)"[^"]*"/$1"'"$VERSION"'"/m' src-tauri/Cargo.toml
|
||||||
|
|
||||||
|
# Cargo.lock — the jellytau entry. Left alone if the lock has not been generated
|
||||||
|
# yet; the next cargo invocation writes it. Cargo would otherwise rewrite the
|
||||||
|
# lock mid-build and dirty the tree.
|
||||||
|
if [ -f src-tauri/Cargo.lock ]; then
|
||||||
|
perl -0pi -e 's/(name = "jellytau"\nversion = )"[^"]*"/$1"'"$VERSION"'"/' src-tauri/Cargo.lock
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Android versionCode ----------------------------------------------------
|
||||||
|
# Only when the generated Android project exists (i.e. after `tauri android
|
||||||
|
# init`); on Linux/Windows jobs there is nothing to stamp.
|
||||||
|
#
|
||||||
|
# `tauri android init` derives a versionCode from the semver (0.0.15 -> 15).
|
||||||
|
# That is both tiny and NOT monotonic across our history: earlier local/dev
|
||||||
|
# builds shipped versionCode 1000 (from a 0.1.0 config), so a plain 15 is a
|
||||||
|
# *downgrade* and Android refuses the update.
|
||||||
|
#
|
||||||
|
# The floor has to clear the highest code actually in the field, which is not the
|
||||||
|
# same as the highest this formula has produced. v0.5.2 shipped versionCode
|
||||||
|
# **5002** under an earlier `minor*1000` scheme; the `minor*100` formula that
|
||||||
|
# replaced it yields only 1502 for that same version, and 1503 for 0.5.3 — so
|
||||||
|
# every 0.5.x release built from it was an un-installable downgrade for anyone
|
||||||
|
# already on v0.5.2, which is exactly the failure this block exists to prevent.
|
||||||
|
# The multipliers are widened and the floor raised past 5002 accordingly.
|
||||||
|
#
|
||||||
|
# code = 10000 + major*1000000 + minor*1000 + patch
|
||||||
|
# e.g. 0.0.14 -> 10014, 0.1.0 -> 11000, 0.5.3 -> 15003, 1.0.0 -> 1010000.
|
||||||
|
PROPS="src-tauri/gen/android/app/tauri.properties"
|
||||||
|
if [ -f "$PROPS" ]; then
|
||||||
|
# Strip any -rc1/+build suffix first: it is not numeric, and feeding it to
|
||||||
|
# $(( )) would abort the script under `set -e`.
|
||||||
|
CORE="${VERSION%%-*}"
|
||||||
|
CORE="${CORE%%+*}"
|
||||||
|
MAJ=$(echo "$CORE" | cut -d. -f1)
|
||||||
|
MIN=$(echo "$CORE" | cut -d. -f2)
|
||||||
|
PAT=$(echo "$CORE" | cut -d. -f3)
|
||||||
|
: "${MAJ:=0}" "${MIN:=0}" "${PAT:=0}"
|
||||||
|
CODE=$(( 10000 + MAJ*1000000 + MIN*1000 + PAT ))
|
||||||
|
echo " versionCode=$CODE (from $CORE)"
|
||||||
|
if grep -q '^tauri.android.versionCode=' "$PROPS"; then
|
||||||
|
sed -i "s/^tauri.android.versionCode=.*/tauri.android.versionCode=$CODE/" "$PROPS"
|
||||||
|
else
|
||||||
|
echo "tauri.android.versionCode=$CODE" >> "$PROPS"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Report -----------------------------------------------------------------
|
||||||
|
echo "✅ Version stamped:"
|
||||||
|
grep -m1 '"version"' package.json | sed 's/^/ package.json: /'
|
||||||
|
grep -m1 '"version"' src-tauri/tauri.conf.json | sed 's/^/ tauri.conf.json: /'
|
||||||
|
grep -m1 '^version' src-tauri/Cargo.toml | sed 's/^/ Cargo.toml: /'
|
||||||
|
[ -f "$PROPS" ] && grep '^tauri.android.versionCode=' "$PROPS" | sed 's/^/ tauri.properties: /'
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* Guards for scripts/set-version.sh — the release version stamper.
|
||||||
|
*
|
||||||
|
* TRACES: DR-153 | UT-150
|
||||||
|
*
|
||||||
|
* These run the real script against a throwaway copy of the manifests, because
|
||||||
|
* the failure modes are all in the shell, not in any TS logic: a regex that also
|
||||||
|
* matches a dependency's version, arithmetic that aborts on a `-rc1` suffix, or
|
||||||
|
* a CI ref reaching the validator verbatim.
|
||||||
|
*
|
||||||
|
* The versionCode formula matters most. Android refuses an update whose code is
|
||||||
|
* lower than the installed one, and builds already in the field shipped code
|
||||||
|
* 1000 — so any formula that can emit a smaller number for a *newer* release
|
||||||
|
* bricks updates for those users, silently and irreversibly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it, beforeEach, afterEach } from "vitest";
|
||||||
|
import { execFileSync } from "child_process";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
import * as os from "os";
|
||||||
|
|
||||||
|
const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
|
||||||
|
const script = path.join(repoRoot, "scripts", "set-version.sh");
|
||||||
|
|
||||||
|
let tmp: string;
|
||||||
|
|
||||||
|
/** A minimal repo skeleton: just the files the script rewrites. */
|
||||||
|
function seed(dir: string) {
|
||||||
|
fs.mkdirSync(path.join(dir, "src-tauri", "gen", "android", "app"), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(dir, "scripts"), { recursive: true });
|
||||||
|
fs.copyFileSync(script, path.join(dir, "scripts", "set-version.sh"));
|
||||||
|
fs.chmodSync(path.join(dir, "scripts", "set-version.sh"), 0o755);
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, "package.json"),
|
||||||
|
JSON.stringify({ name: "jellytau", version: "0.0.1", dependencies: { hls: "1.2.3" } }, null, 2)
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, "src-tauri", "tauri.conf.json"),
|
||||||
|
JSON.stringify({ productName: "jellytau", version: "0.0.1" }, null, 2)
|
||||||
|
);
|
||||||
|
// A dependency carrying its own `version =` is the trap: a greedy regex
|
||||||
|
// rewrites it too and the build then resolves the wrong crate.
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, "src-tauri", "Cargo.toml"),
|
||||||
|
['[package]', 'name = "jellytau"', 'version = "0.0.1"', '', '[dependencies]', 'serde = { version = "1.0.100" }', ''].join("\n")
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, "src-tauri", "Cargo.lock"),
|
||||||
|
['[[package]]', 'name = "serde"', 'version = "1.0.100"', '', '[[package]]', 'name = "jellytau"', 'version = "0.0.1"', ''].join("\n")
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, "src-tauri", "gen", "android", "app", "tauri.properties"),
|
||||||
|
"tauri.android.versionCode=1\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(version: string, dir = tmp) {
|
||||||
|
return execFileSync("bash", [path.join(dir, "scripts", "set-version.sh"), version], {
|
||||||
|
cwd: dir,
|
||||||
|
encoding: "utf-8",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function read(rel: string): string {
|
||||||
|
return fs.readFileSync(path.join(tmp, rel), "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function versionCode(): number {
|
||||||
|
const m = read("src-tauri/gen/android/app/tauri.properties").match(
|
||||||
|
/^tauri\.android\.versionCode=(\d+)$/m
|
||||||
|
);
|
||||||
|
return m ? Number(m[1]) : NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "setversion-"));
|
||||||
|
seed(tmp);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tmp, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("set-version.sh", () => {
|
||||||
|
it("stamps the version into all four manifests", () => {
|
||||||
|
run("0.5.0");
|
||||||
|
expect(JSON.parse(read("package.json")).version).toBe("0.5.0");
|
||||||
|
expect(JSON.parse(read("src-tauri/tauri.conf.json")).version).toBe("0.5.0");
|
||||||
|
expect(read("src-tauri/Cargo.toml")).toContain('version = "0.5.0"');
|
||||||
|
expect(read("src-tauri/Cargo.lock")).toMatch(/name = "jellytau"\nversion = "0\.5\.0"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a leading v, as git tags are written", () => {
|
||||||
|
run("v0.5.0");
|
||||||
|
expect(JSON.parse(read("package.json")).version).toBe("0.5.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
// The regression that motivates anchoring the patterns.
|
||||||
|
it("does not rewrite dependency versions", () => {
|
||||||
|
run("0.5.0");
|
||||||
|
expect(read("src-tauri/Cargo.toml")).toContain('serde = { version = "1.0.100" }');
|
||||||
|
expect(read("src-tauri/Cargo.lock")).toMatch(/name = "serde"\nversion = "1\.0\.100"/);
|
||||||
|
expect(JSON.parse(read("package.json")).dependencies.hls).toBe("1.2.3");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Android versionCode", () => {
|
||||||
|
// A newer release must never produce a smaller number than an older one, or
|
||||||
|
// Android refuses the update. The floor tracks the highest code actually in
|
||||||
|
// the field, which is NOT the same as the highest this formula has produced:
|
||||||
|
// v0.5.2 shipped versionCode 5002 from an earlier `minor*1000` scheme, while
|
||||||
|
// the `minor*100` formula that replaced it yields only 1502 for that same
|
||||||
|
// version — so every 0.5.x release built from it was an un-installable
|
||||||
|
// downgrade for anyone already on v0.5.2. The floor is raised to clear it.
|
||||||
|
it("clears the highest code shipped by earlier builds", () => {
|
||||||
|
run("0.0.1");
|
||||||
|
// v0.5.2 shipped 5002; anything at or below that cannot install over it.
|
||||||
|
expect(versionCode()).toBeGreaterThan(5002);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps 0.5.3 installable over the 5002 that shipped as v0.5.2", () => {
|
||||||
|
run("0.5.3");
|
||||||
|
expect(versionCode()).toBeGreaterThan(5002);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses 10000 + major*1000000 + minor*1000 + patch", () => {
|
||||||
|
const cases: Array<[string, number]> = [
|
||||||
|
["0.0.14", 10014],
|
||||||
|
["0.0.15", 10015],
|
||||||
|
["0.1.0", 11000],
|
||||||
|
["0.4.8", 14008],
|
||||||
|
["0.5.0", 15000],
|
||||||
|
["0.5.3", 15003],
|
||||||
|
["1.0.0", 1010000],
|
||||||
|
];
|
||||||
|
for (const [version, code] of cases) {
|
||||||
|
seed(tmp);
|
||||||
|
run(version);
|
||||||
|
expect(versionCode(), `versionCode for ${version}`).toBe(code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increases monotonically across an upgrade sequence", () => {
|
||||||
|
const ordered = ["0.0.14", "0.0.15", "0.1.0", "0.4.8", "0.5.0", "1.0.0"];
|
||||||
|
const codes = ordered.map((v) => {
|
||||||
|
seed(tmp);
|
||||||
|
run(v);
|
||||||
|
return versionCode();
|
||||||
|
});
|
||||||
|
const sorted = [...codes].sort((a, b) => a - b);
|
||||||
|
expect(codes).toEqual(sorted);
|
||||||
|
expect(new Set(codes).size).toBe(codes.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
// `$(( 0-rc1 ))` aborts the script under `set -e`, so the suffix has to be
|
||||||
|
// stripped before the arithmetic.
|
||||||
|
it("derives the code from the numeric core of a prerelease", () => {
|
||||||
|
run("0.6.0-rc1");
|
||||||
|
expect(versionCode()).toBe(16000);
|
||||||
|
expect(JSON.parse(read("package.json")).version).toBe("0.6.0-rc1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("input validation", () => {
|
||||||
|
it("rejects a malformed version without writing anything", () => {
|
||||||
|
expect(() => run("not-a-version")).toThrow();
|
||||||
|
// The manifests must be untouched, not half-written.
|
||||||
|
expect(JSON.parse(read("package.json")).version).toBe("0.0.1");
|
||||||
|
expect(JSON.parse(read("src-tauri/tauri.conf.json")).version).toBe("0.0.1");
|
||||||
|
});
|
||||||
|
|
||||||
|
// CI passes "${GITHUB_REF#refs/tags/}" unconditionally; on a branch build
|
||||||
|
// that is still a full ref, and must not fail the job.
|
||||||
|
it("falls back to a dev version when handed a non-tag ref", () => {
|
||||||
|
const out = run("refs/heads/master");
|
||||||
|
expect(out).not.toMatch(/refs\/heads/);
|
||||||
|
expect(JSON.parse(read("package.json")).version).not.toBe("0.0.1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -87,6 +87,16 @@ if [ -d "$RES_SRC" ]; then
|
|||||||
cp "$RES_SRC"/values/*.xml "$RES_DST/values/"
|
cp "$RES_SRC"/values/*.xml "$RES_DST/values/"
|
||||||
echo " Copied res: values"
|
echo " Copied res: values"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# xml/ (network_security_config.xml): referenced from the manifest, so a
|
||||||
|
# missing copy fails the resource link rather than degrading quietly.
|
||||||
|
# Merged into Tauri's generated xml/ (which holds file_paths.xml) rather
|
||||||
|
# than replacing it.
|
||||||
|
if [ -d "$RES_SRC/xml" ]; then
|
||||||
|
mkdir -p "$RES_DST/xml"
|
||||||
|
cp "$RES_SRC"/xml/*.xml "$RES_DST/xml/"
|
||||||
|
echo " Copied res: xml"
|
||||||
|
fi
|
||||||
# We ship only the color adaptive icon (background + foreground). Drop any
|
# We ship only the color adaptive icon (background + foreground). Drop any
|
||||||
# monochrome layer Tauri may generate: the themed-icon monochrome doesn't
|
# monochrome layer Tauri may generate: the themed-icon monochrome doesn't
|
||||||
# render well, and our adaptive-icon xml no longer references it, so a stray
|
# render well, and our adaptive-icon xml no longer references it, so a stray
|
||||||
@@ -108,4 +118,26 @@ if [ -d "$RES_SRC" ]; then
|
|||||||
"$RES_DST"/drawable*/ic_launcher_background.xml
|
"$RES_DST"/drawable*/ic_launcher_background.xml
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Gradle wrapper distribution. `tauri android init` regenerates the wrapper
|
||||||
|
# pointing at services.gradle.org, so each build downloads ~130MB of Gradle —
|
||||||
|
# slow, and a hard failure when the CDN drops the connection mid-transfer
|
||||||
|
# ("Unexpected end of file from server"), which is what broke the release APK
|
||||||
|
# job. The builder image ships the matching distribution under /opt/gradle/dist,
|
||||||
|
# so when it's present repoint the wrapper at that local zip and build offline.
|
||||||
|
# Outside the image (dev machines) the properties file is left untouched and the
|
||||||
|
# wrapper downloads as usual.
|
||||||
|
WRAPPER_PROPS="$PROJECT_ROOT/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties"
|
||||||
|
if [ -f "$WRAPPER_PROPS" ]; then
|
||||||
|
WANTED_VERSION="$(sed -n 's#.*/gradle-\([0-9.]*\)-\(bin\|all\)\.zip.*#\1#p' "$WRAPPER_PROPS")"
|
||||||
|
LOCAL_DIST="/opt/gradle/dist/gradle-${WANTED_VERSION}-bin.zip"
|
||||||
|
if [ -n "$WANTED_VERSION" ] && [ -f "$LOCAL_DIST" ]; then
|
||||||
|
# distributionUrl is a java.util.Properties value: ':' must stay escaped.
|
||||||
|
sed -i "s#^distributionUrl=.*#distributionUrl=file\\\\:///opt/gradle/dist/gradle-${WANTED_VERSION}-bin.zip#" \
|
||||||
|
"$WRAPPER_PROPS"
|
||||||
|
echo " Gradle wrapper -> local distribution ($WANTED_VERSION, offline)"
|
||||||
|
elif [ -n "$WANTED_VERSION" ]; then
|
||||||
|
echo " Gradle wrapper: $WANTED_VERSION not in image, will download"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
echo "✓ Android sources synced successfully"
|
echo "✓ Android sources synced successfully"
|
||||||
|
|||||||
Generated
+39
-1
@@ -150,6 +150,12 @@ version = "1.0.100"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ascii"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-broadcast"
|
name = "async-broadcast"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@@ -552,6 +558,12 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chunked_transfer"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cipher"
|
name = "cipher"
|
||||||
version = "0.4.4"
|
version = "0.4.4"
|
||||||
@@ -1671,12 +1683,24 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-range"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httparse"
|
name = "httparse"
|
||||||
version = "1.10.1"
|
version = "1.10.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpdate"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.8.1"
|
version = "1.8.1"
|
||||||
@@ -1994,7 +2018,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jellytau"
|
name = "jellytau"
|
||||||
version = "0.3.0"
|
version = "0.5.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -2025,6 +2049,7 @@ dependencies = [
|
|||||||
"tauri-plugin-os",
|
"tauri-plugin-os",
|
||||||
"tauri-specta",
|
"tauri-specta",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
"tiny_http",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rusqlite",
|
"tokio-rusqlite",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
@@ -4192,6 +4217,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"http",
|
"http",
|
||||||
|
"http-range",
|
||||||
"jni",
|
"jni",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
@@ -4571,6 +4597,18 @@ dependencies = [
|
|||||||
"time-core",
|
"time-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tiny_http"
|
||||||
|
version = "0.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
|
||||||
|
dependencies = [
|
||||||
|
"ascii",
|
||||||
|
"chunked_transfer",
|
||||||
|
"httpdate",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "jellytau"
|
name = "jellytau"
|
||||||
version = "0.3.0"
|
version = "0.5.3"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -23,7 +23,12 @@ debug = "line-tables-only"
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
# protocol-asset serves downloaded media and cached thumbnails to the webview
|
||||||
|
# over http://asset.localhost; without it convertFileSrc yields a URL nothing
|
||||||
|
# answers. Paired with app.security.assetProtocol in tauri.conf.json, which
|
||||||
|
# scopes it to $APPDATA/**.
|
||||||
|
# TRACES: UR-071 | DR-134
|
||||||
|
tauri = { version = "2", features = ["protocol-asset"] }
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-os = "2"
|
tauri-plugin-os = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
@@ -54,6 +59,7 @@ env_logger = "0.11"
|
|||||||
tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
|
tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
|
||||||
specta-typescript = "=0.0.9"
|
specta-typescript = "=0.0.9"
|
||||||
specta = { version = "=2.0.0-rc.22", features = ["chrono", "derive"] }
|
specta = { version = "=2.0.0-rc.22", features = ["chrono", "derive"] }
|
||||||
|
tiny_http = { version = "0.12.0", default-features = false }
|
||||||
|
|
||||||
# Linux-specific dependencies
|
# Linux-specific dependencies
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
# breaking the PiP button in release builds only.
|
# breaking the PiP button in release builds only.
|
||||||
-keep class com.dtourolle.jellytau.PictureInPictureManager { *; }
|
-keep class com.dtourolle.jellytau.PictureInPictureManager { *; }
|
||||||
-keep class com.dtourolle.jellytau.VideoOverlayManager { *; }
|
-keep class com.dtourolle.jellytau.VideoOverlayManager { *; }
|
||||||
|
-keep class com.dtourolle.jellytau.WindowInsetsBridge { *; }
|
||||||
-keepclassmembers class * {
|
-keepclassmembers class * {
|
||||||
@android.webkit.JavascriptInterface <methods>;
|
@android.webkit.JavascriptInterface <methods>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:theme="@style/Theme.jellytau"
|
android:theme="@style/Theme.jellytau"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||||
<activity
|
<activity
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|density"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|density"
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.dtourolle.jellytau
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hides and restores the Android system bars for full-screen video.
|
||||||
|
*
|
||||||
|
* TRACES: UR-066 | DR-157
|
||||||
|
*
|
||||||
|
* ## Why the web layer cannot do this
|
||||||
|
*
|
||||||
|
* `document.documentElement.requestFullscreen()` is the only fullscreen control
|
||||||
|
* the frontend has, and inside an Android WebView it does nothing to the
|
||||||
|
* *Activity*: it expands the fullscreen element within the web viewport and
|
||||||
|
* leaves the window exactly as it was. Combined with `enableEdgeToEdge()` — which
|
||||||
|
* MainActivity must call, and which SDK 36 makes non-optional — the WebView
|
||||||
|
* already spans the whole window, so "fullscreen" was a no-op that changed
|
||||||
|
* nothing on screen while the status bar and navigation/gesture bar stayed
|
||||||
|
* painted over the video.
|
||||||
|
*
|
||||||
|
* Hiding them requires `WindowInsetsControllerCompat` on the Activity's window,
|
||||||
|
* which is reachable only from native code. Hence this bridge.
|
||||||
|
*
|
||||||
|
* ## Behaviour
|
||||||
|
*
|
||||||
|
* [enter] hides both bars and selects `BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE`, so
|
||||||
|
* a swipe from either edge brings them back *transiently* — over the video,
|
||||||
|
* auto-hiding again — rather than permanently resizing the window mid-playback.
|
||||||
|
* That is the standard behaviour for immersive video and keeps the system's own
|
||||||
|
* back/home gestures reachable.
|
||||||
|
*
|
||||||
|
* [exit] restores them. It must be called when leaving fullscreen **and** when
|
||||||
|
* the player is torn down, or the bars stay hidden on the library screens behind
|
||||||
|
* it.
|
||||||
|
*
|
||||||
|
* Both must run on the main thread; the callers in MainActivity post them there,
|
||||||
|
* since `@JavascriptInterface` methods arrive on a WebView binder thread.
|
||||||
|
*
|
||||||
|
* Note the `--jt-inset-*` custom properties follow automatically: hiding the bars
|
||||||
|
* fires the decor view's inset listener with zeroes, so [WindowInsetsBridge]
|
||||||
|
* republishes them and the player's control layer stops reserving space it no
|
||||||
|
* longer needs.
|
||||||
|
*/
|
||||||
|
object ImmersiveModeBridge {
|
||||||
|
|
||||||
|
private fun controller(activity: Activity): WindowInsetsControllerCompat =
|
||||||
|
WindowCompat.getInsetsController(activity.window, activity.window.decorView)
|
||||||
|
|
||||||
|
/** Hide the status and navigation bars, swipe-to-reveal transiently. */
|
||||||
|
fun enter(activity: Activity) {
|
||||||
|
controller(activity).apply {
|
||||||
|
systemBarsBehavior =
|
||||||
|
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
|
hide(WindowInsetsCompat.Type.systemBars())
|
||||||
|
}
|
||||||
|
android.util.Log.d("ImmersiveMode", "system bars hidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restore the system bars. Safe to call when they are already showing. */
|
||||||
|
fun exit(activity: Activity) {
|
||||||
|
controller(activity).show(WindowInsetsCompat.Type.systemBars())
|
||||||
|
android.util.Log.d("ImmersiveMode", "system bars restored")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,28 @@ class MainActivity : TauriActivity() {
|
|||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// enableEdgeToEdge() puts the WebView under the status bar, the navigation/
|
||||||
|
// gesture bar and the display cutout — and targeting SDK 36 makes that
|
||||||
|
// non-optional anyway. Android WebView never surfaces the *system bar*
|
||||||
|
// insets to CSS (only the display cutout), so the web layer has to be told.
|
||||||
|
// Without this the bottom nav renders underneath the navigation bar, badly
|
||||||
|
// so on devices with a tall opaque 3-button bar. (UR-066)
|
||||||
|
WindowInsetsBridge.install(this)
|
||||||
|
|
||||||
|
// Hand the player an Activity reference so it can attach its video
|
||||||
|
// SurfaceView to the content view behind the WebView.
|
||||||
|
//
|
||||||
|
// Without this, JellyTauPlayer.currentActivity stays null forever and
|
||||||
|
// autoAttachSurface() logs "Cannot attach surface - no Activity reference"
|
||||||
|
// and returns — so the SurfaceView is created, wired to ExoPlayer, and then
|
||||||
|
// never added to the view hierarchy. Native video decoded to a surface that
|
||||||
|
// was never on screen. setActivity() stores into a companion-object
|
||||||
|
// WeakReference, so calling it here (before Rust initializes the player over
|
||||||
|
// JNI) is safe and is the case it was written for.
|
||||||
|
//
|
||||||
|
// TRACES: UR-003, UR-041 | DR-151
|
||||||
|
com.dtourolle.jellytau.player.JellyTauPlayer.setActivity(this)
|
||||||
|
|
||||||
// Configure WebView for media playback after Tauri initialization
|
// Configure WebView for media playback after Tauri initialization
|
||||||
handler.postDelayed({
|
handler.postDelayed({
|
||||||
configureWebViewForMedia()
|
configureWebViewForMedia()
|
||||||
@@ -179,6 +201,12 @@ class MainActivity : TauriActivity() {
|
|||||||
//
|
//
|
||||||
// The settings/WebChromeClient work below is idempotent and must keep
|
// The settings/WebChromeClient work below is idempotent and must keep
|
||||||
// running on resume; only the bridge injection is one-shot.
|
// running on resume; only the bridge injection is one-shot.
|
||||||
|
|
||||||
|
// Re-push the safe-area insets. Unlike addJavascriptInterface this is
|
||||||
|
// idempotent and MUST re-run: a page load discards the inline style the
|
||||||
|
// last push set, so the WebView would otherwise be left with no insets.
|
||||||
|
WindowInsetsBridge.attachWebView(webView)
|
||||||
|
|
||||||
if (webView === bridgesInstalledOn) {
|
if (webView === bridgesInstalledOn) {
|
||||||
android.util.Log.d("MainActivity", "JS bridges already installed on this WebView - skipping re-injection")
|
android.util.Log.d("MainActivity", "JS bridges already installed on this WebView - skipping re-injection")
|
||||||
configureWebViewSettings(webView)
|
configureWebViewSettings(webView)
|
||||||
@@ -218,6 +246,19 @@ class MainActivity : TauriActivity() {
|
|||||||
fun setAutoEnterEnabled(enabled: Boolean) {
|
fun setAutoEnterEnabled(enabled: Boolean) {
|
||||||
autoEnterPipEnabled = enabled
|
autoEnterPipEnabled = enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report the WebView `<video>` state.
|
||||||
|
*
|
||||||
|
* Without this PiP only ever knew about the native ExoPlayer surface,
|
||||||
|
* which is behind an experimental flag that defaults to off — so in the
|
||||||
|
* shipping configuration nothing ever satisfied canEnterPip and the
|
||||||
|
* button did nothing. (DR-160)
|
||||||
|
*/
|
||||||
|
@JavascriptInterface
|
||||||
|
fun setHtml5VideoState(active: Boolean, width: Int, height: Int, playing: Boolean) {
|
||||||
|
PictureInPictureManager.setHtml5VideoState(active, width, height, playing)
|
||||||
|
}
|
||||||
}, "AndroidPictureInPicture")
|
}, "AndroidPictureInPicture")
|
||||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidPictureInPicture' added")
|
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidPictureInPicture' added")
|
||||||
|
|
||||||
@@ -257,6 +298,73 @@ class MainActivity : TauriActivity() {
|
|||||||
}, "AndroidNetworkType")
|
}, "AndroidNetworkType")
|
||||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidNetworkType' added")
|
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidNetworkType' added")
|
||||||
|
|
||||||
|
// Native video compositing: let the frontend make the WebView transparent
|
||||||
|
// so the ExoPlayer SurfaceView behind it is visible (UR-003, UR-004).
|
||||||
|
//
|
||||||
|
// Toggled rather than set once because a transparent WebView is only
|
||||||
|
// correct while a native video is on screen — every other screen needs its
|
||||||
|
// opaque background, and leaving the window transparent shows the
|
||||||
|
// launcher/wallpaper through the app.
|
||||||
|
//
|
||||||
|
// The CSS in app.css clears the *web* layer's backgrounds; this clears the
|
||||||
|
// WebView widget's own background, which CSS cannot reach. Both are
|
||||||
|
// required — an opaque WebView hides the surface no matter what the page
|
||||||
|
// paints.
|
||||||
|
//
|
||||||
|
// TRACES: UR-003, UR-004 | DR-150
|
||||||
|
webView.addJavascriptInterface(object : Any() {
|
||||||
|
/** Make the WebView background transparent (true) or opaque (false). */
|
||||||
|
@JavascriptInterface
|
||||||
|
fun setTransparent(transparent: Boolean) {
|
||||||
|
handler.post {
|
||||||
|
val color = if (transparent) {
|
||||||
|
android.graphics.Color.TRANSPARENT
|
||||||
|
} else {
|
||||||
|
android.graphics.Color.BLACK
|
||||||
|
}
|
||||||
|
mediaWebView?.setBackgroundColor(color)
|
||||||
|
// The WebView's window/surface must also stop painting opaque, or a
|
||||||
|
// hardware-accelerated WebView still composites its own background.
|
||||||
|
window.setBackgroundDrawable(
|
||||||
|
android.graphics.drawable.ColorDrawable(color)
|
||||||
|
)
|
||||||
|
android.util.Log.d("MainActivity", "WebView transparent = $transparent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether native-video compositing is available on this platform. */
|
||||||
|
@JavascriptInterface
|
||||||
|
fun isSupported(): Boolean = true
|
||||||
|
}, "AndroidVideoSurface")
|
||||||
|
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidVideoSurface' added")
|
||||||
|
|
||||||
|
// Full-screen video: hide the system bars (UR-066). requestFullscreen()
|
||||||
|
// inside a WebView cannot touch the Activity window, so without this the
|
||||||
|
// status and navigation bars stayed painted over full-screen video.
|
||||||
|
webView.addJavascriptInterface(object : Any() {
|
||||||
|
/** Hide the system bars for full-screen playback. */
|
||||||
|
@JavascriptInterface
|
||||||
|
fun enter() {
|
||||||
|
handler.post { ImmersiveModeBridge.enter(this@MainActivity) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restore the system bars on leaving fullscreen or the player. */
|
||||||
|
@JavascriptInterface
|
||||||
|
fun exit() {
|
||||||
|
handler.post { ImmersiveModeBridge.exit(this@MainActivity) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether native immersive mode exists (false on non-Android). */
|
||||||
|
@JavascriptInterface
|
||||||
|
fun isSupported(): Boolean = true
|
||||||
|
}, "AndroidImmersive")
|
||||||
|
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidImmersive' added")
|
||||||
|
|
||||||
|
// Window insets (safe areas). The push path above races the page load, so
|
||||||
|
// the frontend pulls the current values on mount through this bridge.
|
||||||
|
webView.addJavascriptInterface(WindowInsetsBridge.jsInterface(), "AndroidInsets")
|
||||||
|
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidInsets' added")
|
||||||
|
|
||||||
// Push network changes into the WebView so a queue blocked on "waiting for
|
// Push network changes into the WebView so a queue blocked on "waiting for
|
||||||
// WiFi" resumes the moment an acceptable network appears.
|
// WiFi" resumes the moment an acceptable network appears.
|
||||||
NetworkTypeMonitor.startWatching(this) {
|
NetworkTypeMonitor.startWatching(this) {
|
||||||
|
|||||||
+141
-33
@@ -46,6 +46,62 @@ object PictureInPictureManager {
|
|||||||
private var receiver: BroadcastReceiver? = null
|
private var receiver: BroadcastReceiver? = null
|
||||||
private var hiddenWebView: WebView? = null
|
private var hiddenWebView: WebView? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State of an HTML5 `<video>` playing inside the WebView, reported by the
|
||||||
|
* frontend.
|
||||||
|
*
|
||||||
|
* PiP was written for the native ExoPlayer surface only — [canEnterPip]
|
||||||
|
* required a SurfaceView to be attached and rendering. But native video is
|
||||||
|
* behind `experimentalNativeVideo`, which defaults to **off**, so in the
|
||||||
|
* shipping configuration video plays in the WebView's `<video>` element and
|
||||||
|
* every one of those conditions is false. `enterPip` therefore always bailed
|
||||||
|
* with "no local video playing": PiP could not work at all, however the
|
||||||
|
* button was pressed.
|
||||||
|
*
|
||||||
|
* On this path the WebView *is* the video, which inverts two things: the
|
||||||
|
* WebView must stay visible in PiP rather than be hidden, and play/pause has
|
||||||
|
* to reach the element rather than ExoPlayer. Both are handled below.
|
||||||
|
*
|
||||||
|
* TRACES: UR-041 | DR-160
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
private var html5VideoActive = false
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var html5VideoPlaying = false
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var html5AspectRatio: Rational? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report the WebView `<video>` state from the frontend.
|
||||||
|
*
|
||||||
|
* @param active whether a video element is currently the playback surface
|
||||||
|
* @param width intrinsic video width, for the PiP window's aspect ratio
|
||||||
|
* @param height intrinsic video height
|
||||||
|
* @param playing whether it is playing right now, for the PiP play/pause action
|
||||||
|
*/
|
||||||
|
fun setHtml5VideoState(active: Boolean, width: Int, height: Int, playing: Boolean) {
|
||||||
|
html5VideoActive = active
|
||||||
|
html5VideoPlaying = playing
|
||||||
|
html5AspectRatio = if (active && width > 0 && height > 0) {
|
||||||
|
clampedRatio(width.toDouble() / height.toDouble())
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when PiP would be showing the native surface rather than the WebView. */
|
||||||
|
private fun isNativeVideoPath(): Boolean = try {
|
||||||
|
val player = JellyTauPlayer.getInstance()
|
||||||
|
player.isPlayingVideo() &&
|
||||||
|
player.getSurfaceView() != null &&
|
||||||
|
VideoOverlayManager.isVideoSurfaceAttached()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w(TAG, "native video path check failed", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether this device/OS can do PiP at all. Android 8.0 introduced the API,
|
* Whether this device/OS can do PiP at all. Android 8.0 introduced the API,
|
||||||
* and the user (or device manufacturer) can disable the feature per-app.
|
* and the user (or device manufacturer) can disable the feature per-app.
|
||||||
@@ -64,15 +120,10 @@ object PictureInPictureManager {
|
|||||||
*/
|
*/
|
||||||
fun canEnterPip(activity: Activity): Boolean {
|
fun canEnterPip(activity: Activity): Boolean {
|
||||||
if (!isPipSupported(activity)) return false
|
if (!isPipSupported(activity)) return false
|
||||||
return try {
|
// Either surface will do: the native one, or the WebView's `<video>`,
|
||||||
val player = JellyTauPlayer.getInstance()
|
// which is what actually plays while experimentalNativeVideo is off.
|
||||||
player.isPlayingVideo() &&
|
// (DR-160)
|
||||||
player.getSurfaceView() != null &&
|
return isNativeVideoPath() || html5VideoActive
|
||||||
VideoOverlayManager.isVideoSurfaceAttached()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
android.util.Log.w(TAG, "canEnterPip check failed", e)
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -125,32 +176,47 @@ object PictureInPictureManager {
|
|||||||
val player = try {
|
val player = try {
|
||||||
JellyTauPlayer.getInstance()
|
JellyTauPlayer.getInstance()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
return null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
val surface = player.getSurfaceView() ?: return null
|
|
||||||
// The surface has already been letterboxed to the video's aspect ratio
|
// The surface has already been letterboxed to the video's aspect ratio
|
||||||
// by fitSurfaceToScreen(), so its measured bounds are the video shape.
|
// by fitSurfaceToScreen(), so its measured bounds are the video shape.
|
||||||
val width = surface.width
|
val surface = player?.getSurfaceView()
|
||||||
val height = surface.height
|
if (surface != null && surface.width > 0 && surface.height > 0) {
|
||||||
if (width <= 0 || height <= 0) return null
|
return clampedRatio(surface.width.toDouble() / surface.height.toDouble())
|
||||||
|
}
|
||||||
|
|
||||||
val ratio = width.toDouble() / height.toDouble()
|
// No native surface: the WebView is the video, so use the intrinsic size
|
||||||
val minRatio = 1.0 / 2.39
|
// the frontend reported. (DR-160)
|
||||||
val maxRatio = 2.39
|
return html5AspectRatio
|
||||||
val clamped = ratio.coerceIn(minRatio, maxRatio)
|
}
|
||||||
|
|
||||||
// Scale to integers; Rational(width, height) directly can overflow for
|
/**
|
||||||
// large surfaces, and the clamped value may not match the raw pixels.
|
* Clamp a ratio to the range Android accepts and express it as a [Rational].
|
||||||
|
*
|
||||||
|
* The platform rejects ratios outside roughly 1:2.39 - 2.39:1 with an
|
||||||
|
* IllegalArgumentException, which would otherwise take down the Activity on
|
||||||
|
* unusually tall or wide content. Scaled to integers because
|
||||||
|
* `Rational(width, height)` can overflow for large surfaces, and the clamped
|
||||||
|
* value may not match the raw pixels anyway.
|
||||||
|
*/
|
||||||
|
private fun clampedRatio(ratio: Double): Rational {
|
||||||
|
val clamped = ratio.coerceIn(1.0 / 2.39, 2.39)
|
||||||
return Rational((clamped * 1000).toInt(), 1000)
|
return Rational((clamped * 1000).toInt(), 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
private fun buildPlayPauseAction(activity: Activity): RemoteAction {
|
private fun buildPlayPauseAction(activity: Activity): RemoteAction {
|
||||||
val isPlaying = try {
|
// On the HTML5 path ExoPlayer is idle, so its `isPlaying` is always false
|
||||||
JellyTauPlayer.getInstance().getExoPlayer().isPlaying
|
// and the button would be stuck showing "Play" mid-playback. (DR-160)
|
||||||
} catch (e: Exception) {
|
val isPlaying = if (isNativeVideoPath()) {
|
||||||
false
|
try {
|
||||||
|
JellyTauPlayer.getInstance().getExoPlayer().isPlaying
|
||||||
|
} catch (e: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html5VideoPlaying
|
||||||
}
|
}
|
||||||
|
|
||||||
val (iconRes, title, controlType, requestCode) = if (isPlaying) {
|
val (iconRes, title, controlType, requestCode) = if (isPlaying) {
|
||||||
@@ -222,11 +288,20 @@ object PictureInPictureManager {
|
|||||||
*/
|
*/
|
||||||
fun onPipModeChanged(activity: Activity, isInPipMode: Boolean) {
|
fun onPipModeChanged(activity: Activity, isInPipMode: Boolean) {
|
||||||
if (isInPipMode) {
|
if (isInPipMode) {
|
||||||
hideWebView(activity)
|
// Hiding the WebView is correct only when the video is *behind* it on
|
||||||
|
// the native surface. On the HTML5 path the WebView is the video, so
|
||||||
|
// hiding it would leave an empty black PiP window — the frontend
|
||||||
|
// instead strips its own chrome when it hears the event below.
|
||||||
|
// (DR-160)
|
||||||
|
if (isNativeVideoPath()) {
|
||||||
|
hideWebView(activity)
|
||||||
|
}
|
||||||
registerReceiver(activity)
|
registerReceiver(activity)
|
||||||
|
dispatchWebEvent(activity, "jellytau-pip-entered")
|
||||||
} else {
|
} else {
|
||||||
unregisterReceiver(activity)
|
unregisterReceiver(activity)
|
||||||
showWebView()
|
showWebView()
|
||||||
|
dispatchWebEvent(activity, "jellytau-pip-exited")
|
||||||
// The surface was laid out against the tiny PiP bounds; re-fit it to
|
// The surface was laid out against the tiny PiP bounds; re-fit it to
|
||||||
// the restored full-screen bounds or the video stays postage-stamp sized.
|
// the restored full-screen bounds or the video stays postage-stamp sized.
|
||||||
try {
|
try {
|
||||||
@@ -237,6 +312,23 @@ object PictureInPictureManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire a DOM event into the WebView.
|
||||||
|
*
|
||||||
|
* The HTML5 PiP path is a conversation with the frontend rather than
|
||||||
|
* something native can do alone: it has to be told to strip its chrome when
|
||||||
|
* the window shrinks, and to play/pause the element. (DR-160)
|
||||||
|
*/
|
||||||
|
private fun dispatchWebEvent(activity: Activity, name: String) {
|
||||||
|
val webView = findWebView(activity.window.decorView) ?: return
|
||||||
|
webView.post {
|
||||||
|
webView.evaluateJavascript(
|
||||||
|
"window.dispatchEvent(new CustomEvent('$name'));",
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun hideWebView(activity: Activity) {
|
private fun hideWebView(activity: Activity) {
|
||||||
val webView = findWebView(activity.window.decorView)
|
val webView = findWebView(activity.window.decorView)
|
||||||
if (webView == null) {
|
if (webView == null) {
|
||||||
@@ -264,14 +356,30 @@ object PictureInPictureManager {
|
|||||||
val r = object : BroadcastReceiver() {
|
val r = object : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
if (intent?.action != ACTION_MEDIA_CONTROL) return
|
if (intent?.action != ACTION_MEDIA_CONTROL) return
|
||||||
val player = try {
|
val control = intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)
|
||||||
JellyTauPlayer.getInstance()
|
|
||||||
} catch (e: Exception) {
|
if (isNativeVideoPath()) {
|
||||||
return
|
val player = try {
|
||||||
}
|
JellyTauPlayer.getInstance()
|
||||||
when (intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)) {
|
} catch (e: Exception) {
|
||||||
CONTROL_PLAY -> player.play()
|
return
|
||||||
CONTROL_PAUSE -> player.pause()
|
}
|
||||||
|
when (control) {
|
||||||
|
CONTROL_PLAY -> player.play()
|
||||||
|
CONTROL_PAUSE -> player.pause()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// The WebView owns playback here, so the command has to reach
|
||||||
|
// the `<video>` element. Driving ExoPlayer instead would do
|
||||||
|
// nothing at all, which is what a PiP button on the HTML5 path
|
||||||
|
// used to do. (DR-160)
|
||||||
|
val name = when (control) {
|
||||||
|
CONTROL_PLAY -> "jellytau-pip-play"
|
||||||
|
CONTROL_PAUSE -> "jellytau-pip-pause"
|
||||||
|
else -> return
|
||||||
|
}
|
||||||
|
dispatchWebEvent(activity, name)
|
||||||
|
html5VideoPlaying = control == CONTROL_PLAY
|
||||||
}
|
}
|
||||||
// Swap the button to reflect the new state.
|
// Swap the button to reflect the new state.
|
||||||
updatePipActions(activity)
|
updatePipActions(activity)
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package com.dtourolle.jellytau
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
|
import android.webkit.WebView
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishes the Activity's real window insets to the WebView as CSS custom
|
||||||
|
* properties.
|
||||||
|
*
|
||||||
|
* TRACES: UR-066 | IR-031, DR-112
|
||||||
|
*
|
||||||
|
* ## Why this is necessary
|
||||||
|
*
|
||||||
|
* MainActivity calls `enableEdgeToEdge()`, and the app targets SDK 36 —
|
||||||
|
* edge-to-edge is mandatory from SDK 35 and the opt-out is ignored from SDK 36
|
||||||
|
* — so the Tauri WebView always spans the whole window, underneath the status
|
||||||
|
* bar, the navigation/gesture bar and the display cutout.
|
||||||
|
*
|
||||||
|
* The web layer cannot discover that by itself. Android WebView maps only the
|
||||||
|
* **display cutout** into `env(safe-area-inset-*)` (and only with
|
||||||
|
* `viewport-fit=cover`); the status bar and navigation bar are never reported.
|
||||||
|
* Unlike iOS Safari there is no CSS-visible system-bar inset. So the frontend's
|
||||||
|
* `env()`-based padding evaluated to 0 on every device and the bottom nav
|
||||||
|
* rendered underneath the navigation bar.
|
||||||
|
*
|
||||||
|
* How badly that showed depended entirely on the device's navigation mode: a
|
||||||
|
* thin translucent gesture pill overlaps almost harmlessly, while a tall opaque
|
||||||
|
* 3-button bar swallows the nav outright.
|
||||||
|
*
|
||||||
|
* ## Contract with the frontend
|
||||||
|
*
|
||||||
|
* Insets are reported in **CSS pixels** (density-independent), because that is
|
||||||
|
* the unit CSS will use them in — dividing by `displayMetrics.density` here is
|
||||||
|
* what keeps the padding correct across screen densities.
|
||||||
|
*
|
||||||
|
* - **Push**: on every inset change (rotation, navigation-mode switch, PiP
|
||||||
|
* enter/exit) the four `--jt-inset-*` custom properties are written onto
|
||||||
|
* `document.documentElement` and `jellytau-insets-changed` is dispatched.
|
||||||
|
* - **Pull**: `AndroidInsets.get()` returns the same payload as JSON. Required
|
||||||
|
* because the first inset pass normally lands before the SvelteKit document
|
||||||
|
* exists, and a page load discards any inline style a push had set.
|
||||||
|
*
|
||||||
|
* See `src/lib/utils/safeArea.ts` and the `--safe-*` vars in `src/app.css`.
|
||||||
|
*/
|
||||||
|
object WindowInsetsBridge {
|
||||||
|
|
||||||
|
/** Latest insets in CSS pixels. Written on the main thread, read from the WebView binder thread. */
|
||||||
|
@Volatile
|
||||||
|
private var top = 0
|
||||||
|
@Volatile
|
||||||
|
private var right = 0
|
||||||
|
@Volatile
|
||||||
|
private var bottom = 0
|
||||||
|
@Volatile
|
||||||
|
private var left = 0
|
||||||
|
|
||||||
|
/** Cached so a WebView found later (or re-found on resume) can be primed. */
|
||||||
|
private var webView: WebView? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start listening for window insets on [activity].
|
||||||
|
*
|
||||||
|
* Call from `onCreate` right after `enableEdgeToEdge()`. The listener
|
||||||
|
* returns the insets **unconsumed** so the WebView still receives them for
|
||||||
|
* its own display-cutout handling.
|
||||||
|
*/
|
||||||
|
fun install(activity: Activity) {
|
||||||
|
val density = activity.resources.displayMetrics.density
|
||||||
|
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(activity.window.decorView) { _, insets ->
|
||||||
|
// systemBars() covers the status bar and the navigation/gesture bar;
|
||||||
|
// displayCutout() covers notches and punch-holes, which in landscape
|
||||||
|
// land on a side edge that systemBars() does not describe.
|
||||||
|
val i = insets.getInsets(
|
||||||
|
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()
|
||||||
|
)
|
||||||
|
|
||||||
|
val toCssPx = { px: Int -> if (density > 0f) Math.round(px / density) else px }
|
||||||
|
top = toCssPx(i.top)
|
||||||
|
right = toCssPx(i.right)
|
||||||
|
bottom = toCssPx(i.bottom)
|
||||||
|
left = toCssPx(i.left)
|
||||||
|
|
||||||
|
android.util.Log.d(
|
||||||
|
"WindowInsetsBridge",
|
||||||
|
"insets (css px): top=$top right=$right bottom=$bottom left=$left"
|
||||||
|
)
|
||||||
|
push()
|
||||||
|
|
||||||
|
// Do NOT return CONSUMED - other views (and the WebView's own cutout
|
||||||
|
// handling) still need to see these.
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first pass may already have happened before the listener existed.
|
||||||
|
ViewCompat.requestApplyInsets(activity.window.decorView)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adopt the WebView carrying the UI and push the current insets into it.
|
||||||
|
*
|
||||||
|
* Safe to call repeatedly (MainActivity re-finds the WebView on every
|
||||||
|
* resume): this only writes CSS properties, unlike `addJavascriptInterface`,
|
||||||
|
* which must run exactly once per WebView.
|
||||||
|
*/
|
||||||
|
fun attachWebView(view: WebView) {
|
||||||
|
webView = view
|
||||||
|
push()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current insets as JSON in CSS pixels — the payload `AndroidInsets.get()` returns. */
|
||||||
|
fun currentJson(): String =
|
||||||
|
"""{"top":$top,"right":$right,"bottom":$bottom,"left":$left}"""
|
||||||
|
|
||||||
|
/** The `AndroidInsets` @JavascriptInterface object for the pull path. */
|
||||||
|
fun jsInterface(): Any = object : Any() {
|
||||||
|
@JavascriptInterface
|
||||||
|
fun get(): String = currentJson()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write the custom properties into the live document and signal the change. */
|
||||||
|
private fun push() {
|
||||||
|
val view = webView ?: return
|
||||||
|
val js = """
|
||||||
|
(function() {
|
||||||
|
var s = document.documentElement.style;
|
||||||
|
s.setProperty('--jt-inset-top', '${top}px');
|
||||||
|
s.setProperty('--jt-inset-right', '${right}px');
|
||||||
|
s.setProperty('--jt-inset-bottom', '${bottom}px');
|
||||||
|
s.setProperty('--jt-inset-left', '${left}px');
|
||||||
|
window.dispatchEvent(new CustomEvent('jellytau-insets-changed'));
|
||||||
|
})();
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
view.post { view.evaluateJavascript(js, null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
package com.dtourolle.jellytau.player
|
package com.dtourolle.jellytau.player
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.media.MediaCodecList
|
import android.media.MediaCodecList
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.media3.common.AudioAttributes
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.audio.AudioCapabilities
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detects hardware codec capabilities using MediaCodecList.
|
* Detects hardware codec capabilities using MediaCodecList.
|
||||||
@@ -75,6 +79,36 @@ object CodecDetector {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report how many channels the *current audio output route* can voice.
|
||||||
|
*
|
||||||
|
* This is a different question from "can this device decode 5.1", which the
|
||||||
|
* codec list already answers: a phone decodes a 5.1 AC-3 track happily and
|
||||||
|
* still has only two channels to play it out of. Left unreported, Jellyfin
|
||||||
|
* is free to direct-play the multichannel track, and what the user hears is
|
||||||
|
* device dependent — a failed AudioSink configuration (silence) or dialogue
|
||||||
|
* folded into the surround channels and lost.
|
||||||
|
*
|
||||||
|
* Returns 0 when there is no answer; Rust reads that as "unknown" and falls
|
||||||
|
* back to stereo rather than claiming a capability we have not observed.
|
||||||
|
*/
|
||||||
|
@UnstableApi
|
||||||
|
fun detectMaxAudioChannels(context: Context): Int {
|
||||||
|
return try {
|
||||||
|
val capabilities = AudioCapabilities.getCapabilities(
|
||||||
|
context,
|
||||||
|
AudioAttributes.DEFAULT,
|
||||||
|
/* routedDevice= */ null
|
||||||
|
)
|
||||||
|
val channels = capabilities.maxChannelCount
|
||||||
|
Log.i(TAG, "Audio route max channel count: $channels")
|
||||||
|
channels.coerceAtLeast(0)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error querying audio capabilities", e)
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map Android MIME types to Jellyfin codec names.
|
* Map Android MIME types to Jellyfin codec names.
|
||||||
*
|
*
|
||||||
|
|||||||
+77
-19
@@ -119,6 +119,54 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
nativeOnMediaCommand("seek:$positionSeconds")
|
nativeOnMediaCommand("seek:$positionSeconds")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// media3 seeks by more routes than seekTo(long), and the ones below
|
||||||
|
// reach the *real* ExoPlayer if they are not overridden — bypassing
|
||||||
|
// Rust entirely and operating on the handoff stream's relative
|
||||||
|
// timeline. That is the same mechanism as the truncation bug, reached
|
||||||
|
// by a different door.
|
||||||
|
//
|
||||||
|
// seekToDefaultPosition is deliberately swallowed rather than
|
||||||
|
// forwarded. Util.handlePlayButtonAction calls it on an ended or idle
|
||||||
|
// player and then calls play(); on a handoff stream the seek lands at
|
||||||
|
// stream zero — the point the screen was locked at — which is exactly
|
||||||
|
// the reported jump-back. Sending "seek:0.0" instead would be worse
|
||||||
|
// still, restarting the whole episode. Rust already owns what "play
|
||||||
|
// after the stream ended" means (truncation recovery, or advancing to
|
||||||
|
// the next episode), and the play() that follows reaches it, so the
|
||||||
|
// right move here is to not move at all.
|
||||||
|
//
|
||||||
|
// TRACES: UR-040, UR-005 | DR-159
|
||||||
|
override fun seekToDefaultPosition() {
|
||||||
|
android.util.Log.d(
|
||||||
|
"JellyTauPlaybackService",
|
||||||
|
"Ignoring seekToDefaultPosition — Rust owns end-of-stream handling"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun seekToDefaultPosition(mediaItemIndex: Int) {
|
||||||
|
android.util.Log.d(
|
||||||
|
"JellyTauPlaybackService",
|
||||||
|
"Ignoring seekToDefaultPosition(index) — Rust owns end-of-stream handling"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// `currentPosition` is ExoPlayer's own, so it is relative during a
|
||||||
|
// handoff; the base makes the target absolute, which is what Rust
|
||||||
|
// expects from every command on this boundary.
|
||||||
|
override fun seekBack() {
|
||||||
|
val target =
|
||||||
|
((currentPosition + handoffBaseMs - seekBackIncrement) / 1000.0)
|
||||||
|
.coerceAtLeast(0.0)
|
||||||
|
nativeOnMediaCommand("seek:$target")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun seekForward() {
|
||||||
|
val target =
|
||||||
|
((currentPosition + handoffBaseMs + seekForwardIncrement) / 1000.0)
|
||||||
|
.coerceAtLeast(0.0)
|
||||||
|
nativeOnMediaCommand("seek:$target")
|
||||||
|
}
|
||||||
|
|
||||||
override fun stop() {
|
override fun stop() {
|
||||||
nativeOnMediaCommand("stop")
|
nativeOnMediaCommand("stop")
|
||||||
}
|
}
|
||||||
@@ -262,23 +310,32 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
private var lastArtist: String = ""
|
private var lastArtist: String = ""
|
||||||
private var lastIsPlaying: Boolean = false
|
private var lastIsPlaying: Boolean = false
|
||||||
|
|
||||||
// Base offset (ms) added to every position reported to the lockscreen
|
// The handoff base (ms): during a background-audio handoff the audio stream is
|
||||||
// MediaSession. During a background-audio handoff the audio stream is
|
// requested with StartTimeTicks = the handoff point, so ExoPlayer's timeline
|
||||||
// requested with StartTimeTicks = the handoff point, so ExoPlayer reports
|
// starts at 0 *there* and every position it reports is relative to it. This
|
||||||
// position RELATIVE to that point (starting at 0). The metadata duration,
|
// is the number that converts one back to a real position on the episode.
|
||||||
// however, is the full absolute length — so without this base the scrubber
|
//
|
||||||
// thumb sits near 0:00 on a full-length bar. Set from the known handoff
|
// It is deliberately read, not applied, here. This used to be a display-only
|
||||||
// position via setPositionOffset(); 0 for normal playback.
|
// correction added at the two setPlaybackState calls below, which left every
|
||||||
private var positionOffsetMs: Long = 0L
|
// other consumer — progress reporting to Jellyfin, the frontend, media3's own
|
||||||
|
// seeks — working in the relative timeline while treating it as absolute, each
|
||||||
|
// crossing silently losing exactly `base` seconds. The conversion now happens
|
||||||
|
// once, in JellyTauPlayer's position tick, so everything downstream of it
|
||||||
|
// speaks the episode's timeline; applying it again here would double-count.
|
||||||
|
//
|
||||||
|
// TRACES: UR-040 | DR-159
|
||||||
|
@Volatile
|
||||||
|
var handoffBaseMs: Long = 0L
|
||||||
|
private set
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the base position offset (seconds) applied to lockscreen positions.
|
* Set the handoff base (seconds). Called by the native layer when entering or
|
||||||
* Called by the native layer when entering/exiting a background-audio handoff.
|
* leaving a background-audio handoff; 0 clears it for normal playback, where
|
||||||
* Pass 0 to clear (normal playback, where ExoPlayer's position is absolute).
|
* ExoPlayer's position is already absolute.
|
||||||
*/
|
*/
|
||||||
fun setPositionOffset(offsetSeconds: Double) {
|
fun setHandoffBase(offsetSeconds: Double) {
|
||||||
positionOffsetMs = (offsetSeconds * 1000.0).toLong().coerceAtLeast(0L)
|
handoffBaseMs = (offsetSeconds * 1000.0).toLong().coerceAtLeast(0L)
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Position offset set to ${positionOffsetMs}ms")
|
android.util.Log.d("JellyTauPlaybackService", "Handoff base set to ${handoffBaseMs}ms")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -314,8 +371,9 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
|
|
||||||
session.setMetadata(metadataBuilder.build())
|
session.setMetadata(metadataBuilder.build())
|
||||||
|
|
||||||
// Update MediaSession playback state (position made absolute via the base offset).
|
// Already absolute: this call comes from Rust, whose stored position is on
|
||||||
session.setPlaybackState(buildPlaybackState(isPlaying, position + positionOffsetMs))
|
// the episode's timeline. (DR-159)
|
||||||
|
session.setPlaybackState(buildPlaybackState(isPlaying, position))
|
||||||
|
|
||||||
// While casting, re-assert the remote volume provider. Metadata pushes
|
// While casting, re-assert the remote volume provider. Metadata pushes
|
||||||
// arrive on the session poller thread and can race with (or arrive
|
// arrive on the session poller thread and can race with (or arrive
|
||||||
@@ -337,15 +395,15 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
* notification. Without this, the lockscreen scrubber freezes at the position
|
* notification. Without this, the lockscreen scrubber freezes at the position
|
||||||
* from the last play/pause and drifts out of sync with actual playback.
|
* from the last play/pause and drifts out of sync with actual playback.
|
||||||
*
|
*
|
||||||
* @param position Position in milliseconds
|
* @param position Absolute position in milliseconds, on the item's own
|
||||||
|
* timeline — the caller has already applied [handoffBaseMs].
|
||||||
* @param isPlaying Whether playback is currently active
|
* @param isPlaying Whether playback is currently active
|
||||||
*/
|
*/
|
||||||
fun updatePlaybackPosition(position: Long, isPlaying: Boolean) {
|
fun updatePlaybackPosition(position: Long, isPlaying: Boolean) {
|
||||||
val session = mediaSessionCompat ?: return
|
val session = mediaSessionCompat ?: return
|
||||||
val notificationStateChanged = isPlaying != lastIsPlaying
|
val notificationStateChanged = isPlaying != lastIsPlaying
|
||||||
lastIsPlaying = isPlaying
|
lastIsPlaying = isPlaying
|
||||||
// Absolute position for the scrubber = relative ExoPlayer position + base offset.
|
session.setPlaybackState(buildPlaybackState(isPlaying, position))
|
||||||
session.setPlaybackState(buildPlaybackState(isPlaying, position + positionOffsetMs))
|
|
||||||
// Only rebuild the notification when the play/pause icon actually flips.
|
// Only rebuild the notification when the play/pause icon actually flips.
|
||||||
if (notificationStateChanged) {
|
if (notificationStateChanged) {
|
||||||
updateNotification(lastTitle, lastArtist, isPlaying)
|
updateNotification(lastTitle, lastArtist, isPlaying)
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Detect and report hardware codec capabilities to Rust
|
// Detect and report hardware codec capabilities to Rust
|
||||||
detectAndReportCodecs()
|
detectAndReportCodecs(context.applicationContext)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,23 +141,31 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
* Called during player initialization.
|
* Called during player initialization.
|
||||||
*/
|
*/
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun detectAndReportCodecs() {
|
fun detectAndReportCodecs(context: Context) {
|
||||||
val capabilities = CodecDetector.detectHardwareCodecs()
|
val capabilities = CodecDetector.detectHardwareCodecs()
|
||||||
|
|
||||||
// Convert lists to comma-separated strings for JNI transfer
|
// Convert lists to comma-separated strings for JNI transfer
|
||||||
val videoCodecsStr = capabilities.videoCodecs.joinToString(",")
|
val videoCodecsStr = capabilities.videoCodecs.joinToString(",")
|
||||||
val audioCodecsStr = capabilities.audioCodecs.joinToString(",")
|
val audioCodecsStr = capabilities.audioCodecs.joinToString(",")
|
||||||
|
|
||||||
android.util.Log.i("JellyTauPlayer", "Reporting codecs to Rust: video=$videoCodecsStr, audio=$audioCodecsStr")
|
// What the route can *voice*, which the codec list does not answer.
|
||||||
|
// 0 means "no answer"; Rust falls back to stereo.
|
||||||
|
val maxAudioChannels = CodecDetector.detectMaxAudioChannels(context)
|
||||||
|
|
||||||
|
android.util.Log.i("JellyTauPlayer", "Reporting codecs to Rust: video=$videoCodecsStr, audio=$audioCodecsStr, maxAudioChannels=$maxAudioChannels")
|
||||||
|
|
||||||
// Call native method to store in Rust
|
// Call native method to store in Rust
|
||||||
nativeOnCodecsDetected(videoCodecsStr, audioCodecsStr)
|
nativeOnCodecsDetected(videoCodecsStr, audioCodecsStr, maxAudioChannels)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Native method to report detected codecs to Rust.
|
* Native method to report detected codecs to Rust.
|
||||||
*/
|
*/
|
||||||
private external fun nativeOnCodecsDetected(videoCodecs: String, audioCodecs: String)
|
private external fun nativeOnCodecsDetected(
|
||||||
|
videoCodecs: String,
|
||||||
|
audioCodecs: String,
|
||||||
|
maxAudioChannels: Int
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the player is initialized.
|
* Check if the player is initialized.
|
||||||
@@ -232,6 +240,20 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
}
|
}
|
||||||
private var audioFocusRequest: AudioFocusRequest? = null
|
private var audioFocusRequest: AudioFocusRequest? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set when a video was loaded but audio focus was not granted outright.
|
||||||
|
*
|
||||||
|
* `setAcceptsDelayedFocusGain(true)` means the system may answer DELAYED and
|
||||||
|
* hand us focus later; until then it withholds our audio. Starting playback
|
||||||
|
* anyway plays the video silently, which is exactly the "video has no sound"
|
||||||
|
* symptom. We hold playback and start it from the AUDIOFOCUS_GAIN callback.
|
||||||
|
*/
|
||||||
|
private var pendingPlayOnFocusGain = false
|
||||||
|
|
||||||
|
/** Whether we currently hold audio focus, so `play()` does not re-request
|
||||||
|
* (and leak) a focus request we already own. */
|
||||||
|
private var hasAudioFocus = false
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Configure audio attributes for music playback with audio focus handling
|
// Configure audio attributes for music playback with audio focus handling
|
||||||
val audioAttributes = AudioAttributes.Builder()
|
val audioAttributes = AudioAttributes.Builder()
|
||||||
@@ -361,29 +383,61 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
android.util.Log.d("JellyTauPlayer", " Video group: ${group.length} tracks, selected=${group.isSelected}")
|
android.util.Log.d("JellyTauPlayer", " Video group: ${group.length} tracks, selected=${group.isSelected}")
|
||||||
}
|
}
|
||||||
|
|
||||||
// CRITICAL FIX: Auto-select first audio track if none is selected
|
// TRACES: UR-004 | DR-146
|
||||||
// This fixes the issue where some videos play without audio
|
// Auto-select an audio track if none is selected — some videos
|
||||||
|
// otherwise play with no sound. Pick a track the renderer says it
|
||||||
|
// *supports*: when nothing was selected because track 0 failed to
|
||||||
|
// initialize, forcing track 0 again just reinstates the silence.
|
||||||
if (!hasSelectedAudio && audioTracks.isNotEmpty() && currentMediaType == MediaType.VIDEO) {
|
if (!hasSelectedAudio && audioTracks.isNotEmpty() && currentMediaType == MediaType.VIDEO) {
|
||||||
android.util.Log.w("JellyTauPlayer", "⚠️ NO AUDIO TRACK SELECTED! Auto-selecting first audio track...")
|
android.util.Log.w("JellyTauPlayer", "⚠️ NO AUDIO TRACK SELECTED! Looking for a supported audio track...")
|
||||||
|
|
||||||
val trackSelector = exoPlayer.trackSelector
|
val trackSelector = exoPlayer.trackSelector
|
||||||
if (trackSelector != null) {
|
if (trackSelector != null) {
|
||||||
try {
|
try {
|
||||||
// Select the first audio track group
|
var chosenGroup: androidx.media3.common.Tracks.Group? = null
|
||||||
val firstAudioGroup = audioTracks[0]
|
var chosenIndex = -1
|
||||||
val override = androidx.media3.common.TrackSelectionOverride(
|
outer@ for (group in audioTracks) {
|
||||||
firstAudioGroup.mediaTrackGroup,
|
for (i in 0 until group.length) {
|
||||||
0 // Select the first track in this group
|
if (group.isTrackSupported(i)) {
|
||||||
)
|
chosenGroup = group
|
||||||
|
chosenIndex = i
|
||||||
|
break@outer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val parameters = trackSelector.parameters
|
if (chosenGroup == null) {
|
||||||
.buildUpon()
|
// Every track is undecodable on this device. The
|
||||||
.clearOverridesOfType(C.TRACK_TYPE_AUDIO)
|
// server should have transcoded; say so loudly
|
||||||
.addOverride(override)
|
// rather than leaving a silent video unexplained.
|
||||||
.build()
|
android.util.Log.e(
|
||||||
|
"JellyTauPlayer",
|
||||||
|
"✗ No supported audio track in ${audioTracks.size} group(s) - " +
|
||||||
|
"device cannot decode any of them, expected a transcode"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val format = chosenGroup.getTrackFormat(chosenIndex)
|
||||||
|
val override = androidx.media3.common.TrackSelectionOverride(
|
||||||
|
chosenGroup.mediaTrackGroup,
|
||||||
|
chosenIndex
|
||||||
|
)
|
||||||
|
|
||||||
trackSelector.setParameters(parameters)
|
val parameters = trackSelector.parameters
|
||||||
android.util.Log.d("JellyTauPlayer", "✓ Auto-selected first audio track")
|
.buildUpon()
|
||||||
|
// Audio may also be off because the track type
|
||||||
|
// was disabled; an override alone would not
|
||||||
|
// bring it back.
|
||||||
|
.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false)
|
||||||
|
.clearOverridesOfType(C.TRACK_TYPE_AUDIO)
|
||||||
|
.addOverride(override)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
trackSelector.setParameters(parameters)
|
||||||
|
android.util.Log.d(
|
||||||
|
"JellyTauPlayer",
|
||||||
|
"✓ Auto-selected supported audio track $chosenIndex (${format.sampleMimeType}, ${format.channelCount}ch)"
|
||||||
|
)
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.e("JellyTauPlayer", "Failed to auto-select audio track", e)
|
android.util.Log.e("JellyTauPlayer", "Failed to auto-select audio track", e)
|
||||||
}
|
}
|
||||||
@@ -423,6 +477,15 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
*/
|
*/
|
||||||
fun play() {
|
fun play() {
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
|
// Video manages focus by hand, so an explicit play after a refusal (or
|
||||||
|
// after a LOSS paused us) has to ask again — otherwise it resumes into
|
||||||
|
// a stream the system is still muting.
|
||||||
|
if (currentMediaType == MediaType.VIDEO && !hasAudioFocus && !requestAudioFocus()) {
|
||||||
|
pendingPlayOnFocusGain = true
|
||||||
|
android.util.Log.d("JellyTauPlayer", "play() without audio focus - holding until GAIN")
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
pendingPlayOnFocusGain = false
|
||||||
exoPlayer.play()
|
exoPlayer.play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -811,14 +874,17 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
android.util.Log.d("JellyTauPlayer", "ExoPlayer audio session ID: ${exoPlayer.audioSessionId}")
|
android.util.Log.d("JellyTauPlayer", "ExoPlayer audio session ID: ${exoPlayer.audioSessionId}")
|
||||||
|
|
||||||
// Setup video surface if needed
|
// Setup video surface if needed
|
||||||
|
var focusGranted = true
|
||||||
if (currentMediaType == MediaType.VIDEO) {
|
if (currentMediaType == MediaType.VIDEO) {
|
||||||
getOrCreateSurfaceView()
|
getOrCreateSurfaceView()
|
||||||
android.util.Log.d("JellyTauPlayer", "Video surface created for playback")
|
android.util.Log.d("JellyTauPlayer", "Video surface created for playback")
|
||||||
// Automatically attach the surface to the Activity
|
// Automatically attach the surface to the Activity
|
||||||
autoAttachSurface()
|
autoAttachSurface()
|
||||||
|
|
||||||
// CRITICAL: Request audio focus for video playback
|
// CRITICAL: Request audio focus for video playback. Video manages
|
||||||
requestAudioFocus()
|
// focus by hand (handleAudioFocus=false above), so nothing else
|
||||||
|
// will hold playback back if the request is delayed or refused.
|
||||||
|
focusGranted = requestAudioFocus()
|
||||||
} else {
|
} else {
|
||||||
clearVideoSurface()
|
clearVideoSurface()
|
||||||
// Abandon audio focus when switching to audio (audio uses ExoPlayer's built-in handling)
|
// Abandon audio focus when switching to audio (audio uses ExoPlayer's built-in handling)
|
||||||
@@ -888,8 +954,13 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
}
|
}
|
||||||
android.util.Log.d("JellyTauPlayer", "✓ Current volume: ${exoPlayer.volume}, deviceVolume: ${audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)}/${audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)}")
|
android.util.Log.d("JellyTauPlayer", "✓ Current volume: ${exoPlayer.volume}, deviceVolume: ${audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)}/${audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)}")
|
||||||
|
|
||||||
exoPlayer.playWhenReady = true
|
// Only roll if we hold audio focus. A DELAYED grant means the system
|
||||||
android.util.Log.d("JellyTauPlayer", "playWhenReady set to TRUE. Current state: ${exoPlayer.playbackState}")
|
// is withholding our audio until it calls back with AUDIOFOCUS_GAIN;
|
||||||
|
// playing through it produces picture with no sound. Playback resumes
|
||||||
|
// from the focus listener instead.
|
||||||
|
pendingPlayOnFocusGain = !focusGranted
|
||||||
|
exoPlayer.playWhenReady = focusGranted
|
||||||
|
android.util.Log.d("JellyTauPlayer", "playWhenReady set to $focusGranted (pendingPlayOnFocusGain=$pendingPlayOnFocusGain). Current state: ${exoPlayer.playbackState}")
|
||||||
|
|
||||||
// Start the foreground service for lockscreen controls
|
// Start the foreground service for lockscreen controls
|
||||||
startPlaybackService()
|
startPlaybackService()
|
||||||
@@ -959,16 +1030,41 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
android.util.Log.d("JellyTauPlayer", "Started position updates coroutine")
|
android.util.Log.d("JellyTauPlayer", "Started position updates coroutine")
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
if (exoPlayer.isPlaying) {
|
if (exoPlayer.isPlaying) {
|
||||||
val positionMs = exoPlayer.currentPosition.coerceAtLeast(0)
|
// THE boundary between the two timelines, and the only place
|
||||||
|
// the conversion happens.
|
||||||
|
//
|
||||||
|
// During a background-audio handoff the stream is requested
|
||||||
|
// with StartTimeTicks = the handoff point, so ExoPlayer's zero
|
||||||
|
// is that point and everything it reports is relative to it.
|
||||||
|
// The base used to be added only where a position was *shown*
|
||||||
|
// (the lockscreen scrubber), leaving progress reports to
|
||||||
|
// Jellyfin, the frontend and the truncation maths all working
|
||||||
|
// in the relative timeline while treating it as absolute —
|
||||||
|
// each crossing losing exactly `base` seconds, which is why the
|
||||||
|
// jump-back distance varied with where the screen was locked.
|
||||||
|
// Shifting once, here, means every consumer downstream speaks
|
||||||
|
// the episode's timeline and none of them needs to know a
|
||||||
|
// handoff happened.
|
||||||
|
//
|
||||||
|
// The duration is shifted with it, so position and duration
|
||||||
|
// stay on the same timeline — the stream's own length is only
|
||||||
|
// what remains after the handoff point.
|
||||||
|
//
|
||||||
|
// TRACES: UR-040 | DR-159
|
||||||
|
val service = JellyTauPlaybackService.getInstance()
|
||||||
|
val baseMs = service?.handoffBaseMs ?: 0L
|
||||||
|
|
||||||
|
val positionMs = exoPlayer.currentPosition.coerceAtLeast(0) + baseMs
|
||||||
val position = positionMs / 1000.0
|
val position = positionMs / 1000.0
|
||||||
val duration = if (exoPlayer.duration > 0) exoPlayer.duration / 1000.0 else 0.0
|
val duration =
|
||||||
|
if (exoPlayer.duration > 0) (exoPlayer.duration + baseMs) / 1000.0 else 0.0
|
||||||
android.util.Log.v("JellyTauPlayer", "Position update: $position / $duration")
|
android.util.Log.v("JellyTauPlayer", "Position update: $position / $duration")
|
||||||
nativeOnPositionUpdate(position, duration)
|
nativeOnPositionUpdate(position, duration)
|
||||||
|
|
||||||
// Keep the lockscreen scrubber live. Without this the
|
// Keep the lockscreen scrubber live. Without this the
|
||||||
// MediaSession position only refreshes on play/pause, so the
|
// MediaSession position only refreshes on play/pause, so the
|
||||||
// scrubber freezes mid-track and drifts out of sync.
|
// scrubber freezes mid-track and drifts out of sync.
|
||||||
JellyTauPlaybackService.getInstance()?.updatePlaybackPosition(positionMs, true)
|
service?.updatePlaybackPosition(positionMs, true)
|
||||||
}
|
}
|
||||||
delay(POSITION_UPDATE_INTERVAL_MS)
|
delay(POSITION_UPDATE_INTERVAL_MS)
|
||||||
}
|
}
|
||||||
@@ -1146,8 +1242,14 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
/**
|
/**
|
||||||
* Request audio focus for video playback.
|
* Request audio focus for video playback.
|
||||||
* This is critical for video to have audio on Android.
|
* This is critical for video to have audio on Android.
|
||||||
|
*
|
||||||
|
* TRACES: UR-004 | DR-145
|
||||||
|
*
|
||||||
|
* @return true if focus was granted outright and playback may start now.
|
||||||
|
* false for a DELAYED or refused request — the caller must hold playback
|
||||||
|
* and let the AUDIOFOCUS_GAIN callback start it, or the video plays mute.
|
||||||
*/
|
*/
|
||||||
private fun requestAudioFocus() {
|
private fun requestAudioFocus(): Boolean {
|
||||||
android.util.Log.d("JellyTauPlayer", "Requesting audio focus for video playback")
|
android.util.Log.d("JellyTauPlayer", "Requesting audio focus for video playback")
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
@@ -1164,17 +1266,29 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
when (focusChange) {
|
when (focusChange) {
|
||||||
AudioManager.AUDIOFOCUS_GAIN -> {
|
AudioManager.AUDIOFOCUS_GAIN -> {
|
||||||
android.util.Log.d("JellyTauPlayer", "✓ Audio focus GAINED - ensuring full volume")
|
android.util.Log.d("JellyTauPlayer", "✓ Audio focus GAINED - ensuring full volume")
|
||||||
|
hasAudioFocus = true
|
||||||
if (exoPlayer.volume < 1.0f) {
|
if (exoPlayer.volume < 1.0f) {
|
||||||
exoPlayer.volume = 1.0f
|
exoPlayer.volume = 1.0f
|
||||||
android.util.Log.d("JellyTauPlayer", " Volume restored to 1.0 from ${exoPlayer.volume}")
|
android.util.Log.d("JellyTauPlayer", " Volume restored to 1.0 from ${exoPlayer.volume}")
|
||||||
}
|
}
|
||||||
|
// A delayed grant arriving: this is the point at which
|
||||||
|
// the video may actually be heard, so start it now.
|
||||||
|
if (pendingPlayOnFocusGain) {
|
||||||
|
pendingPlayOnFocusGain = false
|
||||||
|
android.util.Log.d("JellyTauPlayer", " Delayed focus granted - starting held playback")
|
||||||
|
exoPlayer.playWhenReady = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
AudioManager.AUDIOFOCUS_LOSS -> {
|
AudioManager.AUDIOFOCUS_LOSS -> {
|
||||||
android.util.Log.d("JellyTauPlayer", "Audio focus LOST - pausing")
|
android.util.Log.d("JellyTauPlayer", "Audio focus LOST - pausing")
|
||||||
|
hasAudioFocus = false
|
||||||
|
pendingPlayOnFocusGain = false
|
||||||
pause()
|
pause()
|
||||||
}
|
}
|
||||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
||||||
android.util.Log.d("JellyTauPlayer", "Audio focus LOST TRANSIENT - pausing")
|
android.util.Log.d("JellyTauPlayer", "Audio focus LOST TRANSIENT - pausing")
|
||||||
|
hasAudioFocus = false
|
||||||
|
pendingPlayOnFocusGain = false
|
||||||
pause()
|
pause()
|
||||||
}
|
}
|
||||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
|
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
|
||||||
@@ -1185,16 +1299,25 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
}
|
}
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val result = audioManager.requestAudioFocus(audioFocusRequest!!)
|
return when (val result = audioManager.requestAudioFocus(audioFocusRequest!!)) {
|
||||||
when (result) {
|
|
||||||
AudioManager.AUDIOFOCUS_REQUEST_GRANTED -> {
|
AudioManager.AUDIOFOCUS_REQUEST_GRANTED -> {
|
||||||
android.util.Log.d("JellyTauPlayer", "✓ Audio focus GRANTED")
|
android.util.Log.d("JellyTauPlayer", "✓ Audio focus GRANTED")
|
||||||
|
hasAudioFocus = true
|
||||||
|
true
|
||||||
}
|
}
|
||||||
AudioManager.AUDIOFOCUS_REQUEST_FAILED -> {
|
AudioManager.AUDIOFOCUS_REQUEST_FAILED -> {
|
||||||
android.util.Log.e("JellyTauPlayer", "✗ Audio focus REQUEST FAILED!")
|
// Something holds exclusive focus (a call, say). Playing now
|
||||||
|
// would be a silent video, so hold and wait for the grant.
|
||||||
|
android.util.Log.e("JellyTauPlayer", "✗ Audio focus REQUEST FAILED - holding playback")
|
||||||
|
false
|
||||||
}
|
}
|
||||||
AudioManager.AUDIOFOCUS_REQUEST_DELAYED -> {
|
AudioManager.AUDIOFOCUS_REQUEST_DELAYED -> {
|
||||||
android.util.Log.d("JellyTauPlayer", "⏳ Audio focus DELAYED")
|
android.util.Log.d("JellyTauPlayer", "⏳ Audio focus DELAYED - holding playback until GAIN")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
android.util.Log.w("JellyTauPlayer", "Unknown audio focus result: $result - holding playback")
|
||||||
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1206,10 +1329,15 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
AudioManager.STREAM_MUSIC,
|
AudioManager.STREAM_MUSIC,
|
||||||
AudioManager.AUDIOFOCUS_GAIN
|
AudioManager.AUDIOFOCUS_GAIN
|
||||||
)
|
)
|
||||||
if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
|
return if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
|
||||||
android.util.Log.d("JellyTauPlayer", "✓ Audio focus GRANTED (legacy)")
|
android.util.Log.d("JellyTauPlayer", "✓ Audio focus GRANTED (legacy)")
|
||||||
|
hasAudioFocus = true
|
||||||
|
true
|
||||||
} else {
|
} else {
|
||||||
|
// Pre-O has no delayed grant and no listener to resume from, so a
|
||||||
|
// refusal is terminal for this attempt; the user can hit play again.
|
||||||
android.util.Log.e("JellyTauPlayer", "✗ Audio focus REQUEST FAILED (legacy)!")
|
android.util.Log.e("JellyTauPlayer", "✗ Audio focus REQUEST FAILED (legacy)!")
|
||||||
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1220,6 +1348,11 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
private fun abandonAudioFocus() {
|
private fun abandonAudioFocus() {
|
||||||
android.util.Log.d("JellyTauPlayer", "Abandoning audio focus")
|
android.util.Log.d("JellyTauPlayer", "Abandoning audio focus")
|
||||||
|
|
||||||
|
// No focus, nothing to resume: a stale flag would start playback the next
|
||||||
|
// time some unrelated GAIN arrives.
|
||||||
|
pendingPlayOnFocusGain = false
|
||||||
|
hasAudioFocus = false
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
audioFocusRequest?.let {
|
audioFocusRequest?.let {
|
||||||
val result = audioManager.abandonAudioFocusRequest(it)
|
val result = audioManager.abandonAudioFocusRequest(it)
|
||||||
|
|||||||
@@ -1,13 +1,30 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
<!-- Base application theme -->
|
<!--
|
||||||
|
Base application theme.
|
||||||
|
|
||||||
|
This app is EDGE-TO-EDGE: MainActivity calls enableEdgeToEdge(), and
|
||||||
|
targeting SDK 36 makes it mandatory anyway (enforced from SDK 35, with the
|
||||||
|
opt-out ignored from SDK 36). The WebView therefore spans the whole window,
|
||||||
|
under the status bar, the navigation/gesture bar and the display cutout.
|
||||||
|
|
||||||
|
This theme used to declare `android:fitsSystemWindows=true` with a "don't
|
||||||
|
draw behind system bars" comment. That was never true: enableEdgeToEdge()
|
||||||
|
calls setDecorFitsSystemWindows(false) at runtime and wins, and the
|
||||||
|
platform ignores the attribute at this target SDK regardless. Leaving it
|
||||||
|
in only hid the fact that nothing was insetting the content.
|
||||||
|
|
||||||
|
Insets are handled where they can actually be honoured: WindowInsetsBridge
|
||||||
|
reads them and hands them to CSS as jt-inset custom properties.
|
||||||
|
See UR-066 / DR-112.
|
||||||
|
-->
|
||||||
<style name="Theme.jellytau" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
<style name="Theme.jellytau" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||||
<!-- Status bar color -->
|
<!-- System bars are transparent; the app draws its own background behind
|
||||||
|
them (e.g. BottomUi's surface extends under the gesture bar). -->
|
||||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
<!-- Make status bar icons dark or light based on background -->
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
<!-- Light icons on our dark background, both bars. -->
|
||||||
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
|
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
|
||||||
<!-- Don't draw behind status bar -->
|
<item name="android:windowLightNavigationBar" tools:targetApi="o_mr1">false</item>
|
||||||
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||||
<!-- Ensure content doesn't extend into system bars -->
|
|
||||||
<item name="android:fitsSystemWindows">true</item>
|
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Cleartext HTTP stays blocked everywhere except loopback.
|
||||||
|
|
||||||
|
Downloaded media is served to the webview by a local HTTP server on
|
||||||
|
127.0.0.1 (see media_server.rs / DR-137). Release builds set
|
||||||
|
usesCleartextTraffic="false", so Android's network security policy rejected
|
||||||
|
those requests before any I/O happened and offline video failed instantly with
|
||||||
|
NETWORK_NO_SOURCE.
|
||||||
|
|
||||||
|
Only 127.0.0.1 is exempted. The base config keeps the release default, so a
|
||||||
|
remote server still has to be HTTPS — this must not become a blanket
|
||||||
|
cleartext opt-in.
|
||||||
|
|
||||||
|
TRACES: UR-071 | DR-138
|
||||||
|
-->
|
||||||
|
<network-security-config>
|
||||||
|
<base-config cleartextTrafficPermitted="false" />
|
||||||
|
|
||||||
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
|
<domain includeSubdomains="false">127.0.0.1</domain>
|
||||||
|
</domain-config>
|
||||||
|
</network-security-config>
|
||||||
@@ -14,10 +14,12 @@
|
|||||||
//! that were queued offline (they have `stream_url IS NULL`), mirroring the
|
//! that were queued offline (they have `stream_url IS NULL`), mirroring the
|
||||||
//! heal-and-pump pattern in `player_preload_upcoming`.
|
//! heal-and-pump pattern in `player_preload_upcoming`.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use log::{info, warn};
|
use log::{info, warn};
|
||||||
use tauri::State;
|
use tauri::{Emitter, Manager, State};
|
||||||
|
|
||||||
use crate::commands::download::{pump_download_queue, DownloadManagerWrapper};
|
use crate::commands::download::{pump_download_queue, DownloadManagerWrapper};
|
||||||
use crate::commands::repository::RepositoryManagerWrapper;
|
use crate::commands::repository::RepositoryManagerWrapper;
|
||||||
@@ -29,18 +31,89 @@ use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
|||||||
/// full-catalog sync.
|
/// full-catalog sync.
|
||||||
const LAST_CATALOG_SYNC_KEY: &str = "last_catalog_sync";
|
const LAST_CATALOG_SYNC_KEY: &str = "last_catalog_sync";
|
||||||
|
|
||||||
|
/// How long an index stays fresh before a re-index is due.
|
||||||
|
///
|
||||||
|
/// This lives in Rust rather than being a frontend constant because it decides
|
||||||
|
/// *whether the local cache is authoritative* — the same class of decision as
|
||||||
|
/// `include_catalog_browse`, and squarely the "sync policy" the spec review
|
||||||
|
/// checklist keeps out of the presentation layer. If it later becomes
|
||||||
|
/// user-configurable it stays a Rust-owned setting edited through a command.
|
||||||
|
const CATALOG_INDEX_TTL: Duration = Duration::from_secs(6 * 60 * 60);
|
||||||
|
|
||||||
|
/// How often the scheduler wakes to *check* staleness. Far shorter than the TTL
|
||||||
|
/// because a tick is nearly free — one indexed `app_settings` lookup — and it is
|
||||||
|
/// what makes the indexer responsive to events it cannot subscribe to: signing
|
||||||
|
/// in, and coming back online. The TTL, not the tick, decides whether a crawl
|
||||||
|
/// actually happens.
|
||||||
|
const CATALOG_INDEX_TICK: Duration = Duration::from_secs(5 * 60);
|
||||||
|
|
||||||
|
/// Delay before the first staleness check, to let sign-in complete and the
|
||||||
|
/// repository be registered. Without it the first check runs against an empty
|
||||||
|
/// repository manager and a fresh install would sit unindexed until the next
|
||||||
|
/// tick.
|
||||||
|
const CATALOG_INDEX_FIRST_CHECK: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
|
/// Kebab-case, per the project's event convention.
|
||||||
|
pub const CATALOG_INDEX_EVENT: &str = "catalog-index-event";
|
||||||
|
|
||||||
|
/// Guards against two passes running at once. Replaces the frontend's
|
||||||
|
/// `syncInProgress` boolean in `offlineCatalog.ts`, which could not see a pass
|
||||||
|
/// started by the scheduler.
|
||||||
|
static INDEX_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// Clears [`INDEX_IN_PROGRESS`] however the pass leaves — including on the `?`
|
||||||
|
/// early return when `get_libraries` fails, which a plain store at the end of
|
||||||
|
/// the function would leak.
|
||||||
|
struct IndexPassGuard;
|
||||||
|
|
||||||
|
impl Drop for IndexPassGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
INDEX_IN_PROGRESS.store(false, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Progress of a background index pass, for the staleness hint in the UI.
|
||||||
|
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CatalogIndexEvent {
|
||||||
|
/// `started` | `finished` | `failed`
|
||||||
|
pub state: String,
|
||||||
|
pub items_cached: usize,
|
||||||
|
pub items_pruned: usize,
|
||||||
|
pub libraries_failed: usize,
|
||||||
|
/// Present on `failed`.
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Item types worth caching for offline browsing: containers the library
|
/// Item types worth caching for offline browsing: containers the library
|
||||||
/// landing pages render plus the playable leaves users queue for download.
|
/// landing pages render plus the playable leaves users queue for download.
|
||||||
|
/// `MusicArtist` and `Playlist` are here because search groups results by them
|
||||||
|
/// (UR-060's Artists group). Without them in the crawl, the local index can
|
||||||
|
/// never answer an artist query and those groups can only ever be filled by the
|
||||||
|
/// server leg. Keep this in step with what `prune_stale_catalog` is allowed to
|
||||||
|
/// sweep — the crawl is only authoritative for the types it asks for.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-065, UR-060 | DR-111
|
||||||
const CATALOG_ITEM_TYPES: &[&str] = &[
|
const CATALOG_ITEM_TYPES: &[&str] = &[
|
||||||
"MusicAlbum",
|
"MusicAlbum",
|
||||||
|
"MusicArtist",
|
||||||
"Movie",
|
"Movie",
|
||||||
"Series",
|
"Series",
|
||||||
"Season",
|
"Season",
|
||||||
"Episode",
|
"Episode",
|
||||||
"Audio",
|
"Audio",
|
||||||
"BoxSet",
|
"BoxSet",
|
||||||
|
"Playlist",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Jellyfin item types whose download is a *video* stream rather than an audio
|
||||||
|
/// one. The download queue stores an opaque `media_type` ('audio'/'video'); this
|
||||||
|
/// is where the taxonomy that produces it lives, so the frontend never has to
|
||||||
|
/// know which item types are video.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-135
|
||||||
|
const VIDEO_ITEM_TYPES: &[&str] = &["Movie", "Episode", "Video", "MusicVideo"];
|
||||||
|
|
||||||
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct CatalogSyncResult {
|
pub struct CatalogSyncResult {
|
||||||
@@ -48,6 +121,9 @@ pub struct CatalogSyncResult {
|
|||||||
pub items_cached: usize,
|
pub items_cached: usize,
|
||||||
/// Libraries that failed to sync (e.g. server hiccup); best-effort.
|
/// Libraries that failed to sync (e.g. server hiccup); best-effort.
|
||||||
pub libraries_failed: usize,
|
pub libraries_failed: usize,
|
||||||
|
/// Entries removed because the server no longer has them. Always 0 when any
|
||||||
|
/// library failed, since a partial crawl cannot prove an item is gone.
|
||||||
|
pub items_pruned: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
@@ -71,8 +147,6 @@ pub async fn sync_full_catalog(
|
|||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
) -> Result<CatalogSyncResult, String> {
|
) -> Result<CatalogSyncResult, String> {
|
||||||
use crate::repository::MediaRepository;
|
|
||||||
|
|
||||||
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
|
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
|
||||||
let db_service = {
|
let db_service = {
|
||||||
@@ -80,6 +154,27 @@ pub async fn sync_full_catalog(
|
|||||||
Arc::new(database.service())
|
Arc::new(database.service())
|
||||||
};
|
};
|
||||||
|
|
||||||
|
run_index_pass(repo, db_service).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One full-catalog indexing pass, shared by the [`sync_full_catalog`] command
|
||||||
|
/// and the background scheduler (DR-109) so there is exactly one implementation
|
||||||
|
/// and one concurrency guard.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-065 | DR-109, DR-110
|
||||||
|
pub(crate) async fn run_index_pass(
|
||||||
|
repo: Arc<crate::repository::HybridRepository>,
|
||||||
|
db_service: Arc<crate::storage::db_service::RusqliteService>,
|
||||||
|
) -> Result<CatalogSyncResult, String> {
|
||||||
|
use crate::repository::MediaRepository;
|
||||||
|
|
||||||
|
// One pass at a time. The command and the scheduler can both land here, and
|
||||||
|
// two concurrent crawls would double the server load and race on the sweep.
|
||||||
|
if INDEX_IN_PROGRESS.swap(true, Ordering::SeqCst) {
|
||||||
|
return Err("A catalog index pass is already running".to_string());
|
||||||
|
}
|
||||||
|
let _guard = IndexPassGuard;
|
||||||
|
|
||||||
let libraries = repo.get_libraries().await.map_err(|e| e.to_string())?;
|
let libraries = repo.get_libraries().await.map_err(|e| e.to_string())?;
|
||||||
info!(
|
info!(
|
||||||
"[Catalog] Full sync starting across {} libraries",
|
"[Catalog] Full sync starting across {} libraries",
|
||||||
@@ -88,6 +183,10 @@ pub async fn sync_full_catalog(
|
|||||||
|
|
||||||
let include_types: Vec<String> = CATALOG_ITEM_TYPES.iter().map(|s| s.to_string()).collect();
|
let include_types: Vec<String> = CATALOG_ITEM_TYPES.iter().map(|s| s.to_string()).collect();
|
||||||
|
|
||||||
|
// Taken before the crawl: every row the crawl writes gets a `synced_at`
|
||||||
|
// newer than this, so anything still older afterwards is gone server-side.
|
||||||
|
let pass_started_at = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
let mut items_cached = 0usize;
|
let mut items_cached = 0usize;
|
||||||
let mut libraries_failed = 0usize;
|
let mut libraries_failed = 0usize;
|
||||||
|
|
||||||
@@ -118,6 +217,35 @@ pub async fn sync_full_catalog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Propagate server-side deletions — but only after a *complete* crawl.
|
||||||
|
// `sync_full_catalog` is best-effort per library, and `items.parent_id` is
|
||||||
|
// ON DELETE CASCADE, so sweeping when a library failed to fetch could
|
||||||
|
// cascade a whole series away because one request timed out.
|
||||||
|
let mut items_pruned = 0usize;
|
||||||
|
if libraries_failed == 0 && !libraries.is_empty() {
|
||||||
|
match repo
|
||||||
|
.prune_stale_catalog(&pass_started_at, &include_types)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(removed) => {
|
||||||
|
items_pruned = removed;
|
||||||
|
if removed > 0 {
|
||||||
|
info!(
|
||||||
|
"[Catalog] Pruned {} entries no longer on the server",
|
||||||
|
removed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => warn!("[Catalog] Prune of stale catalog entries failed: {:?}", e),
|
||||||
|
}
|
||||||
|
} else if libraries_failed > 0 {
|
||||||
|
info!(
|
||||||
|
"[Catalog] Skipping stale-entry prune: {} librar{} failed to sync, so the crawl is not authoritative",
|
||||||
|
libraries_failed,
|
||||||
|
if libraries_failed == 1 { "y" } else { "ies" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Record the sync time so callers can skip re-syncing too eagerly.
|
// Record the sync time so callers can skip re-syncing too eagerly.
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
let upsert = Query::with_params(
|
let upsert = Query::with_params(
|
||||||
@@ -133,16 +261,169 @@ pub async fn sync_full_catalog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"[Catalog] Full sync complete: {} items cached, {} libraries failed",
|
"[Catalog] Full sync complete: {} items cached, {} pruned, {} libraries failed",
|
||||||
items_cached, libraries_failed
|
items_cached, items_pruned, libraries_failed
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(CatalogSyncResult {
|
Ok(CatalogSyncResult {
|
||||||
items_cached,
|
items_cached,
|
||||||
libraries_failed,
|
libraries_failed,
|
||||||
|
items_pruned,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether an index pass is due, given when one last completed.
|
||||||
|
///
|
||||||
|
/// Pure so the policy is unit-testable without a clock, a server, or a database.
|
||||||
|
/// `None` (never indexed) and an unparseable stored value both mean "due" — a
|
||||||
|
/// corrupt timestamp should trigger a re-index, not silently freeze the catalog.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-065 | DR-109 | UT-115
|
||||||
|
pub(crate) fn index_is_due(
|
||||||
|
last_synced_at: Option<&str>,
|
||||||
|
now: chrono::DateTime<chrono::Utc>,
|
||||||
|
ttl: Duration,
|
||||||
|
) -> bool {
|
||||||
|
let Some(raw) = last_synced_at else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
let Ok(last) = chrono::DateTime::parse_from_rfc3339(raw) else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
now.signed_duration_since(last.with_timezone(&chrono::Utc))
|
||||||
|
.to_std()
|
||||||
|
.map(|elapsed| elapsed >= ttl)
|
||||||
|
// Negative elapsed => the stored stamp is in the future (clock skew).
|
||||||
|
// Not due; a future stamp will age into due-ness on its own.
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the last-sync timestamp straight from `app_settings`.
|
||||||
|
async fn read_last_sync(
|
||||||
|
db_service: &Arc<crate::storage::db_service::RusqliteService>,
|
||||||
|
) -> Option<String> {
|
||||||
|
db_service
|
||||||
|
.query_optional(
|
||||||
|
Query::with_params(
|
||||||
|
"SELECT value FROM app_settings WHERE key = ?",
|
||||||
|
vec![QueryParam::String(LAST_CATALOG_SYNC_KEY.to_string())],
|
||||||
|
),
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the background catalog indexer.
|
||||||
|
///
|
||||||
|
/// Replaces the frontend's startup-only `syncCatalog()` call: index freshness is
|
||||||
|
/// sync policy and belongs in Rust (see the layer assignment in
|
||||||
|
/// docs/specs/catalog-index-search.md). Ticks every [`CATALOG_INDEX_TICK`] and
|
||||||
|
/// runs a pass when a repository exists, the server is reachable, and the index
|
||||||
|
/// is older than [`CATALOG_INDEX_TTL`].
|
||||||
|
///
|
||||||
|
/// TRACES: UR-065 | DR-109, IR-030
|
||||||
|
pub fn spawn_catalog_indexer(app: tauri::AppHandle) {
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
// Check shortly after launch, then on every tick — not tick-then-check,
|
||||||
|
// which would leave a fresh install unindexed for a full tick.
|
||||||
|
tokio::time::sleep(CATALOG_INDEX_FIRST_CHECK).await;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if let Err(e) = maybe_run_scheduled_pass(&app).await {
|
||||||
|
// Never fatal — a failed pass leaves the existing index in place
|
||||||
|
// and we retry on the next tick.
|
||||||
|
warn!("[Catalog] Scheduled index pass skipped: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::time::sleep(CATALOG_INDEX_TICK).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One scheduler tick: check the preconditions, then index if due.
|
||||||
|
async fn maybe_run_scheduled_pass(app: &tauri::AppHandle) -> Result<(), String> {
|
||||||
|
if INDEX_IN_PROGRESS.load(Ordering::SeqCst) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let db_service = {
|
||||||
|
let db = app.state::<DatabaseWrapper>();
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
if !index_is_due(
|
||||||
|
read_last_sync(&db_service).await.as_deref(),
|
||||||
|
chrono::Utc::now(),
|
||||||
|
CATALOG_INDEX_TTL,
|
||||||
|
) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offline: leave the index alone. The crawl would fail every library and,
|
||||||
|
// more importantly, a partial crawl must never reach the deletion sweep.
|
||||||
|
{
|
||||||
|
let monitor = app.state::<crate::commands::connectivity::ConnectivityMonitorWrapper>();
|
||||||
|
let monitor = monitor.0.lock().await;
|
||||||
|
if !monitor.get_status().await.is_server_reachable {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let repo = {
|
||||||
|
let manager = app.state::<RepositoryManagerWrapper>();
|
||||||
|
let handles = manager.0.handles();
|
||||||
|
let Some(handle) = handles.first() else {
|
||||||
|
// Not signed in yet.
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
manager.0.get(handle).ok_or("Repository not found")?
|
||||||
|
};
|
||||||
|
|
||||||
|
info!("[Catalog] Index is stale; starting a scheduled pass");
|
||||||
|
let _ = app.emit(
|
||||||
|
CATALOG_INDEX_EVENT,
|
||||||
|
CatalogIndexEvent {
|
||||||
|
state: "started".to_string(),
|
||||||
|
items_cached: 0,
|
||||||
|
items_pruned: 0,
|
||||||
|
libraries_failed: 0,
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
match run_index_pass(repo, db_service).await {
|
||||||
|
Ok(result) => {
|
||||||
|
let _ = app.emit(
|
||||||
|
CATALOG_INDEX_EVENT,
|
||||||
|
CatalogIndexEvent {
|
||||||
|
state: "finished".to_string(),
|
||||||
|
items_cached: result.items_cached,
|
||||||
|
items_pruned: result.items_pruned,
|
||||||
|
libraries_failed: result.libraries_failed,
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = app.emit(
|
||||||
|
CATALOG_INDEX_EVENT,
|
||||||
|
CatalogIndexEvent {
|
||||||
|
state: "failed".to_string(),
|
||||||
|
items_cached: 0,
|
||||||
|
items_pruned: 0,
|
||||||
|
libraries_failed: 0,
|
||||||
|
error: Some(e.clone()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Report the last-synced timestamp so the UI can show a hint / decide whether
|
/// Report the last-synced timestamp so the UI can show a hint / decide whether
|
||||||
/// to trigger a fresh sync.
|
/// to trigger a fresh sync.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -190,6 +471,51 @@ pub struct ResumeQueuedResult {
|
|||||||
pub failed: usize,
|
pub failed: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Requeue video downloads that were fetched as audio.
|
||||||
|
///
|
||||||
|
/// Before [`resolve_pending_download_urls`] consulted the item's type, a row
|
||||||
|
/// with no `media_type` — which is every row queued from a media card, since
|
||||||
|
/// `download_item` does not record one — resolved against
|
||||||
|
/// `get_audio_stream_url`. A movie queued that way completed with an audio-only
|
||||||
|
/// transcode on disk, so playing it offline could only ever fail. Those rows are
|
||||||
|
/// identifiable after the fact (no `media_type`, but a video item), so reset them
|
||||||
|
/// to pending with no URL and let the resolver fetch the real video.
|
||||||
|
///
|
||||||
|
/// Rows carrying an explicit `media_type` were resolved correctly and are left
|
||||||
|
/// alone, as are genuine audio downloads.
|
||||||
|
///
|
||||||
|
/// Returns the number of rows requeued.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-136 | UT-126
|
||||||
|
pub(crate) async fn requeue_mistyped_video_downloads(
|
||||||
|
db_service: &Arc<crate::storage::db_service::RusqliteService>,
|
||||||
|
) -> Result<usize, String> {
|
||||||
|
let video_types = VIDEO_ITEM_TYPES
|
||||||
|
.iter()
|
||||||
|
.map(|t| format!("'{t}'"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
let query = Query::new(&format!(
|
||||||
|
"UPDATE downloads
|
||||||
|
SET status = 'pending', stream_url = NULL, progress = 0,
|
||||||
|
bytes_downloaded = 0, started_at = NULL, completed_at = NULL
|
||||||
|
WHERE media_type IS NULL
|
||||||
|
AND status = 'completed'
|
||||||
|
AND item_id IN (SELECT id FROM items WHERE item_type IN ({video_types}))"
|
||||||
|
));
|
||||||
|
|
||||||
|
let n = db_service.execute(query).await.map_err(|e| e.to_string())? as usize;
|
||||||
|
|
||||||
|
if n > 0 {
|
||||||
|
info!(
|
||||||
|
"[Catalog] Requeued {} video download(s) that were fetched as audio",
|
||||||
|
n
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
|
||||||
/// 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.
|
||||||
@@ -203,11 +529,31 @@ 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<String>>,
|
||||||
{
|
{
|
||||||
let rows_query = Query::new(
|
// A row's own media_type wins; otherwise the *item's* type decides. Rows
|
||||||
"SELECT id, item_id, COALESCE(media_type, 'audio'), COALESCE(quality_preset, 'original')
|
// queued from a media card never carry one (`download_item` does not record
|
||||||
FROM downloads
|
// it), and defaulting that NULL to 'audio' resolved movies against
|
||||||
WHERE status = 'pending' AND stream_url IS NULL",
|
// `get_audio_stream_url` — the file on disk was an audio-only transcode, so
|
||||||
);
|
// offline video could never play. Falling back to 'audio' only when the item
|
||||||
|
// is unknown keeps the historical behaviour for uncached items.
|
||||||
|
// TRACES: UR-071, UR-052 | DR-135
|
||||||
|
let video_types = VIDEO_ITEM_TYPES
|
||||||
|
.iter()
|
||||||
|
.map(|t| format!("'{t}'"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
let rows_query = Query::new(&format!(
|
||||||
|
"SELECT d.id, d.item_id,
|
||||||
|
COALESCE(
|
||||||
|
d.media_type,
|
||||||
|
CASE WHEN i.item_type IN ({video_types}) THEN 'video'
|
||||||
|
WHEN i.item_type IS NOT NULL THEN 'audio'
|
||||||
|
END,
|
||||||
|
'audio'),
|
||||||
|
COALESCE(d.quality_preset, 'original')
|
||||||
|
FROM downloads d
|
||||||
|
LEFT JOIN items i ON i.id = d.item_id
|
||||||
|
WHERE d.status = 'pending' AND d.stream_url IS NULL"
|
||||||
|
));
|
||||||
let rows: Vec<(i64, String, String, String)> = db_service
|
let rows: Vec<(i64, String, String, String)> = db_service
|
||||||
.query_many(rows_query, |row| {
|
.query_many(rows_query, |row| {
|
||||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
|
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
|
||||||
@@ -285,8 +631,6 @@ pub async fn resume_queued_downloads(
|
|||||||
) -> Result<ResumeQueuedResult, String> {
|
) -> Result<ResumeQueuedResult, String> {
|
||||||
use crate::repository::MediaRepository;
|
use crate::repository::MediaRepository;
|
||||||
|
|
||||||
use crate::repository::HybridRepository;
|
|
||||||
|
|
||||||
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
|
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
|
||||||
// The pump needs a target_dir; use the same storage root the other download
|
// The pump needs a target_dir; use the same storage root the other download
|
||||||
@@ -317,6 +661,16 @@ pub async fn resume_queued_downloads(
|
|||||||
Err(e) => warn!("[Catalog] Failed to reset stale downloads: {}", e),
|
Err(e) => warn!("[Catalog] Failed to reset stale downloads: {}", e),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Repair rows that completed as audio because their media_type was missing;
|
||||||
|
// they hold an audio-only transcode where a video should be, so requeue them
|
||||||
|
// for the resolver below. TRACES: UR-071 | DR-136
|
||||||
|
if let Err(e) = requeue_mistyped_video_downloads(&db_service).await {
|
||||||
|
warn!(
|
||||||
|
"[Catalog] Failed to requeue mis-typed video downloads: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve each row's URL against the (now reachable) repository.
|
// Resolve each row's URL against the (now reachable) repository.
|
||||||
let repo_for_resolve = Arc::clone(&repo);
|
let repo_for_resolve = Arc::clone(&repo);
|
||||||
let outcome = resolve_pending_download_urls(
|
let outcome = resolve_pending_download_urls(
|
||||||
@@ -327,12 +681,13 @@ pub async fn resume_queued_downloads(
|
|||||||
async move {
|
async move {
|
||||||
if media_type == "video" {
|
if media_type == "video" {
|
||||||
Some(
|
Some(
|
||||||
<HybridRepository as MediaRepository>::get_video_download_url(
|
crate::repository::resolve_video_download_url(
|
||||||
repo.as_ref(),
|
repo.as_ref(),
|
||||||
&item_id,
|
&item_id,
|
||||||
&quality,
|
&quality,
|
||||||
None,
|
None,
|
||||||
),
|
)
|
||||||
|
.await,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
match repo.get_audio_stream_url(&item_id).await {
|
match repo.get_audio_stream_url(&item_id).await {
|
||||||
@@ -378,6 +733,43 @@ mod tests {
|
|||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
/// The re-index policy. Pure, so it is testable without a clock, a server or
|
||||||
|
/// a database — which is the reason it was factored out of the scheduler.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-065 | DR-109 | UT-115
|
||||||
|
#[test]
|
||||||
|
fn test_index_is_due() {
|
||||||
|
let ttl = Duration::from_secs(6 * 60 * 60);
|
||||||
|
let now = chrono::DateTime::parse_from_rfc3339("2026-08-04T12:00:00+00:00")
|
||||||
|
.unwrap()
|
||||||
|
.with_timezone(&chrono::Utc);
|
||||||
|
|
||||||
|
// Never indexed => due. This is the first-run case.
|
||||||
|
assert!(index_is_due(None, now, ttl));
|
||||||
|
|
||||||
|
// Indexed 7 hours ago => past the 6h TTL => due.
|
||||||
|
assert!(index_is_due(Some("2026-08-04T05:00:00+00:00"), now, ttl));
|
||||||
|
|
||||||
|
// Indexed 1 hour ago => fresh => not due. This is what stops the
|
||||||
|
// scheduler re-crawling every tick.
|
||||||
|
assert!(!index_is_due(Some("2026-08-04T11:00:00+00:00"), now, ttl));
|
||||||
|
|
||||||
|
// Exactly at the TTL boundary counts as due.
|
||||||
|
assert!(index_is_due(Some("2026-08-04T06:00:00+00:00"), now, ttl));
|
||||||
|
|
||||||
|
// A corrupt stored value must trigger a re-index, not freeze the
|
||||||
|
// catalog forever behind an unparseable timestamp.
|
||||||
|
assert!(index_is_due(Some("not-a-timestamp"), now, ttl));
|
||||||
|
assert!(index_is_due(Some(""), now, ttl));
|
||||||
|
|
||||||
|
// A timestamp in the future (clock skew, or a restored backup) is not
|
||||||
|
// due — it ages into due-ness rather than causing a crawl every tick.
|
||||||
|
assert!(!index_is_due(Some("2026-08-05T00:00:00+00:00"), now, ttl));
|
||||||
|
|
||||||
|
// Offsets other than UTC are compared as instants, not as strings.
|
||||||
|
assert!(!index_is_due(Some("2026-08-04T13:30:00+02:00"), now, ttl));
|
||||||
|
}
|
||||||
|
|
||||||
fn test_db() -> Arc<RusqliteService> {
|
fn test_db() -> Arc<RusqliteService> {
|
||||||
let conn = Connection::open_in_memory().unwrap();
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
@@ -389,7 +781,15 @@ mod tests {
|
|||||||
stream_url TEXT,
|
stream_url TEXT,
|
||||||
target_dir TEXT,
|
target_dir TEXT,
|
||||||
media_type TEXT,
|
media_type TEXT,
|
||||||
quality_preset TEXT
|
quality_preset TEXT,
|
||||||
|
progress REAL DEFAULT 0,
|
||||||
|
bytes_downloaded INTEGER DEFAULT 0,
|
||||||
|
started_at TEXT,
|
||||||
|
completed_at TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE items (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
item_type TEXT
|
||||||
);
|
);
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -397,6 +797,18 @@ mod tests {
|
|||||||
Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
|
Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn insert_item(db: &Arc<RusqliteService>, item_id: &str, item_type: &str) {
|
||||||
|
db.execute(Query::with_params(
|
||||||
|
"INSERT INTO items (id, item_type) VALUES (?, ?)",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(item_id.to_string()),
|
||||||
|
QueryParam::String(item_type.to_string()),
|
||||||
|
],
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
async fn insert_download(
|
async fn insert_download(
|
||||||
db: &Arc<RusqliteService>,
|
db: &Arc<RusqliteService>,
|
||||||
item_id: &str,
|
item_id: &str,
|
||||||
@@ -489,6 +901,137 @@ mod tests {
|
|||||||
assert_eq!(url, None);
|
assert_eq!(url, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A movie queued from a media card has no `media_type` — `download_item`
|
||||||
|
/// never records one. Defaulting that NULL to 'audio' resolved the row
|
||||||
|
/// against `get_audio_stream_url`, so the "downloaded movie" on disk was an
|
||||||
|
/// audio-only transcode and offline video playback could never work. The
|
||||||
|
/// item's own type is the authority.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071, UR-052 | DR-135 | UT-125
|
||||||
|
#[tokio::test]
|
||||||
|
async fn null_media_type_resolves_from_the_item_type_not_audio() {
|
||||||
|
let db = test_db();
|
||||||
|
insert_item(&db, "movie-1", "Movie").await;
|
||||||
|
insert_item(&db, "ep-1", "Episode").await;
|
||||||
|
insert_item(&db, "track-1", "Audio").await;
|
||||||
|
for id in ["movie-1", "ep-1", "track-1"] {
|
||||||
|
insert_download(&db, id, "pending", None, None).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let seen_c = Arc::clone(&seen);
|
||||||
|
resolve_pending_download_urls(&db, "/data", move |item_id, media_type, _q| {
|
||||||
|
let seen = Arc::clone(&seen_c);
|
||||||
|
async move {
|
||||||
|
seen.lock().unwrap().push((item_id.clone(), media_type));
|
||||||
|
Some(format!("http://resolved/{item_id}"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let seen = seen.lock().unwrap().clone();
|
||||||
|
let of = |id: &str| {
|
||||||
|
seen.iter()
|
||||||
|
.find(|(i, _)| i == id)
|
||||||
|
.map(|(_, m)| m.clone())
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
assert_eq!(of("movie-1"), "video", "a Movie must download as video");
|
||||||
|
assert_eq!(of("ep-1"), "video", "an Episode must download as video");
|
||||||
|
assert_eq!(of("track-1"), "audio", "a track is still audio");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An unknown item (never cached locally) has no type to derive from, so it
|
||||||
|
/// keeps the historical audio default rather than failing the row.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-135 | UT-125
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unknown_item_falls_back_to_audio() {
|
||||||
|
let db = test_db();
|
||||||
|
insert_download(&db, "ghost", "pending", None, None).await;
|
||||||
|
|
||||||
|
let seen = Arc::new(Mutex::new(String::new()));
|
||||||
|
let seen_c = Arc::clone(&seen);
|
||||||
|
resolve_pending_download_urls(&db, "/data", move |_id, media_type, _q| {
|
||||||
|
let seen = Arc::clone(&seen_c);
|
||||||
|
async move {
|
||||||
|
*seen.lock().unwrap() = media_type;
|
||||||
|
Some("http://x".to_string())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(*seen.lock().unwrap(), "audio");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An explicit `media_type` on the row always wins over the item's type.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-135 | UT-125
|
||||||
|
#[tokio::test]
|
||||||
|
async fn explicit_media_type_beats_the_item_type() {
|
||||||
|
let db = test_db();
|
||||||
|
insert_item(&db, "odd", "Audio").await;
|
||||||
|
insert_download(&db, "odd", "pending", None, Some("video")).await;
|
||||||
|
|
||||||
|
let seen = Arc::new(Mutex::new(String::new()));
|
||||||
|
let seen_c = Arc::clone(&seen);
|
||||||
|
resolve_pending_download_urls(&db, "/data", move |_id, media_type, _q| {
|
||||||
|
let seen = Arc::clone(&seen_c);
|
||||||
|
async move {
|
||||||
|
*seen.lock().unwrap() = media_type;
|
||||||
|
Some("http://x".to_string())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(*seen.lock().unwrap(), "video");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rows already downloaded under the audio default hold an audio-only
|
||||||
|
/// transcode on disk, so they play as a broken video forever. They are
|
||||||
|
/// identifiable — no `media_type` but a video item — and are requeued so the
|
||||||
|
/// resolver fetches the real video. Correctly-typed rows and genuine audio
|
||||||
|
/// downloads must be left alone.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-136 | UT-126
|
||||||
|
#[tokio::test]
|
||||||
|
async fn requeues_video_downloaded_under_the_audio_default() {
|
||||||
|
let db = test_db();
|
||||||
|
insert_item(&db, "movie-1", "Movie").await;
|
||||||
|
insert_item(&db, "track-1", "Audio").await;
|
||||||
|
insert_item(&db, "movie-ok", "Movie").await;
|
||||||
|
// Mis-downloaded: completed, no media_type, video item.
|
||||||
|
insert_download(&db, "movie-1", "completed", Some("http://audio/url"), None).await;
|
||||||
|
// A real audio download: untouched.
|
||||||
|
insert_download(&db, "track-1", "completed", Some("http://audio/ok"), None).await;
|
||||||
|
// A correctly-typed video download: untouched.
|
||||||
|
insert_download(
|
||||||
|
&db,
|
||||||
|
"movie-ok",
|
||||||
|
"completed",
|
||||||
|
Some("http://video/ok"),
|
||||||
|
Some("video"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let requeued = requeue_mistyped_video_downloads(&db).await.unwrap();
|
||||||
|
assert_eq!(requeued, 1);
|
||||||
|
|
||||||
|
let (status, url, _t) = get_row(&db, "movie-1").await;
|
||||||
|
assert_eq!(status, "pending", "the mis-typed row must download again");
|
||||||
|
assert_eq!(url, None, "its audio URL must be cleared so it re-resolves");
|
||||||
|
|
||||||
|
let (status, url, _t) = get_row(&db, "track-1").await;
|
||||||
|
assert_eq!(status, "completed", "a real audio download is untouched");
|
||||||
|
assert_eq!(url.as_deref(), Some("http://audio/ok"));
|
||||||
|
|
||||||
|
let (status, _u, _t) = get_row(&db, "movie-ok").await;
|
||||||
|
assert_eq!(status, "completed", "a correct video download is untouched");
|
||||||
|
}
|
||||||
|
|
||||||
#[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();
|
||||||
|
|||||||
@@ -270,6 +270,19 @@ pub async fn download_item(
|
|||||||
if !can_download {
|
if !can_download {
|
||||||
warn!("Storage limit reached. Attempting to free space...");
|
warn!("Storage limit reached. Attempting to free space...");
|
||||||
|
|
||||||
|
// Reclaim expired temporary entries first: they are dead weight, so
|
||||||
|
// freeing them may avoid evicting cache that is still within its
|
||||||
|
// life. Best-effort — a failure here just means eviction does more.
|
||||||
|
// TRACES: UR-071 | DR-127
|
||||||
|
match cache_arc
|
||||||
|
.reclaim_expired_async(&db_service, &user_id, &chrono::Utc::now().to_rfc3339())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(n) if n > 0 => info!("Reclaimed {} expired cache entries", n),
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => warn!("Expired-entry reclaim failed: {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
// Try to evict LRU items to make space
|
// Try to evict LRU items to make space
|
||||||
match cache_arc
|
match cache_arc
|
||||||
.evict_lru_async(&db_service, &user_id, size as u64)
|
.evict_lru_async(&db_service, &user_id, size as u64)
|
||||||
@@ -832,7 +845,19 @@ pub async fn get_downloads(
|
|||||||
Ok(DownloadsResponse { downloads, stats })
|
Ok(DownloadsResponse { downloads, stats })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pause a download
|
/// Pause a download.
|
||||||
|
///
|
||||||
|
/// Writing `status = 'paused'` is only half of it, and used to be all of it: the
|
||||||
|
/// streaming task knew nothing about the row and kept running, then overwrote it
|
||||||
|
/// with `completed`/`failed` when it finished. The row flicked to "paused" and
|
||||||
|
/// undid itself — the reported "pause does not work". Signalling the worker is
|
||||||
|
/// what actually stops the bytes; it leaves the `.part` file in place so
|
||||||
|
/// [`resume_download`] can continue from it.
|
||||||
|
///
|
||||||
|
/// A queued (not yet started) download has no worker to signal, and the status
|
||||||
|
/// write alone is enough — the pump skips anything that is not `pending`.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-055 | DR-168
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn pause_download(
|
pub async fn pause_download(
|
||||||
@@ -845,19 +870,34 @@ pub async fn pause_download(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let query = Query::with_params(
|
let query = Query::with_params(
|
||||||
"UPDATE downloads SET status = 'paused' WHERE id = ? AND status = 'downloading'",
|
"UPDATE downloads SET status = 'paused' WHERE id = ? AND status IN ('downloading', 'pending')",
|
||||||
vec![QueryParam::Int64(download_id)],
|
vec![QueryParam::Int64(download_id)],
|
||||||
);
|
);
|
||||||
|
|
||||||
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let was_running = crate::download::stop::signal(download_id);
|
||||||
|
info!(
|
||||||
|
"[pause] Download {} paused (in flight: {})",
|
||||||
|
download_id, was_running
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resume a paused download
|
/// Resume a paused download.
|
||||||
|
///
|
||||||
|
/// Flipping the row back to `pending` is likewise not enough on its own: the
|
||||||
|
/// pump is not a poller, it runs when something calls it, so a resumed download
|
||||||
|
/// sat untouched until some unrelated event happened to pump the queue. That is
|
||||||
|
/// the other half of "resume does not work".
|
||||||
|
///
|
||||||
|
/// TRACES: UR-055 | DR-168
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn resume_download(
|
pub async fn resume_download(
|
||||||
|
app: tauri::AppHandle,
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
download_manager: State<'_, DownloadManagerWrapper>,
|
||||||
download_id: i64,
|
download_id: i64,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let db_service = {
|
let db_service = {
|
||||||
@@ -866,11 +906,22 @@ pub async fn resume_download(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let query = Query::with_params(
|
let query = Query::with_params(
|
||||||
"UPDATE downloads SET status = 'pending' WHERE id = ? AND status = 'paused'",
|
"UPDATE downloads SET status = 'pending', error_message = NULL WHERE id = ? AND status IN ('paused', 'failed')",
|
||||||
vec![QueryParam::Int64(download_id)],
|
vec![QueryParam::Int64(download_id)],
|
||||||
);
|
);
|
||||||
|
|
||||||
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Drop any stale stop flag before the pump can start this id again, or the
|
||||||
|
// resumed run would read the pause that stopped it and halt immediately.
|
||||||
|
crate::download::stop::clear(download_id);
|
||||||
|
|
||||||
|
let active_downloads = {
|
||||||
|
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
manager.get_active_downloads()
|
||||||
|
};
|
||||||
|
pump_download_queue(app, db_service, active_downloads).await;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -910,6 +961,13 @@ pub async fn cancel_download(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Stop the worker if this download is actually running. Without this the
|
||||||
|
// task keeps streaming into a `.part` file whose `downloads` row has just
|
||||||
|
// been deleted — bytes with nothing pointing at them, and the file below is
|
||||||
|
// removed while still being written to. (DR-168)
|
||||||
|
crate::download::stop::signal(download_id);
|
||||||
|
crate::download::stop::clear(download_id);
|
||||||
|
|
||||||
// Unregister from download manager (in case it was active)
|
// Unregister from download manager (in case it was active)
|
||||||
{
|
{
|
||||||
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
||||||
@@ -921,10 +979,12 @@ pub async fn cancel_download(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete partial file if exists
|
// Delete the partial file, and any completed file, if present. Both go
|
||||||
|
// through `partial_path` so this cannot drift from what the worker writes —
|
||||||
|
// it did, and every cancelled download leaked its partial. (DR-169)
|
||||||
if let Some(path) = file_path {
|
if let Some(path) = file_path {
|
||||||
let partial_path = format!("{}.part", path);
|
let target = std::path::PathBuf::from(&path);
|
||||||
let _ = std::fs::remove_file(&partial_path); // Ignore errors
|
let _ = std::fs::remove_file(crate::download::worker::partial_path(&target));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1231,8 +1291,6 @@ pub async fn enqueue_video_downloads(
|
|||||||
download_ids: Vec<i64>,
|
download_ids: Vec<i64>,
|
||||||
target_dir: String,
|
target_dir: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
use crate::repository::MediaRepository;
|
|
||||||
|
|
||||||
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
|
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
|
||||||
let db_service = {
|
let db_service = {
|
||||||
@@ -1257,10 +1315,12 @@ pub async fn enqueue_video_downloads(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build the transcode URL (pure URL builder, no server round-trip).
|
// Build the download URL, resolving the source's audio codec first so a
|
||||||
let stream_url = repo
|
// track this device cannot decode is re-encoded on the way down rather
|
||||||
.as_ref()
|
// than saved as a silent file (DR-167).
|
||||||
.get_video_download_url(&item_id, &quality, None);
|
let stream_url =
|
||||||
|
crate::repository::resolve_video_download_url(repo.as_ref(), &item_id, &quality, None)
|
||||||
|
.await;
|
||||||
|
|
||||||
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 = ? WHERE id = ?",
|
||||||
@@ -1517,7 +1577,11 @@ fn spawn_download_worker(
|
|||||||
let _ = progress_app.emit("download-event", event);
|
let _ = progress_app.emit("download-event", event);
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = worker.download(&task, on_progress).await;
|
// Registering returns a fresh flag, so a download resumed after a pause
|
||||||
|
// does not inherit the stop that ended its previous run. (DR-168)
|
||||||
|
let stop_flag = crate::download::stop::register(download_id);
|
||||||
|
let result = worker.download(&task, &stop_flag, on_progress).await;
|
||||||
|
crate::download::stop::clear(download_id);
|
||||||
|
|
||||||
// Free the slot before pumping so the next download can take it.
|
// Free the slot before pumping so the next download can take it.
|
||||||
if let Ok(mut active) = active_downloads.lock() {
|
if let Ok(mut active) = active_downloads.lock() {
|
||||||
@@ -1588,6 +1652,17 @@ fn spawn_download_worker(
|
|||||||
Err(e) => error!(" Completed event emit failed: {:?}", e),
|
Err(e) => error!(" Completed event emit failed: {:?}", e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A pause or cancel is not a failure. The row already says `paused`
|
||||||
|
// (or the row is gone, for a cancel), and overwriting that with
|
||||||
|
// `failed` is what made a pause look like an error and stranded the
|
||||||
|
// download outside the resumable set. The `.part` file is deliberately
|
||||||
|
// left alone — it is what the resume continues from. (DR-168)
|
||||||
|
Err(e) if e.is_stopped() => {
|
||||||
|
info!(
|
||||||
|
"[pump] Download {} stopped by request; partial file kept for resume",
|
||||||
|
download_id
|
||||||
|
);
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Download failed: {:?}", e);
|
error!("Download failed: {:?}", e);
|
||||||
|
|
||||||
@@ -1835,17 +1910,26 @@ pub async fn clear_stale_downloads(
|
|||||||
Arc::new(database.service())
|
Arc::new(database.service())
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get file paths for stale downloads (pending/paused/failed)
|
// Ids as well as paths: a stale row may still have a worker attached (a
|
||||||
|
// 'downloading' row that was paused mid-flight is 'paused' here), and
|
||||||
|
// deleting the row without stopping the task leaves it writing to a file we
|
||||||
|
// are about to remove. (DR-168)
|
||||||
let file_query = Query::with_params(
|
let file_query = Query::with_params(
|
||||||
"SELECT file_path FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')",
|
"SELECT id, file_path FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')",
|
||||||
vec![QueryParam::String(user_id.clone())],
|
vec![QueryParam::String(user_id.clone())],
|
||||||
);
|
);
|
||||||
|
|
||||||
let file_paths: Vec<String> = db_service
|
let stale: Vec<(i64, String)> = db_service
|
||||||
.query_many(file_query, |row| row.get(0))
|
.query_many(file_query, |row| Ok((row.get(0)?, row.get(1)?)))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
for (id, _) in &stale {
|
||||||
|
crate::download::stop::signal(*id);
|
||||||
|
crate::download::stop::clear(*id);
|
||||||
|
}
|
||||||
|
let file_paths: Vec<String> = stale.into_iter().map(|(_, path)| path).collect();
|
||||||
|
|
||||||
// Delete all pending, paused, and failed downloads (but keep completed ones)
|
// Delete all pending, paused, and failed downloads (but keep completed ones)
|
||||||
let delete_query = Query::with_params(
|
let delete_query = Query::with_params(
|
||||||
"DELETE FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')",
|
"DELETE FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')",
|
||||||
@@ -1857,10 +1941,12 @@ pub async fn clear_stale_downloads(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// Delete any partial files
|
// Delete any partial files, via the shared helper so this cannot drift from
|
||||||
|
// what the worker actually writes. (DR-169)
|
||||||
for path in file_paths {
|
for path in file_paths {
|
||||||
let _ = std::fs::remove_file(&path);
|
let target = std::path::PathBuf::from(&path);
|
||||||
let _ = std::fs::remove_file(format!("{}.part", path));
|
let _ = std::fs::remove_file(&target);
|
||||||
|
let _ = std::fs::remove_file(crate::download::worker::partial_path(&target));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(deleted_count as i64)
|
Ok(deleted_count as i64)
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
//! Pushing favourite toggles made while the server was unreachable.
|
||||||
|
//!
|
||||||
|
//! Favouriting works offline: `storage_toggle_favorite` writes the local
|
||||||
|
//! `user_data` row and sets `pending_sync = 1`. Until DR-120 nothing ever
|
||||||
|
//! cleared that flag — the offline `mark_favorite`/`unmark_favorite` are no-ops
|
||||||
|
//! and `syncService.queueFavorite` had no callers — so an offline toggle was
|
||||||
|
//! silently lost.
|
||||||
|
//!
|
||||||
|
//! The drain lives in Rust, not the frontend, because it must run whether or
|
||||||
|
//! not any view is mounted; a drain started by a component dies with it.
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-069 | DR-120 | UT-103
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use log::{debug, info, warn};
|
||||||
|
use tauri::{Emitter, Listener, Manager};
|
||||||
|
|
||||||
|
use crate::repository::types::RepoError;
|
||||||
|
use crate::repository::MediaRepository;
|
||||||
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
|
||||||
|
|
||||||
|
/// The subset of the repository the drain needs.
|
||||||
|
///
|
||||||
|
/// Narrow on purpose: a test double for `MediaRepository` would be forty
|
||||||
|
/// unimplemented methods, which is how a drain ends up untested.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait FavoriteSink: Send + Sync {
|
||||||
|
async fn push_favorite(&self, item_id: &str, is_favorite: bool) -> Result<(), RepoError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<T: MediaRepository + ?Sized> FavoriteSink for T {
|
||||||
|
async fn push_favorite(&self, item_id: &str, is_favorite: bool) -> Result<(), RepoError> {
|
||||||
|
if is_favorite {
|
||||||
|
self.mark_favorite(item_id).await
|
||||||
|
} else {
|
||||||
|
self.unmark_favorite(item_id).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A local favourite change still waiting to reach the server.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct PendingFavorite {
|
||||||
|
pub item_id: String,
|
||||||
|
pub is_favorite: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read every favourite change this user has pending.
|
||||||
|
async fn read_pending(
|
||||||
|
db: &Arc<RusqliteService>,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<Vec<PendingFavorite>, String> {
|
||||||
|
db.query_many(
|
||||||
|
Query::with_params(
|
||||||
|
"SELECT item_id, is_favorite FROM user_data \
|
||||||
|
WHERE user_id = ? AND pending_sync = 1 AND is_favorite IS NOT NULL",
|
||||||
|
vec![QueryParam::String(user_id.to_string())],
|
||||||
|
),
|
||||||
|
|row| {
|
||||||
|
Ok(PendingFavorite {
|
||||||
|
item_id: row.get::<_, String>(0)?,
|
||||||
|
is_favorite: row.get::<_, Option<i32>>(1)?.unwrap_or(0) != 0,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push pending favourite changes to the server and clear their flags.
|
||||||
|
///
|
||||||
|
/// Returns the ids that reached the server, for the `favorites-changed` event.
|
||||||
|
/// A row whose push fails keeps `pending_sync = 1` and is retried on the next
|
||||||
|
/// reconnect rather than being dropped.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-069 | DR-120 | UT-103
|
||||||
|
pub async fn drain_pending_favorites(
|
||||||
|
db: &Arc<RusqliteService>,
|
||||||
|
sink: &dyn FavoriteSink,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<Vec<String>, String> {
|
||||||
|
let pending = read_pending(db, user_id).await?;
|
||||||
|
if pending.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"[Favorites] Pushing {} favourite change(s) queued while offline",
|
||||||
|
pending.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut pushed = Vec::new();
|
||||||
|
for change in pending {
|
||||||
|
match sink
|
||||||
|
.push_favorite(&change.item_id, change.is_favorite)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => {
|
||||||
|
let cleared = db
|
||||||
|
.execute(Query::with_params(
|
||||||
|
"UPDATE user_data SET pending_sync = 0, synced_at = ? \
|
||||||
|
WHERE user_id = ? AND item_id = ?",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(chrono::Utc::now().to_rfc3339()),
|
||||||
|
QueryParam::String(user_id.to_string()),
|
||||||
|
QueryParam::String(change.item_id.clone()),
|
||||||
|
],
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match cleared {
|
||||||
|
Ok(_) => pushed.push(change.item_id),
|
||||||
|
// The server took it; failing to clear the flag only means
|
||||||
|
// we push it again next time, which is harmless.
|
||||||
|
Err(e) => warn!(
|
||||||
|
"[Favorites] Pushed {} but could not clear pending_sync: {}",
|
||||||
|
change.item_id, e
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Still pending — retried on the next reconnect.
|
||||||
|
debug!(
|
||||||
|
"[Favorites] Deferring {}, server rejected the push: {:?}",
|
||||||
|
change.item_id, e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(pushed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain on every offline→online transition.
|
||||||
|
///
|
||||||
|
/// Hooks the `connectivity:reconnected` event the `ConnectivityMonitor`
|
||||||
|
/// already emits, rather than polling — reachability is derived from real
|
||||||
|
/// traffic (DR-055) and this just reacts to it.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-069 | DR-120
|
||||||
|
pub fn spawn_favorites_drain(app: tauri::AppHandle) {
|
||||||
|
let handle = app.clone();
|
||||||
|
app.listen("connectivity:reconnected", move |_event| {
|
||||||
|
let app = handle.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
if let Err(e) = run_drain(&app).await {
|
||||||
|
warn!("[Favorites] Drain skipped: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_drain(app: &tauri::AppHandle) -> Result<(), String> {
|
||||||
|
let db_service: Arc<RusqliteService> = {
|
||||||
|
let db = app.state::<crate::commands::storage::DatabaseWrapper>();
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let (repo, user_id) = {
|
||||||
|
let manager = app.state::<crate::commands::repository::RepositoryManagerWrapper>();
|
||||||
|
let handles = manager.0.handles();
|
||||||
|
let Some(handle) = handles.first() else {
|
||||||
|
// Not signed in — nothing to push on behalf of.
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let repo = manager.0.get(handle).ok_or("Repository not found")?;
|
||||||
|
let user_id = repo.user_id().to_string();
|
||||||
|
(repo, user_id)
|
||||||
|
};
|
||||||
|
|
||||||
|
let pushed = drain_pending_favorites(&db_service, repo.as_ref(), &user_id).await?;
|
||||||
|
|
||||||
|
if !pushed.is_empty() {
|
||||||
|
let event = crate::commands::repository::FavoritesChangedEvent { item_ids: pushed };
|
||||||
|
if let Err(e) = app.emit(crate::commands::repository::FAVORITES_CHANGED_EVENT, &event) {
|
||||||
|
warn!("[Favorites] Failed to emit change event: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
/// Records what the server was asked to do, and can be told to fail.
|
||||||
|
struct RecordingSink {
|
||||||
|
calls: Mutex<Vec<(String, bool)>>,
|
||||||
|
fail_for: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecordingSink {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
calls: Mutex::new(Vec::new()),
|
||||||
|
fail_for: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn failing_for(item_id: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
calls: Mutex::new(Vec::new()),
|
||||||
|
fail_for: Some(item_id.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calls(&self) -> Vec<(String, bool)> {
|
||||||
|
let mut calls = self.calls.lock().unwrap().clone();
|
||||||
|
calls.sort();
|
||||||
|
calls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl FavoriteSink for RecordingSink {
|
||||||
|
async fn push_favorite(&self, item_id: &str, is_favorite: bool) -> Result<(), RepoError> {
|
||||||
|
if self.fail_for.as_deref() == Some(item_id) {
|
||||||
|
return Err(RepoError::Offline);
|
||||||
|
}
|
||||||
|
self.calls
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push((item_id.to_string(), is_favorite));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_db() -> Arc<RusqliteService> {
|
||||||
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
|
conn.execute_batch(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE user_data (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
item_id TEXT NOT NULL,
|
||||||
|
is_favorite INTEGER,
|
||||||
|
synced_at TEXT,
|
||||||
|
pending_sync INTEGER DEFAULT 0,
|
||||||
|
PRIMARY KEY (user_id, item_id)
|
||||||
|
);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seed(db: &Arc<RusqliteService>, rows: &[(&str, &str, i32, i32)]) {
|
||||||
|
for (user, item, fav, pending) in rows {
|
||||||
|
db.execute(Query::with_params(
|
||||||
|
"INSERT INTO user_data (user_id, item_id, is_favorite, pending_sync) \
|
||||||
|
VALUES (?, ?, ?, ?)",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(user.to_string()),
|
||||||
|
QueryParam::String(item.to_string()),
|
||||||
|
QueryParam::Int(*fav),
|
||||||
|
QueryParam::Int(*pending),
|
||||||
|
],
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pending_flag(db: &Arc<RusqliteService>, item_id: &str) -> Option<i32> {
|
||||||
|
db.query_optional(
|
||||||
|
Query::with_params(
|
||||||
|
"SELECT pending_sync FROM user_data WHERE item_id = ?",
|
||||||
|
vec![QueryParam::String(item_id.to_string())],
|
||||||
|
),
|
||||||
|
|row| row.get::<_, Option<i32>>(0),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UT-103 — the core of the bug: a favourite toggled while offline reaches
|
||||||
|
/// the server on reconnect, and stops being pending.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-069 | DR-120 | UT-103
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_drain_pushes_pending_favorites_and_clears_the_flag() {
|
||||||
|
let db = test_db();
|
||||||
|
seed(
|
||||||
|
&db,
|
||||||
|
&[
|
||||||
|
("u1", "marked-offline", 1, 1),
|
||||||
|
("u1", "unmarked-offline", 0, 1),
|
||||||
|
// Already synced — must not be pushed again.
|
||||||
|
("u1", "already-synced", 1, 0),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let sink = RecordingSink::new();
|
||||||
|
let pushed = drain_pending_favorites(&db, &sink, "u1").await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
sink.calls(),
|
||||||
|
vec![
|
||||||
|
("marked-offline".to_string(), true),
|
||||||
|
("unmarked-offline".to_string(), false),
|
||||||
|
],
|
||||||
|
"both pending changes push, with their direction preserved"
|
||||||
|
);
|
||||||
|
assert_eq!(pushed.len(), 2);
|
||||||
|
assert_eq!(pending_flag(&db, "marked-offline").await, Some(0));
|
||||||
|
assert_eq!(pending_flag(&db, "unmarked-offline").await, Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A push that fails keeps its row pending, so the change is retried rather
|
||||||
|
/// than dropped on the floor.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-069 | DR-120 | UT-103
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_drain_leaves_failed_pushes_pending() {
|
||||||
|
let db = test_db();
|
||||||
|
seed(&db, &[("u1", "ok", 1, 1), ("u1", "boom", 1, 1)]).await;
|
||||||
|
|
||||||
|
let sink = RecordingSink::failing_for("boom");
|
||||||
|
let pushed = drain_pending_favorites(&db, &sink, "u1").await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(pushed, vec!["ok".to_string()]);
|
||||||
|
assert_eq!(pending_flag(&db, "ok").await, Some(0));
|
||||||
|
assert_eq!(
|
||||||
|
pending_flag(&db, "boom").await,
|
||||||
|
Some(1),
|
||||||
|
"a failed push must stay queued for the next reconnect"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Another user's queued changes are not pushed with this user's token.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-069 | DR-120 | UT-103
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_drain_only_touches_the_given_user() {
|
||||||
|
let db = test_db();
|
||||||
|
seed(&db, &[("u1", "mine", 1, 1), ("u2", "theirs", 1, 1)]).await;
|
||||||
|
|
||||||
|
let sink = RecordingSink::new();
|
||||||
|
let pushed = drain_pending_favorites(&db, &sink, "u1").await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(pushed, vec!["mine".to_string()]);
|
||||||
|
assert_eq!(pending_flag(&db, "theirs").await, Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nothing pending means no server calls at all — a reconnect must not
|
||||||
|
/// generate traffic just because it happened.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-069 | DR-120 | UT-103
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_drain_is_a_noop_when_nothing_is_pending() {
|
||||||
|
let db = test_db();
|
||||||
|
seed(&db, &[("u1", "synced", 1, 0)]).await;
|
||||||
|
|
||||||
|
let sink = RecordingSink::new();
|
||||||
|
let pushed = drain_pending_favorites(&db, &sink, "u1").await.unwrap();
|
||||||
|
|
||||||
|
assert!(pushed.is_empty());
|
||||||
|
assert!(sink.calls().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ pub mod connectivity;
|
|||||||
pub mod conversions;
|
pub mod conversions;
|
||||||
pub mod device;
|
pub mod device;
|
||||||
pub mod download;
|
pub mod download;
|
||||||
|
pub mod favorites;
|
||||||
pub mod offline;
|
pub mod offline;
|
||||||
pub mod playback_mode;
|
pub mod playback_mode;
|
||||||
pub mod playback_reporting;
|
pub mod playback_reporting;
|
||||||
@@ -16,6 +17,7 @@ pub mod repository;
|
|||||||
pub mod sessions;
|
pub mod sessions;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub mod sync;
|
pub mod sync;
|
||||||
|
pub mod sync_drain;
|
||||||
|
|
||||||
pub use auth::*;
|
pub use auth::*;
|
||||||
pub use catalog::*;
|
pub use catalog::*;
|
||||||
@@ -33,3 +35,4 @@ pub use repository::{RepositoryManager, RepositoryManagerWrapper, *};
|
|||||||
pub use sessions::*;
|
pub use sessions::*;
|
||||||
pub use storage::*;
|
pub use storage::*;
|
||||||
pub use sync::*;
|
pub use sync::*;
|
||||||
|
pub use sync_drain::*;
|
||||||
|
|||||||
@@ -202,6 +202,27 @@ pub struct PlayItemRequest {
|
|||||||
/// look up the next episode when a background-audio track ends.
|
/// look up the next episode when a background-audio track ends.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub series_id: Option<String>,
|
pub series_id: Option<String>,
|
||||||
|
/// Subtitle tracks to sideload, with URLs the frontend has already resolved.
|
||||||
|
///
|
||||||
|
/// Only the native backends use these: on Android they become the
|
||||||
|
/// `MediaItem.SubtitleConfiguration`s ExoPlayer renders. The HTML5 path
|
||||||
|
/// builds its own `<track>` children instead and ignores this list.
|
||||||
|
///
|
||||||
|
/// **Order is the contract.** `player_set_subtitle_track(n)` reaches
|
||||||
|
/// `JellyTauPlayer.setSubtitleTrack(n)`, which indexes into ExoPlayer's
|
||||||
|
/// *text track groups* — i.e. the position of the sideloaded configuration,
|
||||||
|
/// not the Jellyfin stream index (which is kept on each entry for the UI's
|
||||||
|
/// benefit). So `n` must be a position in this very array, and the array
|
||||||
|
/// must not be reordered or filtered between building it and sending it.
|
||||||
|
/// `nativeSubtitleArrayIndex()` on the frontend computes `n` from the same
|
||||||
|
/// list that is sent here, for exactly this reason.
|
||||||
|
///
|
||||||
|
/// Defaulted so the background-audio handoff and the autoplay/next-episode
|
||||||
|
/// callers, which have no subtitles to offer, need not send the field.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-020 | IR-016, JA-008 | UT-145
|
||||||
|
#[serde(default)]
|
||||||
|
pub subtitles: Vec<crate::player::SubtitleTrack>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Queue context for remote transfer - what type of queue is this?
|
/// Queue context for remote transfer - what type of queue is this?
|
||||||
@@ -321,6 +342,29 @@ pub enum AudioTrackSwitchResponse {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Response for a mid-playback streaming-quality change.
|
||||||
|
///
|
||||||
|
/// Mirrors [`AudioTrackSwitchResponse`]: the backend decides whether the caller
|
||||||
|
/// has to reload anything, so no strategy branch lives in the UI.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-074 | DR-162
|
||||||
|
#[derive(specta::Type, Debug, Serialize)]
|
||||||
|
#[serde(tag = "strategy", rename_all = "camelCase")]
|
||||||
|
pub enum StreamQualityResponse {
|
||||||
|
/// The native backend was reloaded here; nothing left for the frontend.
|
||||||
|
Native {
|
||||||
|
/// Position playback resumed at.
|
||||||
|
position: f64,
|
||||||
|
},
|
||||||
|
/// HTML5 must reload its element with this URL.
|
||||||
|
ReloadStream {
|
||||||
|
/// New stream URL, already transcoded to the requested ceiling.
|
||||||
|
new_url: String,
|
||||||
|
/// Position to resume from.
|
||||||
|
position: f64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
/// Helper function to create MediaItem from video request
|
/// Helper function to create MediaItem from video request
|
||||||
///
|
///
|
||||||
/// PlayItemRequest is now video-only, so we create a video MediaItem.
|
/// PlayItemRequest is now video-only, so we create a video MediaItem.
|
||||||
@@ -373,12 +417,81 @@ pub(super) async fn create_media_item(
|
|||||||
needs_transcoding: req.needs_transcoding,
|
needs_transcoding: req.needs_transcoding,
|
||||||
video_width: None, // Not available from video-only request
|
video_width: None, // Not available from video-only request
|
||||||
video_height: None, // Not available from video-only request
|
video_height: None, // Not available from video-only request
|
||||||
subtitles: vec![],
|
// Sideloaded subtitles, in the order the frontend sent them — that order
|
||||||
|
// is what `player_set_subtitle_track(n)` indexes into on Android.
|
||||||
|
// TRACES: UR-020 | IR-016 | UT-145
|
||||||
|
subtitles: req.subtitles,
|
||||||
series_id: None, // Not available from video-only request
|
series_id: None, // Not available from video-only request
|
||||||
server_id: None, // Not available from video-only request
|
server_id: None, // Not available from video-only request
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pick the source for an audio-only handoff.
|
||||||
|
///
|
||||||
|
/// A downloaded file wins over the audio-only stream URL. No transcode or audio
|
||||||
|
/// extraction is involved or wanted: the native backends already play a video
|
||||||
|
/// container without decoding its video — the Linux MPV backend is configured
|
||||||
|
/// with `video: no`, and ExoPlayer simply has no surface to render to when the
|
||||||
|
/// item is `MediaType::Audio`. Producing a separate audio-only file would cost
|
||||||
|
/// CPU and battery, need an encoder the project does not ship, and leave a
|
||||||
|
/// second artifact to keep in step with the first.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-128 | UT-119
|
||||||
|
pub(super) fn background_audio_source(
|
||||||
|
local_path: Option<String>,
|
||||||
|
stream_url: String,
|
||||||
|
item_id: &str,
|
||||||
|
) -> MediaSource {
|
||||||
|
match local_path {
|
||||||
|
Some(path) => MediaSource::Local {
|
||||||
|
file_path: PathBuf::from(path),
|
||||||
|
jellyfin_item_id: Some(item_id.to_string()),
|
||||||
|
},
|
||||||
|
None => MediaSource::Remote {
|
||||||
|
stream_url,
|
||||||
|
jellyfin_item_id: item_id.to_string(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the on-disk file backing a completed download, if there is one.
|
||||||
|
///
|
||||||
|
/// A `downloads` row is not proof of a file: it can outlive the bytes (manual
|
||||||
|
/// deletion, a cleared cache directory, a restored database). Every caller wants
|
||||||
|
/// "can I play this from disk right now", so existence is checked here rather
|
||||||
|
/// than trusted from the row.
|
||||||
|
///
|
||||||
|
/// Split out from [`check_for_local_download`] so the resolution is testable
|
||||||
|
/// without a `DatabaseWrapper`, and reusable by the video path.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-123 | UT-116
|
||||||
|
pub(super) async fn resolve_local_media_path<S: DatabaseService>(
|
||||||
|
db_service: &Arc<S>,
|
||||||
|
item_id: &str,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
let query = Query::with_params(
|
||||||
|
"SELECT file_path FROM downloads WHERE item_id = ? AND status = 'completed' LIMIT 1",
|
||||||
|
vec![QueryParam::String(item_id.to_string())],
|
||||||
|
);
|
||||||
|
|
||||||
|
let path: Option<String> = db_service
|
||||||
|
.query_optional(query, |row| row.get(0))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
match path {
|
||||||
|
Some(ref file_path) if std::path::Path::new(file_path).exists() => Ok(path),
|
||||||
|
Some(file_path) => {
|
||||||
|
warn!(
|
||||||
|
"[Player] Download entry exists in DB but file not found: {}",
|
||||||
|
file_path
|
||||||
|
);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if an item has a completed download
|
/// Check if an item has a completed download
|
||||||
pub(super) async fn check_for_local_download(
|
pub(super) async fn check_for_local_download(
|
||||||
db: &DatabaseWrapper,
|
db: &DatabaseWrapper,
|
||||||
@@ -389,30 +502,33 @@ pub(super) async fn check_for_local_download(
|
|||||||
Arc::new(database.service())
|
Arc::new(database.service())
|
||||||
};
|
};
|
||||||
|
|
||||||
let query = Query::with_params(
|
resolve_local_media_path(&db_service, item_id).await
|
||||||
"SELECT file_path FROM downloads WHERE item_id = ? AND status = 'completed' LIMIT 1",
|
}
|
||||||
vec![QueryParam::String(item_id.to_string())],
|
|
||||||
);
|
|
||||||
|
|
||||||
let path: Option<String> = db_service
|
/// The on-disk path for a downloaded item, for playback surfaces that resolve
|
||||||
.query_optional(query, |row| row.get(0))
|
/// their own source rather than going through the queue.
|
||||||
.await
|
///
|
||||||
.map_err(|e| e.to_string())?;
|
/// The video player is the reason this exists: audio has preferred local files
|
||||||
|
/// since queue construction, but video asks the repository for a stream URL and
|
||||||
|
/// never consults `downloads`, so a downloaded film was still streamed — costing
|
||||||
|
/// bandwidth that had already been spent and failing outright when offline.
|
||||||
|
///
|
||||||
|
/// Returns `None` when nothing is downloaded *or* the file is missing, so the
|
||||||
|
/// caller falls back to streaming.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-123 | UT-116
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_local_media_path(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
item_id: String,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
// Verify the file actually exists on disk
|
resolve_local_media_path(&db_service, &item_id).await
|
||||||
if let Some(ref file_path) = path {
|
|
||||||
if std::path::Path::new(file_path).exists() {
|
|
||||||
Ok(path)
|
|
||||||
} else {
|
|
||||||
warn!(
|
|
||||||
"[Player] Download entry exists in DB but file not found: {}",
|
|
||||||
file_path
|
|
||||||
);
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-point queued streaming items at completed local downloads.
|
/// Re-point queued streaming items at completed local downloads.
|
||||||
@@ -574,6 +690,7 @@ pub async fn player_play_item(
|
|||||||
pub async fn player_enter_background_audio(
|
pub async fn player_enter_background_audio(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
session: State<'_, MediaSessionManagerWrapper>,
|
session: State<'_, MediaSessionManagerWrapper>,
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
item: PlayItemRequest,
|
item: PlayItemRequest,
|
||||||
position_seconds: f64,
|
position_seconds: f64,
|
||||||
) -> Result<PlayerStatus, String> {
|
) -> Result<PlayerStatus, String> {
|
||||||
@@ -582,6 +699,19 @@ pub async fn player_enter_background_audio(
|
|||||||
item.title, position_seconds
|
item.title, position_seconds
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Prefer the downloaded file over the audio-only stream URL the frontend
|
||||||
|
// resolved. Handing the native backend a local video container yields
|
||||||
|
// audio-only playback for free — no transcode, no second artifact.
|
||||||
|
// TRACES: UR-071 | DR-128
|
||||||
|
let local_path = check_for_local_download(&db, &item.id).await?;
|
||||||
|
if local_path.is_some() {
|
||||||
|
info!(
|
||||||
|
"player_enter_background_audio: using downloaded file for {}",
|
||||||
|
item.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let source = background_audio_source(local_path, item.stream_url, &item.id);
|
||||||
|
|
||||||
// Build an AUDIO media item pointing at the audio-only stream. We do not use
|
// Build an AUDIO media item pointing at the audio-only stream. We do not use
|
||||||
// create_media_item() because that hardcodes MediaType::Video; background
|
// create_media_item() because that hardcodes MediaType::Video; background
|
||||||
// audio must be Audio so no video decode is started.
|
// audio must be Audio so no video decode is started.
|
||||||
@@ -605,10 +735,7 @@ pub async fn player_enter_background_audio(
|
|||||||
duration: item.duration_seconds,
|
duration: item.duration_seconds,
|
||||||
artwork_url: None,
|
artwork_url: None,
|
||||||
media_type: MediaType::Audio,
|
media_type: MediaType::Audio,
|
||||||
source: MediaSource::Remote {
|
source,
|
||||||
stream_url: item.stream_url,
|
|
||||||
jellyfin_item_id: item.id.clone(),
|
|
||||||
},
|
|
||||||
video_codec: None,
|
video_codec: None,
|
||||||
needs_transcoding: false,
|
needs_transcoding: false,
|
||||||
video_width: None,
|
video_width: None,
|
||||||
@@ -634,7 +761,7 @@ pub async fn player_enter_background_audio(
|
|||||||
// this base to the native player's relative position to get the absolute one.
|
// this base to the native player's relative position to get the absolute one.
|
||||||
// The controller owns it so a backend-driven advance to the next episode
|
// The controller owns it so a backend-driven advance to the next episode
|
||||||
// clears it along with the stream it described.
|
// clears it along with the stream it described.
|
||||||
controller.set_background_audio_base(position_seconds);
|
controller.enter_background_audio(position_seconds);
|
||||||
controller
|
controller
|
||||||
.play_item(media_item)
|
.play_item(media_item)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -666,22 +793,23 @@ pub async fn player_enter_background_audio(
|
|||||||
pub async fn player_exit_background_audio(
|
pub async fn player_exit_background_audio(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
) -> Result<f64, String> {
|
) -> Result<f64, String> {
|
||||||
// Back to foreground playback: the lockscreen scrubber is absolute again.
|
|
||||||
let _ = crate::player::set_lockscreen_position_offset(0.0);
|
|
||||||
|
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
// The base offset (handoff position) + native player's relative position =
|
|
||||||
// the absolute position to resume the video at. Zero after a backend-driven
|
// Read the position BEFORE clearing either base. The position tick applies the
|
||||||
// episode advance, whose stream already starts at its own zero.
|
// base natively, so a tick landing between "base cleared" and "position read"
|
||||||
let base = controller.take_background_audio_base();
|
// would hand back a relative position — the whole bug, reintroduced at the one
|
||||||
// Capture position into a `let` BEFORE stop() — never hold work across a lock
|
// moment it matters most. Capturing into a `let` before stop() is also the
|
||||||
// re-entrant call (deadlock discipline, CLAUDE.md).
|
// lock discipline from CLAUDE.md: never hold work across a re-entrant call.
|
||||||
let relative = controller.position();
|
// (DR-159)
|
||||||
|
let absolute = controller.position();
|
||||||
|
|
||||||
|
// Now safe to tear the handoff down, native side first.
|
||||||
|
let _ = crate::player::set_lockscreen_position_offset(0.0);
|
||||||
|
controller.exit_background_audio();
|
||||||
controller.stop().map_err(|e| e.to_string())?;
|
controller.stop().map_err(|e| e.to_string())?;
|
||||||
let absolute = base + relative;
|
|
||||||
info!(
|
info!(
|
||||||
"player_exit_background_audio: base={:.1}s + relative={:.1}s = {:.1}s",
|
"player_exit_background_audio: resuming the video at {:.1}s",
|
||||||
base, relative, absolute
|
absolute
|
||||||
);
|
);
|
||||||
Ok(absolute)
|
Ok(absolute)
|
||||||
}
|
}
|
||||||
@@ -899,6 +1027,16 @@ pub async fn player_stop(
|
|||||||
.clone()
|
.clone()
|
||||||
};
|
};
|
||||||
client.send_session_command(session_id, "Stop").await?;
|
client.send_session_command(session_id, "Stop").await?;
|
||||||
|
|
||||||
|
// Stopping the remote session ends the cast, so the manager returns to
|
||||||
|
// Idle — same as a local stop. This is also what hands OS volume control
|
||||||
|
// back to this device: set_mode releases the Android remote volume
|
||||||
|
// provider on any exit from remote mode. Without it the mode stayed
|
||||||
|
// Remote and the system volume slider remained stuck on the remote
|
||||||
|
// session with no way back to the local speaker.
|
||||||
|
playback_mode
|
||||||
|
.0
|
||||||
|
.set_mode(crate::playback_mode::PlaybackMode::Idle);
|
||||||
} else {
|
} else {
|
||||||
// Local playback
|
// Local playback
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
@@ -1093,9 +1231,12 @@ pub async fn player_seek(
|
|||||||
let position_ticks = (position * 10_000_000.0) as i64;
|
let position_ticks = (position * 10_000_000.0) as i64;
|
||||||
client.session_seek(session_id, position_ticks).await?;
|
client.session_seek(session_id, position_ticks).await?;
|
||||||
} else {
|
} else {
|
||||||
// Local playback
|
// Local playback. seek_absolute, not seek: the position came from the UI,
|
||||||
|
// which shows the whole item, so during a background-audio handoff it has
|
||||||
|
// to be resolved against the episode's timeline rather than the handoff
|
||||||
|
// stream's. (DR-159)
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
controller.seek(position).map_err(|e| e.to_string())?;
|
controller.seek_absolute(position).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
@@ -1329,6 +1470,112 @@ pub async fn player_switch_audio_track(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Change the bandwidth ceiling of the video that is playing *right now*.
|
||||||
|
///
|
||||||
|
/// A cap is a property of the stream the server is producing, so unlike a volume
|
||||||
|
/// change it cannot be applied to a stream already in flight — the stream has to
|
||||||
|
/// be re-opened at the new quality and resumed at the current position. That is
|
||||||
|
/// the same reload the transcoded-seek and audio-track paths use, and the same
|
||||||
|
/// two-sided split: HTML5 gets the URL back and reloads its own element, while a
|
||||||
|
/// native backend is reloaded here.
|
||||||
|
///
|
||||||
|
/// The change applies to this playback *and* to everything started afterwards
|
||||||
|
/// (it sets the process-wide ceiling), but it is deliberately **not** persisted:
|
||||||
|
/// the in-player picker is a "this film, this connection" control, and the
|
||||||
|
/// durable default belongs to Settings. `player_set_video_settings` is the one
|
||||||
|
/// that writes to the database.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-074 | DR-162
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_set_stream_quality(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
||||||
|
video_settings: State<'_, VideoSettingsWrapper>,
|
||||||
|
repository_handle: String,
|
||||||
|
quality: crate::settings::StreamingQuality,
|
||||||
|
use_html5: bool,
|
||||||
|
current_position: Option<f64>,
|
||||||
|
media_source_id: Option<String>,
|
||||||
|
audio_stream_index: Option<i32>,
|
||||||
|
) -> Result<StreamQualityResponse, String> {
|
||||||
|
info!(
|
||||||
|
"[player_set_stream_quality] Switching to {} (use_html5: {}, position: {:?})",
|
||||||
|
quality.label(),
|
||||||
|
use_html5,
|
||||||
|
current_position
|
||||||
|
);
|
||||||
|
|
||||||
|
let repository = repository_manager
|
||||||
|
.0
|
||||||
|
.get(&repository_handle)
|
||||||
|
.ok_or("Repository not found - user may need to log in")?;
|
||||||
|
|
||||||
|
let jellyfin_item_id = {
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
let queue_arc = controller.queue();
|
||||||
|
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let current_item = queue.current().ok_or("No item currently playing")?;
|
||||||
|
|
||||||
|
if current_item.media_type != MediaType::Video {
|
||||||
|
return Err("Current item is not a video".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
current_item
|
||||||
|
.jellyfin_id()
|
||||||
|
.ok_or("Current item has no Jellyfin ID")?
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set the ceiling *before* building the URL — the builder reads it.
|
||||||
|
crate::repository::online::set_streaming_quality(quality);
|
||||||
|
{
|
||||||
|
let mut settings = video_settings.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
settings.streaming_quality = quality;
|
||||||
|
}
|
||||||
|
|
||||||
|
let position = current_position.unwrap_or(0.0);
|
||||||
|
let new_url = repository
|
||||||
|
.get_video_stream_url(
|
||||||
|
&jellyfin_item_id,
|
||||||
|
media_source_id.as_deref(),
|
||||||
|
current_position,
|
||||||
|
audio_stream_index,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to get video stream URL: {:?}", e))?;
|
||||||
|
|
||||||
|
if use_html5 {
|
||||||
|
return Ok(StreamQualityResponse::ReloadStream { new_url, position });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Native backend (Android/ExoPlayer): stop, repoint the queue entry at the
|
||||||
|
// new URL, and reload — mirroring `VideoSeekStrategy::BackendReloadStream`.
|
||||||
|
// The URL already carries `StartTimeTicks`, so the reloaded stream begins at
|
||||||
|
// the current position rather than at zero.
|
||||||
|
{
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
controller.stop().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let queue_arc = controller.queue();
|
||||||
|
{
|
||||||
|
let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
|
||||||
|
if !queue.update_current_stream_url(new_url.clone()) {
|
||||||
|
return Err("Failed to update stream URL in queue".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
|
||||||
|
let updated_item = queue.current().ok_or("No current item after URL update")?;
|
||||||
|
controller
|
||||||
|
.load_and_play(updated_item)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(StreamQualityResponse::Native { position })
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn player_set_audio_track(
|
pub async fn player_set_audio_track(
|
||||||
@@ -1550,6 +1797,43 @@ pub async fn player_get_queue(
|
|||||||
Ok(get_queue_status(&controller))
|
Ok(get_queue_status(&controller))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What playback facilities this platform's backend actually provides.
|
||||||
|
///
|
||||||
|
/// The frontend is presentation-only and must not re-derive backend facts from
|
||||||
|
/// `navigator.userAgent` — that sniffing was a second copy of the same platform
|
||||||
|
/// decision Rust already makes with `cfg!`, and it drifted. These flags are the
|
||||||
|
/// single source of truth; the frontend consumes them.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
|
||||||
|
#[derive(specta::Type, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PlaybackCapabilities {
|
||||||
|
/// True when audio is rendered by a webview `<audio>` element rather than a
|
||||||
|
/// native backend. Native audio exists on Linux (mpv) and Android
|
||||||
|
/// (ExoPlayer); everything else (Windows, future desktops) uses the webview.
|
||||||
|
pub uses_webview_audio: bool,
|
||||||
|
/// True when video can be rendered by a native surface composited *behind*
|
||||||
|
/// a transparent webview. Android only: ExoPlayer draws into a SurfaceView
|
||||||
|
/// beneath the WebView. Linux cannot do this (WebKitGTK/Wayland
|
||||||
|
/// compositing), so it stays on the HTML5 element.
|
||||||
|
pub supports_native_video: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Report this platform's playback capabilities to the frontend.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
|
||||||
|
// Mirrors the cfg gates the backends themselves are built under.
|
||||||
|
let native_audio = cfg!(any(target_os = "android", target_os = "linux"));
|
||||||
|
|
||||||
|
Ok(PlaybackCapabilities {
|
||||||
|
uses_webview_audio: !native_audio,
|
||||||
|
supports_native_video: cfg!(target_os = "android"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
|
pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
|
||||||
// Determine backend at compile time based on platform
|
// Determine backend at compile time based on platform
|
||||||
let (backend, use_html5_element) = if cfg!(target_os = "android") {
|
let (backend, use_html5_element) = if cfg!(target_os = "android") {
|
||||||
@@ -2379,6 +2663,263 @@ pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> R
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
/// The subtitle list the frontend resolved must survive the IPC hop and end
|
||||||
|
/// up on the `MediaItem` the native backend loads.
|
||||||
|
///
|
||||||
|
/// The bug: `VideoPlayer.svelte` built a fully-resolved subtitle array and
|
||||||
|
/// then dropped it on the floor — `PlayItemRequest` had no field to put it
|
||||||
|
/// in — so `create_media_item` always produced `subtitles: vec![]`,
|
||||||
|
/// `android/mod.rs` serialized `[]` across JNI, and ExoPlayer was handed a
|
||||||
|
/// `MediaItem` with zero `SubtitleConfiguration`s. Every later
|
||||||
|
/// `setSubtitleTrack(n)` then found no text track groups and logged
|
||||||
|
/// "Invalid subtitle track index".
|
||||||
|
///
|
||||||
|
/// The payload below is exactly what the frontend sends: camelCase for the
|
||||||
|
/// top-level command params (Tauri v2 converts them), and the subtitle
|
||||||
|
/// entries in the casing of `SubtitleTrack` itself — note `mime_type`.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-020 | IR-016 | UT-145
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_play_item_request_carries_subtitles_into_media_item() {
|
||||||
|
use super::{create_media_item, PlayItemRequest};
|
||||||
|
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"id": "ep-1",
|
||||||
|
"title": "Pilot",
|
||||||
|
"streamUrl": "https://jelly.example/Videos/ep-1/master.m3u8",
|
||||||
|
"videoCodec": "h264",
|
||||||
|
"needsTranscoding": false,
|
||||||
|
"subtitles": [
|
||||||
|
{
|
||||||
|
"index": 2,
|
||||||
|
"url": "https://jelly.example/Videos/ep-1/2/Subtitles/subtitles.vtt",
|
||||||
|
"language": "eng",
|
||||||
|
"label": "English (SRT)",
|
||||||
|
"mime_type": "text/vtt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 3,
|
||||||
|
"url": "https://jelly.example/Videos/ep-1/3/Subtitles/subtitles.vtt",
|
||||||
|
"language": null,
|
||||||
|
"label": null,
|
||||||
|
"mime_type": "text/vtt"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
let req: PlayItemRequest =
|
||||||
|
serde_json::from_value(payload).expect("frontend payload must deserialize");
|
||||||
|
assert_eq!(
|
||||||
|
req.subtitles.len(),
|
||||||
|
2,
|
||||||
|
"PlayItemRequest must carry the subtitle tracks, not silently ignore them"
|
||||||
|
);
|
||||||
|
|
||||||
|
let media = create_media_item(req, None).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
media.subtitles.len(),
|
||||||
|
2,
|
||||||
|
"create_media_item must thread the tracks onto the MediaItem the backend loads"
|
||||||
|
);
|
||||||
|
assert_eq!(media.subtitles[0].index, 2);
|
||||||
|
assert_eq!(media.subtitles[0].language.as_deref(), Some("eng"));
|
||||||
|
assert_eq!(media.subtitles[0].label.as_deref(), Some("English (SRT)"));
|
||||||
|
assert_eq!(media.subtitles[0].mime_type, "text/vtt");
|
||||||
|
// Order is the contract: `player_set_subtitle_track(n)` is a position in
|
||||||
|
// this list (see the note on `PlayItemRequest::subtitles`).
|
||||||
|
assert_eq!(media.subtitles[1].index, 3);
|
||||||
|
assert!(media.subtitles[1].language.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A request without subtitles must still deserialize — the field is
|
||||||
|
/// defaulted so the background-audio handoff and the autoplay/next-episode
|
||||||
|
/// callers keep compiling and sending what they always sent.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-020 | IR-016 | UT-145
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_play_item_request_without_subtitles_defaults_to_empty() {
|
||||||
|
use super::{create_media_item, PlayItemRequest};
|
||||||
|
|
||||||
|
let req: PlayItemRequest = serde_json::from_value(serde_json::json!({
|
||||||
|
"id": "movie-1",
|
||||||
|
"title": "Movie",
|
||||||
|
"streamUrl": "https://jelly.example/Videos/movie-1/stream.mp4",
|
||||||
|
"videoCodec": "h264",
|
||||||
|
"needsTranscoding": false
|
||||||
|
}))
|
||||||
|
.expect("a subtitle-less payload must still deserialize");
|
||||||
|
|
||||||
|
assert!(req.subtitles.is_empty());
|
||||||
|
assert!(create_media_item(req, None)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.subtitles
|
||||||
|
.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The JSON handed to Kotlin over JNI must use the keys
|
||||||
|
/// `JellyTauPlayer.load()` actually reads.
|
||||||
|
///
|
||||||
|
/// `MediaItem` is `rename_all = "camelCase"`, and the instinct (and the
|
||||||
|
/// house IPC rule) is to camelCase nested structs too — but
|
||||||
|
/// `JellyTauPlayer.kt` reads `subtitle.optString("mime_type", …)`. Renaming
|
||||||
|
/// the field would not fail to compile or fail the IPC; it would silently
|
||||||
|
/// fall back to the default MIME type for every track, so this is asserted
|
||||||
|
/// on the exact bytes `android/mod.rs` sends.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-020 | IR-016, JA-008 | UT-146
|
||||||
|
#[test]
|
||||||
|
fn test_subtitle_json_for_jni_uses_the_keys_kotlin_reads() {
|
||||||
|
use crate::player::media::SubtitleTrack;
|
||||||
|
|
||||||
|
let subtitles = vec![SubtitleTrack {
|
||||||
|
index: 2,
|
||||||
|
url: "https://jelly.example/subs.vtt".to_string(),
|
||||||
|
language: Some("eng".to_string()),
|
||||||
|
label: Some("English".to_string()),
|
||||||
|
mime_type: "text/vtt".to_string(),
|
||||||
|
}];
|
||||||
|
|
||||||
|
// Exactly what player/android/mod.rs passes to loadWithMetadata.
|
||||||
|
let json = serde_json::to_string(&subtitles).unwrap();
|
||||||
|
let parsed: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
|
||||||
|
let obj = parsed[0].as_object().unwrap();
|
||||||
|
|
||||||
|
for key in ["url", "language", "label", "mime_type"] {
|
||||||
|
assert!(
|
||||||
|
obj.contains_key(key),
|
||||||
|
"JellyTauPlayer.load() reads `{key}`; serialized keys were {:?}",
|
||||||
|
obj.keys().collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!obj.contains_key("mimeType"),
|
||||||
|
"camelCasing mime_type silently drops every track's MIME type on Android"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The audio-only handoff must play a downloaded file when there is one,
|
||||||
|
/// rather than fetching an audio-only stream for media already on disk.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-128 | UT-119
|
||||||
|
#[test]
|
||||||
|
fn test_background_audio_source_prefers_local_file() {
|
||||||
|
use super::background_audio_source;
|
||||||
|
use crate::player::MediaSource;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
let local = background_audio_source(
|
||||||
|
Some("/downloads/ep1.mkv".to_string()),
|
||||||
|
"https://server/audio-only".to_string(),
|
||||||
|
"ep-1",
|
||||||
|
);
|
||||||
|
match local {
|
||||||
|
MediaSource::Local {
|
||||||
|
file_path,
|
||||||
|
jellyfin_item_id,
|
||||||
|
} => {
|
||||||
|
assert_eq!(file_path, PathBuf::from("/downloads/ep1.mkv"));
|
||||||
|
// The Jellyfin id must survive so progress still syncs back.
|
||||||
|
assert_eq!(jellyfin_item_id.as_deref(), Some("ep-1"));
|
||||||
|
}
|
||||||
|
other => panic!("expected a local source, got {:?}", other),
|
||||||
|
}
|
||||||
|
|
||||||
|
let remote = background_audio_source(None, "https://server/audio-only".to_string(), "ep-1");
|
||||||
|
match remote {
|
||||||
|
MediaSource::Remote {
|
||||||
|
stream_url,
|
||||||
|
jellyfin_item_id,
|
||||||
|
} => {
|
||||||
|
assert_eq!(stream_url, "https://server/audio-only");
|
||||||
|
assert_eq!(jellyfin_item_id, "ep-1");
|
||||||
|
}
|
||||||
|
other => panic!("expected a remote source, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A downloaded item must resolve to its file, and a `downloads` row whose
|
||||||
|
/// file has gone must resolve to `None` so the caller falls back to
|
||||||
|
/// streaming instead of handing the player a path that cannot be opened.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-123 | UT-116
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_resolve_local_media_path() {
|
||||||
|
use super::resolve_local_media_path;
|
||||||
|
use crate::storage::db_service::{DatabaseService, Query, RusqliteService};
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE downloads (id INTEGER PRIMARY KEY, item_id TEXT, status TEXT, file_path TEXT)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let db_service = Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))));
|
||||||
|
|
||||||
|
// A real file on disk, so the existence check passes.
|
||||||
|
let present = std::env::temp_dir().join("jellytau-resolve-local-test.mp4");
|
||||||
|
std::fs::write(&present, b"x").unwrap();
|
||||||
|
let present_str = present.to_string_lossy().to_string();
|
||||||
|
|
||||||
|
for (item, status, path) in [
|
||||||
|
("downloaded", "completed", present_str.as_str()),
|
||||||
|
("still-going", "downloading", present_str.as_str()),
|
||||||
|
(
|
||||||
|
"file-gone",
|
||||||
|
"completed",
|
||||||
|
"/nonexistent/jellytau/missing.mp4",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
db_service
|
||||||
|
.execute(Query::with_params(
|
||||||
|
"INSERT INTO downloads (item_id, status, file_path) VALUES (?, ?, ?)",
|
||||||
|
vec![
|
||||||
|
crate::storage::db_service::QueryParam::String(item.to_string()),
|
||||||
|
crate::storage::db_service::QueryParam::String(status.to_string()),
|
||||||
|
crate::storage::db_service::QueryParam::String(path.to_string()),
|
||||||
|
],
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
resolve_local_media_path(&db_service, "downloaded")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.as_deref(),
|
||||||
|
Some(present_str.as_str()),
|
||||||
|
"a completed download with its file present must resolve"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
resolve_local_media_path(&db_service, "still-going")
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
None,
|
||||||
|
"an in-progress download is not playable from disk"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
resolve_local_media_path(&db_service, "file-gone")
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
None,
|
||||||
|
"a row whose file has gone must fall back to streaming, not hand over a dead path"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
resolve_local_media_path(&db_service, "never-heard-of-it")
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&present);
|
||||||
|
}
|
||||||
|
|
||||||
/// Queue items enqueued as Remote must flip to Local once a completed
|
/// Queue items enqueued as Remote must flip to Local once a completed
|
||||||
/// download exists on disk — this is what makes preloaded tracks (and
|
/// download exists on disk — this is what makes preloaded tracks (and
|
||||||
/// offline playback after a connection drop) actually use the cache.
|
/// offline playback after a connection drop) actually use the cache.
|
||||||
|
|||||||
@@ -1,12 +1,29 @@
|
|||||||
//! Audio and video playback settings commands.
|
//! Audio and video playback settings commands.
|
||||||
//!
|
//!
|
||||||
//! TRACES: UR-022, UR-027, UR-031, UR-032, UR-033 | DR-025, DR-030, DR-034, DR-035, DR-036, IR-020
|
//! TRACES: UR-022, UR-027, UR-031, UR-032, UR-033, UR-074 | DR-025, DR-030, DR-034, DR-035, DR-036, DR-162, IR-020
|
||||||
|
|
||||||
use tauri::State;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use log::{info, warn};
|
||||||
|
use tauri::{Manager, State};
|
||||||
|
|
||||||
use super::{PlayerStateWrapper, VideoSettingsWrapper};
|
use super::{PlayerStateWrapper, VideoSettingsWrapper};
|
||||||
|
use crate::commands::storage::DatabaseWrapper;
|
||||||
use crate::player::AutoplaySettings;
|
use crate::player::AutoplaySettings;
|
||||||
use crate::settings::{AudioSettings, EqPreset, VideoSettings};
|
use crate::settings::{AudioSettings, EqPreset, StreamingQuality, VideoSettings};
|
||||||
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||||
|
use crate::utils::lock::MutexSafe;
|
||||||
|
|
||||||
|
/// `app_settings` key holding the persisted streaming bandwidth ceiling.
|
||||||
|
///
|
||||||
|
/// The cap is persisted (unlike the rest of `VideoSettings`, which is
|
||||||
|
/// process-lifetime state) because forgetting it is the one failure that costs
|
||||||
|
/// the user something real: a limit set for a metered connection that silently
|
||||||
|
/// reverts to uncapped on the next launch spends their data allowance without
|
||||||
|
/// ever showing them a changed setting.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-074 | DR-162
|
||||||
|
const STREAMING_QUALITY_KEY: &str = "streaming_quality";
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
@@ -54,6 +71,7 @@ pub async fn player_get_audio_settings(
|
|||||||
pub async fn player_set_video_settings(
|
pub async fn player_set_video_settings(
|
||||||
video_settings: State<'_, VideoSettingsWrapper>,
|
video_settings: State<'_, VideoSettingsWrapper>,
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
settings: VideoSettings,
|
settings: VideoSettings,
|
||||||
) -> Result<VideoSettings, String> {
|
) -> Result<VideoSettings, String> {
|
||||||
let validated = settings.with_countdown_clamped();
|
let validated = settings.with_countdown_clamped();
|
||||||
@@ -62,6 +80,12 @@ pub async fn player_set_video_settings(
|
|||||||
*current = validated.clone();
|
*current = validated.clone();
|
||||||
} // Drop MutexGuard before await
|
} // Drop MutexGuard before await
|
||||||
|
|
||||||
|
// The bandwidth ceiling is read by the repository's URL builders and by the
|
||||||
|
// PlaybackInfo negotiation, neither of which can see this wrapper.
|
||||||
|
// TRACES: UR-074 | DR-162
|
||||||
|
crate::repository::online::set_streaming_quality(validated.streaming_quality);
|
||||||
|
persist_streaming_quality(&db, validated.streaming_quality).await;
|
||||||
|
|
||||||
// Sync to PlayerController's autoplay settings so on_playback_ended() uses current values
|
// Sync to PlayerController's autoplay settings so on_playback_ended() uses current values
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
controller.set_autoplay_settings(AutoplaySettings {
|
controller.set_autoplay_settings(AutoplaySettings {
|
||||||
@@ -73,6 +97,110 @@ pub async fn player_set_video_settings(
|
|||||||
Ok(validated)
|
Ok(validated)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The bandwidth ceilings the quality picker may offer, each with the label and
|
||||||
|
/// one-line detail to show for it, highest first.
|
||||||
|
///
|
||||||
|
/// The ladder and its numbers are Jellyfin encoding domain vocabulary, so the
|
||||||
|
/// frontend reads them here rather than encoding them — the same arrangement as
|
||||||
|
/// [`player_get_eq_presets`].
|
||||||
|
///
|
||||||
|
/// TRACES: UR-074 | DR-162
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_get_streaming_qualities(
|
||||||
|
) -> Result<Vec<(StreamingQuality, String, String)>, String> {
|
||||||
|
Ok(StreamingQuality::ALL
|
||||||
|
.iter()
|
||||||
|
.map(|q| (*q, q.label().to_string(), q.detail().to_string()))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the ceiling to `app_settings`. Failure is logged, not returned: the
|
||||||
|
/// setting has already been applied in memory, and refusing the whole call
|
||||||
|
/// because the write failed would leave the UI showing a cap that *is* active.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-074 | DR-162
|
||||||
|
async fn persist_streaming_quality(db: &State<'_, DatabaseWrapper>, quality: StreamingQuality) {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock_safe();
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let encoded = match serde_json::to_string(&quality) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("[VideoSettings] Failed to encode streaming quality: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = Query::with_params(
|
||||||
|
"INSERT OR REPLACE INTO app_settings (key, value, updated_at)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP)",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(STREAMING_QUALITY_KEY.to_string()),
|
||||||
|
QueryParam::String(encoded),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Err(e) = db_service.execute(query).await {
|
||||||
|
warn!("[VideoSettings] Failed to persist streaming quality: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore the persisted bandwidth ceiling at startup, into both the repository
|
||||||
|
/// (which enforces it) and `VideoSettings` (which the settings UI reads).
|
||||||
|
///
|
||||||
|
/// Called from the Tauri `setup` hook. A missing or unreadable row leaves the
|
||||||
|
/// default — uncapped — in place, so a database problem degrades to the old
|
||||||
|
/// behaviour rather than to an arbitrary limit.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-074 | DR-162
|
||||||
|
pub async fn restore_streaming_quality(app: &tauri::AppHandle) {
|
||||||
|
let db_service = {
|
||||||
|
let Some(db) = app.try_state::<DatabaseWrapper>() else {
|
||||||
|
warn!("[VideoSettings] No database available; streaming quality stays uncapped");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let database = db.0.lock_safe();
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = Query::with_params(
|
||||||
|
"SELECT value FROM app_settings WHERE key = ?",
|
||||||
|
vec![QueryParam::String(STREAMING_QUALITY_KEY.to_string())],
|
||||||
|
);
|
||||||
|
|
||||||
|
let stored: Option<String> = match db_service.query_optional(query, |row| row.get(0)).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("[VideoSettings] Failed to read streaming quality: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(stored) = stored else { return };
|
||||||
|
let quality: StreamingQuality = match serde_json::from_str(&stored) {
|
||||||
|
Ok(quality) => quality,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
"[VideoSettings] Ignoring unrecognised persisted streaming quality {:?}: {}",
|
||||||
|
stored, e
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
crate::repository::online::set_streaming_quality(quality);
|
||||||
|
if let Some(video_settings) = app.try_state::<VideoSettingsWrapper>() {
|
||||||
|
video_settings.0.lock_safe().streaming_quality = quality;
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
"[VideoSettings] Restored streaming quality cap: {}",
|
||||||
|
quality.label()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn player_get_video_settings(
|
pub async fn player_get_video_settings(
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ pub async fn player_play_next_episode(
|
|||||||
/// - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
|
/// - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
|
||||||
/// - Android JNI callback also triggers this logic directly
|
/// - Android JNI callback also triggers this logic directly
|
||||||
///
|
///
|
||||||
/// TRACES: UR-023, UR-026, UR-040 | DR-047, DR-052
|
/// TRACES: UR-023, UR-026, UR-040 | DR-047, DR-052, DR-129
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn player_on_playback_ended(
|
pub async fn player_on_playback_ended(
|
||||||
@@ -257,11 +257,75 @@ pub async fn player_on_playback_ended(
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AutoplayDecision::ResumeStream { position } => {
|
||||||
|
// The stream was cut short by the network, not by the media ending.
|
||||||
|
// Re-open it where it died — no queue clearing, no PlaybackEnded, and
|
||||||
|
// above all no leaving the player parked in ExoPlayer's STATE_ENDED,
|
||||||
|
// where the next play intent restarts the item from 0:00.
|
||||||
|
log::info!(
|
||||||
|
"[Autoplay] Decision: Resume truncated stream at {:.1}s",
|
||||||
|
position
|
||||||
|
);
|
||||||
|
let controller = controller_arc.lock().await;
|
||||||
|
if let Err(e) = controller.resume_stream_at(position).await {
|
||||||
|
log::error!("[Autoplay] Failed to resume truncated stream: {}", e);
|
||||||
|
if let Some(emitter) = controller.event_emitter() {
|
||||||
|
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Try to recover playback after a **recoverable** player error, reporting
|
||||||
|
/// whether it was handled.
|
||||||
|
///
|
||||||
|
/// The frontend's error handler stops the player, which is right for a real
|
||||||
|
/// failure and wrong for a network blip — it turned every hiccup into "playback
|
||||||
|
/// died". This is the echo path for backends that cannot decide in-process:
|
||||||
|
/// MpvBackend is constructed before `PlayerController` exists ([`lib.rs`]), so
|
||||||
|
/// its event thread has no controller to ask. It emits the error, the frontend
|
||||||
|
/// echoes it here, and the decision stays in Rust — the same shape as
|
||||||
|
/// `PlaybackEnded` → `player_on_playback_ended`.
|
||||||
|
///
|
||||||
|
/// Returns `true` when the stream was re-opened and the caller must NOT stop the
|
||||||
|
/// player; `false` when the error is real and should be surfaced as before.
|
||||||
|
/// Android decides inside its JNI callback and only emits errors it has already
|
||||||
|
/// declined to recover, so this reports `false` for those without a second
|
||||||
|
/// opinion — the shared attempt budget is spent by then either way.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-004, UR-040 | DR-130 | UT-117
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_recover_stream(player: State<'_, PlayerStateWrapper>) -> Result<bool, String> {
|
||||||
|
let (position, delay_secs) = {
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
match controller.recoverable_error_resume() {
|
||||||
|
Some(resume) => resume,
|
||||||
|
None => return Ok(false),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
log::warn!(
|
||||||
|
"[Recovery] Stream failed — re-opening at {:.1}s in {}s",
|
||||||
|
position,
|
||||||
|
delay_secs
|
||||||
|
);
|
||||||
|
// Give a brief outage time to clear; retrying instantly just burns the budget.
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
|
||||||
|
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
match controller.resume_stream_at(position).await {
|
||||||
|
Ok(()) => Ok(true),
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("[Recovery] Failed to re-open stream: {}", e);
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ===== HTML5 video state-report commands =====
|
// ===== HTML5 video state-report commands =====
|
||||||
//
|
//
|
||||||
// On platforms where video renders in the webview (Linux WebKitGTK HTML5
|
// On platforms where video renders in the webview (Linux WebKitGTK HTML5
|
||||||
|
|||||||
@@ -41,6 +41,19 @@ impl RepositoryManager {
|
|||||||
repos.get(handle).cloned()
|
repos.get(handle).cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handles of every live repository.
|
||||||
|
///
|
||||||
|
/// The background catalog indexer (DR-109) runs outside any command, so it
|
||||||
|
/// has no handle passed in and needs to discover one. In practice there is a
|
||||||
|
/// single signed-in repository; returning all of them avoids inventing an
|
||||||
|
/// "active" concept the rest of the code does not have.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-065 | DR-109
|
||||||
|
pub fn handles(&self) -> Vec<String> {
|
||||||
|
let repos = self.repositories.lock_safe();
|
||||||
|
repos.keys().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn destroy(&self, handle: &str) {
|
pub fn destroy(&self, handle: &str) {
|
||||||
let mut repos = self.repositories.lock_safe();
|
let mut repos = self.repositories.lock_safe();
|
||||||
repos.remove(handle);
|
repos.remove(handle);
|
||||||
@@ -699,9 +712,19 @@ pub async fn repository_report_playback_progress(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Report playback stopped
|
/// Report playback stopped
|
||||||
|
///
|
||||||
|
/// A stop-report that cannot reach the server is queued rather than dropped:
|
||||||
|
/// this is the position the resume point is built from, and losing it is
|
||||||
|
/// exactly the "it forgot where I was" the sync queue exists to prevent. The
|
||||||
|
/// drain (DR-131) pushes it on the next reconnect. Queueing is best-effort —
|
||||||
|
/// failing the command because the *queue* write failed would tell the caller
|
||||||
|
/// the report was lost when the local position was already saved.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-025 | DR-154 | UT-151
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn repository_report_playback_stopped(
|
pub async fn repository_report_playback_stopped(
|
||||||
|
db: State<'_, crate::commands::storage::DatabaseWrapper>,
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
item_id: String,
|
item_id: String,
|
||||||
@@ -710,10 +733,39 @@ pub async fn repository_report_playback_stopped(
|
|||||||
// Milliseconds across the boundary; the Jellyfin API wants ticks.
|
// Milliseconds across the boundary; the Jellyfin API wants ticks.
|
||||||
let position_ticks = position_ms * 10_000;
|
let position_ticks = position_ms * 10_000;
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
repo.as_ref()
|
|
||||||
|
let result = repo
|
||||||
|
.as_ref()
|
||||||
.report_playback_stopped(&item_id, position_ticks)
|
.report_playback_stopped(&item_id, position_ticks)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(e) = &result {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|err| err.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
let user_id = repo.user_id().to_string();
|
||||||
|
if let Err(queue_err) = crate::commands::sync_drain::enqueue_playback_stopped(
|
||||||
|
&db_service,
|
||||||
|
&user_id,
|
||||||
|
&item_id,
|
||||||
|
position_ticks,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))
|
{
|
||||||
|
warn!(
|
||||||
|
"[Repository] Stop-report for {} failed ({:?}) and could not be queued: {}",
|
||||||
|
item_id, e, queue_err
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
debug!(
|
||||||
|
"[Repository] Stop-report for {} failed ({:?}); queued for the next reconnect",
|
||||||
|
item_id, e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get image URL for an item
|
/// Get image URL for an item
|
||||||
@@ -752,7 +804,7 @@ pub fn repository_get_subtitle_url(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn repository_get_video_download_url(
|
pub async fn repository_get_video_download_url(
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
item_id: String,
|
item_id: String,
|
||||||
@@ -760,9 +812,16 @@ pub fn repository_get_video_download_url(
|
|||||||
media_source_id: Option<String>,
|
media_source_id: Option<String>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
Ok(repo
|
// Async because the audio-codec policy has to know what the source's audio
|
||||||
.as_ref()
|
// is before it can decide whether the file may be copied verbatim (DR-171).
|
||||||
.get_video_download_url(&item_id, &quality, media_source_id.as_deref()))
|
// The frontend calls this exactly as before — the decision stays in Rust.
|
||||||
|
Ok(crate::repository::resolve_video_download_url(
|
||||||
|
repo.as_ref(),
|
||||||
|
&item_id,
|
||||||
|
&quality,
|
||||||
|
media_source_id.as_deref(),
|
||||||
|
)
|
||||||
|
.await)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark an item as favorite
|
/// Mark an item as favorite
|
||||||
@@ -780,6 +839,125 @@ pub async fn repository_mark_favorite(
|
|||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tauri event announcing that favourite state changed behind the UI's back —
|
||||||
|
/// either because the server disagreed with the cache on a background refresh,
|
||||||
|
/// or because pending offline toggles were pushed on reconnect.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-069 | DR-120
|
||||||
|
pub const FAVORITES_CHANGED_EVENT: &str = "favorites-changed";
|
||||||
|
|
||||||
|
/// Payload for [`FAVORITES_CHANGED_EVENT`] — the ids whose favourite state
|
||||||
|
/// actually flipped, so the frontend refreshes those rather than everything.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-069 | DR-120 | UT-107
|
||||||
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FavoritesChangedEvent {
|
||||||
|
pub item_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ids whose favourite state differs between what we showed and what the server
|
||||||
|
/// has — favourited elsewhere since the cache was written, or un-favourited
|
||||||
|
/// elsewhere.
|
||||||
|
///
|
||||||
|
/// Pulled out of the command so the "emit nothing when nothing changed" rule is
|
||||||
|
/// testable: an unchanged set must leave a quiet page quiet rather than
|
||||||
|
/// triggering a refetch on every visit.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-069 | DR-120 | UT-107
|
||||||
|
fn changed_favorite_ids(
|
||||||
|
cached: &std::collections::HashSet<String>,
|
||||||
|
server: &std::collections::HashSet<String>,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let mut changed: Vec<String> = server.symmetric_difference(cached).cloned().collect();
|
||||||
|
// Deterministic order so the event payload does not depend on hash seeding.
|
||||||
|
changed.sort();
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the viewer has favourited, across libraries, narrowed by scope.
|
||||||
|
///
|
||||||
|
/// Two-phase like `repository_search`: the local answer returns immediately and
|
||||||
|
/// a background server pass emits `favorites-changed` when the server's set
|
||||||
|
/// differs. Without the second phase a favourite marked in another client shows
|
||||||
|
/// up only on the *second* visit to the page, since the cache-first read hands
|
||||||
|
/// back local rows and the refresh is invisible to the frontend.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-067 | DR-115, DR-120, JA-033 | UT-107
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn repository_get_favorites(
|
||||||
|
app: AppHandle,
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
scope: SearchScope,
|
||||||
|
options: Option<GetItemsOptions>,
|
||||||
|
) -> Result<SearchResult, String> {
|
||||||
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
|
||||||
|
let cache_result = repo
|
||||||
|
.get_favorites_cache_only(scope, options.clone())
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
debug!("[Favorites] Cache miss/timeout: {:?}", e);
|
||||||
|
SearchResult {
|
||||||
|
items: Vec::new(),
|
||||||
|
total_record_count: 0,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// With "Show all server media" off the local answer is authoritative
|
||||||
|
// (DR-080) — don't go behind the user's back to the server.
|
||||||
|
if !crate::repository::offline::include_catalog_browse() {
|
||||||
|
return Ok(cache_result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing cached yet — a fresh install, or a viewer whose favourites were
|
||||||
|
// all marked on another client. Returning the empty result here paints
|
||||||
|
// "Nothing favourited yet — tap the heart on anything you like", which is a
|
||||||
|
// *wrong* answer, corrected a server round trip later when the background
|
||||||
|
// refresh fires `favorites-changed`. Ask the repository for a real answer
|
||||||
|
// instead: its `get_favorites` is exactly this read — cache first, server on
|
||||||
|
// a miss, saving through — and it applies the same DR-080 gate.
|
||||||
|
//
|
||||||
|
// TRACES: UR-067 | DR-115
|
||||||
|
if !cache_result.has_content() {
|
||||||
|
debug!("[Favorites] Nothing cached; answering from the server");
|
||||||
|
return repo
|
||||||
|
.get_favorites(scope, options)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{:?}", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
let repo_bg = repo.clone();
|
||||||
|
let cached_ids: std::collections::HashSet<String> =
|
||||||
|
cache_result.items.iter().map(|i| i.id.clone()).collect();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
match repo_bg.get_favorites_server_only(scope, options).await {
|
||||||
|
Ok(server_result) => {
|
||||||
|
let server_ids: std::collections::HashSet<String> =
|
||||||
|
server_result.items.iter().map(|i| i.id.clone()).collect();
|
||||||
|
let changed = changed_favorite_ids(&cached_ids, &server_ids);
|
||||||
|
|
||||||
|
if !changed.is_empty() {
|
||||||
|
let event = FavoritesChangedEvent { item_ids: changed };
|
||||||
|
if let Err(e) = app.emit(FAVORITES_CHANGED_EVENT, &event) {
|
||||||
|
error!("[Favorites] Failed to emit change event: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
"[Favorites] Server refresh failed, keeping cached favourites: {:?}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(cache_result)
|
||||||
|
}
|
||||||
|
|
||||||
/// Unmark an item as favorite
|
/// Unmark an item as favorite
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
@@ -853,6 +1031,44 @@ mod tests {
|
|||||||
assert!(manager.get("any-handle").is_none());
|
assert!(manager.get("any-handle").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ids(values: &[&str]) -> std::collections::HashSet<String> {
|
||||||
|
values.iter().map(|v| v.to_string()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UT-107 — the background refresh reports only what actually changed.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-069 | DR-120 | UT-107
|
||||||
|
#[test]
|
||||||
|
fn test_changed_favorite_ids_reports_both_directions() {
|
||||||
|
// Favourited in another client since we cached.
|
||||||
|
assert_eq!(
|
||||||
|
changed_favorite_ids(&ids(&["a"]), &ids(&["a", "b"])),
|
||||||
|
vec!["b".to_string()]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Un-favourited in another client.
|
||||||
|
assert_eq!(
|
||||||
|
changed_favorite_ids(&ids(&["a", "b"]), &ids(&["a"])),
|
||||||
|
vec!["b".to_string()]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Both at once, in a stable order.
|
||||||
|
assert_eq!(
|
||||||
|
changed_favorite_ids(&ids(&["a", "b"]), &ids(&["b", "c"])),
|
||||||
|
vec!["a".to_string(), "c".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An unchanged set emits nothing — otherwise every visit to the page would
|
||||||
|
/// fire an event and trigger a pointless refetch.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-069 | DR-120 | UT-107
|
||||||
|
#[test]
|
||||||
|
fn test_changed_favorite_ids_is_empty_when_nothing_moved() {
|
||||||
|
assert!(changed_favorite_ids(&ids(&["a", "b"]), &ids(&["b", "a"])).is_empty());
|
||||||
|
assert!(changed_favorite_ids(&ids(&[]), &ids(&[])).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_repository_manager_wrapper_structure() {
|
fn test_repository_manager_wrapper_structure() {
|
||||||
let manager = RepositoryManager::new();
|
let manager = RepositoryManager::new();
|
||||||
|
|||||||
@@ -80,6 +80,30 @@ pub fn storage_init(db: State<DatabaseWrapper>) -> Result<String, String> {
|
|||||||
Ok(database.path().to_string_lossy().to_string())
|
Ok(database.path().to_string_lossy().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A playable URL for a downloaded file on disk.
|
||||||
|
///
|
||||||
|
/// Local media is served over a loopback HTTP server rather than handed to the
|
||||||
|
/// webview as a `file://`/asset URL, because the asset protocol cannot stream a
|
||||||
|
/// large file — it answers a range-less request with the whole thing, which
|
||||||
|
/// Chromium abandons. See `media_server` for why real HTTP is used.
|
||||||
|
///
|
||||||
|
/// The returned URL carries the server's per-session token, so it is only valid
|
||||||
|
/// for this run of the app and must not be persisted.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-137
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn media_local_url(
|
||||||
|
server: State<crate::media_server::MediaServerWrapper>,
|
||||||
|
path: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
server
|
||||||
|
.0
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.url_for(&path))
|
||||||
|
.ok_or_else(|| "Local media server is not running".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// Get storage directory path (parent directory of the database file)
|
/// Get storage directory path (parent directory of the database file)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
@@ -843,6 +867,86 @@ pub async fn storage_mark_played(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the watched flag locally for an item **and everything inside it**.
|
||||||
|
///
|
||||||
|
/// This backs the watched toggle, and is deliberately separate from
|
||||||
|
/// [`storage_mark_played`] — which reports a single track/episode finishing and
|
||||||
|
/// increments `play_count` — because the toggle has two directions and applies
|
||||||
|
/// to containers.
|
||||||
|
///
|
||||||
|
/// The recursion is what makes the toggle honest offline. Jellyfin applies
|
||||||
|
/// `POST`/`DELETE /PlayedItems/{id}` recursively over a season or series, so
|
||||||
|
/// online the server fixes up the children on the next read; with no server to
|
||||||
|
/// ask, marking a season watched would otherwise tick the season and leave every
|
||||||
|
/// episode inside it unwatched. Targets are drawn from `items` by the same link
|
||||||
|
/// columns the rest of the offline layer uses, so an id that is not cached
|
||||||
|
/// selects nothing and the statement is a no-op rather than a foreign-key error.
|
||||||
|
///
|
||||||
|
/// Un-marking clears the resume position too, matching the server, so an item
|
||||||
|
/// un-marked offline does not come back offering to resume from a position it is
|
||||||
|
/// no longer meant to have.
|
||||||
|
///
|
||||||
|
/// `pending_sync = 1` hands the rows to the sync drain.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-073 | DR-158
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn storage_set_watched(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
user_id: String,
|
||||||
|
item_id: String,
|
||||||
|
watched: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
// The item itself plus its descendants: a season's episodes reach it by
|
||||||
|
// season_id, a series' by series_id, its seasons by parent_id, an album's
|
||||||
|
// tracks by album_id.
|
||||||
|
let targets = "SELECT id FROM items
|
||||||
|
WHERE id = ? OR parent_id = ? OR album_id = ?
|
||||||
|
OR season_id = ? OR series_id = ?";
|
||||||
|
|
||||||
|
let sql = if watched {
|
||||||
|
format!(
|
||||||
|
"INSERT INTO user_data (user_id, item_id, is_played, play_count, last_played_at, pending_sync)
|
||||||
|
SELECT ?, id, 1, 1, CURRENT_TIMESTAMP, 1 FROM ({targets})
|
||||||
|
ON CONFLICT(user_id, item_id) DO UPDATE SET
|
||||||
|
is_played = 1,
|
||||||
|
play_count = MAX(user_data.play_count, 1),
|
||||||
|
last_played_at = CURRENT_TIMESTAMP,
|
||||||
|
pending_sync = 1"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"INSERT INTO user_data (user_id, item_id, is_played, play_count, playback_position_ticks, pending_sync)
|
||||||
|
SELECT ?, id, 0, 0, 0, 1 FROM ({targets})
|
||||||
|
ON CONFLICT(user_id, item_id) DO UPDATE SET
|
||||||
|
is_played = 0,
|
||||||
|
play_count = 0,
|
||||||
|
playback_position_ticks = 0,
|
||||||
|
pending_sync = 1"
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = Query::with_params(
|
||||||
|
sql,
|
||||||
|
vec![
|
||||||
|
QueryParam::String(user_id),
|
||||||
|
QueryParam::String(item_id.clone()),
|
||||||
|
QueryParam::String(item_id.clone()),
|
||||||
|
QueryParam::String(item_id.clone()),
|
||||||
|
QueryParam::String(item_id.clone()),
|
||||||
|
QueryParam::String(item_id.clone()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Get playback progress for an item
|
/// Get playback progress for an item
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
|
|||||||
@@ -47,10 +47,25 @@ pub async fn storage_save_person(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let query = Query::with_params(
|
let query = Query::with_params(
|
||||||
"INSERT OR REPLACE INTO people (
|
// A real UPSERT, not INSERT OR REPLACE — `people` is now backed by the
|
||||||
|
// `people_fts` index (migration 022), and REPLACE would orphan an index
|
||||||
|
// entry on every re-cache: it fires no AFTER DELETE trigger without
|
||||||
|
// `recursive_triggers`, and reassigns the rowid that `content_rowid`
|
||||||
|
// refers to. Same defect as DR-110 fixed for `items`.
|
||||||
|
//
|
||||||
|
// TRACES: UR-065 | DR-110, DR-111
|
||||||
|
"INSERT INTO people (
|
||||||
id, server_id, name, overview, primary_image_tag,
|
id, server_id, name, overview, primary_image_tag,
|
||||||
premiere_date, end_date, synced_at
|
premiere_date, end_date, synced_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
|
) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
server_id = excluded.server_id,
|
||||||
|
name = excluded.name,
|
||||||
|
overview = excluded.overview,
|
||||||
|
primary_image_tag = excluded.primary_image_tag,
|
||||||
|
premiere_date = excluded.premiere_date,
|
||||||
|
end_date = excluded.end_date,
|
||||||
|
synced_at = CURRENT_TIMESTAMP",
|
||||||
vec![
|
vec![
|
||||||
QueryParam::String(person.id),
|
QueryParam::String(person.id),
|
||||||
QueryParam::String(person.server_id),
|
QueryParam::String(person.server_id),
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
//!
|
//!
|
||||||
//! The sync queue stores mutations (favorites, playback progress, etc.)
|
//! The sync queue stores mutations (favorites, playback progress, etc.)
|
||||||
//! that need to be synced to the Jellyfin server when connectivity is restored.
|
//! that need to be synced to the Jellyfin server when connectivity is restored.
|
||||||
//! TRACES: UR-002, UR-017, UR-025 | DR-014
|
//! Draining it lives in `sync_drain` (DR-131); this module is the storage and
|
||||||
|
//! read side the UI lists from (DR-132).
|
||||||
|
//! TRACES: UR-002, UR-017, UR-025 | DR-014, DR-131, DR-132
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -24,6 +26,12 @@ pub struct SyncQueueItem {
|
|||||||
pub retry_count: i32,
|
pub retry_count: i32,
|
||||||
pub created_at: Option<String>,
|
pub created_at: Option<String>,
|
||||||
pub error_message: Option<String>,
|
pub error_message: Option<String>,
|
||||||
|
/// Cached title of the item the operation is about, when the catalog knows
|
||||||
|
/// it. Resolved here rather than by a per-row frontend fetch — the queue
|
||||||
|
/// list is otherwise a wall of opaque ids.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-025 | DR-132
|
||||||
|
pub item_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Queue a mutation for sync to server
|
/// Queue a mutation for sync to server
|
||||||
@@ -74,20 +82,20 @@ pub async fn sync_get_pending(
|
|||||||
Arc::new(database.service())
|
Arc::new(database.service())
|
||||||
};
|
};
|
||||||
|
|
||||||
let sql = if let Some(l) = limit {
|
// The `items` join names the queued item where the catalog has it; a row for
|
||||||
format!(
|
// an item that was never cached still lists, with a null name.
|
||||||
"SELECT id, user_id, operation, item_id, payload, status, retry_count, created_at, error_message
|
// `abandoned` rows (DR-131 gave up on them) are excluded here for the same
|
||||||
FROM sync_queue
|
// reason they are excluded from the count — they are no longer waiting.
|
||||||
WHERE user_id = ? AND status IN ('pending', 'failed')
|
const SELECT: &str = "SELECT q.id, q.user_id, q.operation, q.item_id, q.payload, q.status,
|
||||||
ORDER BY created_at ASC
|
COALESCE(q.retry_count, 0), q.created_at, q.error_message, i.name
|
||||||
LIMIT {}",
|
FROM sync_queue q
|
||||||
l
|
LEFT JOIN items i ON i.id = q.item_id
|
||||||
)
|
WHERE q.user_id = ? AND q.status IN ('pending', 'failed')
|
||||||
} else {
|
ORDER BY q.created_at ASC, q.id ASC";
|
||||||
"SELECT id, user_id, operation, item_id, payload, status, retry_count, created_at, error_message
|
|
||||||
FROM sync_queue
|
let sql = match limit {
|
||||||
WHERE user_id = ? AND status IN ('pending', 'failed')
|
Some(l) => format!("{} LIMIT {}", SELECT, l),
|
||||||
ORDER BY created_at ASC".to_string()
|
None => SELECT.to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let query = Query::with_params(sql, vec![QueryParam::String(user_id)]);
|
let query = Query::with_params(sql, vec![QueryParam::String(user_id)]);
|
||||||
@@ -104,6 +112,7 @@ pub async fn sync_get_pending(
|
|||||||
retry_count: row.get(6)?,
|
retry_count: row.get(6)?,
|
||||||
created_at: row.get(7)?,
|
created_at: row.get(7)?,
|
||||||
error_message: row.get(8)?,
|
error_message: row.get(8)?,
|
||||||
|
item_name: row.get(9)?,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -256,6 +265,7 @@ mod tests {
|
|||||||
retry_count: 0,
|
retry_count: 0,
|
||||||
created_at: Some("2024-02-14T08:00:00Z".to_string()),
|
created_at: Some("2024-02-14T08:00:00Z".to_string()),
|
||||||
error_message: None,
|
error_message: None,
|
||||||
|
item_name: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Should serialize successfully
|
// Should serialize successfully
|
||||||
@@ -280,6 +290,7 @@ mod tests {
|
|||||||
retry_count: 3,
|
retry_count: 3,
|
||||||
created_at: Some("2024-02-14T07:00:00Z".to_string()),
|
created_at: Some("2024-02-14T07:00:00Z".to_string()),
|
||||||
error_message: Some("Connection timeout".to_string()),
|
error_message: Some("Connection timeout".to_string()),
|
||||||
|
item_name: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&item).unwrap();
|
let json = serde_json::to_string(&item).unwrap();
|
||||||
@@ -300,6 +311,7 @@ mod tests {
|
|||||||
retry_count: 0,
|
retry_count: 0,
|
||||||
created_at: None,
|
created_at: None,
|
||||||
error_message: None,
|
error_message: None,
|
||||||
|
item_name: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&item).unwrap();
|
let json = serde_json::to_string(&item).unwrap();
|
||||||
@@ -323,6 +335,7 @@ mod tests {
|
|||||||
retry_count: 0,
|
retry_count: 0,
|
||||||
created_at: None,
|
created_at: None,
|
||||||
error_message: None,
|
error_message: None,
|
||||||
|
item_name: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&item).unwrap();
|
let json = serde_json::to_string(&item).unwrap();
|
||||||
@@ -363,6 +376,7 @@ mod tests {
|
|||||||
retry_count: 0,
|
retry_count: 0,
|
||||||
created_at: None,
|
created_at: None,
|
||||||
error_message: None,
|
error_message: None,
|
||||||
|
item_name: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Simulate retries
|
// Simulate retries
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,12 @@ pub struct CacheConfig {
|
|||||||
pub storage_limit: u64,
|
pub storage_limit: u64,
|
||||||
/// Only cache on WiFi
|
/// Only cache on WiFi
|
||||||
pub wifi_only: bool,
|
pub wifi_only: bool,
|
||||||
|
/// How long a temporary (`download_source = 'auto'`) download lives before
|
||||||
|
/// it is reclaimed, in hours. 0 disables expiry, leaving space pressure as
|
||||||
|
/// the only reclaim trigger.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-127
|
||||||
|
pub temporary_ttl_hours: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for CacheConfig {
|
impl Default for CacheConfig {
|
||||||
@@ -37,6 +43,10 @@ impl Default for CacheConfig {
|
|||||||
album_affinity_threshold: 3,
|
album_affinity_threshold: 3,
|
||||||
storage_limit: 10 * 1024 * 1024 * 1024, // 10GB
|
storage_limit: 10 * 1024 * 1024 * 1024, // 10GB
|
||||||
wifi_only: false, // Allow preloading on any connection by default
|
wifi_only: false, // Allow preloading on any connection by default
|
||||||
|
// A week: long enough that re-watching over a weekend still hits
|
||||||
|
// disk, short enough that a one-off play does not hold space
|
||||||
|
// indefinitely.
|
||||||
|
temporary_ttl_hours: 24 * 7,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,7 +193,92 @@ impl SmartCache {
|
|||||||
current_size + new_size <= storage_limit
|
current_size + new_size <= storage_limit
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Evict least recently used items to make space (async version)
|
/// Reclaim temporary downloads whose life limit has passed.
|
||||||
|
///
|
||||||
|
/// The time-based half of the temporary tier (DR-127); [`evict_lru_async`]
|
||||||
|
/// is the space-pressure half. A row is reclaimed by whichever fires first.
|
||||||
|
///
|
||||||
|
/// Scoped to `download_source = 'auto'` for the same reason eviction is: a
|
||||||
|
/// `'user'` row is someone's own download and has no expiry. `COALESCE`
|
||||||
|
/// guards rows predating migration 012, whose source is NULL and whose
|
||||||
|
/// provenance must therefore be treated as the user's.
|
||||||
|
///
|
||||||
|
/// Expiry is normally *derived* — `completed_at` plus the configured TTL —
|
||||||
|
/// rather than stamped at completion. That means a TTL change applies to
|
||||||
|
/// entries already on disk instead of only to future ones, and entries
|
||||||
|
/// predating the column expire without a backfill. `expires_at` is honoured
|
||||||
|
/// as a per-row override when something sets it.
|
||||||
|
///
|
||||||
|
/// `now` is passed in rather than read from the clock so the policy is
|
||||||
|
/// testable without sleeping. Both sides go through SQLite's `datetime()`
|
||||||
|
/// because `completed_at` is written as `CURRENT_TIMESTAMP`
|
||||||
|
/// (`YYYY-MM-DD HH:MM:SS`) while callers pass RFC-3339 (`…T…+00:00`) — a raw
|
||||||
|
/// string comparison between the two formats is wrong, since `' ' < 'T'`.
|
||||||
|
///
|
||||||
|
/// Returns the number of entries reclaimed.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-127 | UT-120
|
||||||
|
pub async fn reclaim_expired_async<S: DatabaseService>(
|
||||||
|
&self,
|
||||||
|
db_service: &Arc<S>,
|
||||||
|
user_id: &str,
|
||||||
|
now: &str,
|
||||||
|
) -> Result<usize, String> {
|
||||||
|
let ttl_hours = {
|
||||||
|
let config = self.config.lock().map_err(|e| e.to_string())?;
|
||||||
|
config.temporary_ttl_hours
|
||||||
|
};
|
||||||
|
// 0 disables time-based reclaim; space pressure remains the only trigger.
|
||||||
|
if ttl_hours == 0 {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let expired: Vec<(i64, String)> = db_service
|
||||||
|
.query_many(
|
||||||
|
Query::with_params(
|
||||||
|
"SELECT id, file_path FROM downloads
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND COALESCE(download_source, 'user') = 'auto'
|
||||||
|
AND status = 'completed'
|
||||||
|
AND datetime(
|
||||||
|
COALESCE(expires_at, datetime(completed_at, '+' || ? || ' hours'))
|
||||||
|
) < datetime(?)",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(user_id.to_string()),
|
||||||
|
QueryParam::String(ttl_hours.to_string()),
|
||||||
|
QueryParam::String(now.to_string()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let mut reclaimed = 0usize;
|
||||||
|
for (id, file_path) in expired {
|
||||||
|
// Best-effort on the file: a missing one still needs its row gone,
|
||||||
|
// or the sweep retries it forever.
|
||||||
|
let _ = std::fs::remove_file(&file_path);
|
||||||
|
db_service
|
||||||
|
.execute(Query::with_params(
|
||||||
|
"DELETE FROM downloads WHERE id = ?",
|
||||||
|
vec![QueryParam::Int64(id)],
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
reclaimed += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if reclaimed > 0 {
|
||||||
|
info!("[SmartCache] Reclaimed {} expired entries", reclaimed);
|
||||||
|
}
|
||||||
|
Ok(reclaimed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evict least recently used items to make space (async version).
|
||||||
|
///
|
||||||
|
/// The space-pressure half of the temporary tier; [`reclaim_expired_async`]
|
||||||
|
/// is the time-based half.
|
||||||
pub async fn evict_lru_async<S: DatabaseService>(
|
pub async fn evict_lru_async<S: DatabaseService>(
|
||||||
&self,
|
&self,
|
||||||
db_service: &Arc<S>,
|
db_service: &Arc<S>,
|
||||||
@@ -207,10 +302,26 @@ impl SmartCache {
|
|||||||
let to_free = (current_size + space_needed) - limit;
|
let to_free = (current_size + space_needed) - limit;
|
||||||
let mut freed: u64 = 0;
|
let mut freed: u64 = 0;
|
||||||
|
|
||||||
// Get downloads ordered by last access (oldest first)
|
// Only the *temporary* tier is evictable. `download_source = 'auto'` is
|
||||||
|
// precache — the cache put it there, the cache may reclaim it. A 'user'
|
||||||
|
// row is a download someone explicitly asked for; deleting it to make
|
||||||
|
// room for a predictive fetch is data loss, and because the old query
|
||||||
|
// ordered purely by `completed_at ASC` it took the oldest — typically
|
||||||
|
// exactly the film saved for a flight.
|
||||||
|
//
|
||||||
|
// COALESCE, not `= 'auto'` alone: migration 012 added the column with a
|
||||||
|
// 'user' default, but rows predating it can be NULL, and an unknown
|
||||||
|
// provenance must be treated as the user's, never as disposable.
|
||||||
|
//
|
||||||
|
// Freeing less than requested is the correct outcome when only user
|
||||||
|
// downloads remain — the caller surfaces "unable to free enough space"
|
||||||
|
// rather than silently deleting them.
|
||||||
|
//
|
||||||
|
// TRACES: UR-071 | DR-126 | UT-108
|
||||||
let query = Query::with_params(
|
let query = Query::with_params(
|
||||||
"SELECT id, file_size, file_path FROM downloads
|
"SELECT id, file_size, file_path FROM downloads
|
||||||
WHERE user_id = ? AND status = 'completed'
|
WHERE user_id = ? AND status = 'completed'
|
||||||
|
AND COALESCE(download_source, 'user') = 'auto'
|
||||||
ORDER BY completed_at ASC",
|
ORDER BY completed_at ASC",
|
||||||
vec![QueryParam::String(user_id.to_string())],
|
vec![QueryParam::String(user_id.to_string())],
|
||||||
);
|
);
|
||||||
@@ -304,6 +415,205 @@ mod tests {
|
|||||||
assert!(cache.should_precache_queue());
|
assert!(cache.should_precache_queue());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Expiry reclaims only temporary entries that are actually past their life
|
||||||
|
/// limit — never a user's download (which has no expiry), and never a
|
||||||
|
/// temporary entry still within its life.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-127 | UT-120
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_reclaim_expired_only_takes_expired_temporary_entries() {
|
||||||
|
use crate::storage::db_service::{DatabaseService, RusqliteService};
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE downloads (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
user_id TEXT,
|
||||||
|
status TEXT,
|
||||||
|
file_size INTEGER,
|
||||||
|
file_path TEXT,
|
||||||
|
completed_at TEXT,
|
||||||
|
download_source TEXT DEFAULT 'user',
|
||||||
|
expires_at TEXT
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// `completed_at` is in SQLite's CURRENT_TIMESTAMP format (space, not
|
||||||
|
// 'T'), deliberately: the query has to compare it against an RFC-3339
|
||||||
|
// "now" and must not do so as raw strings.
|
||||||
|
for (path, source, completed, expires) in [
|
||||||
|
// Completed long ago, no override => derived expiry has passed.
|
||||||
|
("/tmp/jt-expired.mp4", "auto", "2026-01-01 00:00:00", None),
|
||||||
|
// Completed yesterday => still inside the 7-day default TTL.
|
||||||
|
("/tmp/jt-fresh.mp4", "auto", "2026-05-31 00:00:00", None),
|
||||||
|
// Old, but an explicit override keeps it alive.
|
||||||
|
(
|
||||||
|
"/tmp/jt-override.mp4",
|
||||||
|
"auto",
|
||||||
|
"2026-01-01 00:00:00",
|
||||||
|
Some("2026-12-01T00:00:00+00:00"),
|
||||||
|
),
|
||||||
|
// A user download must never carry an expiry, but assert the sweep
|
||||||
|
// ignores it even if one were somehow set.
|
||||||
|
(
|
||||||
|
"/tmp/jt-user.mp4",
|
||||||
|
"user",
|
||||||
|
"2026-01-01 00:00:00",
|
||||||
|
Some("2026-01-01T00:00:00+00:00"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/tmp/jt-user-noexp.mp4",
|
||||||
|
"user",
|
||||||
|
"2026-01-01 00:00:00",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO downloads (user_id, status, file_size, file_path, download_source, completed_at, expires_at)
|
||||||
|
VALUES ('user1', 'completed', 10, ?1, ?2, ?3, ?4)",
|
||||||
|
rusqlite::params![path, source, completed, expires],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let conn_arc = Arc::new(Mutex::new(conn));
|
||||||
|
let db_service = Arc::new(RusqliteService::new(conn_arc.clone()));
|
||||||
|
let cache = SmartCache::new(CacheConfig::default());
|
||||||
|
|
||||||
|
let reclaimed = cache
|
||||||
|
.reclaim_expired_async(&db_service, "user1", "2026-06-01T00:00:00+00:00")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
reclaimed, 1,
|
||||||
|
"only the expired temporary entry is reclaimed"
|
||||||
|
);
|
||||||
|
|
||||||
|
let surviving: Vec<String> = {
|
||||||
|
let guard = conn_arc.lock_safe();
|
||||||
|
let mut stmt = guard
|
||||||
|
.prepare("SELECT file_path FROM downloads ORDER BY id")
|
||||||
|
.unwrap();
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| row.get::<_, String>(0))
|
||||||
|
.unwrap()
|
||||||
|
.map(|r| r.unwrap())
|
||||||
|
.collect();
|
||||||
|
rows
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
surviving,
|
||||||
|
vec![
|
||||||
|
"/tmp/jt-fresh.mp4".to_string(),
|
||||||
|
"/tmp/jt-override.mp4".to_string(),
|
||||||
|
"/tmp/jt-user.mp4".to_string(),
|
||||||
|
"/tmp/jt-user-noexp.mp4".to_string(),
|
||||||
|
],
|
||||||
|
"entries within their life, those with a later override, and every user download must survive"
|
||||||
|
);
|
||||||
|
|
||||||
|
// TTL of 0 disables time-based reclaim entirely.
|
||||||
|
let cache_no_ttl = SmartCache::new(CacheConfig {
|
||||||
|
temporary_ttl_hours: 0,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
cache_no_ttl
|
||||||
|
.reclaim_expired_async(&db_service, "user1", "2027-01-01T00:00:00+00:00")
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
0,
|
||||||
|
"a zero TTL leaves space pressure as the only reclaim trigger"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eviction must only reclaim *temporary* (`download_source = 'auto'`)
|
||||||
|
/// downloads — the precache tier. A download the user explicitly asked for
|
||||||
|
/// is their file: it may be deleted by them, never by the cache making room
|
||||||
|
/// for a predictive fetch.
|
||||||
|
///
|
||||||
|
/// Before the fix, `evict_lru_async` selected every completed row ordered by
|
||||||
|
/// `completed_at ASC` with no source filter, so hitting the storage limit
|
||||||
|
/// deleted the *oldest* download — typically the film someone downloaded for
|
||||||
|
/// a flight — in favour of a newer auto-precached track.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-126 | UT-108
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_evict_lru_never_deletes_user_downloads() {
|
||||||
|
use crate::storage::db_service::RusqliteService;
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE downloads (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
user_id TEXT,
|
||||||
|
status TEXT,
|
||||||
|
file_size INTEGER,
|
||||||
|
file_path TEXT,
|
||||||
|
completed_at TEXT,
|
||||||
|
download_source TEXT DEFAULT 'user'
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// The user's own download is the OLDEST, so a purely time-ordered
|
||||||
|
// eviction would take it first.
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO downloads (user_id, status, file_size, file_path, completed_at, download_source)
|
||||||
|
VALUES ('user1', 'completed', 600, '/tmp/jellytau-test-user.mp4', '2026-01-01', 'user')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// A newer, auto-precached item.
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO downloads (user_id, status, file_size, file_path, completed_at, download_source)
|
||||||
|
VALUES ('user1', 'completed', 600, '/tmp/jellytau-test-auto.mp4', '2026-06-01', 'auto')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let conn_arc = Arc::new(Mutex::new(conn));
|
||||||
|
let db_service = Arc::new(RusqliteService::new(conn_arc.clone()));
|
||||||
|
|
||||||
|
let cache = SmartCache::new(CacheConfig {
|
||||||
|
storage_limit: 1000,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1200 bytes held against a 1000 limit: eviction must free something.
|
||||||
|
let freed = cache
|
||||||
|
.evict_lru_async(&db_service, "user1", 0)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(freed > 0, "eviction should have reclaimed the auto entry");
|
||||||
|
|
||||||
|
let surviving: Vec<String> = {
|
||||||
|
let guard = conn_arc.lock_safe();
|
||||||
|
let mut stmt = guard
|
||||||
|
.prepare("SELECT download_source FROM downloads ORDER BY id")
|
||||||
|
.unwrap();
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| row.get::<_, String>(0))
|
||||||
|
.unwrap()
|
||||||
|
.map(|r| r.unwrap())
|
||||||
|
.collect();
|
||||||
|
rows
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
surviving,
|
||||||
|
vec!["user".to_string()],
|
||||||
|
"the user's own download must survive; only the 'auto' entry is evictable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_storage_limit_check() {
|
async fn test_storage_limit_check() {
|
||||||
use crate::storage::db_service::RusqliteService;
|
use crate::storage::db_service::RusqliteService;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
pub mod cache;
|
pub mod cache;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod network;
|
pub mod network;
|
||||||
|
pub mod stop;
|
||||||
pub mod worker;
|
pub mod worker;
|
||||||
|
|
||||||
use crate::utils::lock::MutexSafe;
|
use crate::utils::lock::MutexSafe;
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
//! Stop signalling for in-flight downloads.
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-055 | DR-168
|
||||||
|
//!
|
||||||
|
//! Pausing and cancelling used to be database-only: `pause_download` wrote
|
||||||
|
//! `status = 'paused'` and nothing else. No cancellation existed anywhere in the
|
||||||
|
//! download stack — no token, no flag, no abort — so the streaming task kept
|
||||||
|
//! running, kept writing bytes, and on finishing overwrote the row with
|
||||||
|
//! `completed` or `failed`. The row flicked to "paused" and then undid itself,
|
||||||
|
//! which is precisely the reported "pause does not work".
|
||||||
|
//!
|
||||||
|
//! This is the missing half: a flag per in-flight download that the worker reads
|
||||||
|
//! between chunks. Setting it makes the worker return [`Stopped`] promptly and
|
||||||
|
//! leave the `.part` file **intact**, which is what lets a resume pick up from
|
||||||
|
//! where it stopped via the existing HTTP Range request.
|
||||||
|
//!
|
||||||
|
//! Kept as a module-level registry rather than on `DownloadManager` because the
|
||||||
|
//! two sides never meet: the command handler holds the manager's lock, while the
|
||||||
|
//! worker runs detached inside `tauri::async_runtime::spawn` with no access to
|
||||||
|
//! Tauri state. A registry both can reach is the smallest thing that works.
|
||||||
|
//!
|
||||||
|
//! [`Stopped`]: crate::download::worker::DownloadError::Stopped
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
|
||||||
|
use crate::utils::lock::MutexSafe;
|
||||||
|
|
||||||
|
/// download id → its stop flag, for downloads currently in flight.
|
||||||
|
fn registry() -> &'static Mutex<HashMap<i64, Arc<AtomicBool>>> {
|
||||||
|
static REGISTRY: OnceLock<Mutex<HashMap<i64, Arc<AtomicBool>>>> = OnceLock::new();
|
||||||
|
REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register `download_id` as in-flight and hand back its stop flag.
|
||||||
|
///
|
||||||
|
/// Called by the worker as it starts. A previous flag for the same id is
|
||||||
|
/// replaced, so a download that is paused and later resumed does not inherit the
|
||||||
|
/// set flag from its last run and stop immediately.
|
||||||
|
pub fn register(download_id: i64) -> Arc<AtomicBool> {
|
||||||
|
let flag = Arc::new(AtomicBool::new(false));
|
||||||
|
registry().lock_safe().insert(download_id, flag.clone());
|
||||||
|
flag
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask an in-flight download to stop.
|
||||||
|
///
|
||||||
|
/// Returns whether one was actually in flight — the caller uses this to tell a
|
||||||
|
/// running download (which will stop shortly) from a merely queued one (which
|
||||||
|
/// the database update alone has already handled).
|
||||||
|
pub fn signal(download_id: i64) -> bool {
|
||||||
|
match registry().lock_safe().get(&download_id) {
|
||||||
|
Some(flag) => {
|
||||||
|
flag.store(true, Ordering::SeqCst);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget a download's flag. Called when its task finishes, however it ended.
|
||||||
|
pub fn clear(download_id: i64) {
|
||||||
|
registry().lock_safe().remove(&download_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a stop has been requested for `download_id`.
|
||||||
|
///
|
||||||
|
/// The worker reads its own `Arc<AtomicBool>` directly rather than looking the id
|
||||||
|
/// up, so this exists for the tests that assert the registry's behaviour.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn is_stopping(download_id: i64) -> bool {
|
||||||
|
registry()
|
||||||
|
.lock_safe()
|
||||||
|
.get(&download_id)
|
||||||
|
.map(|f| f.load(Ordering::SeqCst))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Ids are per-test so the shared registry cannot leak between them.
|
||||||
|
fn unique_id(seed: i64) -> i64 {
|
||||||
|
900_000 + seed
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_a_registered_download_starts_unflagged() {
|
||||||
|
let id = unique_id(1);
|
||||||
|
let flag = register(id);
|
||||||
|
assert!(!flag.load(Ordering::SeqCst));
|
||||||
|
assert!(!is_stopping(id));
|
||||||
|
clear(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_signal_sets_the_flag_the_worker_reads() {
|
||||||
|
let id = unique_id(2);
|
||||||
|
let flag = register(id);
|
||||||
|
|
||||||
|
assert!(signal(id), "a registered download reports as in flight");
|
||||||
|
assert!(
|
||||||
|
flag.load(Ordering::SeqCst),
|
||||||
|
"the worker's own handle sees it"
|
||||||
|
);
|
||||||
|
assert!(is_stopping(id));
|
||||||
|
|
||||||
|
clear(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pump only needs to abort a task that exists; a queued row is handled
|
||||||
|
/// by its database status alone.
|
||||||
|
#[test]
|
||||||
|
fn test_signalling_an_unregistered_download_reports_not_in_flight() {
|
||||||
|
assert!(!signal(unique_id(3)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clear_forgets_the_download() {
|
||||||
|
let id = unique_id(4);
|
||||||
|
register(id);
|
||||||
|
signal(id);
|
||||||
|
clear(id);
|
||||||
|
|
||||||
|
assert!(!is_stopping(id));
|
||||||
|
assert!(!signal(id), "a cleared download is no longer in flight");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bug this guards: pause sets the flag, and resume re-runs the same
|
||||||
|
/// download id. If registering reused the old flag, the resumed run would see
|
||||||
|
/// a set flag and stop instantly — a download that could never be resumed.
|
||||||
|
#[test]
|
||||||
|
fn test_reregistering_clears_a_previous_stop() {
|
||||||
|
let id = unique_id(5);
|
||||||
|
register(id);
|
||||||
|
signal(id);
|
||||||
|
assert!(is_stopping(id));
|
||||||
|
|
||||||
|
let fresh = register(id);
|
||||||
|
assert!(!fresh.load(Ordering::SeqCst));
|
||||||
|
assert!(
|
||||||
|
!is_stopping(id),
|
||||||
|
"a resumed download must not inherit the pause"
|
||||||
|
);
|
||||||
|
|
||||||
|
clear(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
//! Download worker for HTTP streaming with progress tracking and retry logic
|
//! Download worker for HTTP streaming with progress tracking and retry logic
|
||||||
|
|
||||||
use log::warn;
|
use log::warn;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
@@ -31,10 +32,18 @@ impl DownloadWorker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download a file with retry logic and progress tracking
|
/// Download a file with retry logic and progress tracking.
|
||||||
|
///
|
||||||
|
/// `stop` is the pause/cancel flag (see [`crate::download::stop`]). It is
|
||||||
|
/// checked between chunks and again between retries, so a paused download
|
||||||
|
/// stops promptly rather than after its next backoff — up to 45 seconds
|
||||||
|
/// away, which reads as the pause having done nothing.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-055 | DR-168
|
||||||
pub async fn download<F>(
|
pub async fn download<F>(
|
||||||
&self,
|
&self,
|
||||||
task: &DownloadTask,
|
task: &DownloadTask,
|
||||||
|
stop: &AtomicBool,
|
||||||
on_progress: F,
|
on_progress: F,
|
||||||
) -> Result<DownloadResult, DownloadError>
|
) -> Result<DownloadResult, DownloadError>
|
||||||
where
|
where
|
||||||
@@ -43,7 +52,10 @@ impl DownloadWorker {
|
|||||||
let mut retries = 0;
|
let mut retries = 0;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match self.try_download(task, &on_progress).await {
|
if stop.load(Ordering::SeqCst) {
|
||||||
|
return Err(DownloadError::Stopped);
|
||||||
|
}
|
||||||
|
match self.try_download(task, stop, &on_progress).await {
|
||||||
Ok(result) => return Ok(result),
|
Ok(result) => return Ok(result),
|
||||||
Err(e) if retries < self.max_retries && e.is_retryable() => {
|
Err(e) if retries < self.max_retries && e.is_retryable() => {
|
||||||
retries += 1;
|
retries += 1;
|
||||||
@@ -63,6 +75,7 @@ impl DownloadWorker {
|
|||||||
async fn try_download<F>(
|
async fn try_download<F>(
|
||||||
&self,
|
&self,
|
||||||
task: &DownloadTask,
|
task: &DownloadTask,
|
||||||
|
stop: &AtomicBool,
|
||||||
on_progress: &F,
|
on_progress: &F,
|
||||||
) -> Result<DownloadResult, DownloadError>
|
) -> Result<DownloadResult, DownloadError>
|
||||||
where
|
where
|
||||||
@@ -76,7 +89,7 @@ impl DownloadWorker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for partial download
|
// Check for partial download
|
||||||
let temp_path = task.target_path.with_extension("part");
|
let temp_path = partial_path(&task.target_path);
|
||||||
let existing_bytes = if temp_path.exists() {
|
let existing_bytes = if temp_path.exists() {
|
||||||
fs::metadata(&temp_path).await.map(|m| m.len()).unwrap_or(0)
|
fs::metadata(&temp_path).await.map(|m| m.len()).unwrap_or(0)
|
||||||
} else {
|
} else {
|
||||||
@@ -100,22 +113,32 @@ impl DownloadWorker {
|
|||||||
return Err(DownloadError::Http(response.status().as_u16()));
|
return Err(DownloadError::Http(response.status().as_u16()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get content length
|
// Did the server actually honour the Range? A transcode does not, and
|
||||||
|
// answers 200 with the whole stream — appending that would duplicate what
|
||||||
|
// we already hold. (DR-170)
|
||||||
|
let resume_from = resume_offset(existing_bytes, response.status().as_u16());
|
||||||
|
if existing_bytes > 0 && resume_from == 0 {
|
||||||
|
warn!(
|
||||||
|
"Server ignored the Range request (HTTP {}) — restarting {} from the beginning \
|
||||||
|
instead of appending to {} existing bytes",
|
||||||
|
response.status().as_u16(),
|
||||||
|
task.target_path.display(),
|
||||||
|
existing_bytes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get content length. Absent on a chunked transcode, which is why progress
|
||||||
|
// for a non-`original` preset has no percentage to show.
|
||||||
let _total_bytes = response
|
let _total_bytes = response
|
||||||
.headers()
|
.headers()
|
||||||
.get(reqwest::header::CONTENT_LENGTH)
|
.get(reqwest::header::CONTENT_LENGTH)
|
||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.and_then(|v| v.parse::<u64>().ok())
|
.and_then(|v| v.parse::<u64>().ok())
|
||||||
.map(|len| {
|
.map(|len| len + resume_from);
|
||||||
if existing_bytes > 0 {
|
|
||||||
len + existing_bytes
|
|
||||||
} else {
|
|
||||||
len
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Open file for appending
|
// Append only when resuming a range the server agreed to; otherwise
|
||||||
let mut file = if existing_bytes > 0 {
|
// create/truncate so the restarted stream replaces the stale bytes.
|
||||||
|
let mut file = if resume_from > 0 {
|
||||||
fs::OpenOptions::new().append(true).open(&temp_path).await
|
fs::OpenOptions::new().append(true).open(&temp_path).await
|
||||||
} else {
|
} else {
|
||||||
fs::File::create(&temp_path).await
|
fs::File::create(&temp_path).await
|
||||||
@@ -123,11 +146,22 @@ impl DownloadWorker {
|
|||||||
.map_err(|e| DownloadError::FileSystem(e.to_string()))?;
|
.map_err(|e| DownloadError::FileSystem(e.to_string()))?;
|
||||||
|
|
||||||
// Stream download with progress tracking
|
// Stream download with progress tracking
|
||||||
let mut downloaded = existing_bytes;
|
let mut downloaded = resume_from;
|
||||||
let mut stream = response.bytes_stream();
|
let mut stream = response.bytes_stream();
|
||||||
let mut last_progress_emit = std::time::Instant::now();
|
let mut last_progress_emit = std::time::Instant::now();
|
||||||
|
|
||||||
while let Some(chunk) = stream.next().await {
|
while let Some(chunk) = stream.next().await {
|
||||||
|
// Checked before writing, so a paused download stops on a byte
|
||||||
|
// boundary the `.part` file already accounts for — the Range request
|
||||||
|
// on resume then asks for exactly what is missing. Flushing what we
|
||||||
|
// have and leaving the file in place is the whole mechanism behind
|
||||||
|
// "resume", so this must never delete it. (DR-168)
|
||||||
|
if stop.load(Ordering::SeqCst) {
|
||||||
|
let _ = file.flush().await;
|
||||||
|
let _ = file.sync_all().await;
|
||||||
|
return Err(DownloadError::Stopped);
|
||||||
|
}
|
||||||
|
|
||||||
let chunk = chunk.map_err(|e| DownloadError::Network(e.to_string()))?;
|
let chunk = chunk.map_err(|e| DownloadError::Network(e.to_string()))?;
|
||||||
|
|
||||||
file.write_all(&chunk)
|
file.write_all(&chunk)
|
||||||
@@ -138,7 +172,7 @@ impl DownloadWorker {
|
|||||||
|
|
||||||
// Emit progress every 500ms or every MB
|
// Emit progress every 500ms or every MB
|
||||||
if last_progress_emit.elapsed() > Duration::from_millis(500)
|
if last_progress_emit.elapsed() > Duration::from_millis(500)
|
||||||
|| downloaded % (1024 * 1024) == 0
|
|| downloaded.is_multiple_of(1024 * 1024)
|
||||||
{
|
{
|
||||||
last_progress_emit = std::time::Instant::now();
|
last_progress_emit = std::time::Instant::now();
|
||||||
on_progress(downloaded, _total_bytes);
|
on_progress(downloaded, _total_bytes);
|
||||||
@@ -167,6 +201,56 @@ impl DownloadWorker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where to resume writing a partial download, given how the server answered.
|
||||||
|
///
|
||||||
|
/// A byte offset of 0 means "start the file again"; anything else means "append
|
||||||
|
/// from here".
|
||||||
|
///
|
||||||
|
/// This is what makes non-`original` downloads survive. Those presets ask
|
||||||
|
/// Jellyfin to **transcode**, and a live transcode is chunked with no
|
||||||
|
/// `Content-Length` and cannot be byte-seeked: the server ignores `Range` and
|
||||||
|
/// answers `200` with the whole stream from the beginning, not `206` with the
|
||||||
|
/// requested tail. The worker sent the header and appended the body regardless,
|
||||||
|
/// so every retry — and every resume — concatenated a fresh copy of the whole
|
||||||
|
/// transcode onto the bytes already on disk. The file grew past its real size
|
||||||
|
/// and would not play. Only a `206` actually promises the tail; a `200` means we
|
||||||
|
/// must discard what we have and take the stream from the top.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-170
|
||||||
|
pub fn resume_offset(existing_bytes: u64, status: u16) -> u64 {
|
||||||
|
if existing_bytes == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// 206 Partial Content is the only answer that honours the Range request.
|
||||||
|
if status == 206 {
|
||||||
|
existing_bytes
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The partial-download sidecar for `target`.
|
||||||
|
///
|
||||||
|
/// **Appends** `.part` rather than replacing the extension. The worker used
|
||||||
|
/// `Path::with_extension("part")`, which replaces: `movie.mp4` became
|
||||||
|
/// `movie.part`. Every cleanup path meanwhile deleted `"{file_path}.part"` —
|
||||||
|
/// `movie.mp4.part` — so nothing ever matched and the partial file of every
|
||||||
|
/// cancelled or failed download was left on disk forever, invisible to the
|
||||||
|
/// disk-usage totals because no `downloads` row pointed at it. That is the
|
||||||
|
/// reported "failure is not cleaned".
|
||||||
|
///
|
||||||
|
/// Appending also removes a collision the old form had: `movie.mp4` and
|
||||||
|
/// `movie.mkv` both mapped to `movie.part` and would have fought over one file.
|
||||||
|
///
|
||||||
|
/// One function so the writer and the cleaners cannot disagree again.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-055 | DR-169
|
||||||
|
pub fn partial_path(target: &std::path::Path) -> std::path::PathBuf {
|
||||||
|
let mut s = target.as_os_str().to_os_string();
|
||||||
|
s.push(".part");
|
||||||
|
std::path::PathBuf::from(s)
|
||||||
|
}
|
||||||
|
|
||||||
/// Result of a successful download
|
/// Result of a successful download
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct DownloadResult {
|
pub struct DownloadResult {
|
||||||
@@ -179,6 +263,10 @@ pub enum DownloadError {
|
|||||||
Network(String),
|
Network(String),
|
||||||
Http(u16),
|
Http(u16),
|
||||||
FileSystem(String),
|
FileSystem(String),
|
||||||
|
/// The download was asked to stop (paused or cancelled). Not a failure: the
|
||||||
|
/// row's status already says what happened, and the partial file is kept so a
|
||||||
|
/// resume can continue from it.
|
||||||
|
Stopped,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DownloadError {
|
impl DownloadError {
|
||||||
@@ -188,8 +276,16 @@ impl DownloadError {
|
|||||||
DownloadError::Network(_) => true,
|
DownloadError::Network(_) => true,
|
||||||
DownloadError::Http(status) => *status >= 500, // Retry server errors
|
DownloadError::Http(status) => *status >= 500, // Retry server errors
|
||||||
DownloadError::FileSystem(_) => false,
|
DownloadError::FileSystem(_) => false,
|
||||||
|
// Retrying would restart the very download the user just paused.
|
||||||
|
DownloadError::Stopped => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether this outcome means "the user stopped it", rather than a failure to
|
||||||
|
/// record and report.
|
||||||
|
pub fn is_stopped(&self) -> bool {
|
||||||
|
matches!(self, DownloadError::Stopped)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for DownloadError {
|
impl std::fmt::Display for DownloadError {
|
||||||
@@ -198,6 +294,7 @@ impl std::fmt::Display for DownloadError {
|
|||||||
DownloadError::Network(msg) => write!(f, "Network error: {}", msg),
|
DownloadError::Network(msg) => write!(f, "Network error: {}", msg),
|
||||||
DownloadError::Http(status) => write!(f, "HTTP error {}", status),
|
DownloadError::Http(status) => write!(f, "HTTP error {}", status),
|
||||||
DownloadError::FileSystem(msg) => write!(f, "File system error: {}", msg),
|
DownloadError::FileSystem(msg) => write!(f, "File system error: {}", msg),
|
||||||
|
DownloadError::Stopped => write!(f, "Download stopped by request"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,6 +305,64 @@ impl std::error::Error for DownloadError {}
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// The bitrate-download corruption: a transcode ignores `Range` and answers
|
||||||
|
/// `200` with the whole stream. Appending that to the bytes already on disk
|
||||||
|
/// duplicated them, so every retry grew the file past its real size and left
|
||||||
|
/// it unplayable. Only `206` promises the requested tail.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-170 | UT-164
|
||||||
|
#[test]
|
||||||
|
fn test_resume_offset_only_appends_when_the_server_honoured_the_range() {
|
||||||
|
// Nothing on disk: start at the beginning either way.
|
||||||
|
assert_eq!(resume_offset(0, 200), 0);
|
||||||
|
assert_eq!(resume_offset(0, 206), 0);
|
||||||
|
|
||||||
|
// The server agreed to the range — append to what we have.
|
||||||
|
assert_eq!(resume_offset(5_000, 206), 5_000);
|
||||||
|
|
||||||
|
// The server ignored it and is sending the whole file (a transcode).
|
||||||
|
// Restart, or the bytes are duplicated.
|
||||||
|
assert_eq!(
|
||||||
|
resume_offset(5_000, 200),
|
||||||
|
0,
|
||||||
|
"a 200 carries the whole stream; appending it corrupts the file"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The regression: `with_extension` replaced the extension, so the worker
|
||||||
|
/// wrote `movie.part` while every cleanup path deleted `movie.mp4.part`.
|
||||||
|
/// Nothing matched, and partial files accumulated forever.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-055 | DR-169 | UT-163
|
||||||
|
#[test]
|
||||||
|
fn test_partial_path_appends_rather_than_replacing_the_extension() {
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
partial_path(Path::new("/media/movie.mp4")),
|
||||||
|
Path::new("/media/movie.mp4.part"),
|
||||||
|
"the cleanup paths delete \"{{file_path}}.part\"; this must produce it"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Two sources for one title must not fight over a single partial file.
|
||||||
|
assert_ne!(
|
||||||
|
partial_path(Path::new("/media/movie.mp4")),
|
||||||
|
partial_path(Path::new("/media/movie.mkv")),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extension-less targets still get a sidecar rather than being clobbered.
|
||||||
|
assert_eq!(
|
||||||
|
partial_path(Path::new("/media/track")),
|
||||||
|
Path::new("/media/track.part"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// A dotted name keeps every part of its own name.
|
||||||
|
assert_eq!(
|
||||||
|
partial_path(Path::new("/media/S01.E02.episode.mkv")),
|
||||||
|
Path::new("/media/S01.E02.episode.mkv.part"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_exponential_backoff() {
|
fn test_exponential_backoff() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -231,5 +386,9 @@ mod tests {
|
|||||||
assert!(DownloadError::Http(503).is_retryable());
|
assert!(DownloadError::Http(503).is_retryable());
|
||||||
assert!(!DownloadError::Http(404).is_retryable());
|
assert!(!DownloadError::Http(404).is_retryable());
|
||||||
assert!(!DownloadError::FileSystem("disk full".to_string()).is_retryable());
|
assert!(!DownloadError::FileSystem("disk full".to_string()).is_retryable());
|
||||||
|
// Retrying a paused download would restart what the user just stopped.
|
||||||
|
assert!(!DownloadError::Stopped.is_retryable());
|
||||||
|
assert!(DownloadError::Stopped.is_stopped());
|
||||||
|
assert!(!DownloadError::Network("timeout".to_string()).is_stopped());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+104
-8
@@ -5,6 +5,7 @@ mod credentials;
|
|||||||
mod domain;
|
mod domain;
|
||||||
mod download;
|
mod download;
|
||||||
mod jellyfin;
|
mod jellyfin;
|
||||||
|
mod media_server;
|
||||||
mod playback_mode;
|
mod playback_mode;
|
||||||
mod playback_reporting;
|
mod playback_reporting;
|
||||||
mod player;
|
mod player;
|
||||||
@@ -85,6 +86,7 @@ use commands::{
|
|||||||
lms_unsync_player,
|
lms_unsync_player,
|
||||||
mark_download_completed,
|
mark_download_completed,
|
||||||
mark_download_failed,
|
mark_download_failed,
|
||||||
|
media_local_url,
|
||||||
offline_get_items,
|
offline_get_items,
|
||||||
offline_is_available,
|
offline_is_available,
|
||||||
offline_search,
|
offline_search,
|
||||||
@@ -121,13 +123,17 @@ use commands::{
|
|||||||
player_get_audio_settings,
|
player_get_audio_settings,
|
||||||
player_get_autoplay_settings,
|
player_get_autoplay_settings,
|
||||||
player_get_cache_config,
|
player_get_cache_config,
|
||||||
|
player_get_capabilities,
|
||||||
player_get_eq_presets,
|
player_get_eq_presets,
|
||||||
player_get_queue,
|
player_get_queue,
|
||||||
// Session management commands
|
// Session management commands
|
||||||
player_get_session,
|
player_get_session,
|
||||||
player_get_sleep_timer,
|
player_get_sleep_timer,
|
||||||
player_get_status,
|
player_get_status,
|
||||||
|
player_get_streaming_qualities,
|
||||||
player_get_video_settings,
|
player_get_video_settings,
|
||||||
|
// Preload commands
|
||||||
|
player_local_media_path,
|
||||||
player_move_in_queue,
|
player_move_in_queue,
|
||||||
player_next,
|
player_next,
|
||||||
player_on_playback_ended,
|
player_on_playback_ended,
|
||||||
@@ -138,9 +144,9 @@ use commands::{
|
|||||||
player_play_next_episode,
|
player_play_next_episode,
|
||||||
player_play_queue,
|
player_play_queue,
|
||||||
player_play_tracks,
|
player_play_tracks,
|
||||||
// Preload commands
|
|
||||||
player_preload_upcoming,
|
player_preload_upcoming,
|
||||||
player_previous,
|
player_previous,
|
||||||
|
player_recover_stream,
|
||||||
player_remove_from_queue,
|
player_remove_from_queue,
|
||||||
player_report_media_loaded,
|
player_report_media_loaded,
|
||||||
player_report_position,
|
player_report_position,
|
||||||
@@ -154,6 +160,7 @@ use commands::{
|
|||||||
player_set_cache_config,
|
player_set_cache_config,
|
||||||
// Sleep timer and autoplay commands
|
// Sleep timer and autoplay commands
|
||||||
player_set_sleep_timer,
|
player_set_sleep_timer,
|
||||||
|
player_set_stream_quality,
|
||||||
player_set_subtitle_track,
|
player_set_subtitle_track,
|
||||||
player_set_video_settings,
|
player_set_video_settings,
|
||||||
player_set_volume,
|
player_set_volume,
|
||||||
@@ -187,6 +194,7 @@ use commands::{
|
|||||||
repository_get_download_disk_usage,
|
repository_get_download_disk_usage,
|
||||||
repository_get_downloaded_items,
|
repository_get_downloaded_items,
|
||||||
repository_get_downloaded_libraries,
|
repository_get_downloaded_libraries,
|
||||||
|
repository_get_favorites,
|
||||||
repository_get_genres,
|
repository_get_genres,
|
||||||
repository_get_image_url,
|
repository_get_image_url,
|
||||||
repository_get_item,
|
repository_get_item,
|
||||||
@@ -259,6 +267,7 @@ use commands::{
|
|||||||
storage_save_user,
|
storage_save_user,
|
||||||
storage_search_items,
|
storage_search_items,
|
||||||
storage_set_active_user,
|
storage_set_active_user,
|
||||||
|
storage_set_watched,
|
||||||
storage_toggle_favorite,
|
storage_toggle_favorite,
|
||||||
storage_update_playback_context,
|
storage_update_playback_context,
|
||||||
storage_update_playback_progress,
|
storage_update_playback_progress,
|
||||||
@@ -270,6 +279,7 @@ use commands::{
|
|||||||
sync_mark_completed,
|
sync_mark_completed,
|
||||||
sync_mark_failed,
|
sync_mark_failed,
|
||||||
sync_mark_processing,
|
sync_mark_processing,
|
||||||
|
sync_process_pending,
|
||||||
// Sync queue commands
|
// Sync queue commands
|
||||||
sync_queue_mutation,
|
sync_queue_mutation,
|
||||||
thumbnail_clear_cache,
|
thumbnail_clear_cache,
|
||||||
@@ -417,6 +427,28 @@ impl MediaSessionHandler {
|
|||||||
|
|
||||||
/// Drive the local player for a transport command.
|
/// Drive the local player for a transport command.
|
||||||
fn handle_local_command(&self, command: &str) {
|
fn handle_local_command(&self, command: &str) {
|
||||||
|
// A lockscreen scrub is an ABSOLUTE position — the scrubber shows the
|
||||||
|
// whole episode — and resolving it during a background-audio handoff means
|
||||||
|
// re-opening the stream, which is async. So it runs on the runtime and,
|
||||||
|
// critically, is handled *before* the blocking lock below: taking that
|
||||||
|
// guard and then spawning a task that waits for the same mutex would
|
||||||
|
// deadlock the media session. (DR-159)
|
||||||
|
if let Some(raw) = command.strip_prefix("seek:") {
|
||||||
|
match raw.parse::<f64>() {
|
||||||
|
Ok(position) => {
|
||||||
|
let player = self.player.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let controller = player.lock().await;
|
||||||
|
if let Err(e) = controller.seek_absolute(position).await {
|
||||||
|
error!("[MediaSession] Seek to {:.1}s failed: {}", position, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(_) => warn!("[MediaSession] Bad seek command: {}", command),
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Use blocking_lock since this is called from a non-async JNI callback
|
// Use blocking_lock since this is called from a non-async JNI callback
|
||||||
let controller = self.player.blocking_lock();
|
let controller = self.player.blocking_lock();
|
||||||
|
|
||||||
@@ -426,13 +458,6 @@ impl MediaSessionHandler {
|
|||||||
"next" => controller.next(),
|
"next" => controller.next(),
|
||||||
"previous" => controller.previous(),
|
"previous" => controller.previous(),
|
||||||
"stop" => controller.stop(),
|
"stop" => controller.stop(),
|
||||||
cmd if cmd.starts_with("seek:") => match cmd[5..].parse::<f64>() {
|
|
||||||
Ok(pos) => controller.seek(pos),
|
|
||||||
Err(_) => {
|
|
||||||
warn!("[MediaSession] Bad seek command: {}", command);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
},
|
|
||||||
_ => {
|
_ => {
|
||||||
warn!("[MediaSession] Unknown command: {}", command);
|
warn!("[MediaSession] Unknown command: {}", command);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -676,6 +701,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
|||||||
player_cycle_repeat,
|
player_cycle_repeat,
|
||||||
player_get_status,
|
player_get_status,
|
||||||
player_get_queue,
|
player_get_queue,
|
||||||
|
player_get_capabilities,
|
||||||
player_add_to_queue,
|
player_add_to_queue,
|
||||||
player_add_track_by_id,
|
player_add_track_by_id,
|
||||||
player_add_tracks_by_ids,
|
player_add_tracks_by_ids,
|
||||||
@@ -687,6 +713,8 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
|||||||
player_get_eq_presets,
|
player_get_eq_presets,
|
||||||
player_set_video_settings,
|
player_set_video_settings,
|
||||||
player_get_video_settings,
|
player_get_video_settings,
|
||||||
|
player_get_streaming_qualities,
|
||||||
|
player_set_stream_quality,
|
||||||
// Sleep timer and autoplay commands
|
// Sleep timer and autoplay commands
|
||||||
player_set_sleep_timer,
|
player_set_sleep_timer,
|
||||||
player_cancel_sleep_timer,
|
player_cancel_sleep_timer,
|
||||||
@@ -696,10 +724,12 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
|||||||
player_cancel_autoplay_countdown,
|
player_cancel_autoplay_countdown,
|
||||||
player_play_next_episode,
|
player_play_next_episode,
|
||||||
player_on_playback_ended,
|
player_on_playback_ended,
|
||||||
|
player_recover_stream,
|
||||||
player_report_state,
|
player_report_state,
|
||||||
player_report_position,
|
player_report_position,
|
||||||
player_report_media_loaded,
|
player_report_media_loaded,
|
||||||
// Preload commands
|
// Preload commands
|
||||||
|
player_local_media_path,
|
||||||
player_preload_upcoming,
|
player_preload_upcoming,
|
||||||
player_set_cache_config,
|
player_set_cache_config,
|
||||||
player_get_cache_config,
|
player_get_cache_config,
|
||||||
@@ -779,6 +809,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
|||||||
storage_update_playback_progress,
|
storage_update_playback_progress,
|
||||||
storage_update_playback_context,
|
storage_update_playback_context,
|
||||||
storage_mark_played,
|
storage_mark_played,
|
||||||
|
storage_set_watched,
|
||||||
storage_get_playback_progress,
|
storage_get_playback_progress,
|
||||||
storage_mark_synced,
|
storage_mark_synced,
|
||||||
storage_toggle_favorite,
|
storage_toggle_favorite,
|
||||||
@@ -801,6 +832,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
|||||||
get_download_storage_stats,
|
get_download_storage_stats,
|
||||||
mark_download_completed,
|
mark_download_completed,
|
||||||
mark_download_failed,
|
mark_download_failed,
|
||||||
|
media_local_url,
|
||||||
start_download,
|
start_download,
|
||||||
enqueue_download,
|
enqueue_download,
|
||||||
enqueue_video_downloads,
|
enqueue_video_downloads,
|
||||||
@@ -841,6 +873,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
|||||||
sync_mark_completed,
|
sync_mark_completed,
|
||||||
sync_mark_failed,
|
sync_mark_failed,
|
||||||
sync_get_pending_count,
|
sync_get_pending_count,
|
||||||
|
sync_process_pending,
|
||||||
sync_cleanup_completed,
|
sync_cleanup_completed,
|
||||||
sync_clear_user,
|
sync_clear_user,
|
||||||
// Thumbnail cache and image commands
|
// Thumbnail cache and image commands
|
||||||
@@ -893,6 +926,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
|||||||
repository_get_image_url,
|
repository_get_image_url,
|
||||||
repository_mark_favorite,
|
repository_mark_favorite,
|
||||||
repository_unmark_favorite,
|
repository_unmark_favorite,
|
||||||
|
repository_get_favorites,
|
||||||
repository_get_person,
|
repository_get_person,
|
||||||
repository_get_items_by_person,
|
repository_get_items_by_person,
|
||||||
repository_get_similar_items,
|
repository_get_similar_items,
|
||||||
@@ -995,6 +1029,16 @@ fn set_env_if_unset(key: &str, value: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Downloaded media and cached thumbnails are handed to the webview as
|
||||||
|
/// `http://asset.localhost/…` URLs by `convertFileSrc`. Tauri only answers that
|
||||||
|
/// origin when the `protocol-asset` cargo feature is compiled in *and*
|
||||||
|
/// `app.security.assetProtocol.enable` is set in `tauri.conf.json`, which also
|
||||||
|
/// scopes it to `$APPDATA/**` — the storage root holding the database,
|
||||||
|
/// `downloads/` and the thumbnail cache. Both are required together: with either
|
||||||
|
/// missing the URL resolves to nothing and the webview reports
|
||||||
|
/// `NETWORK_NO_SOURCE`, which is how offline video came to fail silently.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-134
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
// Initialize logger
|
// Initialize logger
|
||||||
@@ -1202,6 +1246,20 @@ pub fn run() {
|
|||||||
let video_settings = VideoSettingsWrapper(Mutex::new(VideoSettings::default()));
|
let video_settings = VideoSettingsWrapper(Mutex::new(VideoSettings::default()));
|
||||||
app.manage(video_settings);
|
app.manage(video_settings);
|
||||||
|
|
||||||
|
// Restore the persisted streaming bandwidth ceiling. Deferred to the
|
||||||
|
// async runtime because the read is async, and ordered after the
|
||||||
|
// wrapper above because it writes into it. Until it lands, streams
|
||||||
|
// are uncapped — the pre-existing behaviour — and no playback can
|
||||||
|
// have started this early anyway (login happens after setup).
|
||||||
|
//
|
||||||
|
// TRACES: UR-074 | DR-162
|
||||||
|
{
|
||||||
|
let handle = app.handle().clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
crate::commands::restore_streaming_quality(&handle).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize thumbnail cache
|
// Initialize thumbnail cache
|
||||||
info!("[INIT] Initializing thumbnail cache...");
|
info!("[INIT] Initializing thumbnail cache...");
|
||||||
let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
|
let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
|
||||||
@@ -1222,6 +1280,22 @@ pub fn run() {
|
|||||||
let smart_cache_wrapper = SmartCacheWrapper(Mutex::new(smart_cache));
|
let smart_cache_wrapper = SmartCacheWrapper(Mutex::new(smart_cache));
|
||||||
app.manage(smart_cache_wrapper);
|
app.manage(smart_cache_wrapper);
|
||||||
|
|
||||||
|
// Serve downloaded media over loopback HTTP. The webview cannot
|
||||||
|
// stream a large file through the asset protocol (see media_server),
|
||||||
|
// so local playback resolves its URL from here instead.
|
||||||
|
// TRACES: UR-071 | DR-137
|
||||||
|
info!("[INIT] Starting local media server...");
|
||||||
|
let media_server = match media_server::start(app_data_dir.clone()) {
|
||||||
|
Ok(s) => Some(s),
|
||||||
|
Err(e) => {
|
||||||
|
// Not fatal: streaming still works, and the command reports
|
||||||
|
// a clear error if local playback is attempted.
|
||||||
|
error!("[INIT ERROR] Local media server failed to start: {}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
app.manage(media_server::MediaServerWrapper(media_server));
|
||||||
|
|
||||||
// Initialize download manager
|
// Initialize download manager
|
||||||
info!("[INIT] Initializing download manager...");
|
info!("[INIT] Initializing download manager...");
|
||||||
let download_dir = app_data_dir.join("downloads");
|
let download_dir = app_data_dir.join("downloads");
|
||||||
@@ -1288,6 +1362,28 @@ pub fn run() {
|
|||||||
let playback_reporter_wrapper = PlaybackReporterWrapper(playback_reporter.clone());
|
let playback_reporter_wrapper = PlaybackReporterWrapper(playback_reporter.clone());
|
||||||
app.manage(playback_reporter_wrapper);
|
app.manage(playback_reporter_wrapper);
|
||||||
|
|
||||||
|
// Keep the local search index fresh. Ownership of *when* to re-index
|
||||||
|
// sits here rather than in the frontend: it is sync policy over
|
||||||
|
// domain data, and a startup-only trigger left a long session
|
||||||
|
// searching a stale catalog.
|
||||||
|
// TRACES: UR-065 | DR-109, IR-030
|
||||||
|
info!("[INIT] Starting background catalog indexer...");
|
||||||
|
commands::catalog::spawn_catalog_indexer(app.handle().clone());
|
||||||
|
|
||||||
|
// Push favourite toggles made while the server was unreachable, on
|
||||||
|
// every reconnect. In Rust rather than the frontend so it runs
|
||||||
|
// whether or not the screen that made the change is still mounted.
|
||||||
|
// TRACES: UR-069 | DR-120
|
||||||
|
info!("[INIT] Starting favourites drain...");
|
||||||
|
commands::favorites::spawn_favorites_drain(app.handle().clone());
|
||||||
|
|
||||||
|
// Push playback reports queued while the server was unreachable.
|
||||||
|
// Without this the `sync_queue` rows the reporter writes offline
|
||||||
|
// are never sent and the offline banner's count only grows.
|
||||||
|
// TRACES: UR-025, UR-002 | DR-131
|
||||||
|
info!("[INIT] Starting sync-queue drain...");
|
||||||
|
commands::sync_drain::spawn_sync_queue_drain(app.handle().clone());
|
||||||
|
|
||||||
info!("[INIT] Application setup completed successfully");
|
info!("[INIT] Application setup completed successfully");
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,588 @@
|
|||||||
|
//! A loopback HTTP server for locally downloaded media.
|
||||||
|
//!
|
||||||
|
//! Tauri's built-in `asset` protocol cannot serve a downloaded film to the
|
||||||
|
//! webview. Its response to a request *without* a `Range` header reads the whole
|
||||||
|
//! file into a `Vec<u8>`, and it only advertises `Accept-Ranges: bytes` from
|
||||||
|
//! inside the range branch — so the first request never learns ranges are
|
||||||
|
//! available and a multi-gigabyte body is attempted instead. Chromium abandoned
|
||||||
|
//! it with `PIPELINE_ERROR_READ` after ~31s, which reached the user as
|
||||||
|
//! "downloaded video does not play offline".
|
||||||
|
//!
|
||||||
|
//! Serving over real HTTP on 127.0.0.1 rather than a custom URI scheme is
|
||||||
|
//! deliberate: it makes range support a property of the transport instead of
|
||||||
|
//! depending on whether a platform's webview forwards `Range` to a custom
|
||||||
|
//! scheme, which differs between Android and the desktop webviews.
|
||||||
|
//!
|
||||||
|
//! Two things confine it, because **loopback is shared between apps on
|
||||||
|
//! Android** — any other installed app can connect to this port:
|
||||||
|
//!
|
||||||
|
//! - it binds `127.0.0.1` only, so nothing off-device can reach it; and
|
||||||
|
//! - every URL carries a random per-session token, so another app cannot guess a
|
||||||
|
//! working URL, and paths are confined to the app data directory even if one
|
||||||
|
//! did.
|
||||||
|
//!
|
||||||
|
//! Phase 1 serves local files only. The same origin is the intended home for
|
||||||
|
//! remote passthrough (and download-while-watching) later; see the stage-2 spec.
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-071 | DR-137 | UT-127
|
||||||
|
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use log::{debug, error, info, warn};
|
||||||
|
use rand::Rng;
|
||||||
|
use tiny_http::{Header, Response, Server, StatusCode};
|
||||||
|
|
||||||
|
/// Bytes per response. Large enough that a film needs relatively few round
|
||||||
|
/// trips, small enough that one response is never a memory problem on a phone.
|
||||||
|
/// Tauri's asset protocol uses 1 MiB; 4 MiB quarters the request count for the
|
||||||
|
/// multi-gigabyte files this exists to serve.
|
||||||
|
const CHUNK_LEN: u64 = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Managed state. `None` when the server could not bind — local playback then
|
||||||
|
/// fails with a clear error instead of the app refusing to start.
|
||||||
|
pub struct MediaServerWrapper(pub Option<MediaServer>);
|
||||||
|
|
||||||
|
/// A running server. Dropping this does not stop the thread; the server lives
|
||||||
|
/// for the life of the process by design, since playback can start at any time.
|
||||||
|
pub struct MediaServer {
|
||||||
|
port: u16,
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MediaServer {
|
||||||
|
/// The base a media URL is built on, e.g. `http://127.0.0.1:53412/<token>`.
|
||||||
|
pub fn base_url(&self) -> String {
|
||||||
|
format!("http://127.0.0.1:{}/{}", self.port, self.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A playable URL for an absolute on-disk path.
|
||||||
|
pub fn url_for(&self, path: &str) -> String {
|
||||||
|
format!("{}/{}", self.base_url(), urlencoding::encode(path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bind to an ephemeral loopback port and start serving `root` in a background
|
||||||
|
/// thread.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-137
|
||||||
|
pub fn start(root: PathBuf) -> Result<MediaServer, String> {
|
||||||
|
// Port 0 → the OS picks a free one. Binding 127.0.0.1 (not 0.0.0.0) keeps
|
||||||
|
// this off the network.
|
||||||
|
let server =
|
||||||
|
Server::http("127.0.0.1:0").map_err(|e| format!("Failed to bind media server: {e}"))?;
|
||||||
|
|
||||||
|
let port = server
|
||||||
|
.server_addr()
|
||||||
|
.to_ip()
|
||||||
|
.ok_or_else(|| "Media server bound to a non-IP address".to_string())?
|
||||||
|
.port();
|
||||||
|
|
||||||
|
let token: String = {
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
(0..32)
|
||||||
|
.map(|_| char::from_digit(rng.gen_range(0..16), 16).unwrap())
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"[MediaServer] Serving {} on 127.0.0.1:{}",
|
||||||
|
root.display(),
|
||||||
|
port
|
||||||
|
);
|
||||||
|
|
||||||
|
let server = Arc::new(server);
|
||||||
|
let shared = Arc::new((root, token.clone()));
|
||||||
|
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("media-server".into())
|
||||||
|
.spawn(move || loop {
|
||||||
|
let request = match server.recv() {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
error!("[MediaServer] accept failed: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let shared = Arc::clone(&shared);
|
||||||
|
// A thread per request: media clients open several connections at
|
||||||
|
// once, and a blocking read of one must not stall the others.
|
||||||
|
if let Err(e) = std::thread::Builder::new()
|
||||||
|
.name("media-server-req".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let (root, token) = &*shared;
|
||||||
|
handle(request, root, token);
|
||||||
|
})
|
||||||
|
{
|
||||||
|
error!("[MediaServer] could not spawn handler: {e}");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("Failed to start media server thread: {e}"))?;
|
||||||
|
|
||||||
|
Ok(MediaServer { port, token })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header(name: &str, value: &str) -> Header {
|
||||||
|
Header::from_bytes(name.as_bytes(), value.as_bytes())
|
||||||
|
.expect("static header name/value are valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty(status: u16) -> Response<std::io::Empty> {
|
||||||
|
Response::empty(StatusCode(status)).with_header(header("Accept-Ranges", "bytes"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle(request: tiny_http::Request, root: &Path, token: &str) {
|
||||||
|
let url = request.url().to_string();
|
||||||
|
let method = request.method().as_str().to_string();
|
||||||
|
|
||||||
|
let outcome = match route(&url, root, token) {
|
||||||
|
Ok(path) => path,
|
||||||
|
Err(status) => {
|
||||||
|
let _ = request.respond(empty(status));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if method != "GET" && method != "HEAD" {
|
||||||
|
let _ = request.respond(empty(405));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut file = match File::open(&outcome) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("[MediaServer] {}: {}", outcome.display(), e);
|
||||||
|
let _ = request.respond(empty(404));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let len = match file.metadata() {
|
||||||
|
Ok(m) => m.len(),
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
"[MediaServer] metadata failed for {}: {}",
|
||||||
|
outcome.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
let _ = request.respond(empty(404));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let range = request
|
||||||
|
.headers()
|
||||||
|
.iter()
|
||||||
|
.find(|h| h.field.equiv("Range"))
|
||||||
|
.map(|h| h.value.as_str().to_string());
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"[MediaServer] {} {} ({} bytes) range={:?}",
|
||||||
|
method,
|
||||||
|
outcome.display(),
|
||||||
|
len,
|
||||||
|
range
|
||||||
|
);
|
||||||
|
|
||||||
|
let Some(span) = span_for(range.as_deref(), len) else {
|
||||||
|
let _ = request
|
||||||
|
.respond(empty(416).with_header(header("Content-Range", &format!("bytes */{len}"))));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sniff before seeking to the span, for extension-less files.
|
||||||
|
let mut head = [0u8; 16];
|
||||||
|
let head_len = file.read(&mut head).unwrap_or(0);
|
||||||
|
let mime = content_type(&outcome, &head[..head_len]);
|
||||||
|
|
||||||
|
if method == "HEAD" {
|
||||||
|
let _ = request.respond(
|
||||||
|
empty(200)
|
||||||
|
.with_header(header("Content-Type", mime))
|
||||||
|
.with_header(header("Content-Length", &len.to_string())),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = file.seek(SeekFrom::Start(span.start)) {
|
||||||
|
warn!("[MediaServer] seek failed for {}: {}", outcome.display(), e);
|
||||||
|
let _ = request.respond(empty(500));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streamed straight from the file handle: at no point is more than the
|
||||||
|
// span in memory, and the span is capped at CHUNK_LEN.
|
||||||
|
let nbytes = span.len();
|
||||||
|
let body = file.take(nbytes);
|
||||||
|
// tiny_http switches to chunked transfer above a 32 KiB default, which drops
|
||||||
|
// Content-Length — and a 206 without one is unusable to Chromium's media
|
||||||
|
// loader, which needs the range's size. Raising the threshold past our own
|
||||||
|
// cap keeps every response length-delimited.
|
||||||
|
let response = Response::new(
|
||||||
|
StatusCode(206),
|
||||||
|
vec![
|
||||||
|
header("Accept-Ranges", "bytes"),
|
||||||
|
header("Content-Type", mime),
|
||||||
|
header(
|
||||||
|
"Content-Range",
|
||||||
|
&format!("bytes {}-{}/{}", span.start, span.end, len),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
body,
|
||||||
|
Some(nbytes as usize),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.with_chunked_threshold(usize::MAX);
|
||||||
|
|
||||||
|
if let Err(e) = request.respond(response) {
|
||||||
|
// A client that seeks away closes the connection mid-body; that is
|
||||||
|
// normal and must not be logged as a failure.
|
||||||
|
debug!("[MediaServer] response ended early: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check the token and resolve the path, or return the status to answer with.
|
||||||
|
fn route(url: &str, root: &Path, token: &str) -> Result<PathBuf, u16> {
|
||||||
|
let trimmed = url.trim_start_matches('/');
|
||||||
|
let (got_token, rest) = trimmed.split_once('/').ok_or(404u16)?;
|
||||||
|
|
||||||
|
// Constant-time-ish: length check first, then a byte compare. The token is
|
||||||
|
// the only thing standing between another app on the device and this server.
|
||||||
|
if got_token.len() != token.len() || got_token != token {
|
||||||
|
warn!("[MediaServer] Rejected a request with a bad token");
|
||||||
|
return Err(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip any query string before decoding.
|
||||||
|
let raw = rest.split('?').next().unwrap_or("");
|
||||||
|
match resolve_path(raw, root) {
|
||||||
|
Resolved::Allow(p) => Ok(p),
|
||||||
|
Resolved::Forbidden => {
|
||||||
|
warn!("[MediaServer] Refused a path outside the app data directory");
|
||||||
|
Err(403)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a request path resolved to, before any file is touched.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub enum Resolved {
|
||||||
|
Allow(PathBuf),
|
||||||
|
/// Escaped the allowed root.
|
||||||
|
Forbidden,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a percent-encoded request path to a file inside `root`.
|
||||||
|
///
|
||||||
|
/// `..` segments are folded away lexically rather than through `canonicalize`,
|
||||||
|
/// so a missing file still resolves (and then 404s) instead of being reported as
|
||||||
|
/// a scope violation.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-137 | UT-127
|
||||||
|
pub fn resolve_path(raw: &str, root: &Path) -> Resolved {
|
||||||
|
let decoded = match urlencoding::decode(raw) {
|
||||||
|
Ok(d) => d.into_owned(),
|
||||||
|
Err(_) => raw.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut normalised = PathBuf::new();
|
||||||
|
for part in Path::new(&decoded).components() {
|
||||||
|
match part {
|
||||||
|
std::path::Component::ParentDir => {
|
||||||
|
normalised.pop();
|
||||||
|
}
|
||||||
|
std::path::Component::CurDir => {}
|
||||||
|
other => normalised.push(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalised.starts_with(root) {
|
||||||
|
Resolved::Allow(normalised)
|
||||||
|
} else {
|
||||||
|
Resolved::Forbidden
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The byte range a response should carry. `end` is inclusive.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub struct Span {
|
||||||
|
pub start: u64,
|
||||||
|
pub end: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Span {
|
||||||
|
pub fn len(&self) -> u64 {
|
||||||
|
self.end + 1 - self.start
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide which span to send for a `Range` header (or its absence).
|
||||||
|
///
|
||||||
|
/// `None` means unsatisfiable — answer 416. A missing or unparseable header
|
||||||
|
/// yields the first chunk, so a client that did not ask for a range still gets a
|
||||||
|
/// bounded response it can continue from, which is exactly the case the asset
|
||||||
|
/// protocol answered with the whole file.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-137 | UT-127
|
||||||
|
pub fn span_for(range: Option<&str>, len: u64) -> Option<Span> {
|
||||||
|
if len == 0 {
|
||||||
|
return Some(Span { start: 0, end: 0 });
|
||||||
|
}
|
||||||
|
let last = len - 1;
|
||||||
|
let first_chunk = Span {
|
||||||
|
start: 0,
|
||||||
|
end: (CHUNK_LEN - 1).min(last),
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(raw) = range else {
|
||||||
|
return Some(first_chunk);
|
||||||
|
};
|
||||||
|
let Some(spec) = raw.trim().strip_prefix("bytes=") else {
|
||||||
|
return Some(first_chunk);
|
||||||
|
};
|
||||||
|
// Only the first range of a multi-range request is honoured; media clients
|
||||||
|
// ask for one, and a single 206 is a valid answer either way.
|
||||||
|
let spec = spec.split(',').next().unwrap_or("").trim();
|
||||||
|
let Some((from, to)) = spec.split_once('-') else {
|
||||||
|
return Some(first_chunk);
|
||||||
|
};
|
||||||
|
|
||||||
|
let (start, end) = if from.is_empty() {
|
||||||
|
// Suffix form: `-500` is the final 500 bytes.
|
||||||
|
let suffix: u64 = match to.parse() {
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(_) => return Some(first_chunk),
|
||||||
|
};
|
||||||
|
if suffix == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
(len.saturating_sub(suffix), last)
|
||||||
|
} else {
|
||||||
|
let start: u64 = match from.parse() {
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(_) => return Some(first_chunk),
|
||||||
|
};
|
||||||
|
let end = if to.is_empty() {
|
||||||
|
last
|
||||||
|
} else {
|
||||||
|
match to.parse::<u64>() {
|
||||||
|
Ok(n) => n.min(last),
|
||||||
|
Err(_) => return Some(first_chunk),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(start, end)
|
||||||
|
};
|
||||||
|
|
||||||
|
if start > last || end < start {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(Span {
|
||||||
|
start,
|
||||||
|
end: end.min(start + CHUNK_LEN - 1),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Guess a content type.
|
||||||
|
///
|
||||||
|
/// **Magic bytes win over the extension.** Downloading at `original` quality
|
||||||
|
/// asks Jellyfin for a direct static copy, which returns the *source file's*
|
||||||
|
/// bytes under a `.mp4` name whatever the real container is — a downloaded film
|
||||||
|
/// named `.mp4` turned out to be an AVI holding XVID. Trusting the extension
|
||||||
|
/// there labels it `video/mp4` and the player is handed a container that is not
|
||||||
|
/// what the header claims. The extension is only a fallback for a file whose
|
||||||
|
/// bytes are unrecognised, and for the extension-less files the offline queue
|
||||||
|
/// writes under an item id.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-137 | UT-127
|
||||||
|
fn content_type(path: &Path, head: &[u8]) -> &'static str {
|
||||||
|
// `ftyp` at offset 4 marks an ISO base media file (mp4 and friends).
|
||||||
|
if head.len() > 11 && &head[4..8] == b"ftyp" {
|
||||||
|
return "video/mp4";
|
||||||
|
}
|
||||||
|
if head.len() > 11 && head.starts_with(b"RIFF") && &head[8..11] == b"AVI" {
|
||||||
|
return "video/x-msvideo";
|
||||||
|
}
|
||||||
|
if head.starts_with(b"\x1aE\xdf\xa3") {
|
||||||
|
return "video/x-matroska";
|
||||||
|
}
|
||||||
|
if head.starts_with(b"ID3") || head.starts_with(b"\xff\xfb") {
|
||||||
|
return "audio/mpeg";
|
||||||
|
}
|
||||||
|
if head.starts_with(b"OggS") {
|
||||||
|
return "audio/ogg";
|
||||||
|
}
|
||||||
|
if head.starts_with(b"fLaC") {
|
||||||
|
return "audio/flac";
|
||||||
|
}
|
||||||
|
|
||||||
|
match path
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.map(|e| e.to_ascii_lowercase())
|
||||||
|
.as_deref()
|
||||||
|
{
|
||||||
|
Some("mp4" | "m4v" | "mov") => return "video/mp4",
|
||||||
|
Some("mkv") => return "video/x-matroska",
|
||||||
|
Some("webm") => return "video/webm",
|
||||||
|
Some("mp3") => return "audio/mpeg",
|
||||||
|
Some("m4a" | "aac") => return "audio/mp4",
|
||||||
|
Some("flac") => return "audio/flac",
|
||||||
|
Some("ogg" | "opus") => return "audio/ogg",
|
||||||
|
Some("wav") => return "audio/wav",
|
||||||
|
Some("avi") => return "video/x-msvideo",
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
"application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The whole point: a request with no `Range` must still come back bounded.
|
||||||
|
/// That is the case Tauri's asset protocol answers with the entire file —
|
||||||
|
/// the read Chromium abandoned after 31s.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-137 | UT-127
|
||||||
|
#[test]
|
||||||
|
fn a_rangeless_request_is_answered_with_one_chunk_not_the_file() {
|
||||||
|
let huge = 8 * 1024 * 1024 * 1024; // 8 GiB
|
||||||
|
let span = span_for(None, huge).unwrap();
|
||||||
|
assert_eq!(span.start, 0);
|
||||||
|
assert_eq!(span.len(), CHUNK_LEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: UR-071 | DR-137 | UT-127
|
||||||
|
#[test]
|
||||||
|
fn no_response_ever_exceeds_one_chunk() {
|
||||||
|
let len = 8 * 1024 * 1024 * 1024;
|
||||||
|
for h in [
|
||||||
|
"bytes=0-",
|
||||||
|
"bytes=0-99999999999",
|
||||||
|
"bytes=1024-",
|
||||||
|
"bytes=-99999999",
|
||||||
|
] {
|
||||||
|
let span = span_for(Some(h), len).unwrap();
|
||||||
|
assert!(span.len() <= CHUNK_LEN, "{h} produced {} bytes", span.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: UR-071 | DR-137 | UT-127
|
||||||
|
#[test]
|
||||||
|
fn ranges_are_honoured() {
|
||||||
|
let len = 1000u64;
|
||||||
|
assert_eq!(
|
||||||
|
span_for(Some("bytes=100-199"), len).unwrap(),
|
||||||
|
Span {
|
||||||
|
start: 100,
|
||||||
|
end: 199
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// Open-ended runs to the end of a small file.
|
||||||
|
assert_eq!(
|
||||||
|
span_for(Some("bytes=900-"), len).unwrap(),
|
||||||
|
Span {
|
||||||
|
start: 900,
|
||||||
|
end: 999
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// Suffix form.
|
||||||
|
assert_eq!(
|
||||||
|
span_for(Some("bytes=-100"), len).unwrap(),
|
||||||
|
Span {
|
||||||
|
start: 900,
|
||||||
|
end: 999
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// Past the end is unsatisfiable, not a clamp — a clamp would make a
|
||||||
|
// seek past the end silently replay earlier bytes.
|
||||||
|
assert!(span_for(Some("bytes=1000-"), len).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A malformed header must not fail the request: playing from the start is
|
||||||
|
/// strictly better than refusing to open the file.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-137 | UT-127
|
||||||
|
#[test]
|
||||||
|
fn a_malformed_range_falls_back_to_the_first_chunk() {
|
||||||
|
assert_eq!(span_for(Some("pages=1-2"), 5000).unwrap().start, 0);
|
||||||
|
assert_eq!(span_for(Some("bytes=abc-def"), 5000).unwrap().start, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: UR-071 | DR-137 | UT-127
|
||||||
|
#[test]
|
||||||
|
fn reads_are_confined_to_the_app_data_directory() {
|
||||||
|
let root = Path::new("/data/user/0/app");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
resolve_path("/data/user/0/app/videos/f.mp4", root),
|
||||||
|
Resolved::Allow(PathBuf::from("/data/user/0/app/videos/f.mp4"))
|
||||||
|
);
|
||||||
|
// Percent-encoded, as the URL builder produces.
|
||||||
|
assert_eq!(
|
||||||
|
resolve_path("%2Fdata%2Fuser%2F0%2Fapp%2Fa%20b.mp4", root),
|
||||||
|
Resolved::Allow(PathBuf::from("/data/user/0/app/a b.mp4"))
|
||||||
|
);
|
||||||
|
// Traversal out of the root, and an unrelated absolute path, are refused.
|
||||||
|
assert_eq!(
|
||||||
|
resolve_path("/data/user/0/app/../../../etc/passwd", root),
|
||||||
|
Resolved::Forbidden
|
||||||
|
);
|
||||||
|
assert_eq!(resolve_path("/etc/passwd", root), Resolved::Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loopback is shared between apps on Android, so the token is the only
|
||||||
|
/// thing stopping another installed app from reading downloaded media.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-137 | UT-127
|
||||||
|
#[test]
|
||||||
|
fn a_request_without_the_right_token_is_refused() {
|
||||||
|
let root = Path::new("/data/user/0/app");
|
||||||
|
let good = "0123456789abcdef0123456789abcdef";
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
route(
|
||||||
|
&format!("/{good}/%2Fdata%2Fuser%2F0%2Fapp%2Ff.mp4"),
|
||||||
|
root,
|
||||||
|
good
|
||||||
|
),
|
||||||
|
Ok(PathBuf::from("/data/user/0/app/f.mp4"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
route("/wrong-token/%2Fdata%2Fuser%2F0%2Fapp%2Ff.mp4", root, good),
|
||||||
|
Err(403)
|
||||||
|
);
|
||||||
|
// No token segment at all.
|
||||||
|
assert_eq!(route("/f.mp4", root, good), Err(404));
|
||||||
|
// Right token, but a path outside the root is still refused.
|
||||||
|
assert_eq!(
|
||||||
|
route(&format!("/{good}/%2Fetc%2Fpasswd"), root, good),
|
||||||
|
Err(403)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: UR-071 | DR-137 | UT-127
|
||||||
|
#[test]
|
||||||
|
fn content_type_uses_the_extension_then_the_magic_bytes() {
|
||||||
|
assert_eq!(content_type(Path::new("/a/f.mp4"), &[]), "video/mp4");
|
||||||
|
assert_eq!(content_type(Path::new("/a/f.mp3"), &[]), "audio/mpeg");
|
||||||
|
// A `.mp4` that is really an AVI: downloading at `original` quality
|
||||||
|
// copies the source bytes under an mp4 name, so the extension lies and
|
||||||
|
// the magic bytes must win.
|
||||||
|
let avi_head = b"RIFF\xcc\xf3\xbc\x2bAVI LIST";
|
||||||
|
assert_eq!(
|
||||||
|
content_type(Path::new("/a/film.mp4"), avi_head),
|
||||||
|
"video/x-msvideo"
|
||||||
|
);
|
||||||
|
// Extension-less, as the offline queue writes them: sniff instead.
|
||||||
|
let mp4_head = b"\x00\x00\x00\x20ftypisom\x00\x00\x02\x00";
|
||||||
|
assert_eq!(content_type(Path::new("/a/abc123"), mp4_head), "video/mp4");
|
||||||
|
assert_eq!(
|
||||||
|
content_type(Path::new("/a/abc123"), b"ID3\x03junk"),
|
||||||
|
"audio/mpeg"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,10 @@ const TICKS_PER_SECOND: f64 = 10_000_000.0;
|
|||||||
/// send a resume position, so a fresh track casts from 0 rather than ~0.
|
/// send a resume position, so a fresh track casts from 0 rather than ~0.
|
||||||
const RESUME_THRESHOLD_SECONDS: f64 = 0.5;
|
const RESUME_THRESHOLD_SECONDS: f64 = 0.5;
|
||||||
|
|
||||||
|
/// Volume level (0-100) the remote volume slider starts at. The real level is
|
||||||
|
/// corrected by the session poller once the remote session reports its volume.
|
||||||
|
const DEFAULT_REMOTE_VOLUME: i32 = 50;
|
||||||
|
|
||||||
/// Convert a live playback position (seconds) into the `StartPositionTicks` to
|
/// Convert a live playback position (seconds) into the `StartPositionTicks` to
|
||||||
/// hand to a remote session, or `None` if we're effectively at the start.
|
/// hand to a remote session, or `None` if we're effectively at the start.
|
||||||
///
|
///
|
||||||
@@ -42,6 +46,50 @@ fn start_position_ticks_from_seconds(position_seconds: f64) -> Option<i64> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Platform hook for attaching/detaching the OS remote-volume control.
|
||||||
|
///
|
||||||
|
/// On Android, entering remote mode hands the `MediaSession` a
|
||||||
|
/// `VolumeProviderCompat` so hardware volume buttons and the system slider drive
|
||||||
|
/// the *remote* session; leaving remote mode must hand it back to the local
|
||||||
|
/// media stream. Behind a trait so the routing rule (see
|
||||||
|
/// [`PlaybackModeManager::set_mode`]) is unit-testable off-device — the real
|
||||||
|
/// implementation is JNI and only exists on Android.
|
||||||
|
pub trait RemoteVolumeControl: Send + Sync {
|
||||||
|
/// Attach remote-volume control (and, on Android, start the playback service).
|
||||||
|
fn enable(&self, initial_volume: i32);
|
||||||
|
/// Return volume control to the local device speaker.
|
||||||
|
fn disable(&self);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Production hook: forwards to the Android JNI bridge; no-op elsewhere.
|
||||||
|
struct PlatformRemoteVolumeControl;
|
||||||
|
|
||||||
|
impl RemoteVolumeControl for PlatformRemoteVolumeControl {
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
fn enable(&self, initial_volume: i32) {
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
if let Err(e) = crate::player::enable_remote_volume(initial_volume) {
|
||||||
|
log::warn!(
|
||||||
|
"[PlaybackMode] Failed to enable remote volume/service: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
// Non-fatal - continue; the next poll tick will retry metadata.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disable(&self) {
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
if let Err(e) = crate::player::disable_remote_volume() {
|
||||||
|
log::warn!("[PlaybackMode] Failed to disable remote volume: {}", e);
|
||||||
|
// Non-fatal - the mode change itself has already happened.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Manages playback mode transfers between local and remote sessions
|
/// Manages playback mode transfers between local and remote sessions
|
||||||
pub struct PlaybackModeManager {
|
pub struct PlaybackModeManager {
|
||||||
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
|
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
|
||||||
@@ -51,6 +99,8 @@ pub struct PlaybackModeManager {
|
|||||||
/// Optional emitter used to notify the frontend when the mode changes, so its
|
/// Optional emitter used to notify the frontend when the mode changes, so its
|
||||||
/// mirror store stays in sync with this authoritative one. `None` in tests.
|
/// mirror store stays in sync with this authoritative one. `None` in tests.
|
||||||
event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
|
event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
|
||||||
|
/// Platform hook for OS-level remote volume routing (swapped in tests).
|
||||||
|
remote_volume: Arc<dyn RemoteVolumeControl>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlaybackModeManager {
|
impl PlaybackModeManager {
|
||||||
@@ -65,6 +115,24 @@ impl PlaybackModeManager {
|
|||||||
current_mode: Arc::new(RwLock::new(PlaybackMode::Idle)),
|
current_mode: Arc::new(RwLock::new(PlaybackMode::Idle)),
|
||||||
is_transferring: Arc::new(AtomicBool::new(false)),
|
is_transferring: Arc::new(AtomicBool::new(false)),
|
||||||
event_emitter: Arc::new(Mutex::new(None)),
|
event_emitter: Arc::new(Mutex::new(None)),
|
||||||
|
remote_volume: Arc::new(PlatformRemoteVolumeControl),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct with a custom remote-volume hook (tests).
|
||||||
|
#[cfg(test)]
|
||||||
|
fn with_remote_volume(
|
||||||
|
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
|
||||||
|
player_controller: Arc<TokioMutex<PlayerController>>,
|
||||||
|
remote_volume: Arc<dyn RemoteVolumeControl>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
jellyfin_client,
|
||||||
|
player_controller,
|
||||||
|
current_mode: Arc::new(RwLock::new(PlaybackMode::Idle)),
|
||||||
|
is_transferring: Arc::new(AtomicBool::new(false)),
|
||||||
|
event_emitter: Arc::new(Mutex::new(None)),
|
||||||
|
remote_volume,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,19 +154,39 @@ impl PlaybackModeManager {
|
|||||||
/// the frontend's mirror store reconciles to this authoritative value. The
|
/// the frontend's mirror store reconciles to this authoritative value. The
|
||||||
/// write lock is released before emitting to avoid holding it across the
|
/// write lock is released before emitting to avoid holding it across the
|
||||||
/// emitter call.
|
/// emitter call.
|
||||||
|
///
|
||||||
|
/// Also owns **OS volume routing**, which is derived from the transition
|
||||||
|
/// rather than from each call site: entering remote mode attaches the remote
|
||||||
|
/// volume control, and *any* exit from remote mode hands it back to the local
|
||||||
|
/// speaker. Doing this per-call-site is what caused the bug where stopping a
|
||||||
|
/// remote session (`player_stop` → Idle) left Android stuck on the remote
|
||||||
|
/// volume slider — only the transfer-to-local path tore it down.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-010 | DR-059, IR-021
|
||||||
pub fn set_mode(&self, mode: PlaybackMode) {
|
pub fn set_mode(&self, mode: PlaybackMode) {
|
||||||
log::info!("[PlaybackMode] Setting mode to: {:?}", mode);
|
log::info!("[PlaybackMode] Setting mode to: {:?}", mode);
|
||||||
let changed = {
|
let (changed, was_remote) = {
|
||||||
let mut current = self.current_mode.write_safe();
|
let mut current = self.current_mode.write_safe();
|
||||||
let changed = *current != mode;
|
let changed = *current != mode;
|
||||||
|
let was_remote = matches!(*current, PlaybackMode::Remote { .. });
|
||||||
*current = mode.clone();
|
*current = mode.clone();
|
||||||
changed
|
(changed, was_remote)
|
||||||
};
|
};
|
||||||
|
|
||||||
if !changed {
|
if !changed {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Volume routing follows the transition. Note remote->remote (switching
|
||||||
|
// target session) re-arms rather than releasing control.
|
||||||
|
let is_remote = matches!(mode, PlaybackMode::Remote { .. });
|
||||||
|
if is_remote {
|
||||||
|
self.remote_volume.enable(DEFAULT_REMOTE_VOLUME);
|
||||||
|
} else if was_remote {
|
||||||
|
log::info!("[PlaybackMode] Leaving remote mode - restoring local volume control");
|
||||||
|
self.remote_volume.disable();
|
||||||
|
}
|
||||||
|
|
||||||
let (mode_str, session_id) = match &mode {
|
let (mode_str, session_id) = match &mode {
|
||||||
PlaybackMode::Local => ("local".to_string(), None),
|
PlaybackMode::Local => ("local".to_string(), None),
|
||||||
PlaybackMode::Idle => ("idle".to_string(), None),
|
PlaybackMode::Idle => ("idle".to_string(), None),
|
||||||
@@ -122,18 +210,13 @@ impl PlaybackModeManager {
|
|||||||
/// Both symptoms share this one cause, so this must not be skipped on any
|
/// Both symptoms share this one cause, so this must not be skipped on any
|
||||||
/// remote-entry path (notably the empty-queue early return in
|
/// remote-entry path (notably the empty-queue early return in
|
||||||
/// `transfer_to_remote_inner`). No-op / non-Android builds do nothing.
|
/// `transfer_to_remote_inner`). No-op / non-Android builds do nothing.
|
||||||
#[allow(unused_variables)]
|
///
|
||||||
|
/// [`set_mode`](Self::set_mode) already arms this on entry into remote mode;
|
||||||
|
/// calling it again is harmless (the service start is idempotent) and keeps
|
||||||
|
/// the guarantee when the mode was already remote, which `set_mode` skips as
|
||||||
|
/// a no-op transition.
|
||||||
fn enable_remote_control(&self) {
|
fn enable_remote_control(&self) {
|
||||||
#[cfg(target_os = "android")]
|
self.remote_volume.enable(DEFAULT_REMOTE_VOLUME);
|
||||||
{
|
|
||||||
if let Err(e) = crate::player::enable_remote_volume(50) {
|
|
||||||
log::warn!(
|
|
||||||
"[PlaybackMode] Failed to enable remote volume/service: {}",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
// Non-fatal - continue; the next poll tick will retry metadata.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if currently transferring
|
/// Check if currently transferring
|
||||||
@@ -766,18 +849,10 @@ impl PlaybackModeManager {
|
|||||||
// This will be improved in Phase 3 when repository is migrated to Rust.
|
// This will be improved in Phase 3 when repository is migrated to Rust.
|
||||||
log::debug!("[PlaybackMode] Cannot load media item in Rust yet - frontend handled it");
|
log::debug!("[PlaybackMode] Cannot load media item in Rust yet - frontend handled it");
|
||||||
|
|
||||||
// Update mode to local
|
// Update mode to local. This also returns volume control to the local
|
||||||
|
// device speaker — set_mode owns that for every exit from remote mode.
|
||||||
self.set_mode(PlaybackMode::Local);
|
self.set_mode(PlaybackMode::Local);
|
||||||
|
|
||||||
// Disable remote volume control on Android (return to system volume)
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
{
|
|
||||||
if let Err(e) = crate::player::disable_remote_volume() {
|
|
||||||
log::warn!("[PlaybackMode] Failed to disable remote volume: {}", e);
|
|
||||||
// Non-fatal - continue with transfer
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("[PlaybackMode] Successfully transferred to local");
|
log::info!("[PlaybackMode] Successfully transferred to local");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -893,6 +968,118 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Records enable/disable calls so tests can assert volume routing.
|
||||||
|
struct RecordingVolumeControl {
|
||||||
|
calls: Mutex<Vec<&'static str>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RemoteVolumeControl for RecordingVolumeControl {
|
||||||
|
fn enable(&self, _initial_volume: i32) {
|
||||||
|
self.calls.lock().unwrap().push("enable");
|
||||||
|
}
|
||||||
|
fn disable(&self) {
|
||||||
|
self.calls.lock().unwrap().push("disable");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manager_with_volume_control() -> (PlaybackModeManager, Arc<RecordingVolumeControl>) {
|
||||||
|
let volume = Arc::new(RecordingVolumeControl {
|
||||||
|
calls: Mutex::new(Vec::new()),
|
||||||
|
});
|
||||||
|
let manager = PlaybackModeManager::with_remote_volume(
|
||||||
|
Arc::new(Mutex::new(None)),
|
||||||
|
Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
|
||||||
|
volume.clone(),
|
||||||
|
);
|
||||||
|
(manager, volume)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leaving remote mode must hand volume control back to the local device.
|
||||||
|
///
|
||||||
|
/// Stopping a remote session (`player_stop`) drives the manager
|
||||||
|
/// Remote -> Idle without going through `transfer_to_local`. Before this was
|
||||||
|
/// centralised in `set_mode`, only the transfer path tore the Android
|
||||||
|
/// `VolumeProviderCompat` down, so a plain stop left the system stuck on the
|
||||||
|
/// remote volume slider with no way back to the phone speaker.
|
||||||
|
///
|
||||||
|
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
|
||||||
|
#[test]
|
||||||
|
fn test_leaving_remote_mode_restores_local_volume() {
|
||||||
|
let (manager, volume) = manager_with_volume_control();
|
||||||
|
|
||||||
|
manager.set_mode(PlaybackMode::Remote {
|
||||||
|
session_id: "sess-1".to_string(),
|
||||||
|
});
|
||||||
|
// The stop path: remote -> idle, no transfer involved.
|
||||||
|
manager.set_mode(PlaybackMode::Idle);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
*volume.calls.lock().unwrap(),
|
||||||
|
vec!["enable", "disable"],
|
||||||
|
"remote->idle must return volume control to the local speaker"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same must hold for remote -> local (transfer back to this device).
|
||||||
|
///
|
||||||
|
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
|
||||||
|
#[test]
|
||||||
|
fn test_remote_to_local_restores_local_volume() {
|
||||||
|
let (manager, volume) = manager_with_volume_control();
|
||||||
|
|
||||||
|
manager.set_mode(PlaybackMode::Remote {
|
||||||
|
session_id: "sess-1".to_string(),
|
||||||
|
});
|
||||||
|
manager.set_mode(PlaybackMode::Local);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
*volume.calls.lock().unwrap(),
|
||||||
|
vec!["enable", "disable"],
|
||||||
|
"remote->local must return volume control to the local speaker"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Volume routing must not be touched by transitions that never involve
|
||||||
|
/// remote mode — an idle->local start would otherwise issue a pointless
|
||||||
|
/// `setPlaybackToLocal` on every playback start.
|
||||||
|
///
|
||||||
|
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
|
||||||
|
#[test]
|
||||||
|
fn test_non_remote_transitions_leave_volume_routing_alone() {
|
||||||
|
let (manager, volume) = manager_with_volume_control();
|
||||||
|
|
||||||
|
manager.set_mode(PlaybackMode::Local);
|
||||||
|
manager.set_mode(PlaybackMode::Idle);
|
||||||
|
manager.set_mode(PlaybackMode::Local);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
volume.calls.lock().unwrap().is_empty(),
|
||||||
|
"local/idle transitions must not touch remote volume routing"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switching directly between two remote sessions stays remote: control must
|
||||||
|
/// remain attached (re-armed for the new session), never handed back local.
|
||||||
|
///
|
||||||
|
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
|
||||||
|
#[test]
|
||||||
|
fn test_remote_to_remote_keeps_remote_volume() {
|
||||||
|
let (manager, volume) = manager_with_volume_control();
|
||||||
|
|
||||||
|
manager.set_mode(PlaybackMode::Remote {
|
||||||
|
session_id: "sess-1".to_string(),
|
||||||
|
});
|
||||||
|
manager.set_mode(PlaybackMode::Remote {
|
||||||
|
session_id: "sess-2".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
*volume.calls.lock().unwrap(),
|
||||||
|
vec!["enable", "enable"],
|
||||||
|
"remote->remote re-arms control without releasing it to local"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Setting the same mode twice must not re-emit — the frontend reconciler
|
/// Setting the same mode twice must not re-emit — the frontend reconciler
|
||||||
/// (and the event channel) shouldn't be spammed on no-op transitions.
|
/// (and the event channel) shouldn't be spammed on no-op transitions.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -58,11 +58,20 @@ static POSITION_THROTTLER: OnceLock<Arc<EventThrottler>> = OnceLock::new();
|
|||||||
struct DetectedCodecs {
|
struct DetectedCodecs {
|
||||||
video_codecs: Vec<String>,
|
video_codecs: Vec<String>,
|
||||||
audio_codecs: Vec<String>,
|
audio_codecs: Vec<String>,
|
||||||
|
/// Channels the *current audio output route* accepts, as reported by
|
||||||
|
/// media3's `AudioCapabilities`. Distinct from the codec lists: a device
|
||||||
|
/// decodes 5.1 happily and still has only two channels to play it out of.
|
||||||
|
/// `None` when the platform had no answer.
|
||||||
|
max_audio_channels: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DetectedCodecs {
|
impl DetectedCodecs {
|
||||||
/// Create from comma-separated codec strings (from JNI)
|
/// Create from comma-separated codec strings (from JNI)
|
||||||
fn from_jni_strings(video_codecs: &str, audio_codecs: &str) -> Self {
|
fn from_jni_strings(
|
||||||
|
video_codecs: &str,
|
||||||
|
audio_codecs: &str,
|
||||||
|
max_audio_channels: Option<u32>,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
video_codecs: video_codecs
|
video_codecs: video_codecs
|
||||||
.split(',')
|
.split(',')
|
||||||
@@ -74,6 +83,7 @@ impl DetectedCodecs {
|
|||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
.collect(),
|
.collect(),
|
||||||
|
max_audio_channels,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,11 +98,19 @@ impl DetectedCodecs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Public function to get detected codecs (for use in repository layer)
|
/// Public function to get detected codecs (for use in repository layer).
|
||||||
pub fn get_detected_codecs() -> Option<(String, String)> {
|
///
|
||||||
DETECTED_CODECS
|
/// Returns `(video, audio, max_audio_channels)` — the third element is how many
|
||||||
.get()
|
/// channels the current audio output can actually voice, which bounds what the
|
||||||
.map(|codecs| (codecs.video_codecs_string(), codecs.audio_codecs_string()))
|
/// server may direct-play.
|
||||||
|
pub fn get_detected_codecs() -> Option<(String, String, Option<u32>)> {
|
||||||
|
DETECTED_CODECS.get().map(|codecs| {
|
||||||
|
(
|
||||||
|
codecs.video_codecs_string(),
|
||||||
|
codecs.audio_codecs_string(),
|
||||||
|
codecs.max_audio_channels,
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trait for handling media commands from Android MediaSession.
|
/// Trait for handling media commands from Android MediaSession.
|
||||||
@@ -930,6 +948,25 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(AutoplayDecision::ResumeStream { position }) => {
|
||||||
|
// ExoPlayer reported ENDED because the progressive transcode's
|
||||||
|
// connection dropped, not because the episode finished. This
|
||||||
|
// is the arm that matters while backgrounded: it needs no
|
||||||
|
// frontend echo, so the stream re-opens even with the webview
|
||||||
|
// suspended — and playback never parks in STATE_ENDED, where
|
||||||
|
// the next lockscreen/Bluetooth play restarts the item at 0:00.
|
||||||
|
log::info!(
|
||||||
|
"[Autoplay] Decision: Resume truncated stream at {:.1}s",
|
||||||
|
position
|
||||||
|
);
|
||||||
|
let ctrl = controller.lock().await;
|
||||||
|
if let Err(e) = ctrl.resume_stream_at(position).await {
|
||||||
|
log::error!("[Autoplay] Failed to resume truncated stream: {}", e);
|
||||||
|
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||||
|
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("[Autoplay] Decision failed: {}", e);
|
log::error!("[Autoplay] Decision failed: {}", e);
|
||||||
// Emit PlaybackEnded event on error
|
// Emit PlaybackEnded event on error
|
||||||
@@ -974,11 +1011,61 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
.get_string(&message)
|
.get_string(&message)
|
||||||
.map(|s| s.into())
|
.map(|s| s.into())
|
||||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||||
|
let recoverable = recoverable != 0;
|
||||||
|
|
||||||
|
// A background audio-only handoff is an mp3 the device was already decoding,
|
||||||
|
// so a recoverable failure part-way through is the network. Surfacing it as a
|
||||||
|
// player error stops playback for good (the frontend's handler calls
|
||||||
|
// player_stop); re-opening the stream where it died is the "buffer and
|
||||||
|
// resume" this actually is. Everything else keeps reporting the error.
|
||||||
|
if recoverable {
|
||||||
|
if let Some(controller) = PLAYER_CONTROLLER.get() {
|
||||||
|
let controller = controller.clone();
|
||||||
|
let message_str = message_str.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let resume = controller.lock().await.recoverable_error_resume();
|
||||||
|
let Some((position, delay_secs)) = resume else {
|
||||||
|
// Declined here, so report it as NOT recoverable: the frontend
|
||||||
|
// would otherwise echo it into player_recover_stream and ask
|
||||||
|
// the same question a second time.
|
||||||
|
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||||
|
emitter.emit(PlayerStatusEvent::Error {
|
||||||
|
message: message_str,
|
||||||
|
recoverable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
log::warn!(
|
||||||
|
"[ExoPlayer] Recoverable stream error ({}) — re-opening at {:.1}s in {}s",
|
||||||
|
message_str,
|
||||||
|
position,
|
||||||
|
delay_secs
|
||||||
|
);
|
||||||
|
// Give a brief outage time to clear before asking the server for
|
||||||
|
// the stream again; retrying instantly just burns the budget.
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
|
||||||
|
|
||||||
|
let ctrl = controller.lock().await;
|
||||||
|
if let Err(e) = ctrl.resume_stream_at(position).await {
|
||||||
|
log::error!("[ExoPlayer] Failed to resume after error: {}", e);
|
||||||
|
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||||
|
emitter.emit(PlayerStatusEvent::Error {
|
||||||
|
message: message_str,
|
||||||
|
recoverable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||||
emitter.emit(PlayerStatusEvent::Error {
|
emitter.emit(PlayerStatusEvent::Error {
|
||||||
message: message_str,
|
message: message_str,
|
||||||
recoverable: recoverable != 0,
|
recoverable,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1056,6 +1143,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_00024Co
|
|||||||
_class: JClass,
|
_class: JClass,
|
||||||
video_codecs: JString,
|
video_codecs: JString,
|
||||||
audio_codecs: JString,
|
audio_codecs: JString,
|
||||||
|
max_audio_channels: jint,
|
||||||
) {
|
) {
|
||||||
let video_str: String = env
|
let video_str: String = env
|
||||||
.get_string(&video_codecs)
|
.get_string(&video_codecs)
|
||||||
@@ -1067,7 +1155,10 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_00024Co
|
|||||||
.map(|s| s.into())
|
.map(|s| s.into())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let codecs = DetectedCodecs::from_jni_strings(&video_str, &audio_str);
|
// Kotlin sends 0 when AudioCapabilities had no answer for the current route.
|
||||||
|
let channels = u32::try_from(max_audio_channels).ok().filter(|c| *c > 0);
|
||||||
|
|
||||||
|
let codecs = DetectedCodecs::from_jni_strings(&video_str, &audio_str, channels);
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"[CodecDetection] Detected {} video codecs: {}",
|
"[CodecDetection] Detected {} video codecs: {}",
|
||||||
@@ -1079,6 +1170,10 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_00024Co
|
|||||||
codecs.audio_codecs.len(),
|
codecs.audio_codecs.len(),
|
||||||
codecs.audio_codecs_string()
|
codecs.audio_codecs_string()
|
||||||
);
|
);
|
||||||
|
log::info!(
|
||||||
|
"[CodecDetection] Audio route max channels: {:?}",
|
||||||
|
codecs.max_audio_channels
|
||||||
|
);
|
||||||
|
|
||||||
// Store in global state
|
// Store in global state
|
||||||
if DETECTED_CODECS.set(codecs).is_err() {
|
if DETECTED_CODECS.set(codecs).is_err() {
|
||||||
@@ -1379,9 +1474,11 @@ pub fn update_lockscreen_metadata(meta: &LockscreenMetadata) -> Result<(), Strin
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the base position offset (seconds) on the lockscreen MediaSession.
|
/// Set the background-audio handoff base (seconds) on the playback service.
|
||||||
///
|
///
|
||||||
/// Calls `JellyTauPlaybackService.setPositionOffset(double)`. No-op if the
|
/// The service holds it for `JellyTauPlayer`'s position tick, which is the one
|
||||||
|
/// place the relative handoff timeline is converted to the episode's own — see
|
||||||
|
/// DR-159. Calls `JellyTauPlaybackService.setHandoffBase(double)`. No-op if the
|
||||||
/// service isn't running yet, so it's safe to call unconditionally.
|
/// service isn't running yet, so it's safe to call unconditionally.
|
||||||
pub fn set_position_offset(offset_seconds: f64) -> Result<(), String> {
|
pub fn set_position_offset(offset_seconds: f64) -> Result<(), String> {
|
||||||
let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?;
|
let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?;
|
||||||
@@ -1430,11 +1527,11 @@ pub fn set_position_offset(offset_seconds: f64) -> Result<(), String> {
|
|||||||
|
|
||||||
env.call_method(
|
env.call_method(
|
||||||
&service_obj,
|
&service_obj,
|
||||||
"setPositionOffset",
|
"setHandoffBase",
|
||||||
"(D)V",
|
"(D)V",
|
||||||
&[JValue::Double(offset_seconds)],
|
&[JValue::Double(offset_seconds)],
|
||||||
)
|
)
|
||||||
.map_err(|e| format!("Failed to set position offset: {}", e))?;
|
.map_err(|e| format!("Failed to set handoff base: {}", e))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ pub enum AutoplayDecision {
|
|||||||
Stop,
|
Stop,
|
||||||
/// Advance to next track in queue (for audio/movies)
|
/// Advance to next track in queue (for audio/movies)
|
||||||
AdvanceToNext,
|
AdvanceToNext,
|
||||||
|
/// The stream ended well short of the item's runtime — the connection
|
||||||
|
/// dropped, not the media. Re-open the same stream at `position` instead of
|
||||||
|
/// running any end-of-item logic (UR-040).
|
||||||
|
ResumeStream { position: f64 },
|
||||||
/// Show next episode popup with countdown
|
/// Show next episode popup with countdown
|
||||||
ShowNextEpisodePopup {
|
ShowNextEpisodePopup {
|
||||||
current_episode: MediaItem,
|
current_episode: MediaItem,
|
||||||
|
|||||||
@@ -23,6 +23,23 @@ pub enum QueueContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Represents a subtitle track
|
/// Represents a subtitle track
|
||||||
|
///
|
||||||
|
/// 🔴 **Do not add `#[serde(rename_all = "camelCase")]` here.** This is the one
|
||||||
|
/// struct in the player that deliberately keeps snake_case on the wire, because
|
||||||
|
/// the *same* serialization feeds two consumers that both spell `mime_type`:
|
||||||
|
///
|
||||||
|
/// * the JNI boundary — `player/android/mod.rs` serializes `MediaItem::subtitles`
|
||||||
|
/// with `serde_json` and hands the string to `JellyTauPlayer.loadWithMetadata`,
|
||||||
|
/// whose parser reads `url`, `language`, `label` and `optString("mime_type")`;
|
||||||
|
/// * the IPC boundary — `PlayItemRequest::subtitles` deserializes this same type
|
||||||
|
/// from the frontend, and the generated binding (`SubtitleTrack` in
|
||||||
|
/// `bindings.ts`) therefore also declares `mime_type`.
|
||||||
|
///
|
||||||
|
/// Renaming would not break the build and would not fail the IPC: Kotlin's
|
||||||
|
/// `optString` would just fall back to its default MIME type for every track, so
|
||||||
|
/// the failure would be silent. UT-146 asserts the serialized keys.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-020 | IR-016, JA-008 | UT-146
|
||||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct SubtitleTrack {
|
pub struct SubtitleTrack {
|
||||||
/// Stream index in the media source
|
/// Stream index in the media source
|
||||||
@@ -33,7 +50,8 @@ pub struct SubtitleTrack {
|
|||||||
pub language: Option<String>,
|
pub language: Option<String>,
|
||||||
/// Display title
|
/// Display title
|
||||||
pub label: Option<String>,
|
pub label: Option<String>,
|
||||||
/// MIME type (e.g., "text/vtt", "application/x-subrip")
|
/// MIME type (e.g., "text/vtt", "application/x-subrip").
|
||||||
|
/// Snake_case on purpose — see the note on the struct.
|
||||||
pub mime_type: String,
|
pub mime_type: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+839
-3
@@ -11,6 +11,7 @@ pub mod seek;
|
|||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod sleep_timer;
|
pub mod sleep_timer;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
pub mod stream_end;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod mpv_backend_test;
|
mod mpv_backend_test;
|
||||||
@@ -31,7 +32,7 @@ pub mod webview_audio_backend;
|
|||||||
pub use autoplay::{AutoplayDecision, AutoplaySettings};
|
pub use autoplay::{AutoplayDecision, AutoplaySettings};
|
||||||
pub use backend::{NullBackend, PlayerBackend, PlayerError};
|
pub use backend::{NullBackend, PlayerBackend, PlayerError};
|
||||||
pub use events::{PlayerEventEmitter, PlayerStatusEvent, TauriEventEmitter};
|
pub use events::{PlayerEventEmitter, PlayerStatusEvent, TauriEventEmitter};
|
||||||
pub use media::{MediaItem, MediaSource, MediaType, QueueContext};
|
pub use media::{MediaItem, MediaSource, MediaType, QueueContext, SubtitleTrack};
|
||||||
pub use queue::{QueueManager, RepeatMode};
|
pub use queue::{QueueManager, RepeatMode};
|
||||||
pub use seek::{determine_video_seek_strategy, VideoSeekStrategy};
|
pub use seek::{determine_video_seek_strategy, VideoSeekStrategy};
|
||||||
pub use session::{MediaSessionManager, MediaSessionType};
|
pub use session::{MediaSessionManager, MediaSessionType};
|
||||||
@@ -54,6 +55,16 @@ pub use android::{
|
|||||||
set_remote_volume_handler, MediaCommandHandler, RemoteVolumeHandler,
|
set_remote_volume_handler, MediaCommandHandler, RemoteVolumeHandler,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Seconds added per attempt before retrying a stream that failed with an error.
|
||||||
|
///
|
||||||
|
/// Attempt 1 waits this long, attempt 2 twice as long, and so on — a spread that
|
||||||
|
/// covers roughly a quarter-minute of outage across the retry budget without
|
||||||
|
/// leaving the user staring at a dead notification when the network is truly gone.
|
||||||
|
/// Only *read* by the Android error callback (`#[cfg(android)]`), but compiled
|
||||||
|
/// and unit-tested on the host, hence `allow(dead_code)` off-Android.
|
||||||
|
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||||
|
const RESUME_BACKOFF_STEP_SECS: u64 = 2;
|
||||||
|
|
||||||
/// 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
|
||||||
@@ -168,6 +179,26 @@ pub struct PlayerController {
|
|||||||
// TRACES: UR-040 | DR-052
|
// TRACES: UR-040 | DR-052
|
||||||
background_audio_base: Arc<Mutex<f64>>,
|
background_audio_base: Arc<Mutex<f64>>,
|
||||||
|
|
||||||
|
// True while a background-audio handoff owns playback: the native audio
|
||||||
|
// player is the real player and the webview <video> has been torn down.
|
||||||
|
//
|
||||||
|
// The teardown is what makes this necessary. It fires a DOM `pause` that the
|
||||||
|
// frontend reports like any other, which would otherwise leave the controller
|
||||||
|
// believing webview media is still active — aiming lockscreen transport at an
|
||||||
|
// element that no longer exists (see `is_html5_active`).
|
||||||
|
//
|
||||||
|
// TRACES: UR-040 | DR-052, DR-097
|
||||||
|
background_audio_active: Arc<Mutex<bool>>,
|
||||||
|
|
||||||
|
// Budget for re-opening a stream that ended short of the item's runtime.
|
||||||
|
//
|
||||||
|
// A resume re-requests the same URL, so a server that is genuinely gone would
|
||||||
|
// otherwise end → resume → end without limit. The tracker only bounds retries
|
||||||
|
// that make no progress; a resume that plays on refills it.
|
||||||
|
//
|
||||||
|
// TRACES: UR-040 | DR-129
|
||||||
|
stream_resume: Arc<Mutex<stream_end::ResumeTracker>>,
|
||||||
|
|
||||||
// Last state reported by a webview-rendered HTML5 <video>/<audio> element.
|
// Last state reported by a webview-rendered HTML5 <video>/<audio> element.
|
||||||
//
|
//
|
||||||
// Webview-rendered media is played by an element the native backend cannot
|
// Webview-rendered media is played by an element the native backend cannot
|
||||||
@@ -201,6 +232,8 @@ impl PlayerController {
|
|||||||
end_reason: Arc::new(Mutex::new(None)),
|
end_reason: Arc::new(Mutex::new(None)),
|
||||||
autoplay_episode_count: Arc::new(Mutex::new(0)),
|
autoplay_episode_count: Arc::new(Mutex::new(0)),
|
||||||
background_audio_base: Arc::new(Mutex::new(0.0)),
|
background_audio_base: Arc::new(Mutex::new(0.0)),
|
||||||
|
background_audio_active: Arc::new(Mutex::new(false)),
|
||||||
|
stream_resume: Arc::new(Mutex::new(stream_end::ResumeTracker::default())),
|
||||||
html5_playing: Arc::new(Mutex::new(None)),
|
html5_playing: Arc::new(Mutex::new(None)),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -271,6 +304,16 @@ impl PlayerController {
|
|||||||
self.end_reason.lock_safe().take()
|
self.end_reason.lock_safe().take()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read the end reason WITHOUT consuming it.
|
||||||
|
///
|
||||||
|
/// `take_end_reason` has an owner: on Android the JNI ended-callback consumes
|
||||||
|
/// the `NewTrackLoaded` every load sets, and the frontend's echoed call is the
|
||||||
|
/// one that sees `None` and decides. The truncated-stream check runs in both
|
||||||
|
/// calls and must not disturb that hand-off, so it peeks.
|
||||||
|
fn peek_end_reason(&self) -> Option<EndReason> {
|
||||||
|
*self.end_reason.lock_safe()
|
||||||
|
}
|
||||||
|
|
||||||
/// Record that playback is being stopped by an expiring sleep timer.
|
/// Record that playback is being stopped by an expiring sleep timer.
|
||||||
///
|
///
|
||||||
/// Stopping the backend makes it fire its ended callback (ExoPlayer does on
|
/// Stopping the backend makes it fire its ended callback (ExoPlayer does on
|
||||||
@@ -711,12 +754,50 @@ impl PlayerController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Seek to a position in seconds
|
/// Seek to a position in seconds, **on the player's own timeline**.
|
||||||
|
///
|
||||||
|
/// During a background-audio handoff that timeline is relative to the handoff
|
||||||
|
/// point, so this is not the call a lockscreen scrub or a UI seek wants — use
|
||||||
|
/// [`seek_absolute`](Self::seek_absolute), which speaks the episode's
|
||||||
|
/// timeline and is what every caller outside the player itself means.
|
||||||
pub fn seek(&self, position: f64) -> Result<(), PlayerError> {
|
pub fn seek(&self, position: f64) -> Result<(), PlayerError> {
|
||||||
let mut backend = self.backend.lock_safe();
|
let mut backend = self.backend.lock_safe();
|
||||||
backend.seek(position)
|
backend.seek(position)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Seek to an **absolute** position on the item's own timeline.
|
||||||
|
///
|
||||||
|
/// This is the boundary every outside seek comes through — the UI, the
|
||||||
|
/// lockscreen scrubber, a headset gesture — because all of them are looking
|
||||||
|
/// at the whole episode, not at whatever fragment of it the player happens to
|
||||||
|
/// be streaming.
|
||||||
|
///
|
||||||
|
/// Outside a background-audio handoff the two timelines are the same and this
|
||||||
|
/// is an ordinary seek. Inside one they differ by the handoff base, and the
|
||||||
|
/// stream cannot be seeked at all: `/Audio/{id}/universal` is a chunked
|
||||||
|
/// transcode with no length, so ExoPlayer either refuses or clamps — and a
|
||||||
|
/// clamped seek lands at stream zero, which is the handoff point. That is the
|
||||||
|
/// "jumps back to where I locked the screen" symptom. Honouring the seek means
|
||||||
|
/// re-opening the URL at the new position, which is exactly what the
|
||||||
|
/// truncation recovery already does, so it shares `resume_stream_at`.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-040, UR-005 | DR-159 | UT-155
|
||||||
|
pub async fn seek_absolute(&self, position: f64) -> Result<(), String> {
|
||||||
|
let rebuild = self.is_background_audio_active() && {
|
||||||
|
let queue = self.queue.lock_safe();
|
||||||
|
queue
|
||||||
|
.current()
|
||||||
|
.map(Self::is_audio_only_video)
|
||||||
|
.unwrap_or(false)
|
||||||
|
};
|
||||||
|
|
||||||
|
if rebuild {
|
||||||
|
return self.resume_stream_at(position.max(0.0)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.seek(position).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// Set volume (0.0 - 1.0)
|
/// Set volume (0.0 - 1.0)
|
||||||
pub fn set_volume(&self, volume: f32) -> Result<(), PlayerError> {
|
pub fn set_volume(&self, volume: f32) -> Result<(), PlayerError> {
|
||||||
self.backend.lock_safe().set_volume(volume)
|
self.backend.lock_safe().set_volume(volume)
|
||||||
@@ -969,6 +1050,14 @@ impl PlayerController {
|
|||||||
/// Re-emits a `StateChanged` event identical to what MpvBackend/ExoPlayer
|
/// Re-emits a `StateChanged` event identical to what MpvBackend/ExoPlayer
|
||||||
/// would emit, so `playerEvents.ts` needs no HTML5-specific branch.
|
/// would emit, so `playerEvents.ts` needs no HTML5-specific branch.
|
||||||
pub fn report_html5_state(&self, state: String, media_id: Option<String>) {
|
pub fn report_html5_state(&self, state: String, media_id: Option<String>) {
|
||||||
|
// A background-audio handoff has already moved playback to the native
|
||||||
|
// player and torn the element down; anything it still reports describes
|
||||||
|
// a video that is no longer playing. Dropping it keeps the UI on the
|
||||||
|
// audio that IS playing and leaves transport with the native backend.
|
||||||
|
if self.is_background_audio_active() {
|
||||||
|
debug!("[PlayerController] Ignoring HTML5 state '{state}' during background audio");
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Track it: this is the authoritative play/pause state for
|
// Track it: this is the authoritative play/pause state for
|
||||||
// webview-rendered media, and what transport decisions read (DR-097).
|
// webview-rendered media, and what transport decisions read (DR-097).
|
||||||
// "stopped"/"idle" mean the element is gone, so hand authority back to
|
// "stopped"/"idle" mean the element is gone, so hand authority back to
|
||||||
@@ -996,6 +1085,11 @@ impl PlayerController {
|
|||||||
/// Re-emits a `PositionUpdate` event mirroring the native backends' periodic
|
/// Re-emits a `PositionUpdate` event mirroring the native backends' periodic
|
||||||
/// position updates (the adapter is expected to throttle to ~250ms like MPV).
|
/// position updates (the adapter is expected to throttle to ~250ms like MPV).
|
||||||
pub fn report_html5_position(&self, position: f64, duration: f64) {
|
pub fn report_html5_position(&self, position: f64, duration: f64) {
|
||||||
|
// Stale by definition during a handoff — the native player's ticks are
|
||||||
|
// the real position. See `report_html5_state`.
|
||||||
|
if self.is_background_audio_active() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
|
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
|
||||||
emitter.emit(PlayerStatusEvent::PositionUpdate { position, duration });
|
emitter.emit(PlayerStatusEvent::PositionUpdate { position, duration });
|
||||||
}
|
}
|
||||||
@@ -1004,6 +1098,10 @@ impl PlayerController {
|
|||||||
/// Report that the HTML5 <video> element finished loading and knows its
|
/// Report that the HTML5 <video> element finished loading and knows its
|
||||||
/// duration. Mirrors the native `MediaLoaded` event.
|
/// duration. Mirrors the native `MediaLoaded` event.
|
||||||
pub fn report_html5_media_loaded(&self, duration: f64) {
|
pub fn report_html5_media_loaded(&self, duration: f64) {
|
||||||
|
// See `report_html5_state` — the element is not the player right now.
|
||||||
|
if self.is_background_audio_active() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
|
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
|
||||||
emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
|
emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
|
||||||
}
|
}
|
||||||
@@ -1034,6 +1132,15 @@ impl PlayerController {
|
|||||||
/// Only triggers autoplay if the track finished naturally (EndReason::Finished or None).
|
/// Only triggers autoplay if the track finished naturally (EndReason::Finished or None).
|
||||||
/// If EndReason is NewTrackLoaded, UserStop, UserSkip, or Error, returns Stop without autoplay.
|
/// If EndReason is NewTrackLoaded, UserStop, UserSkip, or Error, returns Stop without autoplay.
|
||||||
pub async fn on_playback_ended(&self) -> Result<AutoplayDecision, String> {
|
pub async fn on_playback_ended(&self) -> Result<AutoplayDecision, String> {
|
||||||
|
// A truncated stream is not an end at all, so this is decided BEFORE the
|
||||||
|
// end-reason gate below — which returns early for the `NewTrackLoaded`
|
||||||
|
// that every load sets, and would therefore swallow the whole question on
|
||||||
|
// Android's JNI callback: the one call guaranteed to run while the app is
|
||||||
|
// backgrounded and the webview cannot echo anything back.
|
||||||
|
if let Some(position) = self.truncated_stream_resume_position() {
|
||||||
|
return Ok(AutoplayDecision::ResumeStream { position });
|
||||||
|
}
|
||||||
|
|
||||||
// Check why playback ended
|
// Check why playback ended
|
||||||
let end_reason = self.take_end_reason();
|
let end_reason = self.take_end_reason();
|
||||||
|
|
||||||
@@ -1194,6 +1301,42 @@ impl PlayerController {
|
|||||||
*self.background_audio_base.lock_safe() = seconds.max(0.0);
|
*self.background_audio_base.lock_safe() = seconds.max(0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enter a background-audio handoff at `position` (the video's position, and
|
||||||
|
/// therefore the audio stream's zero).
|
||||||
|
///
|
||||||
|
/// Hands transport authority to the native audio player: the webview
|
||||||
|
/// `<video>` is about to be torn down, so its last reports — including the
|
||||||
|
/// `pause` the teardown itself fires — must not keep it looking like the
|
||||||
|
/// player. Without this the lockscreen pause emitted a ControlCommand at a
|
||||||
|
/// dead element and the audio played straight through it.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-040, UR-005 | DR-052, DR-097
|
||||||
|
pub fn enter_background_audio(&self, position: f64) {
|
||||||
|
self.set_background_audio_base(position);
|
||||||
|
*self.background_audio_active.lock_safe() = true;
|
||||||
|
*self.html5_playing.lock_safe() = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leave a background-audio handoff, returning the base offset to add to the
|
||||||
|
/// native player's relative position.
|
||||||
|
///
|
||||||
|
/// The webview `<video>` becomes the player again once it reloads, so its
|
||||||
|
/// reports are honoured from here on.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-040, UR-005 | DR-052, DR-097
|
||||||
|
pub fn exit_background_audio(&self) -> f64 {
|
||||||
|
*self.background_audio_active.lock_safe() = false;
|
||||||
|
self.take_background_audio_base()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True while the native audio player owns playback via a background-audio
|
||||||
|
/// handoff.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-040 | DR-052
|
||||||
|
pub fn is_background_audio_active(&self) -> bool {
|
||||||
|
*self.background_audio_active.lock_safe()
|
||||||
|
}
|
||||||
|
|
||||||
/// Read and clear the background-audio base offset.
|
/// Read and clear the background-audio base offset.
|
||||||
///
|
///
|
||||||
/// TRACES: UR-040 | DR-052
|
/// TRACES: UR-040 | DR-052
|
||||||
@@ -1248,6 +1391,198 @@ impl PlayerController {
|
|||||||
self.start_autoplay_countdown(next_episode, countdown_seconds);
|
self.start_autoplay_countdown(next_episode, countdown_seconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A video item played through the native *audio* path — i.e. the background
|
||||||
|
/// audio-only handoff, the only place a length-less progressive transcode is
|
||||||
|
/// used. Jellyfin's item-type taxonomy stays in Rust (CLAUDE.md).
|
||||||
|
fn is_audio_only_video(item: &MediaItem) -> bool {
|
||||||
|
item.media_type == MediaType::Audio
|
||||||
|
&& matches!(item.item_type.as_deref(), Some("Episode") | Some("Movie"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claim a resume attempt for the current stream, returning the absolute
|
||||||
|
/// position to re-open at and the 1-based attempt number. `None` when the
|
||||||
|
/// current item cannot meaningfully be re-requested, or when retrying at this
|
||||||
|
/// position has stopped helping.
|
||||||
|
///
|
||||||
|
/// Only `Remote` sources qualify. A downloaded file cannot fail because of
|
||||||
|
/// the network, so re-opening one would paper over a real read error; a
|
||||||
|
/// `DirectUrl` is a plugin's endpoint with no Jellyfin item behind it.
|
||||||
|
///
|
||||||
|
/// The player's position is relative to the stream's own zero (the handoff
|
||||||
|
/// URL's `StartTimeTicks`), so the base is added back to get an absolute one.
|
||||||
|
/// It is zero for everything else, where positions are already absolute.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-040, UR-004 | DR-129 | UT-117
|
||||||
|
fn claim_stream_resume(&self) -> Option<(f64, u32)> {
|
||||||
|
let current = {
|
||||||
|
let queue = self.queue.lock_safe();
|
||||||
|
queue.current().cloned()
|
||||||
|
}?;
|
||||||
|
if !matches!(current.source, MediaSource::Remote { .. }) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already absolute: the Android position tick shifts by the handoff base
|
||||||
|
// before anything sees the value, so adding it again here would
|
||||||
|
// double-count it. (DR-159)
|
||||||
|
let absolute = self.position().max(0.0);
|
||||||
|
|
||||||
|
match self.stream_resume.lock_safe().allow_attempt(absolute) {
|
||||||
|
Some(attempt) => Some((absolute, attempt)),
|
||||||
|
None => {
|
||||||
|
warn!(
|
||||||
|
"[PlayerController] Stream for {} keeps failing at {:.1}s — giving up on resuming",
|
||||||
|
current.id, absolute
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The absolute position to re-open the current stream at, when the reported
|
||||||
|
/// end was really a dropped connection — `None` when the end looks genuine,
|
||||||
|
/// when this is not an audio-only handoff, or when retrying has stopped
|
||||||
|
/// helping.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-040 | DR-129 | UT-117
|
||||||
|
fn truncated_stream_resume_position(&self) -> Option<f64> {
|
||||||
|
// An explicit user intent already explains the end; never resume over it.
|
||||||
|
if matches!(
|
||||||
|
self.peek_end_reason(),
|
||||||
|
Some(EndReason::UserStop) | Some(EndReason::UserSkip) | Some(EndReason::Error)
|
||||||
|
) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One lock at a time — `position()` reaches into the backend, and nesting
|
||||||
|
// that inside the queue lock would invent a lock order nothing else here
|
||||||
|
// takes.
|
||||||
|
let item_duration = {
|
||||||
|
let queue = self.queue.lock_safe();
|
||||||
|
let current = queue.current()?;
|
||||||
|
if !Self::is_audio_only_video(current) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
current.duration
|
||||||
|
};
|
||||||
|
// Already absolute — see claim_stream_resume. (DR-159)
|
||||||
|
let absolute = self.position().max(0.0);
|
||||||
|
|
||||||
|
// Only spend a resume attempt once the runtime says this really was cut
|
||||||
|
// short — a genuine end must stay a genuine end.
|
||||||
|
if !stream_end::is_truncated_end(
|
||||||
|
absolute,
|
||||||
|
item_duration,
|
||||||
|
stream_end::TRUNCATED_STREAM_TOLERANCE_SECS,
|
||||||
|
) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.claim_stream_resume().map(|(position, _)| position)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where to re-open the current stream after a *recoverable* playback error,
|
||||||
|
/// plus how many seconds to wait first.
|
||||||
|
///
|
||||||
|
/// The media was decoding fine a moment ago, so a mid-playback failure on a
|
||||||
|
/// server stream is the network — and stopping the player (the previous
|
||||||
|
/// behaviour, via the frontend's error handler) turns a hiccup into "playback
|
||||||
|
/// just died". Applies to every streamed item, not only the audio-only
|
||||||
|
/// handoff: music and video reach here instead of the truncation path because
|
||||||
|
/// their streams declare a length, so a cut connection surfaces as an error
|
||||||
|
/// rather than a phantom end.
|
||||||
|
///
|
||||||
|
/// The wait grows with the attempt number so a short outage has time to
|
||||||
|
/// clear, and the shared budget stops the retries when it doesn't.
|
||||||
|
///
|
||||||
|
/// Called from the Android error callback, which decides in-process, and from
|
||||||
|
/// `player_recover_stream`, which is how the same decision reaches the
|
||||||
|
/// backends whose event thread has no controller to call — MPV is built
|
||||||
|
/// before the controller exists, so on Linux the error is emitted, echoed by
|
||||||
|
/// the frontend, and decided here.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-040, UR-004 | DR-129, DR-130 | UT-117
|
||||||
|
pub fn recoverable_error_resume(&self) -> Option<(f64, u64)> {
|
||||||
|
self.claim_stream_resume()
|
||||||
|
.map(|(position, attempt)| (position, attempt as u64 * RESUME_BACKOFF_STEP_SECS))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-open the current stream at `position` after the network cut it short.
|
||||||
|
///
|
||||||
|
/// Single place every dispatcher agrees on, for the same reason
|
||||||
|
/// `auto_advance_to_next_episode` is: the Android JNI callbacks and the
|
||||||
|
/// frontend-invoked command must not disagree about what a failed stream
|
||||||
|
/// means. None of them emits `PlaybackEnded` for this, so nothing downstream
|
||||||
|
/// clears the queue or tears the session down — from the outside this is a
|
||||||
|
/// buffering hiccup, which is what it actually was.
|
||||||
|
///
|
||||||
|
/// Reloads the item **in place** rather than through `play_item`, which
|
||||||
|
/// replaces the queue with a single item: recovering a track that way would
|
||||||
|
/// throw away the rest of the album, turning a network blip into lost state.
|
||||||
|
///
|
||||||
|
/// Two shapes of stream, two ways back to `position`:
|
||||||
|
///
|
||||||
|
/// - The audio-only handoff's `/Audio/{id}/universal` transcode is chunked
|
||||||
|
/// with no length, so it cannot be seeked. Its URL is rewritten to start at
|
||||||
|
/// the position instead — edited, not rebuilt from the repository, since it
|
||||||
|
/// already carries the user's audio track and media source and recovering
|
||||||
|
/// from a network failure must not itself need a network round-trip.
|
||||||
|
/// - Everything else (a static file with byte ranges, an HLS playlist)
|
||||||
|
/// declares its whole timeline, so re-preparing the URL it already has and
|
||||||
|
/// seeking lands in the right place — and leaves any transcode session
|
||||||
|
/// behind it alone.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-040, UR-004 | DR-129 | UT-117
|
||||||
|
pub async fn resume_stream_at(&self, position: f64) -> Result<(), String> {
|
||||||
|
let current = {
|
||||||
|
let queue = self.queue.lock_safe();
|
||||||
|
queue.current().cloned()
|
||||||
|
}
|
||||||
|
.ok_or_else(|| "No current item to resume".to_string())?;
|
||||||
|
|
||||||
|
let MediaSource::Remote { stream_url, .. } = ¤t.source else {
|
||||||
|
return Err(format!(
|
||||||
|
"Cannot resume a non-remote source for {}",
|
||||||
|
current.id
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"[PlayerController] Stream for {} failed — re-opening at {:.1}s",
|
||||||
|
current.id, position
|
||||||
|
);
|
||||||
|
|
||||||
|
if !Self::is_audio_only_video(¤t) {
|
||||||
|
self.load_and_play(¤t).map_err(|e| e.to_string())?;
|
||||||
|
if position > 0.5 {
|
||||||
|
self.seek(position).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let restarted_url = stream_end::with_start_time(stream_url, position);
|
||||||
|
{
|
||||||
|
let queue_arc = self.queue.clone();
|
||||||
|
let mut queue = queue_arc.lock_safe();
|
||||||
|
if !queue.update_current_stream_url(restarted_url) {
|
||||||
|
return Err(format!("Failed to update stream URL for {}", current.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let resumed = {
|
||||||
|
let queue = self.queue.lock_safe();
|
||||||
|
queue.current().cloned()
|
||||||
|
}
|
||||||
|
.ok_or_else(|| "Current item vanished mid-resume".to_string())?;
|
||||||
|
|
||||||
|
// The re-opened stream's timeline starts at `position` (StartTimeTicks),
|
||||||
|
// so that is its zero: the exit-to-foreground maths and the lockscreen
|
||||||
|
// scrubber both read absolute positions off this base.
|
||||||
|
self.set_background_audio_base(position);
|
||||||
|
let _ = set_lockscreen_position_offset(position.max(0.0));
|
||||||
|
|
||||||
|
self.load_and_play(&resumed).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// Advance to the next episode while playing audio-only in the background.
|
/// Advance to the next episode while playing audio-only in the background.
|
||||||
///
|
///
|
||||||
/// The normal autoplay-next path navigates the frontend to `/player/<id>`,
|
/// The normal autoplay-next path navigates the frontend to `/player/<id>`,
|
||||||
@@ -1322,6 +1657,9 @@ impl PlayerController {
|
|||||||
// back to the foreground) and the lockscreen scrubber's matching shift.
|
// back to the foreground) and the lockscreen scrubber's matching shift.
|
||||||
self.set_background_audio_base(0.0);
|
self.set_background_audio_base(0.0);
|
||||||
let _ = set_lockscreen_position_offset(0.0);
|
let _ = set_lockscreen_position_offset(0.0);
|
||||||
|
// Different stream entirely: whatever was stuck about the last one is not
|
||||||
|
// this one's problem.
|
||||||
|
self.stream_resume.lock_safe().reset();
|
||||||
|
|
||||||
self.play_item(media_item).map_err(|e| e.to_string())
|
self.play_item(media_item).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
@@ -1769,6 +2107,84 @@ mod tests {
|
|||||||
assert_eq!(controls, vec!["play".to_string(), "pause".to_string()]);
|
assert_eq!(controls, vec!["play".to_string(), "pause".to_string()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_background_audio_handoff_moves_transport_to_native_backend() {
|
||||||
|
// Lockscreen pause while playing a video's audio in the background.
|
||||||
|
//
|
||||||
|
// The handoff tears the WebView <video> down AFTER native audio starts,
|
||||||
|
// and that teardown fires a DOM `pause` the frontend dutifully reports.
|
||||||
|
// That report used to leave `html5_playing = Some(false)`, so transport
|
||||||
|
// kept being aimed at an element that no longer exists: the lockscreen
|
||||||
|
// pause emitted a ControlCommand into the void and the audio played on.
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
let emitter = Arc::new(CapturingEmitter::new());
|
||||||
|
controller.set_event_emitter(emitter.clone());
|
||||||
|
|
||||||
|
// Video was playing in the webview.
|
||||||
|
controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
|
||||||
|
assert!(controller.is_html5_active());
|
||||||
|
|
||||||
|
// Hand off to the native audio player, then tear the element down.
|
||||||
|
controller.enter_background_audio(1200.0);
|
||||||
|
controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!controller.is_html5_active(),
|
||||||
|
"native audio owns transport during a background-audio handoff"
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.pause().unwrap();
|
||||||
|
let controls: Vec<_> = emitter
|
||||||
|
.events()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| match e {
|
||||||
|
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
controls.is_empty(),
|
||||||
|
"pause must drive the native backend, not a torn-down element: {:?}",
|
||||||
|
controls
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_background_audio_handoff_suppresses_stale_element_events() {
|
||||||
|
// The dying element's pause/position reports describe the video, not the
|
||||||
|
// audio now playing — re-emitting them flips the UI to paused and yanks
|
||||||
|
// the position backwards while native audio keeps going.
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
let emitter = Arc::new(CapturingEmitter::new());
|
||||||
|
controller.set_event_emitter(emitter.clone());
|
||||||
|
|
||||||
|
controller.enter_background_audio(1200.0);
|
||||||
|
controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));
|
||||||
|
controller.report_html5_position(1200.0, 2400.0);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
emitter.events().is_empty(),
|
||||||
|
"stale webview reports must not reach the event pipeline: {:?}",
|
||||||
|
emitter.events()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_exit_background_audio_returns_transport_to_the_webview() {
|
||||||
|
// Back in the foreground the <video> is the player again, so its reports
|
||||||
|
// must be honoured — and the base offset still comes back for the resume.
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
let emitter = Arc::new(CapturingEmitter::new());
|
||||||
|
controller.set_event_emitter(emitter.clone());
|
||||||
|
|
||||||
|
controller.enter_background_audio(1200.0);
|
||||||
|
assert_eq!(controller.exit_background_audio(), 1200.0);
|
||||||
|
|
||||||
|
controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
|
||||||
|
assert!(controller.is_html5_active());
|
||||||
|
assert!(controller.html5_is_playing());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_html5_stopped_report_releases_transport_to_native_backend() {
|
fn test_html5_stopped_report_releases_transport_to_native_backend() {
|
||||||
// When webview video goes away, transport must fall back to the native
|
// When webview video goes away, transport must fall back to the native
|
||||||
@@ -2853,7 +3269,13 @@ mod tests {
|
|||||||
fn get_subtitle_url(&self, _: &str, _: &str, _: i32, _: &str) -> String {
|
fn get_subtitle_url(&self, _: &str, _: &str, _: i32, _: &str) -> String {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
fn get_video_download_url(&self, _: &str, _: &str, _: Option<&str>) -> String {
|
fn get_video_download_url(
|
||||||
|
&self,
|
||||||
|
_: &str,
|
||||||
|
_: &str,
|
||||||
|
_: Option<&str>,
|
||||||
|
_: Option<&str>,
|
||||||
|
) -> String {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
async fn mark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
async fn mark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
||||||
@@ -2862,9 +3284,19 @@ mod tests {
|
|||||||
async fn unmark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
async fn unmark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
async fn get_favorites(
|
||||||
|
&self,
|
||||||
|
_: repo_types::SearchScope,
|
||||||
|
_: Option<repo_types::GetItemsOptions>,
|
||||||
|
) -> Result<repo_types::SearchResult, repo_types::RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
async fn clear_watch_history(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
async fn clear_watch_history(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
async fn mark_played(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
async fn get_person(
|
async fn get_person(
|
||||||
&self,
|
&self,
|
||||||
_: &str,
|
_: &str,
|
||||||
@@ -3018,6 +3450,7 @@ mod tests {
|
|||||||
item_type: Some("Episode".to_string()),
|
item_type: Some("Episode".to_string()),
|
||||||
media_type: MediaType::Audio, // audio-only handoff, not Video
|
media_type: MediaType::Audio, // audio-only handoff, not Video
|
||||||
series_id: Some("series1".to_string()),
|
series_id: Some("series1".to_string()),
|
||||||
|
duration: Some(180.0),
|
||||||
source: MediaSource::Remote {
|
source: MediaSource::Remote {
|
||||||
stream_url: "http://example.com/ep2-audio.m3u8".to_string(),
|
stream_url: "http://example.com/ep2-audio.m3u8".to_string(),
|
||||||
jellyfin_item_id: "ep2".to_string(),
|
jellyfin_item_id: "ep2".to_string(),
|
||||||
@@ -3026,6 +3459,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
controller.play_queue(vec![episode], 0).unwrap();
|
controller.play_queue(vec![episode], 0).unwrap();
|
||||||
|
|
||||||
|
// Played through to the end — a natural finish, not a stream cut short.
|
||||||
|
controller.seek(180.0).unwrap();
|
||||||
// Clear the NewTrackLoaded reason to simulate natural track end.
|
// Clear the NewTrackLoaded reason to simulate natural track end.
|
||||||
controller.take_end_reason();
|
controller.take_end_reason();
|
||||||
|
|
||||||
@@ -3158,6 +3593,407 @@ mod tests {
|
|||||||
assert!(controller.current_is_audio_episode());
|
assert!(controller.current_is_audio_episode());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the audio-only episode the background handoff loads: a video item
|
||||||
|
/// played through the native audio path, with a known runtime and a stream
|
||||||
|
/// URL carrying the handoff position.
|
||||||
|
fn audio_only_episode(runtime_seconds: f64) -> MediaItem {
|
||||||
|
MediaItem {
|
||||||
|
id: "ep2".to_string(),
|
||||||
|
item_type: Some("Episode".to_string()),
|
||||||
|
media_type: MediaType::Audio,
|
||||||
|
series_id: Some("series1".to_string()),
|
||||||
|
duration: Some(runtime_seconds),
|
||||||
|
source: MediaSource::Remote {
|
||||||
|
stream_url:
|
||||||
|
"http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=0"
|
||||||
|
.to_string(),
|
||||||
|
jellyfin_item_id: "ep2".to_string(),
|
||||||
|
},
|
||||||
|
..create_test_items(1).remove(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A flaky connection truncates the progressive mp3 transcode that carries
|
||||||
|
/// background audio-only playback. ExoPlayer sees end-of-input on a stream
|
||||||
|
/// with no reliable length, so it reports STATE_ENDED ten minutes into a
|
||||||
|
/// twenty-five minute episode — indistinguishable, to the player, from the
|
||||||
|
/// real end.
|
||||||
|
///
|
||||||
|
/// Treating that as "the episode finished" is what the user experiences as
|
||||||
|
/// the episode randomly restarting: playback parks in STATE_ENDED and the
|
||||||
|
/// next play intent (lockscreen, notification, Bluetooth reconnect) seeks an
|
||||||
|
/// ended player to position 0 before playing. The runtime we already know
|
||||||
|
/// says the stream died early, so the decision must be to resume it.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_truncated_background_audio_stream_resumes_instead_of_ending() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
||||||
|
|
||||||
|
controller
|
||||||
|
.play_queue(vec![audio_only_episode(1500.0)], 0)
|
||||||
|
.unwrap();
|
||||||
|
// The connection dropped 10 minutes into a 25-minute episode.
|
||||||
|
controller.seek(600.0).unwrap();
|
||||||
|
controller.take_end_reason();
|
||||||
|
|
||||||
|
let decision = controller.on_playback_ended().await.unwrap();
|
||||||
|
|
||||||
|
match decision {
|
||||||
|
AutoplayDecision::ResumeStream { position } => {
|
||||||
|
assert_eq!(position, 600.0, "must resume where the stream died");
|
||||||
|
}
|
||||||
|
other => panic!(
|
||||||
|
"a stream that ended 15 minutes short of the runtime must resume, \
|
||||||
|
not run end-of-episode logic; got {:?}",
|
||||||
|
other
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A seek arriving during a background-audio handoff is **absolute** — the
|
||||||
|
/// lockscreen scrubber shows the whole episode, so a scrub to 25:00 means
|
||||||
|
/// 25:00 of the episode, not 25:00 into the handoff stream.
|
||||||
|
///
|
||||||
|
/// The handoff stream cannot be seeked at all (a chunked, length-less
|
||||||
|
/// transcode), so honouring it means re-opening the URL at the new position,
|
||||||
|
/// exactly as the truncation recovery does. Passing the number through to
|
||||||
|
/// ExoPlayer instead — which is what used to happen — asked a stream that
|
||||||
|
/// cannot seek to jump past its own end, and a clamped seek lands at stream
|
||||||
|
/// zero: the handoff point.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-040, UR-005 | DR-159 | UT-155
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_seek_during_handoff_reopens_the_stream_at_the_absolute_position() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
||||||
|
controller
|
||||||
|
.play_queue(vec![audio_only_episode(1500.0)], 0)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Handed off 20 minutes in, so the stream's zero is 1200s.
|
||||||
|
controller.enter_background_audio(1200.0);
|
||||||
|
|
||||||
|
// The viewer scrubs the lockscreen to 25:00 absolute.
|
||||||
|
controller.seek_absolute(1490.0).await.unwrap();
|
||||||
|
|
||||||
|
let url = {
|
||||||
|
let queue = controller.queue();
|
||||||
|
let queue = queue.lock_safe();
|
||||||
|
match &queue.current().unwrap().source {
|
||||||
|
MediaSource::Remote { stream_url, .. } => stream_url.clone(),
|
||||||
|
other => panic!("expected a remote source, got {:?}", other),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
url.contains(&format!(
|
||||||
|
"StartTimeTicks={}",
|
||||||
|
(1490.0 * 10_000_000.0) as i64
|
||||||
|
)),
|
||||||
|
"the stream must be re-opened at the absolute position; got {}",
|
||||||
|
url
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
*controller.background_audio_base.lock_safe(),
|
||||||
|
1490.0,
|
||||||
|
"the re-opened stream's zero is the position it was opened at, or \
|
||||||
|
every later reading is off by the difference"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outside a handoff there is no base and nothing to re-open: an absolute
|
||||||
|
/// seek is just a seek, and must not be turned into a stream rebuild.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-005 | DR-159 | UT-155
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_seek_outside_a_handoff_is_an_ordinary_seek() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
||||||
|
controller
|
||||||
|
.play_queue(vec![audio_only_episode(1500.0)], 0)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
controller.seek_absolute(300.0).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(controller.position(), 300.0);
|
||||||
|
assert_eq!(
|
||||||
|
*controller.background_audio_base.lock_safe(),
|
||||||
|
0.0,
|
||||||
|
"an ordinary seek must not invent a handoff base"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The truncation check compares the position against the item's runtime, so
|
||||||
|
/// both must be on the same timeline.
|
||||||
|
///
|
||||||
|
/// They now are by construction: the Android position tick shifts by the
|
||||||
|
/// handoff base before anything sees the value, so what the player reports is
|
||||||
|
/// already a position on the episode. The base is therefore *not* added here —
|
||||||
|
/// doing so would double-count it and make the last minute of a handoff look
|
||||||
|
/// like a truncation. What the mock backend holds is what the real one would
|
||||||
|
/// report: 24:56 absolute, not 0:56 into the handoff stream. (DR-159)
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_truncated_check_uses_the_absolute_position() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
||||||
|
|
||||||
|
controller
|
||||||
|
.play_queue(vec![audio_only_episode(1500.0)], 0)
|
||||||
|
.unwrap();
|
||||||
|
// Handed off at 24:00; the stream then played its last 56 seconds out, so
|
||||||
|
// the player reports 24:56 of the episode.
|
||||||
|
controller.set_background_audio_base(1440.0);
|
||||||
|
controller.seek(1496.0).unwrap();
|
||||||
|
controller.take_end_reason();
|
||||||
|
|
||||||
|
let decision = controller.on_playback_ended().await.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(decision, AutoplayDecision::ShowNextEpisodePopup { .. }),
|
||||||
|
"24:56 of a 25:00 episode is the real end, not a truncation; got {:?}",
|
||||||
|
decision
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The resume re-opens the same URL, so a server that is actually gone would
|
||||||
|
/// otherwise end → resume → end forever. After the budget runs out the
|
||||||
|
/// decision falls back to normal end-of-item handling.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_repeated_truncation_at_the_same_position_gives_up() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
||||||
|
|
||||||
|
controller
|
||||||
|
.play_queue(vec![audio_only_episode(1500.0)], 0)
|
||||||
|
.unwrap();
|
||||||
|
controller.seek(600.0).unwrap();
|
||||||
|
|
||||||
|
for attempt in 1..=stream_end::MAX_STALLED_RESUME_ATTEMPTS {
|
||||||
|
controller.take_end_reason();
|
||||||
|
let decision = controller.on_playback_ended().await.unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(decision, AutoplayDecision::ResumeStream { .. }),
|
||||||
|
"attempt {} should still resume, got {:?}",
|
||||||
|
attempt,
|
||||||
|
decision
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.take_end_reason();
|
||||||
|
let decision = controller.on_playback_ended().await.unwrap();
|
||||||
|
assert!(
|
||||||
|
!matches!(decision, AutoplayDecision::ResumeStream { .. }),
|
||||||
|
"a stream stuck at the same position must stop retrying, got {:?}",
|
||||||
|
decision
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ordinary music is not covered: its streams are not the length-less
|
||||||
|
/// progressive transcode this guards, and a short track legitimately ends
|
||||||
|
/// well before a stale duration would suggest.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_truncation_check_does_not_touch_plain_audio_tracks() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
|
||||||
|
let mut items = create_test_items(2);
|
||||||
|
items[0].duration = Some(1500.0);
|
||||||
|
controller.play_queue(items, 0).unwrap();
|
||||||
|
controller.seek(60.0).unwrap();
|
||||||
|
controller.take_end_reason();
|
||||||
|
|
||||||
|
let decision = controller.on_playback_ended().await.unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(decision, AutoplayDecision::AdvanceToNext),
|
||||||
|
"plain queue audio must keep advancing, got {:?}",
|
||||||
|
decision
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Music and video stream from URLs that declare their own length (a static
|
||||||
|
/// file with byte ranges, an HLS playlist), so a truncation reaches the
|
||||||
|
/// player as an *error* rather than a phantom end. It is the same network
|
||||||
|
/// failure, and the same recovery applies — the previous behaviour turned it
|
||||||
|
/// into `playerStop()` and silence.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_recoverable_error_resumes_a_music_track() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
|
||||||
|
let mut items = create_test_items(3);
|
||||||
|
for item in &mut items {
|
||||||
|
item.source = MediaSource::Remote {
|
||||||
|
stream_url: format!("http://s/Audio/{}/stream?Static=true", item.id),
|
||||||
|
jellyfin_item_id: item.id.clone(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
controller.play_queue(items, 1).unwrap();
|
||||||
|
controller.seek(45.0).unwrap();
|
||||||
|
|
||||||
|
let (position, _) = controller
|
||||||
|
.recoverable_error_resume()
|
||||||
|
.expect("a streamed music track must be resumable after a network error");
|
||||||
|
assert_eq!(position, 45.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The resume must reload the failed track IN PLACE. `play_item` replaces the
|
||||||
|
/// whole queue with a single item, so recovering a track that way would throw
|
||||||
|
/// away the rest of the album — turning a network blip into lost state.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_resume_keeps_the_rest_of_the_queue() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
|
||||||
|
let mut items = create_test_items(3);
|
||||||
|
for item in &mut items {
|
||||||
|
item.source = MediaSource::Remote {
|
||||||
|
stream_url: format!("http://s/Audio/{}/stream?Static=true", item.id),
|
||||||
|
jellyfin_item_id: item.id.clone(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
controller.play_queue(items, 1).unwrap();
|
||||||
|
|
||||||
|
controller
|
||||||
|
.resume_stream_at(45.0)
|
||||||
|
.await
|
||||||
|
.expect("resume should succeed");
|
||||||
|
|
||||||
|
let queue = controller.queue.lock_safe();
|
||||||
|
assert_eq!(queue.items().len(), 3, "the queue must survive a resume");
|
||||||
|
assert_eq!(queue.current_index(), Some(1), "still on the same track");
|
||||||
|
assert_eq!(queue.current().unwrap().id, "item_1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A seekable stream is re-opened by re-preparing the URL it already has and
|
||||||
|
/// seeking — its timeline is intact, and rewriting the URL would restart a
|
||||||
|
/// transcode session for no reason.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_resume_seeks_a_seekable_stream_rather_than_rewriting_its_url() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
|
||||||
|
let mut items = create_test_items(1);
|
||||||
|
items[0].source = MediaSource::Remote {
|
||||||
|
stream_url: "http://s/Audio/item_0/stream?Static=true".to_string(),
|
||||||
|
jellyfin_item_id: "item_0".to_string(),
|
||||||
|
};
|
||||||
|
controller.play_queue(items, 0).unwrap();
|
||||||
|
|
||||||
|
controller.resume_stream_at(45.0).await.unwrap();
|
||||||
|
|
||||||
|
match &controller.queue.lock_safe().current().unwrap().source {
|
||||||
|
MediaSource::Remote { stream_url, .. } => {
|
||||||
|
assert_eq!(
|
||||||
|
stream_url, "http://s/Audio/item_0/stream?Static=true",
|
||||||
|
"a seekable stream's URL must be left alone"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("expected Remote source, got {:?}", other),
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
controller.position(),
|
||||||
|
45.0,
|
||||||
|
"and it must land at the position"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloaded media cannot fail from the network, and re-opening a local file
|
||||||
|
/// would paper over a real read error.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_recoverable_error_ignores_local_media() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
|
||||||
|
let mut items = create_test_items(1);
|
||||||
|
items[0].source = MediaSource::Local {
|
||||||
|
file_path: "/music/track.flac".into(),
|
||||||
|
jellyfin_item_id: Some("item_0".to_string()),
|
||||||
|
};
|
||||||
|
controller.play_queue(items, 0).unwrap();
|
||||||
|
controller.seek(45.0).unwrap();
|
||||||
|
|
||||||
|
assert!(controller.recoverable_error_resume().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A recoverable error during background audio-only playback is the network,
|
||||||
|
/// not the media — the previous behaviour (surface it, frontend stops the
|
||||||
|
/// player) turned a hiccup into silence. Retrying must also back off, or the
|
||||||
|
/// three attempts are spent inside a second and the outage outlives them.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_recoverable_error_during_audio_only_resumes_with_backoff() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
|
||||||
|
controller
|
||||||
|
.play_queue(vec![audio_only_episode(1500.0)], 0)
|
||||||
|
.unwrap();
|
||||||
|
controller.seek(600.0).unwrap();
|
||||||
|
|
||||||
|
let mut waits = Vec::new();
|
||||||
|
for attempt in 1..=stream_end::MAX_STALLED_RESUME_ATTEMPTS {
|
||||||
|
let (position, delay) = controller
|
||||||
|
.recoverable_error_resume()
|
||||||
|
.unwrap_or_else(|| panic!("attempt {} should still retry", attempt));
|
||||||
|
assert_eq!(position, 600.0);
|
||||||
|
waits.push(delay);
|
||||||
|
}
|
||||||
|
assert_eq!(waits, vec![2, 4, 6], "the wait must grow between attempts");
|
||||||
|
assert!(
|
||||||
|
controller.recoverable_error_resume().is_none(),
|
||||||
|
"a stream that keeps failing at the same spot must surface the error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin/channel `DirectUrl` sources are somebody else's endpoint with no
|
||||||
|
/// Jellyfin item behind them, so the resume has nothing to re-request.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_recoverable_error_ignores_direct_url_playback() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
controller.play_queue(create_test_items(2), 0).unwrap();
|
||||||
|
|
||||||
|
assert!(controller.recoverable_error_resume().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-opening the stream must land where it died and keep playing, with the
|
||||||
|
/// handoff base moved to the new stream's zero so returning to the
|
||||||
|
/// foreground still resolves an absolute position.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_resume_truncated_stream_reloads_at_position() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
|
||||||
|
controller
|
||||||
|
.play_queue(vec![audio_only_episode(1500.0)], 0)
|
||||||
|
.unwrap();
|
||||||
|
controller.set_background_audio_base(0.0);
|
||||||
|
|
||||||
|
controller
|
||||||
|
.resume_stream_at(600.0)
|
||||||
|
.await
|
||||||
|
.expect("resume should succeed");
|
||||||
|
|
||||||
|
let current = controller
|
||||||
|
.queue
|
||||||
|
.lock_safe()
|
||||||
|
.current()
|
||||||
|
.cloned()
|
||||||
|
.expect("the same item should still be loaded");
|
||||||
|
assert_eq!(current.id, "ep2", "resume must not change the item");
|
||||||
|
match ¤t.source {
|
||||||
|
MediaSource::Remote { stream_url, .. } => {
|
||||||
|
assert!(
|
||||||
|
stream_url.contains("StartTimeTicks=6000000000"),
|
||||||
|
"stream must re-open at 600s, got {}",
|
||||||
|
stream_url
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
stream_url.contains("AudioStreamIndex=2"),
|
||||||
|
"the selected audio track must survive the resume, got {}",
|
||||||
|
stream_url
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("expected Remote source, got {:?}", other),
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
controller.take_background_audio_base(),
|
||||||
|
600.0,
|
||||||
|
"the re-opened stream's zero is the resume position"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Foreground video playback keeps the countdown-driven advance: the frontend
|
/// Foreground video playback keeps the countdown-driven advance: the frontend
|
||||||
/// owns the navigation there, so the backend must NOT load the next episode
|
/// owns the navigation there, so the backend must NOT load the next episode
|
||||||
/// itself (that would race the page transition and double-start playback).
|
/// itself (that would race the page transition and double-start playback).
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use super::backend::{PlayerBackend, PlayerError};
|
|||||||
use super::events::{PlayerEventEmitter, PlayerStatusEvent};
|
use super::events::{PlayerEventEmitter, PlayerStatusEvent};
|
||||||
use super::media::{MediaItem, MediaSource};
|
use super::media::{MediaItem, MediaSource};
|
||||||
use super::state::PlayerState;
|
use super::state::PlayerState;
|
||||||
|
use super::stream_end::ObservedTime;
|
||||||
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
|
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
|
||||||
use crate::settings::{AudioSettings, VolumeLevel, EQ_BANDS};
|
use crate::settings::{AudioSettings, VolumeLevel, EQ_BANDS};
|
||||||
use crate::utils::conversions::{seconds_to_ticks, volume_to_percent};
|
use crate::utils::conversions::{seconds_to_ticks, volume_to_percent};
|
||||||
@@ -26,6 +27,13 @@ pub struct MpvBackend {
|
|||||||
playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
|
playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
|
||||||
position_throttler: Arc<EventThrottler>,
|
position_throttler: Arc<EventThrottler>,
|
||||||
last_seek_time: Arc<AtomicU64>,
|
last_seek_time: Arc<AtomicU64>,
|
||||||
|
/// Last position/duration seen while a file was loaded.
|
||||||
|
///
|
||||||
|
/// `time-pos` and `duration` are live properties of the *loaded* file: at
|
||||||
|
/// EOF MPV unloads it and both stop resolving, so reading them straight
|
||||||
|
/// through reported 0.0 / unknown exactly when end-of-file handling needed to
|
||||||
|
/// know where playback reached. See [`ObservedTime`].
|
||||||
|
observed: Arc<Mutex<ObservedTime>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct InternalState {
|
struct InternalState {
|
||||||
@@ -139,6 +147,31 @@ impl MpvBackend {
|
|||||||
message: format!("Failed to set initial volume: {:?}", e),
|
message: format!("Failed to set initial volume: {:?}", e),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// Survive a flaky connection instead of dying on it. Without these,
|
||||||
|
// ffmpeg's HTTP demuxer gives up the moment a read fails and MPV raises
|
||||||
|
// EndFile(ERROR) — a blip on wifi kills the track outright. Reconnecting
|
||||||
|
// in the demuxer handles the common case entirely below our level, so
|
||||||
|
// most outages never reach the recovery in `player_recover_stream`.
|
||||||
|
//
|
||||||
|
// Non-fatal: these are ffmpeg-side options whose availability varies with
|
||||||
|
// the libmpv/ffmpeg build, and losing resilience is not a reason to
|
||||||
|
// refuse to play anything (graceful backend init, CLAUDE.md).
|
||||||
|
mpv.set_property(
|
||||||
|
"stream-lavf-o",
|
||||||
|
"reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1,reconnect_delay_max=5",
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
warn!(
|
||||||
|
"[MpvBackend] Could not enable stream reconnection: {:?} — \
|
||||||
|
playback will not survive network interruptions",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
});
|
||||||
|
mpv.set_property("network-timeout", 15i64)
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
warn!("[MpvBackend] Could not set network timeout: {:?}", e);
|
||||||
|
});
|
||||||
|
|
||||||
let state = Arc::new(Mutex::new(InternalState {
|
let state = Arc::new(Mutex::new(InternalState {
|
||||||
current_media: None,
|
current_media: None,
|
||||||
volume: 1.0,
|
volume: 1.0,
|
||||||
@@ -152,6 +185,7 @@ impl MpvBackend {
|
|||||||
playback_reporter,
|
playback_reporter,
|
||||||
position_throttler,
|
position_throttler,
|
||||||
last_seek_time: Arc::new(AtomicU64::new(0)),
|
last_seek_time: Arc::new(AtomicU64::new(0)),
|
||||||
|
observed: Arc::new(Mutex::new(ObservedTime::default())),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start event loop in background thread
|
// Start event loop in background thread
|
||||||
@@ -250,8 +284,22 @@ impl MpvBackend {
|
|||||||
debug!("[MpvBackend] Player quitting, NOT emitting PlaybackEnded");
|
debug!("[MpvBackend] Player quitting, NOT emitting PlaybackEnded");
|
||||||
// Don't emit - player is shutting down
|
// Don't emit - player is shutting down
|
||||||
} else if reason == MPV_END_FILE_REASON_ERROR {
|
} else if reason == MPV_END_FILE_REASON_ERROR {
|
||||||
warn!("[MpvBackend] Track ended with error, NOT emitting PlaybackEnded");
|
// NOT PlaybackEnded — the track did not finish, so
|
||||||
// Don't emit - we should handle errors separately
|
// autoplay must not advance. It is an error, and it
|
||||||
|
// has to be *said*: emitting nothing here left
|
||||||
|
// playback halted with the UI still showing
|
||||||
|
// "playing" and no way back. Marked recoverable so
|
||||||
|
// the frontend echoes it into player_recover_stream,
|
||||||
|
// which re-opens the stream where it stopped —
|
||||||
|
// MPV's own reconnect handles shorter blips before
|
||||||
|
// they ever get this far.
|
||||||
|
warn!("[MpvBackend] Track ended with an error — reporting as recoverable");
|
||||||
|
if let Some(emitter) = &event_emitter {
|
||||||
|
emitter.emit(PlayerStatusEvent::Error {
|
||||||
|
message: "Playback stream failed".to_string(),
|
||||||
|
recoverable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
debug!("[MpvBackend] Unknown end file reason {}, NOT emitting PlaybackEnded", reason);
|
debug!("[MpvBackend] Unknown end file reason {}, NOT emitting PlaybackEnded", reason);
|
||||||
}
|
}
|
||||||
@@ -283,6 +331,7 @@ impl MpvBackend {
|
|||||||
let reporter_for_position = reporter.clone();
|
let reporter_for_position = reporter.clone();
|
||||||
let throttler_for_position = throttler.clone();
|
let throttler_for_position = throttler.clone();
|
||||||
let last_seek_time_for_position = self.last_seek_time.clone();
|
let last_seek_time_for_position = self.last_seek_time.clone();
|
||||||
|
let observed_for_position = self.observed.clone();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
loop {
|
loop {
|
||||||
@@ -294,6 +343,13 @@ impl MpvBackend {
|
|||||||
mpv_for_position.get_property::<f64>("time-pos"),
|
mpv_for_position.get_property::<f64>("time-pos"),
|
||||||
mpv_for_position.get_property::<f64>("duration"),
|
mpv_for_position.get_property::<f64>("duration"),
|
||||||
) {
|
) {
|
||||||
|
// Remember it: both properties belong to the *loaded* file and
|
||||||
|
// stop resolving the instant MPV unloads it at EOF, which is
|
||||||
|
// exactly when end-of-file handling asks where playback got to.
|
||||||
|
// Recorded before the post-seek skip below so a track that ends
|
||||||
|
// right after a seek still reports the seek target, not zero.
|
||||||
|
observed_for_position.lock_safe().record(pos, dur);
|
||||||
|
|
||||||
// Check if we recently seeked - skip position updates briefly after seeks
|
// Check if we recently seeked - skip position updates briefly after seeks
|
||||||
// to avoid "jumping to zero" visual glitches while MPV is seeking
|
// to avoid "jumping to zero" visual glitches while MPV is seeking
|
||||||
let now = SystemTime::now()
|
let now = SystemTime::now()
|
||||||
@@ -404,6 +460,9 @@ impl PlayerBackend for MpvBackend {
|
|||||||
let mut state = self.state.lock_safe();
|
let mut state = self.state.lock_safe();
|
||||||
state.current_media = Some(media.clone());
|
state.current_media = Some(media.clone());
|
||||||
}
|
}
|
||||||
|
// A different file: the previous one's timestamp must not survive as this
|
||||||
|
// one's "last observed" position.
|
||||||
|
self.observed.lock_safe().reset();
|
||||||
|
|
||||||
// Load the media file
|
// Load the media file
|
||||||
self.mpv
|
self.mpv
|
||||||
@@ -469,6 +528,10 @@ impl PlayerBackend for MpvBackend {
|
|||||||
message: format!("Failed to seek: {:?}", e),
|
message: format!("Failed to seek: {:?}", e),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// The poll thread suppresses updates for 150ms after a seek, so without
|
||||||
|
// this a file ending inside that window would report the pre-seek time.
|
||||||
|
self.observed.lock_safe().record_position(position);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,15 +554,26 @@ impl PlayerBackend for MpvBackend {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Current position — the live `time-pos`, or the last one observed while a
|
||||||
|
/// file was loaded.
|
||||||
|
///
|
||||||
|
/// The fallback is the point: `time-pos` is a property of the *loaded* file,
|
||||||
|
/// so at EOF it stops resolving and a bare `unwrap_or(0.0)` reported 0:00 at
|
||||||
|
/// exactly the moment end-of-file handling asks where playback reached.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-005 | DR-130 | UT-121
|
||||||
fn position(&self) -> f64 {
|
fn position(&self) -> f64 {
|
||||||
self.mpv.get_property::<f64>("time-pos").unwrap_or(0.0)
|
let live = self.mpv.get_property::<f64>("time-pos").ok();
|
||||||
|
self.observed.lock_safe().position_or_last(live)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Total duration — live, or the last one observed. Unloaded at EOF for the
|
||||||
|
/// same reason as `position`.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-005 | DR-130 | UT-121
|
||||||
fn duration(&self) -> Option<f64> {
|
fn duration(&self) -> Option<f64> {
|
||||||
self.mpv
|
let live = self.mpv.get_property::<f64>("duration").ok();
|
||||||
.get_property::<f64>("duration")
|
self.observed.lock_safe().duration_or_last(live)
|
||||||
.ok()
|
|
||||||
.filter(|d| *d > 0.0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn state(&self) -> PlayerState {
|
fn state(&self) -> PlayerState {
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
//! Telling a *finished* stream apart from a *truncated* one.
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-040 | DR-129 | UT-117
|
||||||
|
//!
|
||||||
|
//! Background audio-only playback of a video item streams a **progressive mp3
|
||||||
|
//! transcode over plain HTTP** (see
|
||||||
|
//! `OnlineRepository::build_audio_only_stream_url_for_video`). That response has
|
||||||
|
//! no reliable length — a live transcode is chunked — so when the connection
|
||||||
|
//! drops mid-episode the data source simply sees end-of-input. ExoPlayer cannot
|
||||||
|
//! distinguish that from the real end of the media and reports
|
||||||
|
//! `Player.STATE_ENDED`, which the app then treats as "the episode finished".
|
||||||
|
//!
|
||||||
|
//! The user-visible damage is not the missed advance itself. Playback parks in
|
||||||
|
//! ExoPlayer's `STATE_ENDED`, and the next play intent from the lockscreen,
|
||||||
|
//! notification or a Bluetooth reconnect goes through media3's
|
||||||
|
//! `Util.handlePlayButtonAction`, which seeks an ENDED player to its default
|
||||||
|
//! position before playing — so **the episode starts over from 0:00**. On a
|
||||||
|
//! flaky connection that reads as "it randomly restarts the episode".
|
||||||
|
//!
|
||||||
|
//! The player itself has no way to know; the *duration* does. Jellyfin gives us
|
||||||
|
//! the item's real runtime, so an end reported well short of it is a truncation,
|
||||||
|
//! not a finish — and the right response is to re-open the stream where it died,
|
||||||
|
//! which is the "buffer and resume" the user expects.
|
||||||
|
|
||||||
|
/// How far short of the item's runtime a stream may end and still count as a
|
||||||
|
/// natural finish.
|
||||||
|
///
|
||||||
|
/// Sized to swallow the two sources of slack in the comparison — the position
|
||||||
|
/// poll is up to 250 ms stale, and Jellyfin's reported runtime can disagree with
|
||||||
|
/// the transcoded output by a second or two — while staying far below the
|
||||||
|
/// minutes-long gap a dropped connection leaves. Erring long is the safe
|
||||||
|
/// direction: a false "finished" is the bug we are fixing, whereas a false
|
||||||
|
/// "truncated" only re-opens the stream for its last few seconds and then ends
|
||||||
|
/// again normally.
|
||||||
|
pub const TRUNCATED_STREAM_TOLERANCE_SECS: f64 = 10.0;
|
||||||
|
|
||||||
|
/// Consecutive resume attempts allowed at the same position before giving up.
|
||||||
|
///
|
||||||
|
/// A resume re-opens the same URL, so a server that is genuinely gone would
|
||||||
|
/// otherwise end → resume → end forever. Progress past the last attempt resets
|
||||||
|
/// the budget (see [`ResumeTracker`]), so this only bounds *stuck* retries.
|
||||||
|
pub const MAX_STALLED_RESUME_ATTEMPTS: u32 = 3;
|
||||||
|
|
||||||
|
/// Position change that counts as "this is a different playback context" —
|
||||||
|
/// either the resume made progress, or a different item is loaded.
|
||||||
|
const RESUME_PROGRESS_EPSILON_SECS: f64 = 1.0;
|
||||||
|
|
||||||
|
/// Did this end-of-stream happen far enough short of the item's runtime to be a
|
||||||
|
/// truncation rather than a finish?
|
||||||
|
///
|
||||||
|
/// `position` and `duration` must be on the same timeline — for a handoff stream
|
||||||
|
/// built with `StartTimeTicks`, that means the *absolute* position (handoff base
|
||||||
|
/// + the player's relative position) against the item's full runtime.
|
||||||
|
///
|
||||||
|
/// An unknown or non-positive `duration` answers `false`: with nothing to
|
||||||
|
/// compare against, the reported end is taken at face value (previous behaviour).
|
||||||
|
pub fn is_truncated_end(position: f64, duration: Option<f64>, tolerance: f64) -> bool {
|
||||||
|
let Some(duration) = duration else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if duration <= 0.0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
position.max(0.0) + tolerance < duration
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrite an audio-only stream URL to start at `position_seconds`.
|
||||||
|
///
|
||||||
|
/// Resuming re-opens *the stream we were already playing*, so the URL is edited
|
||||||
|
/// in place rather than rebuilt from the repository: every other parameter —
|
||||||
|
/// `AudioStreamIndex` (the track the user picked in the video player),
|
||||||
|
/// `MediaSourceId`, `api_key` — is carried over untouched, and no network call
|
||||||
|
/// is needed to recover from a network failure.
|
||||||
|
pub fn with_start_time(url: &str, position_seconds: f64) -> String {
|
||||||
|
let ticks = (position_seconds.max(0.0) * 10_000_000.0) as i64;
|
||||||
|
let param = format!("StartTimeTicks={}", ticks);
|
||||||
|
|
||||||
|
let (base, query) = match url.split_once('?') {
|
||||||
|
Some((base, query)) => (base, query),
|
||||||
|
// No query string at all: the URL was not built by us, but appending the
|
||||||
|
// parameter is still the correct request to make.
|
||||||
|
None => return format!("{}?{}", url, param),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut replaced = false;
|
||||||
|
let mut parts: Vec<String> = query
|
||||||
|
.split('&')
|
||||||
|
.map(|part| {
|
||||||
|
if part.split('=').next() == Some("StartTimeTicks") {
|
||||||
|
replaced = true;
|
||||||
|
param.clone()
|
||||||
|
} else {
|
||||||
|
part.to_string()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !replaced {
|
||||||
|
parts.push(param);
|
||||||
|
}
|
||||||
|
|
||||||
|
format!("{}?{}", base, parts.join("&"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last playback time actually observed while media was loaded.
|
||||||
|
///
|
||||||
|
/// Some backends expose position and duration as **live** properties of the
|
||||||
|
/// loaded file — MPV's `time-pos` and `duration` stop resolving the moment it
|
||||||
|
/// unloads the file at EOF. Reading them straight through means that at exactly
|
||||||
|
/// the moment end-of-file handling wants to know where playback got to, the
|
||||||
|
/// answer is `0.0` / unknown: the player appears to rewind to 0:00 as it ends.
|
||||||
|
///
|
||||||
|
/// The polling thread records here, and the accessors fall back to it, so an EOF
|
||||||
|
/// reads as the last timestamp rather than as zero.
|
||||||
|
#[derive(Debug, Default, Clone, Copy)]
|
||||||
|
pub struct ObservedTime {
|
||||||
|
position: f64,
|
||||||
|
duration: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObservedTime {
|
||||||
|
/// Record a live reading. Non-positive durations are treated as unknown —
|
||||||
|
/// that is how a backend reports "not established yet", not a real zero.
|
||||||
|
pub fn record(&mut self, position: f64, duration: f64) {
|
||||||
|
self.position = position.max(0.0);
|
||||||
|
if duration > 0.0 {
|
||||||
|
self.duration = Some(duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a position alone, e.g. straight after a seek, before the next poll.
|
||||||
|
pub fn record_position(&mut self, position: f64) {
|
||||||
|
self.position = position.max(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget everything — a different file is loading, and the previous one's
|
||||||
|
/// timestamp must not leak into it.
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
*self = Self::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The live reading if there is one, else the last observed value.
|
||||||
|
pub fn position_or_last(&self, live: Option<f64>) -> f64 {
|
||||||
|
live.filter(|p| *p >= 0.0).unwrap_or(self.position)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The live reading if there is one, else the last observed value.
|
||||||
|
pub fn duration_or_last(&self, live: Option<f64>) -> Option<f64> {
|
||||||
|
live.filter(|d| *d > 0.0).or(self.duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Budget for consecutive resume attempts that make no progress.
|
||||||
|
///
|
||||||
|
/// Held by the player controller across ends of the *same* stream. Any position
|
||||||
|
/// change larger than [`RESUME_PROGRESS_EPSILON_SECS`] — the resume played on,
|
||||||
|
/// or a different item was loaded — is a fresh context and refills the budget.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct ResumeTracker {
|
||||||
|
last_position: Option<f64>,
|
||||||
|
attempts: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResumeTracker {
|
||||||
|
/// Record an attempt at `position`, returning its 1-based number — or `None`
|
||||||
|
/// once the budget is spent. Callers use the number to back off: a stream
|
||||||
|
/// that failed twice at the same spot is waiting on something slower than an
|
||||||
|
/// immediate retry can outrun.
|
||||||
|
pub fn allow_attempt(&mut self, position: f64) -> Option<u32> {
|
||||||
|
let progressed = match self.last_position {
|
||||||
|
Some(last) => (position - last).abs() > RESUME_PROGRESS_EPSILON_SECS,
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
if progressed {
|
||||||
|
self.attempts = 0;
|
||||||
|
}
|
||||||
|
self.last_position = Some(position);
|
||||||
|
self.attempts += 1;
|
||||||
|
(self.attempts <= MAX_STALLED_RESUME_ATTEMPTS).then_some(self.attempts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget the budget — a new item is playing, so nothing is stuck.
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.last_position = None;
|
||||||
|
self.attempts = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_end_near_duration_is_a_natural_finish() {
|
||||||
|
// Episode runtime 25:00, stream ended at 24:56 — that is the end.
|
||||||
|
assert!(!is_truncated_end(
|
||||||
|
1496.0,
|
||||||
|
Some(1500.0),
|
||||||
|
TRUNCATED_STREAM_TOLERANCE_SECS
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_end_far_short_of_duration_is_truncated() {
|
||||||
|
// Episode runtime 25:00, stream died at 10:00 — the connection dropped.
|
||||||
|
assert!(is_truncated_end(
|
||||||
|
600.0,
|
||||||
|
Some(1500.0),
|
||||||
|
TRUNCATED_STREAM_TOLERANCE_SECS
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unknown_duration_is_taken_at_face_value() {
|
||||||
|
// Nothing to compare against: keep the previous end-of-track behaviour
|
||||||
|
// rather than resuming a stream that may really have finished.
|
||||||
|
assert!(!is_truncated_end(
|
||||||
|
600.0,
|
||||||
|
None,
|
||||||
|
TRUNCATED_STREAM_TOLERANCE_SECS
|
||||||
|
));
|
||||||
|
assert!(!is_truncated_end(
|
||||||
|
600.0,
|
||||||
|
Some(0.0),
|
||||||
|
TRUNCATED_STREAM_TOLERANCE_SECS
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tolerance_boundary() {
|
||||||
|
// Exactly one tolerance short still counts as finished, so poll staleness
|
||||||
|
// and runtime rounding never fabricate a truncation.
|
||||||
|
assert!(!is_truncated_end(1490.0, Some(1500.0), 10.0));
|
||||||
|
assert!(is_truncated_end(1489.0, Some(1500.0), 10.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_with_start_time_replaces_existing_ticks() {
|
||||||
|
let url = "http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=1200000000&Container=mp3";
|
||||||
|
let out = with_start_time(url, 600.0);
|
||||||
|
assert_eq!(
|
||||||
|
out,
|
||||||
|
"http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=6000000000&Container=mp3"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_with_start_time_appends_when_absent() {
|
||||||
|
// The next-episode stream is built without StartTimeTicks.
|
||||||
|
let url = "http://s/Audio/ep3/universal?api_key=k&AudioStreamIndex=0";
|
||||||
|
let out = with_start_time(url, 90.0);
|
||||||
|
assert_eq!(
|
||||||
|
out,
|
||||||
|
"http://s/Audio/ep3/universal?api_key=k&AudioStreamIndex=0&StartTimeTicks=900000000"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_with_start_time_preserves_selected_audio_track() {
|
||||||
|
// The whole point of editing the URL instead of rebuilding it: the track
|
||||||
|
// the user chose in the video player survives the resume.
|
||||||
|
let url = "http://s/Audio/ep2/universal?AudioStreamIndex=3&MediaSourceId=src-1";
|
||||||
|
let out = with_start_time(url, 10.0);
|
||||||
|
assert!(out.contains("AudioStreamIndex=3"));
|
||||||
|
assert!(out.contains("MediaSourceId=src-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_with_start_time_without_query() {
|
||||||
|
assert_eq!(
|
||||||
|
with_start_time("http://s/Audio/ep2/universal", 1.0),
|
||||||
|
"http://s/Audio/ep2/universal?StartTimeTicks=10000000"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bug: MPV unloads the file at EOF, so `time-pos` stops resolving and a
|
||||||
|
/// straight read reports 0.0 — the position collapses to zero at precisely
|
||||||
|
/// the moment end-of-file handling needs to know where playback reached.
|
||||||
|
#[test]
|
||||||
|
fn test_eof_reads_as_the_last_observed_timestamp() {
|
||||||
|
let mut observed = ObservedTime::default();
|
||||||
|
observed.record(178.0, 180.0);
|
||||||
|
|
||||||
|
// The file is gone: both live properties fail.
|
||||||
|
assert_eq!(observed.position_or_last(None), 178.0);
|
||||||
|
assert_eq!(observed.duration_or_last(None), Some(180.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_live_readings_win_while_the_file_is_loaded() {
|
||||||
|
let mut observed = ObservedTime::default();
|
||||||
|
observed.record(178.0, 180.0);
|
||||||
|
|
||||||
|
assert_eq!(observed.position_or_last(Some(12.0)), 12.0);
|
||||||
|
assert_eq!(observed.duration_or_last(Some(240.0)), Some(240.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unestablished_duration_is_not_recorded_as_zero() {
|
||||||
|
let mut observed = ObservedTime::default();
|
||||||
|
// A backend reports 0.0 for "duration not known yet", not a real zero.
|
||||||
|
observed.record(5.0, 0.0);
|
||||||
|
assert_eq!(observed.duration_or_last(None), None);
|
||||||
|
assert_eq!(observed.position_or_last(None), 5.0);
|
||||||
|
|
||||||
|
observed.record(6.0, 180.0);
|
||||||
|
assert_eq!(observed.duration_or_last(Some(0.0)), Some(180.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reset_stops_the_previous_file_leaking_into_the_next() {
|
||||||
|
let mut observed = ObservedTime::default();
|
||||||
|
observed.record(178.0, 180.0);
|
||||||
|
observed.reset();
|
||||||
|
|
||||||
|
assert_eq!(observed.position_or_last(None), 0.0);
|
||||||
|
assert_eq!(observed.duration_or_last(None), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_seek_updates_the_last_position_before_the_next_poll() {
|
||||||
|
let mut observed = ObservedTime::default();
|
||||||
|
observed.record(10.0, 180.0);
|
||||||
|
observed.record_position(120.0);
|
||||||
|
|
||||||
|
assert_eq!(observed.position_or_last(None), 120.0);
|
||||||
|
assert_eq!(
|
||||||
|
observed.duration_or_last(None),
|
||||||
|
Some(180.0),
|
||||||
|
"seeking does not change how long the file is"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resume_tracker_bounds_stalled_retries() {
|
||||||
|
let mut tracker = ResumeTracker::default();
|
||||||
|
// Same position over and over: the stream is not recovering.
|
||||||
|
for n in 1..=MAX_STALLED_RESUME_ATTEMPTS {
|
||||||
|
assert_eq!(
|
||||||
|
tracker.allow_attempt(600.0),
|
||||||
|
Some(n),
|
||||||
|
"attempts are numbered so callers can back off"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
tracker.allow_attempt(600.0),
|
||||||
|
None,
|
||||||
|
"a stream that ends at the same position every time must stop retrying"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resume_tracker_refills_after_progress() {
|
||||||
|
let mut tracker = ResumeTracker::default();
|
||||||
|
for _ in 0..MAX_STALLED_RESUME_ATTEMPTS {
|
||||||
|
tracker.allow_attempt(600.0);
|
||||||
|
}
|
||||||
|
assert_eq!(tracker.allow_attempt(600.0), None);
|
||||||
|
// The next drop happened further in — the resumes are working, so the
|
||||||
|
// budget must not be exhausted by earlier trouble.
|
||||||
|
assert_eq!(tracker.allow_attempt(900.0), Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resume_tracker_reset() {
|
||||||
|
let mut tracker = ResumeTracker::default();
|
||||||
|
for _ in 0..=MAX_STALLED_RESUME_ATTEMPTS {
|
||||||
|
tracker.allow_attempt(600.0);
|
||||||
|
}
|
||||||
|
tracker.reset();
|
||||||
|
assert_eq!(tracker.allow_attempt(600.0), Some(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
//! Device-profile policy: turning what a device *reports* about its audio
|
||||||
|
//! output into the constraints we send Jellyfin.
|
||||||
|
//!
|
||||||
|
//! The platform layer reports raw facts (what `MediaCodecList` enumerates, how
|
||||||
|
//! many channels the current audio route accepts); deciding what those facts
|
||||||
|
//! mean for a `DeviceProfile` is domain logic and lives here, on the Rust side
|
||||||
|
//! of the boundary, where it is testable without a device.
|
||||||
|
|
||||||
|
/// Channel count assumed when the platform cannot tell us — every audio route
|
||||||
|
/// can voice stereo, so it is the only safe floor.
|
||||||
|
const FALLBACK_AUDIO_CHANNELS: u32 = 2;
|
||||||
|
|
||||||
|
/// Upper bound we are willing to claim. Jellyfin profiles top out at 7.1, and a
|
||||||
|
/// nonsense reading from a driver should not become a nonsense profile.
|
||||||
|
const MAX_SUPPORTED_AUDIO_CHANNELS: u32 = 8;
|
||||||
|
|
||||||
|
/// Decide the `MaxAudioChannels` to advertise, given what the current audio
|
||||||
|
/// route reported.
|
||||||
|
///
|
||||||
|
/// Without this constraint Jellyfin is free to direct-play a 5.1 or 7.1 track to
|
||||||
|
/// a sink that only has two channels. What the user hears then is device
|
||||||
|
/// dependent and rarely correct — a failed `AudioSink` configuration (silence),
|
||||||
|
/// or centre-channel dialogue folded away to near-inaudibility. Naming the real
|
||||||
|
/// channel count makes the server downmix instead, which is always audible.
|
||||||
|
///
|
||||||
|
/// A missing or zero reading means "route not established yet", not "no audio":
|
||||||
|
/// fall back to stereo rather than claiming a capability we have not seen.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-004 | DR-141 | UT-141
|
||||||
|
pub fn clamp_max_audio_channels(reported: Option<u32>) -> u32 {
|
||||||
|
match reported {
|
||||||
|
Some(channels) if channels >= 1 => channels.min(MAX_SUPPORTED_AUDIO_CHANNELS),
|
||||||
|
_ => FALLBACK_AUDIO_CHANNELS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The channel cap for this device, reading the platform's report where one
|
||||||
|
/// exists.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-004 | DR-141 | UT-141
|
||||||
|
pub fn max_audio_channels() -> u32 {
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
let reported = crate::player::get_detected_codecs().and_then(|(_, _, channels)| channels);
|
||||||
|
|
||||||
|
// Desktop plays video through the WebKitGTK HTML5 <video> element, which we
|
||||||
|
// do not interrogate for a channel count; stereo is the safe assumption.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
let reported: Option<u32> = None;
|
||||||
|
|
||||||
|
clamp_max_audio_channels(reported)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Audio codecs the webview's `<video>` element can decode.
|
||||||
|
///
|
||||||
|
/// Deliberately narrower than what the platform reports: see
|
||||||
|
/// [`video_audio_codecs`].
|
||||||
|
const WEBVIEW_AUDIO_CODECS: &[&str] = &["aac", "mp3", "opus", "vorbis", "flac"];
|
||||||
|
|
||||||
|
/// The codec claimed when a device reports nothing we can use. Every renderer
|
||||||
|
/// decodes AAC, and claiming *something* is what makes the server transcode to
|
||||||
|
/// it rather than give up.
|
||||||
|
const FALLBACK_AUDIO_CODEC: &str = "aac";
|
||||||
|
|
||||||
|
/// Narrow a detected audio-codec list to what the renderer that will actually
|
||||||
|
/// play the **video** can decode.
|
||||||
|
///
|
||||||
|
/// The platform list comes from `MediaCodecList`, which describes ExoPlayer —
|
||||||
|
/// but the webview `<video>` element may be what renders the video, and
|
||||||
|
/// Chromium/WebKit decode a much smaller set than the platform does. Advertising
|
||||||
|
/// the raw list makes Jellyfin direct-play a track the webview cannot decode, and
|
||||||
|
/// the user gets picture with no sound.
|
||||||
|
///
|
||||||
|
/// Which renderer gets it is not fixed: Linux is always the element, and Android
|
||||||
|
/// follows `experimentalNativeVideo`, which took ExoPlayer as its default in
|
||||||
|
/// DR-161 but is a user setting either way. So the *narrow* list is the only one
|
||||||
|
/// that holds on both sides of that switch. The cost is a Dolby-licensed Android
|
||||||
|
/// device transcoding an E-AC-3 track its ExoPlayer could have direct-played;
|
||||||
|
/// the alternative is silence for everyone the switch lands the other way, which
|
||||||
|
/// is the bug this exists to prevent.
|
||||||
|
///
|
||||||
|
/// The gap is widest on devices whose vendor licenses Dolby: a phone with
|
||||||
|
/// `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
|
||||||
|
/// really is ExoPlayer's, so its profile keeps the full platform list.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-004 | DR-148 | UT-142
|
||||||
|
pub fn video_audio_codecs(detected: &str) -> String {
|
||||||
|
let kept: Vec<&str> = detected
|
||||||
|
.split(',')
|
||||||
|
.filter_map(|codec| {
|
||||||
|
let codec = codec.trim();
|
||||||
|
// Match case-insensitively but emit our own spelling: the platform
|
||||||
|
// list is assembled from MIME strings and its casing is not ours to
|
||||||
|
// forward to the server.
|
||||||
|
WEBVIEW_AUDIO_CODECS
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.find(|supported| supported.eq_ignore_ascii_case(codec))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if kept.is_empty() {
|
||||||
|
FALLBACK_AUDIO_CODEC.to_string()
|
||||||
|
} else {
|
||||||
|
kept.join(",")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the webview `<video>` element can decode this audio codec.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-004 | DR-149 | UT-148
|
||||||
|
pub fn webview_can_decode_audio(codec: &str) -> bool {
|
||||||
|
WEBVIEW_AUDIO_CODECS
|
||||||
|
.iter()
|
||||||
|
.any(|supported| supported.eq_ignore_ascii_case(codec.trim()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide whether we must transcode *regardless of what the server negotiated*,
|
||||||
|
/// given the source's audio streams as `(codec, is_default)` in source order.
|
||||||
|
///
|
||||||
|
/// Advertising a narrow profile ([`video_audio_codecs`]) is necessary but not
|
||||||
|
/// sufficient: Jellyfin 10.11.5 enforces a `DirectPlayProfile`'s container and
|
||||||
|
/// video codec but **ignores its audio codec** — an E-AC-3 track is offered for
|
||||||
|
/// direct play even when the profile lists only AAC, and neither a `VideoAudio`
|
||||||
|
/// `CodecProfile` nor `MaxAudioChannels` changes that. So the client cannot
|
||||||
|
/// delegate this decision; it knows what its own renderer can decode and must
|
||||||
|
/// apply that itself.
|
||||||
|
///
|
||||||
|
/// The track that matters is the one the server will actually serve (see
|
||||||
|
/// [`served_audio_codec`]). An unknown codec is left alone — forcing a transcode
|
||||||
|
/// on a guess would burn server CPU for files that play.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-004 | DR-149 | UT-148
|
||||||
|
pub fn audio_forces_transcode(streams: &[(Option<&str>, bool)]) -> bool {
|
||||||
|
match served_audio_codec(streams) {
|
||||||
|
Some(codec) => !webview_can_decode_audio(codec),
|
||||||
|
// No audio at all, or a codec the server did not name: leave it alone.
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The codec of the audio track the server will actually serve, given the
|
||||||
|
/// source's audio streams as `(codec, is_default)` in source order: the default,
|
||||||
|
/// or the first when none is marked.
|
||||||
|
///
|
||||||
|
/// `None` means "nothing to judge" — no audio streams, or the server named no
|
||||||
|
/// codec for the one it would serve. Both callers of this rule treat that as
|
||||||
|
/// leave-well-alone, never as a licence to assume compatibility.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-004, UR-071 | DR-149, DR-171 | UT-148, UT-166
|
||||||
|
pub fn served_audio_codec<'a>(streams: &[(Option<&'a str>, bool)]) -> Option<&'a str> {
|
||||||
|
streams
|
||||||
|
.iter()
|
||||||
|
.find(|(_, is_default)| *is_default)
|
||||||
|
.or_else(|| streams.first())
|
||||||
|
.and_then(|(codec, _)| *codec)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_undecodable_default_track_forces_a_transcode() {
|
||||||
|
// The reported bug: one E-AC-3 track, which the webview cannot decode.
|
||||||
|
assert!(audio_forces_transcode(&[(Some("eac3"), false)]));
|
||||||
|
assert!(audio_forces_transcode(&[(Some("ac3"), true)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_decodable_track_is_left_to_direct_play() {
|
||||||
|
// Never spend server CPU on a file that already plays.
|
||||||
|
assert!(!audio_forces_transcode(&[(Some("aac"), true)]));
|
||||||
|
assert!(!audio_forces_transcode(&[(Some("mp3"), false)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_default_track_decides_not_the_first() {
|
||||||
|
// The webview plays the default track, so that is the one that has to be
|
||||||
|
// decodable — a supported track further down does not save us.
|
||||||
|
assert!(audio_forces_transcode(&[
|
||||||
|
(Some("aac"), false),
|
||||||
|
(Some("eac3"), true)
|
||||||
|
]));
|
||||||
|
assert!(!audio_forces_transcode(&[
|
||||||
|
(Some("eac3"), false),
|
||||||
|
(Some("aac"), true)
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn with_no_default_marked_the_first_track_decides() {
|
||||||
|
// Jellyfin leaves IsDefault false on every stream for some files; the
|
||||||
|
// server then serves the first, so judge that one.
|
||||||
|
assert!(audio_forces_transcode(&[
|
||||||
|
(Some("eac3"), false),
|
||||||
|
(Some("aac"), false)
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_source_with_no_audio_is_not_transcoded() {
|
||||||
|
// Nothing to rescue, and a transcode would not create audio.
|
||||||
|
assert!(!audio_forces_transcode(&[]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_codec_is_not_second_guessed() {
|
||||||
|
// The server did not tell us the codec; assuming the worst would
|
||||||
|
// transcode files that play perfectly.
|
||||||
|
assert!(!audio_forces_transcode(&[(None, true)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The download path needs the codec itself, not just the verdict, so it can
|
||||||
|
/// tell the server what to re-encode. It picks the same track the streaming
|
||||||
|
/// verdict is formed from — one rule, one place.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-171 | UT-166
|
||||||
|
#[test]
|
||||||
|
fn the_served_codec_is_the_one_the_verdict_is_formed_from() {
|
||||||
|
assert_eq!(
|
||||||
|
served_audio_codec(&[(Some("aac"), false), (Some("eac3"), true)]),
|
||||||
|
Some("eac3")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
served_audio_codec(&[(Some("eac3"), false), (Some("aac"), false)]),
|
||||||
|
Some("eac3")
|
||||||
|
);
|
||||||
|
assert_eq!(served_audio_codec(&[]), None);
|
||||||
|
assert_eq!(served_audio_codec(&[(None, true)]), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_dolby_device_does_not_advertise_dolby_for_video() {
|
||||||
|
// The bug: a Motorola reporting c2.dolby.eac3.decoder direct-played
|
||||||
|
// E-AC-3 into a webview that cannot decode it — silent video, on that
|
||||||
|
// device only.
|
||||||
|
let codecs = video_audio_codecs("aac,ac3,amrnb,amrwb,eac3,flac,mp3,opus,pcm,vorbis");
|
||||||
|
assert_eq!(codecs, "aac,flac,mp3,opus,vorbis");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codecs_the_webview_cannot_decode_are_dropped() {
|
||||||
|
// AMR and raw PCM come from the AOSP set, so this is not a Dolby-only
|
||||||
|
// problem — it is just rarer content.
|
||||||
|
assert_eq!(video_audio_codecs("amrnb,amrwb,pcm,aac"), "aac");
|
||||||
|
assert_eq!(video_audio_codecs("dts,truehd,mp3"), "mp3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_list_the_webview_fully_supports_is_untouched() {
|
||||||
|
assert_eq!(
|
||||||
|
video_audio_codecs("aac,mp3,opus,vorbis,flac"),
|
||||||
|
"aac,mp3,opus,vorbis,flac"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_decodable_still_claims_aac() {
|
||||||
|
// Claiming an empty list invites the server to give up rather than
|
||||||
|
// transcode. AAC is universally decodable, so ask for it.
|
||||||
|
assert_eq!(video_audio_codecs("eac3,dts"), "aac");
|
||||||
|
assert_eq!(video_audio_codecs(""), "aac");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spacing_and_case_in_the_platform_list_are_tolerated() {
|
||||||
|
// The list is assembled from MediaCodecList strings; do not let
|
||||||
|
// whitespace decide whether the user gets sound.
|
||||||
|
assert_eq!(video_audio_codecs("aac, EAC3 , Mp3"), "aac,mp3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_route_falls_back_to_stereo() {
|
||||||
|
// Codec detection has not run yet, or the platform has no answer. Never
|
||||||
|
// claim surround we have not seen — every sink can do stereo.
|
||||||
|
assert_eq!(clamp_max_audio_channels(None), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_zero_reading_is_not_a_capability() {
|
||||||
|
// A route that has not been established reports 0; taking that literally
|
||||||
|
// would advertise a device with no audio at all.
|
||||||
|
assert_eq!(clamp_max_audio_channels(Some(0)), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stereo_sink_is_reported_as_stereo() {
|
||||||
|
// The phone speaker / Bluetooth headset case: the server must downmix
|
||||||
|
// 5.1 rather than direct-play it.
|
||||||
|
assert_eq!(clamp_max_audio_channels(Some(2)), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_surround_route_keeps_its_channels() {
|
||||||
|
// HDMI to an AVR: 5.1 and 7.1 direct play stay available.
|
||||||
|
assert_eq!(clamp_max_audio_channels(Some(6)), 6);
|
||||||
|
assert_eq!(clamp_max_audio_channels(Some(8)), 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_absurd_reading_is_capped_rather_than_forwarded() {
|
||||||
|
// Some drivers report the AudioTrack maximum rather than the route's.
|
||||||
|
assert_eq!(clamp_max_audio_channels(Some(32)), 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_mono_route_is_taken_at_its_word() {
|
||||||
|
assert_eq!(clamp_max_audio_channels(Some(1)), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,12 +41,34 @@ impl HybridRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The signed-in user this repository acts for.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-069 | DR-120
|
||||||
|
pub fn user_id(&self) -> &str {
|
||||||
|
self.online.user_id()
|
||||||
|
}
|
||||||
|
|
||||||
/// Download raw bytes from a URL using the shared authenticated HTTP client.
|
/// Download raw bytes from a URL using the shared authenticated HTTP client.
|
||||||
/// Delegates to online repository for connection reuse and proper auth.
|
/// Delegates to online repository for connection reuse and proper auth.
|
||||||
pub async fn download_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
|
pub async fn download_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
|
||||||
self.online.download_bytes(url).await
|
self.online.download_bytes(url).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove catalog entries the server no longer has. Cache-only, so it goes
|
||||||
|
/// straight to the offline repository. Callers must only invoke this after a
|
||||||
|
/// crawl in which every library succeeded — see
|
||||||
|
/// `OfflineRepository::prune_stale_catalog` for why a partial crawl must not
|
||||||
|
/// sweep.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-065 | DR-110
|
||||||
|
pub async fn prune_stale_catalog(
|
||||||
|
&self,
|
||||||
|
cutoff: &str,
|
||||||
|
item_types: &[String],
|
||||||
|
) -> Result<usize, RepoError> {
|
||||||
|
self.offline.prune_stale_catalog(cutoff, item_types).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Query the JRay plugin for actors on screen at time `t`. Online-only
|
/// Query the JRay plugin for actors on screen at time `t`. Online-only
|
||||||
/// (the plugin lives on the Jellyfin server); empty when JRay isn't present.
|
/// (the plugin lives on the Jellyfin server); empty when JRay isn't present.
|
||||||
pub async fn get_jray_actors(
|
pub async fn get_jray_actors(
|
||||||
@@ -113,6 +135,41 @@ impl HybridRepository {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Favourites held locally, without touching the server. Backs the instant
|
||||||
|
/// leg of the two-phase favourites read in the command layer.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-067 | DR-115
|
||||||
|
pub async fn get_favorites_cache_only(
|
||||||
|
&self,
|
||||||
|
scope: SearchScope,
|
||||||
|
options: Option<GetItemsOptions>,
|
||||||
|
) -> Result<SearchResult, RepoError> {
|
||||||
|
let offline = Arc::clone(&self.offline);
|
||||||
|
self.cache_with_timeout(async move { offline.get_favorites(scope, options).await })
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Favourites straight from the server, persisted to the cache on the way
|
||||||
|
/// through — which is also what mirrors their favourite flags into
|
||||||
|
/// `user_data` (DR-114), so the next offline read agrees with the server.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-067 | DR-115
|
||||||
|
pub async fn get_favorites_server_only(
|
||||||
|
&self,
|
||||||
|
scope: SearchScope,
|
||||||
|
options: Option<GetItemsOptions>,
|
||||||
|
) -> Result<SearchResult, RepoError> {
|
||||||
|
let result = self.online.get_favorites(scope, options).await?;
|
||||||
|
if !result.items.is_empty() {
|
||||||
|
// Favourites span libraries, so there is no single parent to file
|
||||||
|
// them under; the parent id is only used for stub rows.
|
||||||
|
if let Err(e) = self.offline.save_to_cache("favorites", &result.items).await {
|
||||||
|
debug!("[HybridRepo] Failed to cache favourites: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetch a folder's items from the live server and persist them to the
|
/// Fetch a folder's items from the live server and persist them to the
|
||||||
/// offline cache synchronously (unlike `get_items`, which saves in a
|
/// offline cache synchronously (unlike `get_items`, which saves in a
|
||||||
/// fire-and-forget background task after a 100ms cache race).
|
/// fire-and-forget background task after a 100ms cache race).
|
||||||
@@ -262,6 +319,49 @@ impl HybridRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`Self::parallel_race`], plus a callback fired on the fast path so the
|
||||||
|
/// caller can refresh the cache in the background.
|
||||||
|
///
|
||||||
|
/// A plain cache hit answers from data that may be arbitrarily old, which
|
||||||
|
/// is right for the *response* and wrong for what it leaves behind: per-user
|
||||||
|
/// state (watch positions, favourites) only reaches the local tables when a
|
||||||
|
/// server result is cached, so a surface that always hits cache never learns
|
||||||
|
/// what another device did. `get_items` had a bespoke version of this; this
|
||||||
|
/// is the same idea, reusable.
|
||||||
|
///
|
||||||
|
/// The callback runs only on a cache hit — on a miss the server result is
|
||||||
|
/// already being fetched and cached by the normal path.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-002, UR-025 | DR-155
|
||||||
|
async fn race_with_refresh<T, F1, F2, R>(
|
||||||
|
&self,
|
||||||
|
cache_future: F1,
|
||||||
|
server_future: F2,
|
||||||
|
on_cache_hit: R,
|
||||||
|
) -> Result<T, RepoError>
|
||||||
|
where
|
||||||
|
T: MeaningfulContent + Clone + Send + 'static,
|
||||||
|
F1: std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||||
|
F2: std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||||
|
R: FnOnce(),
|
||||||
|
{
|
||||||
|
let cache_result = cache_future.await;
|
||||||
|
|
||||||
|
if let Ok(data) = &cache_result {
|
||||||
|
if data.has_content() {
|
||||||
|
debug!("[HybridRepo] Cache hit, returning immediately (refreshing in background)");
|
||||||
|
on_cache_hit();
|
||||||
|
return Ok(data.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("[HybridRepo] Cache miss, querying server");
|
||||||
|
match server_future.await {
|
||||||
|
Ok(data) => Ok(data),
|
||||||
|
Err(e) => cache_result.or(Err(e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Simple timeout wrapper for cache queries (100ms timeout)
|
/// Simple timeout wrapper for cache queries (100ms timeout)
|
||||||
///
|
///
|
||||||
/// @req: DR-013 - Repository pattern (cache-first with timeout)
|
/// @req: DR-013 - Repository pattern (cache-first with timeout)
|
||||||
@@ -432,6 +532,21 @@ impl MediaRepository for HybridRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A single item, cache-first — and, on a cache hit, refreshed in the
|
||||||
|
/// background so the stored copy keeps up with the server.
|
||||||
|
///
|
||||||
|
/// 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
|
||||||
|
/// position set on another device reaches the local `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 —
|
||||||
|
/// `get_items` already refreshes this way, so browsing a season worked
|
||||||
|
/// while opening the episode directly did not.
|
||||||
|
///
|
||||||
|
/// The refreshed value lands for the *next* read rather than this one: the
|
||||||
|
/// point of the cache-first race is to answer immediately.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-025, UR-002 | DR-155 | UT-152
|
||||||
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
|
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
|
||||||
let offline = Arc::clone(&self.offline);
|
let offline = Arc::clone(&self.offline);
|
||||||
let online = Arc::clone(&self.online);
|
let online = Arc::clone(&self.online);
|
||||||
@@ -440,9 +555,32 @@ impl MediaRepository for HybridRepository {
|
|||||||
|
|
||||||
let cache_future = self.cache_with_timeout(async move { offline.get_item(&item_id).await });
|
let cache_future = self.cache_with_timeout(async move { offline.get_item(&item_id).await });
|
||||||
|
|
||||||
|
let online_for_refresh = Arc::clone(&self.online);
|
||||||
|
let offline_for_save = Arc::clone(&self.offline);
|
||||||
|
let refresh_id = item_id_clone.clone();
|
||||||
|
let on_cache_hit = move || {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
match online_for_refresh.get_item(&refresh_id).await {
|
||||||
|
Ok(fresh) => {
|
||||||
|
// `save_to_cache` files the row under a parent; the item's
|
||||||
|
// own parent keeps it where a later listing expects it.
|
||||||
|
let parent = fresh
|
||||||
|
.parent_id
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "item".to_string());
|
||||||
|
if let Err(e) = offline_for_save.save_to_cache(&parent, &[fresh]).await {
|
||||||
|
debug!("[HybridRepo] Background item refresh failed: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => debug!("[HybridRepo] Background item refresh unavailable: {:?}", e),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
let server_future = async move { online.get_item(&item_id_clone).await };
|
let server_future = async move { online.get_item(&item_id_clone).await };
|
||||||
|
|
||||||
self.parallel_race(cache_future, server_future).await
|
self.race_with_refresh(cache_future, server_future, on_cache_hit)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_latest_items(
|
async fn get_latest_items(
|
||||||
@@ -734,10 +872,11 @@ impl MediaRepository for HybridRepository {
|
|||||||
item_id: &str,
|
item_id: &str,
|
||||||
quality: &str,
|
quality: &str,
|
||||||
media_source_id: Option<&str>,
|
media_source_id: Option<&str>,
|
||||||
|
source_audio_codec: Option<&str>,
|
||||||
) -> String {
|
) -> String {
|
||||||
// Always use online URL for downloads
|
// Always use online URL for downloads
|
||||||
self.online
|
self.online
|
||||||
.get_video_download_url(item_id, quality, media_source_id)
|
.get_video_download_url(item_id, quality, media_source_id, source_audio_codec)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
||||||
@@ -755,6 +894,11 @@ impl MediaRepository for HybridRepository {
|
|||||||
self.online.clear_watch_history(item_id).await
|
self.online.clear_watch_history(item_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn mark_played(&self, item_id: &str) -> Result<(), RepoError> {
|
||||||
|
// Write operations go directly to server
|
||||||
|
self.online.mark_played(item_id).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
|
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
|
||||||
let offline = Arc::clone(&self.offline);
|
let offline = Arc::clone(&self.offline);
|
||||||
let online = Arc::clone(&self.online);
|
let online = Arc::clone(&self.online);
|
||||||
@@ -790,6 +934,41 @@ impl MediaRepository for HybridRepository {
|
|||||||
self.parallel_race(cache_future, server_future).await
|
self.parallel_race(cache_future, server_future).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: UR-067 | DR-115
|
||||||
|
async fn get_favorites(
|
||||||
|
&self,
|
||||||
|
scope: SearchScope,
|
||||||
|
options: Option<GetItemsOptions>,
|
||||||
|
) -> Result<SearchResult, RepoError> {
|
||||||
|
let cache_result = self.get_favorites_cache_only(scope, options.clone()).await;
|
||||||
|
|
||||||
|
// Downloads-only gate: with "Show all server media" off, an empty local
|
||||||
|
// result means "nothing favourited is on this device" and is
|
||||||
|
// authoritative. Falling through to the server here would re-pad the
|
||||||
|
// page with the full favourited catalog and defeat the filter (DR-080).
|
||||||
|
if !crate::repository::offline::include_catalog_browse() {
|
||||||
|
if let Ok(data) = &cache_result {
|
||||||
|
return Ok(data.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(data) = &cache_result {
|
||||||
|
if data.has_content() {
|
||||||
|
return Ok(data.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss — answer from the server, *saving through* on the way back.
|
||||||
|
// Every other read path persists what it fetches; skipping it here would
|
||||||
|
// mean the favourites page re-queries the server on every visit and the
|
||||||
|
// offline mirror (DR-114) never learns about favourites marked
|
||||||
|
// elsewhere, since this path is what fills it on a fresh install.
|
||||||
|
match self.get_favorites_server_only(scope, options).await {
|
||||||
|
Ok(data) => Ok(data),
|
||||||
|
Err(e) => cache_result.or(Err(e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_similar_items(
|
async fn get_similar_items(
|
||||||
&self,
|
&self,
|
||||||
item_id: &str,
|
item_id: &str,
|
||||||
@@ -1121,6 +1300,7 @@ mod tests {
|
|||||||
_item_id: &str,
|
_item_id: &str,
|
||||||
_quality: &str,
|
_quality: &str,
|
||||||
_media_source_id: Option<&str>,
|
_media_source_id: Option<&str>,
|
||||||
|
_source_audio_codec: Option<&str>,
|
||||||
) -> String {
|
) -> String {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
@@ -1133,10 +1313,22 @@ mod tests {
|
|||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_favorites(
|
||||||
|
&self,
|
||||||
|
_scope: SearchScope,
|
||||||
|
_options: Option<GetItemsOptions>,
|
||||||
|
) -> Result<SearchResult, RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
|
async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
|
async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
@@ -1383,6 +1575,7 @@ mod tests {
|
|||||||
_item_id: &str,
|
_item_id: &str,
|
||||||
_quality: &str,
|
_quality: &str,
|
||||||
_media_source_id: Option<&str>,
|
_media_source_id: Option<&str>,
|
||||||
|
_source_audio_codec: Option<&str>,
|
||||||
) -> String {
|
) -> String {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
@@ -1395,10 +1588,22 @@ mod tests {
|
|||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_favorites(
|
||||||
|
&self,
|
||||||
|
_scope: SearchScope,
|
||||||
|
_options: Option<GetItemsOptions>,
|
||||||
|
) -> Result<SearchResult, RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
|
async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
|
async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod device_profile;
|
||||||
pub mod hybrid;
|
pub mod hybrid;
|
||||||
pub mod offline;
|
pub mod offline;
|
||||||
pub mod online;
|
pub mod online;
|
||||||
@@ -196,14 +197,24 @@ pub trait MediaRepository: Send + Sync {
|
|||||||
format: &str,
|
format: &str,
|
||||||
) -> String;
|
) -> String;
|
||||||
|
|
||||||
/// Get video download URL (synchronous - just constructs URL)
|
/// Build the URL a video download is fetched from. Synchronous — it only
|
||||||
/// Called by frontend via Tauri invoke (getVideoDownloadUrl in VideoDownloadButton.svelte)
|
/// constructs a URL, so it stays testable without a server. Reach it through
|
||||||
|
/// [`resolve_video_download_url`] rather than calling it directly.
|
||||||
|
///
|
||||||
|
/// `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
|
||||||
|
/// `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
|
||||||
|
/// played back with no server in reach, so it has to be decodable *here*.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-171
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
fn get_video_download_url(
|
fn get_video_download_url(
|
||||||
&self,
|
&self,
|
||||||
item_id: &str,
|
item_id: &str,
|
||||||
quality: &str,
|
quality: &str,
|
||||||
media_source_id: Option<&str>,
|
media_source_id: Option<&str>,
|
||||||
|
source_audio_codec: Option<&str>,
|
||||||
) -> String;
|
) -> String;
|
||||||
|
|
||||||
/// Mark item as favorite
|
/// Mark item as favorite
|
||||||
@@ -212,6 +223,20 @@ pub trait MediaRepository: Send + Sync {
|
|||||||
/// Unmark item as favorite
|
/// Unmark item as favorite
|
||||||
async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError>;
|
async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError>;
|
||||||
|
|
||||||
|
/// Everything the viewer has favourited, across every library.
|
||||||
|
///
|
||||||
|
/// Separate from `get_items` because favourites span libraries and
|
||||||
|
/// `get_items` is `ParentId`-shaped. `scope` is the opaque enum the
|
||||||
|
/// frontend sends; this layer expands it to item types (DR-063) so no
|
||||||
|
/// Jellyfin taxonomy is needed on the other side of the IPC boundary.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-067 | DR-115, JA-033 | UT-100, UT-101
|
||||||
|
async fn get_favorites(
|
||||||
|
&self,
|
||||||
|
scope: SearchScope,
|
||||||
|
options: Option<GetItemsOptions>,
|
||||||
|
) -> Result<SearchResult, RepoError>;
|
||||||
|
|
||||||
/// Erase the viewer's watch history for an item: clear its played flag and
|
/// Erase the viewer's watch history for an item: clear its played flag and
|
||||||
/// its resume position. On a container (series, season) this applies to
|
/// its resume position. On a container (series, season) this applies to
|
||||||
/// everything inside it, so a series is returned to "never watched" and
|
/// everything inside it, so a series is returned to "never watched" and
|
||||||
@@ -220,6 +245,14 @@ pub trait MediaRepository: Send + Sync {
|
|||||||
/// TRACES: UR-064 | DR-106
|
/// TRACES: UR-064 | DR-106
|
||||||
async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError>;
|
async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError>;
|
||||||
|
|
||||||
|
/// Mark an item played — the inverse of `clear_watch_history`. Needed by the
|
||||||
|
/// sync-queue drain, which replays `mark_played` rows queued while the
|
||||||
|
/// server was unreachable; reporting a stop at a made-up position was the
|
||||||
|
/// previous stand-in and does not set the played flag reliably.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-025 | DR-131 | JA-035
|
||||||
|
async fn mark_played(&self, item_id: &str) -> Result<(), RepoError>;
|
||||||
|
|
||||||
/// Get person details
|
/// Get person details
|
||||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError>;
|
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError>;
|
||||||
|
|
||||||
@@ -300,3 +333,44 @@ pub trait MediaRepository: Send + Sync {
|
|||||||
new_index: u32,
|
new_index: u32,
|
||||||
) -> Result<(), RepoError>;
|
) -> Result<(), RepoError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The audio codec the server would serve for `item_id` — the default track, or
|
||||||
|
/// the first when none is marked, matching the track Jellyfin picks.
|
||||||
|
///
|
||||||
|
/// `None` when the item has no audio, names no codec, or cannot be fetched. A
|
||||||
|
/// caller must read that as "unknown", never as "fine": it is the input to a
|
||||||
|
/// policy that only *adds* a transcode, so an unknown codec leaves behaviour
|
||||||
|
/// exactly as it was.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-171 | UT-166
|
||||||
|
pub async fn served_audio_codec(repo: &dyn MediaRepository, item_id: &str) -> Option<String> {
|
||||||
|
let item = repo.get_item(item_id).await.ok()?;
|
||||||
|
let audio: Vec<(Option<&str>, bool)> = item
|
||||||
|
.media_streams
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.stream_type == "Audio")
|
||||||
|
.map(|s| (s.codec.as_deref(), s.is_default))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
device_profile::served_audio_codec(&audio).map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the download URL for a video, applying the audio-codec policy that
|
||||||
|
/// 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
|
||||||
|
pub async fn resolve_video_download_url(
|
||||||
|
repo: &dyn MediaRepository,
|
||||||
|
item_id: &str,
|
||||||
|
quality: &str,
|
||||||
|
media_source_id: Option<&str>,
|
||||||
|
) -> String {
|
||||||
|
let codec = served_audio_codec(repo, item_id).await;
|
||||||
|
repo.get_video_download_url(item_id, quality, media_source_id, codec.as_deref())
|
||||||
|
}
|
||||||
|
|||||||
+1489
-51
File diff suppressed because it is too large
Load Diff
+891
-103
File diff suppressed because it is too large
Load Diff
@@ -97,9 +97,14 @@ fn belongs_to_series(item: &MediaItem, series_id: &str) -> bool {
|
|||||||
/// working through.
|
/// working through.
|
||||||
/// 2. **The server's Next Up** for this series — it accounts for watch history
|
/// 2. **The server's Next Up** for this series — it accounts for watch history
|
||||||
/// we do not cache locally.
|
/// we do not cache locally.
|
||||||
/// 3. **The first unwatched episode** in series order. This is the offline path:
|
/// 3. **The episode after the furthest-watched one**, falling back to the first
|
||||||
/// `OfflineRepository::get_next_up_episodes` returns an empty vec, so without
|
/// unwatched episode when nothing has been watched or the series is finished.
|
||||||
/// this rung the whole feature would be online-only.
|
/// This is the offline path: `OfflineRepository::get_next_up_episodes`
|
||||||
|
/// returns an empty vec, so without this rung the whole feature would be
|
||||||
|
/// online-only. It deliberately does *not* return the first unwatched
|
||||||
|
/// episode outright — an unwatched episode behind the viewer's furthest
|
||||||
|
/// point was skipped on purpose, and sending them back to it is the bug
|
||||||
|
/// DR-101 was reopened for.
|
||||||
/// 4. **The first episode**, so a never-watched series opens on its premiere
|
/// 4. **The first episode**, so a never-watched series opens on its premiere
|
||||||
/// rather than on nothing.
|
/// rather than on nothing.
|
||||||
///
|
///
|
||||||
@@ -136,7 +141,18 @@ pub fn pick_current_episode(
|
|||||||
return Some(matched.unwrap_or(found).clone());
|
return Some(matched.unwrap_or(found).clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. First unwatched in series order.
|
// 3. The episode after the furthest-watched one. Not simply the first
|
||||||
|
// unwatched: a viewer who skipped the pilot but is deep into season 3
|
||||||
|
// must not be dragged back to S1E1. An earlier gap is a deliberate skip;
|
||||||
|
// where they stopped is the *last* thing they watched.
|
||||||
|
if let Some(furthest) = episodes.iter().rposition(is_played) {
|
||||||
|
if let Some(found) = episodes.get(furthest + 1) {
|
||||||
|
return Some(found.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing watched yet (or the furthest-watched episode is the finale):
|
||||||
|
// the first unwatched episode in series order.
|
||||||
if let Some(found) = episodes.iter().find(|e| !is_played(e)) {
|
if let Some(found) = episodes.iter().find(|e| !is_played(e)) {
|
||||||
return Some(found.clone());
|
return Some(found.clone());
|
||||||
}
|
}
|
||||||
@@ -352,6 +368,54 @@ mod tests {
|
|||||||
assert_eq!(current.id, "s2e2");
|
assert_eq!(current.id, "s2e2");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A viewer deep in season 3 who never watched the pilot must not be sent
|
||||||
|
/// back to it: the gap was a skip, not the place they stopped.
|
||||||
|
#[test]
|
||||||
|
fn resumes_after_the_furthest_watched_episode_not_the_first_gap() {
|
||||||
|
let mut eps = [season(1, 4), season(2, 4), season(3, 4)].concat();
|
||||||
|
for ep in eps.iter_mut() {
|
||||||
|
// Everything through S3E3 watched, except the never-watched pilot.
|
||||||
|
let watched_through = ep.parent_index_number < Some(3) || ep.index_number <= Some(3);
|
||||||
|
if watched_through && ep.id != "s1e1" {
|
||||||
|
*ep = watched(ep.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
||||||
|
assert_eq!(current.id, "s3e4");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The furthest-watched episode being a finale must still roll into the
|
||||||
|
/// next season rather than stopping the series.
|
||||||
|
#[test]
|
||||||
|
fn resumes_into_the_next_season_after_a_skipped_earlier_episode() {
|
||||||
|
let mut eps = [season(1, 3), season(2, 3)].concat();
|
||||||
|
for ep in eps.iter_mut() {
|
||||||
|
if ep.parent_index_number == Some(1) && ep.id != "s1e1" {
|
||||||
|
*ep = watched(ep.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
||||||
|
assert_eq!(current.id, "s2e1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Specials sort last, so watching one must not mark the series finished
|
||||||
|
/// while numbered episodes remain.
|
||||||
|
#[test]
|
||||||
|
fn a_watched_special_does_not_end_the_series() {
|
||||||
|
let mut eps = [season(1, 3), vec![episode("s0e1", 0, 1)]].concat();
|
||||||
|
sort_series_order(&mut eps);
|
||||||
|
for ep in eps.iter_mut() {
|
||||||
|
if ep.id == "s1e1" || ep.id == "s0e1" {
|
||||||
|
*ep = watched(ep.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
||||||
|
assert_eq!(current.id, "s1e2");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn crosses_a_season_boundary_when_a_season_is_finished() {
|
fn crosses_a_season_boundary_when_a_season_is_finished() {
|
||||||
let mut eps = [season(1, 3), season(2, 3)].concat();
|
let mut eps = [season(1, 3), season(2, 3)].concat();
|
||||||
|
|||||||
@@ -292,6 +292,12 @@ pub struct GetItemsOptions {
|
|||||||
pub fields: Option<Vec<String>>,
|
pub fields: Option<Vec<String>>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub genres: Option<Vec<String>>,
|
pub genres: Option<Vec<String>>,
|
||||||
|
/// Restrict the listing to favourited items. Backs the per-library
|
||||||
|
/// favourites toggle; composes with every other filter here.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-067 | DR-116 | UT-104
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub favorites_only: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An opaque search scope the frontend selects; Rust owns what it *means*.
|
/// An opaque search scope the frontend selects; Rust owns what it *means*.
|
||||||
|
|||||||
@@ -148,6 +148,138 @@ impl AudioSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A ceiling on how much bandwidth a *video* stream may consume.
|
||||||
|
///
|
||||||
|
/// A quality step is a bundle of concrete transcode parameters — total stream
|
||||||
|
/// ceiling, the audio share of it, and the resolution that ceiling can carry —
|
||||||
|
/// not just a label. Those numbers are Jellyfin encoding domain vocabulary, so
|
||||||
|
/// they live here and the frontend only ever names a variant; the labels the
|
||||||
|
/// picker shows are served over IPC by `player_get_streaming_qualities`.
|
||||||
|
///
|
||||||
|
/// The ladder is deliberately expressed in bandwidth rather than resolution: it
|
||||||
|
/// exists to fit a connection, and the resolution cap is chosen *from* the
|
||||||
|
/// bitrate so the encoder does not spend a small budget on pixels it cannot
|
||||||
|
/// afford. See docs/specs/streaming-bitrate-cap.md.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-074 | DR-162
|
||||||
|
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum StreamingQuality {
|
||||||
|
/// No client-imposed cap — the server may direct-play the source as-is.
|
||||||
|
#[default]
|
||||||
|
Original,
|
||||||
|
Mbps20,
|
||||||
|
Mbps10,
|
||||||
|
Mbps8,
|
||||||
|
Mbps4,
|
||||||
|
Mbps2,
|
||||||
|
Mbps1,
|
||||||
|
Kbps720,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StreamingQuality {
|
||||||
|
/// The ladder, highest first, for enumerating across the IPC boundary.
|
||||||
|
pub const ALL: [StreamingQuality; 8] = [
|
||||||
|
StreamingQuality::Original,
|
||||||
|
StreamingQuality::Mbps20,
|
||||||
|
StreamingQuality::Mbps10,
|
||||||
|
StreamingQuality::Mbps8,
|
||||||
|
StreamingQuality::Mbps4,
|
||||||
|
StreamingQuality::Mbps2,
|
||||||
|
StreamingQuality::Mbps1,
|
||||||
|
StreamingQuality::Kbps720,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Total bits per second the stream may use (video + audio), or `None` for
|
||||||
|
/// the uncapped `Original`.
|
||||||
|
///
|
||||||
|
/// This is the number that goes to `PlaybackInfo` as `MaxStreamingBitrate`
|
||||||
|
/// and into the device profile. Sending it there — not just on the transcode
|
||||||
|
/// URL — is what makes the cap real: a stream the server decides to *direct
|
||||||
|
/// play* is served at the source file's own bitrate, and no URL parameter
|
||||||
|
/// afterwards can reduce it.
|
||||||
|
pub fn max_bitrate(&self) -> Option<u64> {
|
||||||
|
match self {
|
||||||
|
StreamingQuality::Original => None,
|
||||||
|
StreamingQuality::Mbps20 => Some(20_000_000),
|
||||||
|
StreamingQuality::Mbps10 => Some(10_000_000),
|
||||||
|
StreamingQuality::Mbps8 => Some(8_000_000),
|
||||||
|
StreamingQuality::Mbps4 => Some(4_000_000),
|
||||||
|
StreamingQuality::Mbps2 => Some(2_000_000),
|
||||||
|
StreamingQuality::Mbps1 => Some(1_000_000),
|
||||||
|
StreamingQuality::Kbps720 => Some(720_000),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bits per second allotted to the audio track.
|
||||||
|
///
|
||||||
|
/// The value shrinks with the ladder because at the bottom rungs a fixed
|
||||||
|
/// 384 kbps would be a third of the entire budget.
|
||||||
|
pub fn audio_bitrate(&self) -> u64 {
|
||||||
|
match self {
|
||||||
|
StreamingQuality::Original
|
||||||
|
| StreamingQuality::Mbps20
|
||||||
|
| StreamingQuality::Mbps10
|
||||||
|
| StreamingQuality::Mbps8 => 384_000,
|
||||||
|
StreamingQuality::Mbps4 => 256_000,
|
||||||
|
StreamingQuality::Mbps2 => 192_000,
|
||||||
|
StreamingQuality::Mbps1 => 128_000,
|
||||||
|
StreamingQuality::Kbps720 => 96_000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bits per second allotted to the video track: the total minus the audio
|
||||||
|
/// share, so the two together honour [`max_bitrate`](Self::max_bitrate)
|
||||||
|
/// rather than overshooting it by the size of the audio track.
|
||||||
|
pub fn video_bitrate(&self) -> Option<u64> {
|
||||||
|
self.max_bitrate()
|
||||||
|
.map(|total| total.saturating_sub(self.audio_bitrate()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolution ceiling that suits the bitrate, or `None` to leave the source
|
||||||
|
/// resolution alone. Scaling down is what keeps a small budget looking like
|
||||||
|
/// clean video instead of blocky 1080p.
|
||||||
|
pub fn max_height(&self) -> Option<u32> {
|
||||||
|
match self {
|
||||||
|
// 20 Mbps carries 4K, so it caps bandwidth without capping pixels.
|
||||||
|
StreamingQuality::Original | StreamingQuality::Mbps20 => None,
|
||||||
|
StreamingQuality::Mbps10 | StreamingQuality::Mbps8 => Some(1080),
|
||||||
|
StreamingQuality::Mbps4 | StreamingQuality::Mbps2 => Some(720),
|
||||||
|
StreamingQuality::Mbps1 => Some(480),
|
||||||
|
StreamingQuality::Kbps720 => Some(360),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human label for the picker. Lives in Rust with the numbers it describes,
|
||||||
|
/// so the two cannot drift apart.
|
||||||
|
pub fn label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
StreamingQuality::Original => "Original",
|
||||||
|
StreamingQuality::Mbps20 => "20 Mbps",
|
||||||
|
StreamingQuality::Mbps10 => "10 Mbps",
|
||||||
|
StreamingQuality::Mbps8 => "8 Mbps",
|
||||||
|
StreamingQuality::Mbps4 => "4 Mbps",
|
||||||
|
StreamingQuality::Mbps2 => "2 Mbps",
|
||||||
|
StreamingQuality::Mbps1 => "1 Mbps",
|
||||||
|
StreamingQuality::Kbps720 => "720 kbps",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Secondary line for the picker: what the cap means in practice.
|
||||||
|
pub fn detail(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
StreamingQuality::Original => "No limit — highest quality",
|
||||||
|
StreamingQuality::Mbps20 => "Up to 4K",
|
||||||
|
StreamingQuality::Mbps10 => "1080p, high quality",
|
||||||
|
StreamingQuality::Mbps8 => "1080p",
|
||||||
|
StreamingQuality::Mbps4 => "720p",
|
||||||
|
StreamingQuality::Mbps2 => "720p, reduced",
|
||||||
|
StreamingQuality::Mbps1 => "480p",
|
||||||
|
StreamingQuality::Kbps720 => "360p — slowest connections",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Video playback settings
|
/// Video playback settings
|
||||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -159,6 +291,14 @@ pub struct VideoSettings {
|
|||||||
/// Maximum number of episodes to auto-play consecutively (0 = unlimited)
|
/// Maximum number of episodes to auto-play consecutively (0 = unlimited)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub auto_play_max_episodes: u32,
|
pub auto_play_max_episodes: u32,
|
||||||
|
/// Bandwidth ceiling applied to every video stream.
|
||||||
|
///
|
||||||
|
/// `#[serde(default)]` so settings JSON persisted before this field existed
|
||||||
|
/// loads as the previous behaviour (uncapped).
|
||||||
|
///
|
||||||
|
/// TRACES: UR-074 | DR-162
|
||||||
|
#[serde(default)]
|
||||||
|
pub streaming_quality: StreamingQuality,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for VideoSettings {
|
impl Default for VideoSettings {
|
||||||
@@ -167,6 +307,7 @@ impl Default for VideoSettings {
|
|||||||
auto_play_next_episode: true,
|
auto_play_next_episode: true,
|
||||||
auto_play_countdown_seconds: 10,
|
auto_play_countdown_seconds: 10,
|
||||||
auto_play_max_episodes: 0,
|
auto_play_max_episodes: 0,
|
||||||
|
streaming_quality: StreamingQuality::Original,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -427,12 +568,14 @@ mod tests {
|
|||||||
auto_play_next_episode: false,
|
auto_play_next_episode: false,
|
||||||
auto_play_countdown_seconds: 15,
|
auto_play_countdown_seconds: 15,
|
||||||
auto_play_max_episodes: 5,
|
auto_play_max_episodes: 5,
|
||||||
|
streaming_quality: StreamingQuality::Mbps4,
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&settings).unwrap();
|
let json = serde_json::to_string(&settings).unwrap();
|
||||||
assert!(json.contains("\"autoPlayNextEpisode\":false"));
|
assert!(json.contains("\"autoPlayNextEpisode\":false"));
|
||||||
assert!(json.contains("\"autoPlayCountdownSeconds\":15"));
|
assert!(json.contains("\"autoPlayCountdownSeconds\":15"));
|
||||||
assert!(json.contains("\"autoPlayMaxEpisodes\":5"));
|
assert!(json.contains("\"autoPlayMaxEpisodes\":5"));
|
||||||
|
assert!(json.contains("\"streamingQuality\":\"mbps4\""));
|
||||||
|
|
||||||
let parsed: VideoSettings = serde_json::from_str(&json).unwrap();
|
let parsed: VideoSettings = serde_json::from_str(&json).unwrap();
|
||||||
assert!(!parsed.auto_play_next_episode);
|
assert!(!parsed.auto_play_next_episode);
|
||||||
@@ -448,5 +591,90 @@ mod tests {
|
|||||||
assert!(parsed.auto_play_next_episode);
|
assert!(parsed.auto_play_next_episode);
|
||||||
assert_eq!(parsed.auto_play_countdown_seconds, 10);
|
assert_eq!(parsed.auto_play_countdown_seconds, 10);
|
||||||
assert_eq!(parsed.auto_play_max_episodes, 0);
|
assert_eq!(parsed.auto_play_max_episodes, 0);
|
||||||
|
// Settings persisted before the cap existed must load as uncapped —
|
||||||
|
// inventing a limit for an upgrading user would silently degrade their
|
||||||
|
// picture with no setting having been changed.
|
||||||
|
assert_eq!(parsed.streaming_quality, StreamingQuality::Original);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole point of a step is the number of bits it promises not to
|
||||||
|
/// exceed, so video + audio must fit inside the total — a video bitrate set
|
||||||
|
/// to the full cap would overshoot it by the size of the audio track.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-074 | DR-162 | UT-157
|
||||||
|
#[test]
|
||||||
|
fn test_streaming_quality_budget_is_internally_consistent() {
|
||||||
|
for quality in StreamingQuality::ALL {
|
||||||
|
let Some(total) = quality.max_bitrate() else {
|
||||||
|
assert_eq!(
|
||||||
|
quality,
|
||||||
|
StreamingQuality::Original,
|
||||||
|
"only Original may be uncapped"
|
||||||
|
);
|
||||||
|
assert!(quality.video_bitrate().is_none());
|
||||||
|
assert!(quality.max_height().is_none());
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let video = quality.video_bitrate().expect("a capped step caps video");
|
||||||
|
assert_eq!(
|
||||||
|
video + quality.audio_bitrate(),
|
||||||
|
total,
|
||||||
|
"{:?}: video + audio must equal the cap",
|
||||||
|
quality
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
video > 0,
|
||||||
|
"{:?}: audio must not consume the budget",
|
||||||
|
quality
|
||||||
|
);
|
||||||
|
assert!(!quality.label().is_empty());
|
||||||
|
assert!(!quality.detail().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ladder is presented to the user as descending, and the resolution cap
|
||||||
|
/// must fall with it — a lower bitrate paired with a higher resolution would
|
||||||
|
/// spend the smaller budget on more pixels, which is backwards.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-074 | DR-162 | UT-157
|
||||||
|
#[test]
|
||||||
|
fn test_streaming_quality_ladder_descends() {
|
||||||
|
let steps = StreamingQuality::ALL;
|
||||||
|
for pair in steps.windows(2) {
|
||||||
|
let (higher, lower) = (pair[0], pair[1]);
|
||||||
|
let higher_bitrate = higher.max_bitrate().unwrap_or(u64::MAX);
|
||||||
|
let lower_bitrate = lower.max_bitrate().unwrap_or(u64::MAX);
|
||||||
|
assert!(
|
||||||
|
higher_bitrate > lower_bitrate,
|
||||||
|
"{:?} must sit above {:?}",
|
||||||
|
higher,
|
||||||
|
lower
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
higher.max_height().unwrap_or(u32::MAX) >= lower.max_height().unwrap_or(u32::MAX),
|
||||||
|
"{:?} must not cap resolution below {:?}",
|
||||||
|
higher,
|
||||||
|
lower
|
||||||
|
);
|
||||||
|
assert!(higher.audio_bitrate() >= lower.audio_bitrate());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The persisted form is the serde token, and it must survive a round trip —
|
||||||
|
/// a rename here silently resets everyone's saved cap to uncapped.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-074 | DR-162 | UT-157
|
||||||
|
#[test]
|
||||||
|
fn test_streaming_quality_round_trips_through_json() {
|
||||||
|
for quality in StreamingQuality::ALL {
|
||||||
|
let json = serde_json::to_string(&quality).expect("serialises");
|
||||||
|
let parsed: StreamingQuality = serde_json::from_str(&json).expect("parses back");
|
||||||
|
assert_eq!(parsed, quality);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&StreamingQuality::Mbps10).unwrap(),
|
||||||
|
"\"mbps10\""
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ pub const MIGRATIONS: &[(&str, &str)] = &[
|
|||||||
("018_items_is_folder", MIGRATION_018),
|
("018_items_is_folder", MIGRATION_018),
|
||||||
("019_genres_cache", MIGRATION_019),
|
("019_genres_cache", MIGRATION_019),
|
||||||
("020_items_season_index", MIGRATION_020),
|
("020_items_season_index", MIGRATION_020),
|
||||||
|
("021_rebuild_items_fts", MIGRATION_021),
|
||||||
|
("022_people_fts", MIGRATION_022),
|
||||||
|
("023_downloads_expiry", MIGRATION_023),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Initial schema migration
|
/// Initial schema migration
|
||||||
@@ -728,3 +731,91 @@ CREATE INDEX IF NOT EXISTS idx_genres_scope ON genres(server_id, library_id);
|
|||||||
const MIGRATION_020: &str = r#"
|
const MIGRATION_020: &str = r#"
|
||||||
CREATE INDEX IF NOT EXISTS idx_items_season ON items(season_id);
|
CREATE INDEX IF NOT EXISTS idx_items_season ON items(season_id);
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
/// Discard and rebuild the FTS index from the `items` table.
|
||||||
|
///
|
||||||
|
/// Until DR-110, `save_to_cache` used `INSERT OR REPLACE INTO items`. REPLACE
|
||||||
|
/// deletes the conflicting row and inserts a new one, but SQLite only fires
|
||||||
|
/// `AFTER DELETE` triggers on that implicit delete when `recursive_triggers` is
|
||||||
|
/// enabled — it is not (storage/mod.rs sets only `foreign_keys` and
|
||||||
|
/// `journal_mode`), so `items_ad` never ran and the old index row was orphaned.
|
||||||
|
/// Worse, `items.id` is a `TEXT PRIMARY KEY`, so the replacement row also took a
|
||||||
|
/// *fresh rowid* and `items_ai` appended a second entry. Every catalog pass
|
||||||
|
/// therefore left another duplicate behind, and existing installs carry one
|
||||||
|
/// stale entry per item per sync since the database was created.
|
||||||
|
///
|
||||||
|
/// This was invisible in results — the `JOIN items_fts fts ON fts.rowid =
|
||||||
|
/// i.rowid` drops rowids that no longer exist — but it degrades `MATCH`
|
||||||
|
/// permanently, and it becomes a *correctness* problem the moment rowids are
|
||||||
|
/// freed and reused: a new item landing on a freed rowid inherits the orphan's
|
||||||
|
/// index entry and matches queries for the deleted item's title. The DR-110
|
||||||
|
/// deletion sweep frees rowids, so this rebuild must run before it.
|
||||||
|
///
|
||||||
|
/// `'rebuild'` is the FTS5 command for exactly this: it truncates the index and
|
||||||
|
/// repopulates it from the external content table.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-065 | DR-110
|
||||||
|
const MIGRATION_021: &str = r#"
|
||||||
|
INSERT INTO items_fts(items_fts) VALUES('rebuild');
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Full-text index over `people`, mirroring `items_fts`.
|
||||||
|
///
|
||||||
|
/// People live in their own table (migration 009) rather than in `items`, and
|
||||||
|
/// had no FTS index at all — so the People group UR-060 requires could only ever
|
||||||
|
/// be filled by the server leg of search. With the local index now answering
|
||||||
|
/// first, an actor's name has to be findable offline too.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-065, UR-060 | DR-111
|
||||||
|
const MIGRATION_022: &str = r#"
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS people_fts USING fts5(
|
||||||
|
name,
|
||||||
|
overview,
|
||||||
|
content='people',
|
||||||
|
content_rowid='rowid'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS people_ai AFTER INSERT ON people BEGIN
|
||||||
|
INSERT INTO people_fts(rowid, name, overview)
|
||||||
|
VALUES (new.rowid, new.name, new.overview);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS people_ad AFTER DELETE ON people BEGIN
|
||||||
|
INSERT INTO people_fts(people_fts, rowid, name, overview)
|
||||||
|
VALUES('delete', old.rowid, old.name, old.overview);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS people_au AFTER UPDATE ON people BEGIN
|
||||||
|
INSERT INTO people_fts(people_fts, rowid, name, overview)
|
||||||
|
VALUES('delete', old.rowid, old.name, old.overview);
|
||||||
|
INSERT INTO people_fts(rowid, name, overview)
|
||||||
|
VALUES (new.rowid, new.name, new.overview);
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Backfill for rows cached before this index existed.
|
||||||
|
INSERT INTO people_fts(people_fts) VALUES('rebuild');
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Give temporary downloads a life limit.
|
||||||
|
///
|
||||||
|
/// A cache entry is not a different kind of object from a download — it is a
|
||||||
|
/// download with a shorter life. Modelling it as one `downloads` row with an
|
||||||
|
/// expiry (rather than a parallel cache store) means there is a single storage
|
||||||
|
/// accounting, a single eviction path, and no way for a cache and a download
|
||||||
|
/// library to disagree about what is on disk.
|
||||||
|
///
|
||||||
|
/// `expires_at` is NULL for permanent rows, which is every row that exists
|
||||||
|
/// today: `download_source` defaults to `'user'`, and a user's own download
|
||||||
|
/// never expires. Only `'auto'` rows get a timestamp, and they are reclaimed by
|
||||||
|
/// whichever comes first — the expiry passing, or LRU eviction under space
|
||||||
|
/// pressure (DR-126).
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-127
|
||||||
|
const MIGRATION_023: &str = r#"
|
||||||
|
ALTER TABLE downloads ADD COLUMN expires_at TEXT;
|
||||||
|
|
||||||
|
-- Reclaim scans filter on expiry among 'auto' rows; index both so the sweep
|
||||||
|
-- stays cheap as the cache tier grows.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_downloads_expiry
|
||||||
|
ON downloads(download_source, expires_at);
|
||||||
|
"#;
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"transparent": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "jellytau",
|
"productName": "jellytau",
|
||||||
"version": "0.3.0",
|
"version": "0.5.3",
|
||||||
"identifier": "com.dtourolle.jellytau",
|
"identifier": "com.dtourolle.jellytau",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
@@ -18,7 +18,11 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": null
|
"csp": null,
|
||||||
|
"assetProtocol": {
|
||||||
|
"enable": true,
|
||||||
|
"scope": ["$APPDATA/**"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
+55
-5
@@ -14,18 +14,68 @@
|
|||||||
--color-surface-hover: #252525;
|
--color-surface-hover: #252525;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Safe-area insets — the single source of edge padding for the whole app.
|
||||||
|
*
|
||||||
|
* TRACES: UR-066 | DR-112
|
||||||
|
*
|
||||||
|
* Two independent sources have to be folded together:
|
||||||
|
*
|
||||||
|
* - `env(safe-area-inset-*)` — iOS/desktop, and the *display cutout* on
|
||||||
|
* Android. Requires `viewport-fit=cover` (see src/app.html) or it is 0px.
|
||||||
|
* - `var(--jt-inset-*)` — real Android `WindowInsets` (status bar, navigation/
|
||||||
|
* gesture bar, cutout) pushed in from Kotlin, because Android WebView never
|
||||||
|
* reports the *system bars* through `env()`. See WindowInsetsBridge.kt and
|
||||||
|
* $lib/utils/safeArea.ts.
|
||||||
|
*
|
||||||
|
* `max()` takes whichever is real on this platform; both are 0 on desktop.
|
||||||
|
* Consumers must use `--safe-*` and never `env()` directly — a bare `env()` is
|
||||||
|
* silently 0 for the Android system bars, which is what put the bottom nav
|
||||||
|
* under the navigation bar on 3-button-nav devices.
|
||||||
|
*
|
||||||
|
* Applied at the edges that own them: the app shell (top/left/right) and
|
||||||
|
* BottomUi (bottom, so its surface colour extends behind the gesture bar).
|
||||||
|
* Deliberately NOT applied to `body` — the shell is `h-screen`, and body
|
||||||
|
* padding would push 100vh past the viewport, and `position: fixed` overlays
|
||||||
|
* (the video/audio players) ignore body padding anyway.
|
||||||
|
*/
|
||||||
|
:root {
|
||||||
|
--safe-top: max(env(safe-area-inset-top, 0px), var(--jt-inset-top, 0px));
|
||||||
|
--safe-right: max(env(safe-area-inset-right, 0px), var(--jt-inset-right, 0px));
|
||||||
|
--safe-bottom: max(env(safe-area-inset-bottom, 0px), var(--jt-inset-bottom, 0px));
|
||||||
|
--safe-left: max(env(safe-area-inset-left, 0px), var(--jt-inset-left, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
/* Global styles */
|
/* Global styles */
|
||||||
html, body {
|
html, body {
|
||||||
@apply h-full;
|
@apply h-full;
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Native-video compositing (Android).
|
||||||
|
*
|
||||||
|
* TRACES: UR-003, UR-004 | DR-150
|
||||||
|
*
|
||||||
|
* When ExoPlayer renders into a SurfaceView *behind* the WebView, every opaque
|
||||||
|
* layer between the viewport and that surface hides the video. The WebView
|
||||||
|
* itself is made transparent by `"transparent": true` in
|
||||||
|
* tauri.android.conf.json; these rules clear the app's own painted backgrounds.
|
||||||
|
*
|
||||||
|
* Scoped to `[data-native-video="active"]` — set on <html> by
|
||||||
|
* $lib/stores/nativeVideo.ts only while a native video session is on screen —
|
||||||
|
* because every other screen genuinely needs its opaque background. The app
|
||||||
|
* shell (+layout.svelte) also paints --color-background across the viewport, so
|
||||||
|
* it is cleared here too; the shell is the layer directly over the surface.
|
||||||
|
*
|
||||||
|
* `background: transparent` (not a colour) is required: an alpha-0 colour still
|
||||||
|
* composites in some WebView versions.
|
||||||
|
*/
|
||||||
|
html[data-native-video="active"],
|
||||||
|
html[data-native-video="active"] body,
|
||||||
|
html[data-native-video="active"] [data-app-shell] {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply text-white antialiased;
|
@apply text-white antialiased;
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
/* Handle safe areas for mobile devices (status bar, notches, etc.) */
|
|
||||||
padding-top: env(safe-area-inset-top);
|
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
|
||||||
padding-left: env(safe-area-inset-left);
|
|
||||||
padding-right: env(safe-area-inset-right);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -3,7 +3,16 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<!--
|
||||||
|
`viewport-fit=cover` is REQUIRED: without it every `env(safe-area-inset-*)`
|
||||||
|
resolves to 0px, so the safe-area padding in app.css/BottomUi is a no-op
|
||||||
|
and the bottom nav renders under the Android navigation bar. See
|
||||||
|
$lib/utils/safeArea.ts for the other half (native WindowInsets → CSS vars).
|
||||||
|
-->
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1, viewport-fit=cover"
|
||||||
|
/>
|
||||||
<title>JellyTau</title>
|
<title>JellyTau</title>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
+344
-10
@@ -143,6 +143,14 @@ async playerGetStatus() : Promise<PlayerStatus> {
|
|||||||
async playerGetQueue() : Promise<QueueStatus> {
|
async playerGetQueue() : Promise<QueueStatus> {
|
||||||
return await TAURI_INVOKE("player_get_queue");
|
return await TAURI_INVOKE("player_get_queue");
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Report this platform's playback capabilities to the frontend.
|
||||||
|
*
|
||||||
|
* TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
|
||||||
|
*/
|
||||||
|
async playerGetCapabilities() : Promise<PlaybackCapabilities> {
|
||||||
|
return await TAURI_INVOKE("player_get_capabilities");
|
||||||
|
},
|
||||||
async playerAddToQueue(request: AddToQueueRequest) : Promise<QueueStatus> {
|
async playerAddToQueue(request: AddToQueueRequest) : Promise<QueueStatus> {
|
||||||
return await TAURI_INVOKE("player_add_to_queue", { request });
|
return await TAURI_INVOKE("player_add_to_queue", { request });
|
||||||
},
|
},
|
||||||
@@ -189,6 +197,40 @@ async playerSetVideoSettings(settings: VideoSettings) : Promise<VideoSettings> {
|
|||||||
async playerGetVideoSettings() : Promise<VideoSettings> {
|
async playerGetVideoSettings() : Promise<VideoSettings> {
|
||||||
return await TAURI_INVOKE("player_get_video_settings");
|
return await TAURI_INVOKE("player_get_video_settings");
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* The bandwidth ceilings the quality picker may offer, each with the label and
|
||||||
|
* one-line detail to show for it, highest first.
|
||||||
|
*
|
||||||
|
* The ladder and its numbers are Jellyfin encoding domain vocabulary, so the
|
||||||
|
* frontend reads them here rather than encoding them — the same arrangement as
|
||||||
|
* [`player_get_eq_presets`].
|
||||||
|
*
|
||||||
|
* TRACES: UR-074 | DR-162
|
||||||
|
*/
|
||||||
|
async playerGetStreamingQualities() : Promise<([StreamingQuality, string, string])[]> {
|
||||||
|
return await TAURI_INVOKE("player_get_streaming_qualities");
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Change the bandwidth ceiling of the video that is playing *right now*.
|
||||||
|
*
|
||||||
|
* A cap is a property of the stream the server is producing, so unlike a volume
|
||||||
|
* change it cannot be applied to a stream already in flight — the stream has to
|
||||||
|
* be re-opened at the new quality and resumed at the current position. That is
|
||||||
|
* the same reload the transcoded-seek and audio-track paths use, and the same
|
||||||
|
* two-sided split: HTML5 gets the URL back and reloads its own element, while a
|
||||||
|
* native backend is reloaded here.
|
||||||
|
*
|
||||||
|
* The change applies to this playback *and* to everything started afterwards
|
||||||
|
* (it sets the process-wide ceiling), but it is deliberately **not** persisted:
|
||||||
|
* the in-player picker is a "this film, this connection" control, and the
|
||||||
|
* durable default belongs to Settings. `player_set_video_settings` is the one
|
||||||
|
* that writes to the database.
|
||||||
|
*
|
||||||
|
* TRACES: UR-074 | DR-162
|
||||||
|
*/
|
||||||
|
async playerSetStreamQuality(repositoryHandle: string, quality: StreamingQuality, useHtml5: boolean, currentPosition: number | null, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<StreamQualityResponse> {
|
||||||
|
return await TAURI_INVOKE("player_set_stream_quality", { repositoryHandle, quality, useHtml5, currentPosition, mediaSourceId, audioStreamIndex });
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Set sleep timer mode
|
* Set sleep timer mode
|
||||||
*/
|
*/
|
||||||
@@ -238,11 +280,34 @@ async playerPlayNextEpisode(item: PlayItemRequest) : Promise<PlayerStatus> {
|
|||||||
* - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
|
* - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
|
||||||
* - Android JNI callback also triggers this logic directly
|
* - Android JNI callback also triggers this logic directly
|
||||||
*
|
*
|
||||||
* TRACES: UR-023, UR-026, UR-040 | DR-047, DR-052
|
* TRACES: UR-023, UR-026, UR-040 | DR-047, DR-052, DR-129
|
||||||
*/
|
*/
|
||||||
async playerOnPlaybackEnded(itemId: string | null, repositoryHandle: string | null) : Promise<null> {
|
async playerOnPlaybackEnded(itemId: string | null, repositoryHandle: string | null) : Promise<null> {
|
||||||
return await TAURI_INVOKE("player_on_playback_ended", { itemId, repositoryHandle });
|
return await TAURI_INVOKE("player_on_playback_ended", { itemId, repositoryHandle });
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Try to recover playback after a **recoverable** player error, reporting
|
||||||
|
* whether it was handled.
|
||||||
|
*
|
||||||
|
* The frontend's error handler stops the player, which is right for a real
|
||||||
|
* failure and wrong for a network blip — it turned every hiccup into "playback
|
||||||
|
* died". This is the echo path for backends that cannot decide in-process:
|
||||||
|
* MpvBackend is constructed before `PlayerController` exists ([`lib.rs`]), so
|
||||||
|
* its event thread has no controller to ask. It emits the error, the frontend
|
||||||
|
* echoes it here, and the decision stays in Rust — the same shape as
|
||||||
|
* `PlaybackEnded` → `player_on_playback_ended`.
|
||||||
|
*
|
||||||
|
* Returns `true` when the stream was re-opened and the caller must NOT stop the
|
||||||
|
* player; `false` when the error is real and should be surfaced as before.
|
||||||
|
* Android decides inside its JNI callback and only emits errors it has already
|
||||||
|
* declined to recover, so this reports `false` for those without a second
|
||||||
|
* opinion — the shared attempt budget is spent by then either way.
|
||||||
|
*
|
||||||
|
* TRACES: UR-004, UR-040 | DR-130 | UT-117
|
||||||
|
*/
|
||||||
|
async playerRecoverStream() : Promise<boolean> {
|
||||||
|
return await TAURI_INVOKE("player_recover_stream");
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Report an HTML5 <video> state change (playing/paused/loading/stopped/idle).
|
* Report an HTML5 <video> state change (playing/paused/loading/stopped/idle).
|
||||||
*/
|
*/
|
||||||
@@ -262,6 +327,23 @@ async playerReportPosition(position: number, duration: number) : Promise<null> {
|
|||||||
async playerReportMediaLoaded(duration: number) : Promise<null> {
|
async playerReportMediaLoaded(duration: number) : Promise<null> {
|
||||||
return await TAURI_INVOKE("player_report_media_loaded", { duration });
|
return await TAURI_INVOKE("player_report_media_loaded", { duration });
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* The on-disk path for a downloaded item, for playback surfaces that resolve
|
||||||
|
* their own source rather than going through the queue.
|
||||||
|
*
|
||||||
|
* The video player is the reason this exists: audio has preferred local files
|
||||||
|
* since queue construction, but video asks the repository for a stream URL and
|
||||||
|
* never consults `downloads`, so a downloaded film was still streamed — costing
|
||||||
|
* bandwidth that had already been spent and failing outright when offline.
|
||||||
|
*
|
||||||
|
* Returns `None` when nothing is downloaded *or* the file is missing, so the
|
||||||
|
* caller falls back to streaming.
|
||||||
|
*
|
||||||
|
* TRACES: UR-071 | DR-123 | UT-116
|
||||||
|
*/
|
||||||
|
async playerLocalMediaPath(itemId: string) : Promise<string | null> {
|
||||||
|
return await TAURI_INVOKE("player_local_media_path", { itemId });
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Preload upcoming tracks from the queue
|
* Preload upcoming tracks from the queue
|
||||||
* This queues background downloads for the next N tracks that aren't already downloaded
|
* This queues background downloads for the next N tracks that aren't already downloaded
|
||||||
@@ -698,6 +780,33 @@ async storageUpdatePlaybackContext(userId: string, itemId: string, positionMs: n
|
|||||||
async storageMarkPlayed(userId: string, itemId: string) : Promise<null> {
|
async storageMarkPlayed(userId: string, itemId: string) : Promise<null> {
|
||||||
return await TAURI_INVOKE("storage_mark_played", { userId, itemId });
|
return await TAURI_INVOKE("storage_mark_played", { userId, itemId });
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Set the watched flag locally for an item **and everything inside it**.
|
||||||
|
*
|
||||||
|
* This backs the watched toggle, and is deliberately separate from
|
||||||
|
* [`storage_mark_played`] — which reports a single track/episode finishing and
|
||||||
|
* increments `play_count` — because the toggle has two directions and applies
|
||||||
|
* to containers.
|
||||||
|
*
|
||||||
|
* The recursion is what makes the toggle honest offline. Jellyfin applies
|
||||||
|
* `POST`/`DELETE /PlayedItems/{id}` recursively over a season or series, so
|
||||||
|
* online the server fixes up the children on the next read; with no server to
|
||||||
|
* ask, marking a season watched would otherwise tick the season and leave every
|
||||||
|
* episode inside it unwatched. Targets are drawn from `items` by the same link
|
||||||
|
* columns the rest of the offline layer uses, so an id that is not cached
|
||||||
|
* selects nothing and the statement is a no-op rather than a foreign-key error.
|
||||||
|
*
|
||||||
|
* Un-marking clears the resume position too, matching the server, so an item
|
||||||
|
* un-marked offline does not come back offering to resume from a position it is
|
||||||
|
* no longer meant to have.
|
||||||
|
*
|
||||||
|
* `pending_sync = 1` hands the rows to the sync drain.
|
||||||
|
*
|
||||||
|
* TRACES: UR-073 | DR-158
|
||||||
|
*/
|
||||||
|
async storageSetWatched(userId: string, itemId: string, watched: boolean) : Promise<null> {
|
||||||
|
return await TAURI_INVOKE("storage_set_watched", { userId, itemId, watched });
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Get playback progress for an item
|
* Get playback progress for an item
|
||||||
*/
|
*/
|
||||||
@@ -761,13 +870,32 @@ async getDownloads(userId: string, statusFilter: string[] | null) : Promise<Down
|
|||||||
return await TAURI_INVOKE("get_downloads", { userId, statusFilter });
|
return await TAURI_INVOKE("get_downloads", { userId, statusFilter });
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Pause a download
|
* Pause a download.
|
||||||
|
*
|
||||||
|
* Writing `status = 'paused'` is only half of it, and used to be all of it: the
|
||||||
|
* streaming task knew nothing about the row and kept running, then overwrote it
|
||||||
|
* with `completed`/`failed` when it finished. The row flicked to "paused" and
|
||||||
|
* undid itself — the reported "pause does not work". Signalling the worker is
|
||||||
|
* what actually stops the bytes; it leaves the `.part` file in place so
|
||||||
|
* [`resume_download`] can continue from it.
|
||||||
|
*
|
||||||
|
* A queued (not yet started) download has no worker to signal, and the status
|
||||||
|
* write alone is enough — the pump skips anything that is not `pending`.
|
||||||
|
*
|
||||||
|
* TRACES: UR-055 | DR-168
|
||||||
*/
|
*/
|
||||||
async pauseDownload(downloadId: number) : Promise<null> {
|
async pauseDownload(downloadId: number) : Promise<null> {
|
||||||
return await TAURI_INVOKE("pause_download", { downloadId });
|
return await TAURI_INVOKE("pause_download", { downloadId });
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Resume a paused download
|
* Resume a paused download.
|
||||||
|
*
|
||||||
|
* Flipping the row back to `pending` is likewise not enough on its own: the
|
||||||
|
* pump is not a poller, it runs when something calls it, so a resumed download
|
||||||
|
* sat untouched until some unrelated event happened to pump the queue. That is
|
||||||
|
* the other half of "resume does not work".
|
||||||
|
*
|
||||||
|
* TRACES: UR-055 | DR-168
|
||||||
*/
|
*/
|
||||||
async resumeDownload(downloadId: number) : Promise<null> {
|
async resumeDownload(downloadId: number) : Promise<null> {
|
||||||
return await TAURI_INVOKE("resume_download", { downloadId });
|
return await TAURI_INVOKE("resume_download", { downloadId });
|
||||||
@@ -833,6 +961,22 @@ async markDownloadCompleted(downloadId: number, bytesDownloaded: number, filePat
|
|||||||
async markDownloadFailed(downloadId: number, errorMessage: string) : Promise<null> {
|
async markDownloadFailed(downloadId: number, errorMessage: string) : Promise<null> {
|
||||||
return await TAURI_INVOKE("mark_download_failed", { downloadId, errorMessage });
|
return await TAURI_INVOKE("mark_download_failed", { downloadId, errorMessage });
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* A playable URL for a downloaded file on disk.
|
||||||
|
*
|
||||||
|
* Local media is served over a loopback HTTP server rather than handed to the
|
||||||
|
* webview as a `file://`/asset URL, because the asset protocol cannot stream a
|
||||||
|
* large file — it answers a range-less request with the whole thing, which
|
||||||
|
* Chromium abandons. See `media_server` for why real HTTP is used.
|
||||||
|
*
|
||||||
|
* The returned URL carries the server's per-session token, so it is only valid
|
||||||
|
* for this run of the app and must not be persisted.
|
||||||
|
*
|
||||||
|
* TRACES: UR-071 | DR-137
|
||||||
|
*/
|
||||||
|
async mediaLocalUrl(path: string) : Promise<string> {
|
||||||
|
return await TAURI_INVOKE("media_local_url", { path });
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Start downloading a file immediately
|
* Start downloading a file immediately
|
||||||
* This command actually downloads the file using the worker
|
* This command actually downloads the file using the worker
|
||||||
@@ -1090,6 +1234,14 @@ async syncMarkFailed(id: number, error: string) : Promise<null> {
|
|||||||
async syncGetPendingCount(userId: string) : Promise<number> {
|
async syncGetPendingCount(userId: string) : Promise<number> {
|
||||||
return await TAURI_INVOKE("sync_get_pending_count", { userId });
|
return await TAURI_INVOKE("sync_get_pending_count", { userId });
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Push the queue now, on the user's say-so, instead of waiting for a reconnect.
|
||||||
|
*
|
||||||
|
* TRACES: UR-025 | DR-132
|
||||||
|
*/
|
||||||
|
async syncProcessPending() : Promise<DrainReport> {
|
||||||
|
return await TAURI_INVOKE("sync_process_pending");
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Delete completed sync operations older than specified days
|
* Delete completed sync operations older than specified days
|
||||||
*/
|
*/
|
||||||
@@ -1400,6 +1552,15 @@ async repositoryReportPlaybackProgress(handle: string, itemId: string, positionM
|
|||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Report playback stopped
|
* Report playback stopped
|
||||||
|
*
|
||||||
|
* A stop-report that cannot reach the server is queued rather than dropped:
|
||||||
|
* this is the position the resume point is built from, and losing it is
|
||||||
|
* exactly the "it forgot where I was" the sync queue exists to prevent. The
|
||||||
|
* drain (DR-131) pushes it on the next reconnect. Queueing is best-effort —
|
||||||
|
* failing the command because the *queue* write failed would tell the caller
|
||||||
|
* the report was lost when the local position was already saved.
|
||||||
|
*
|
||||||
|
* TRACES: UR-025 | DR-154 | UT-151
|
||||||
*/
|
*/
|
||||||
async repositoryReportPlaybackStopped(handle: string, itemId: string, positionMs: number) : Promise<null> {
|
async repositoryReportPlaybackStopped(handle: string, itemId: string, positionMs: number) : Promise<null> {
|
||||||
return await TAURI_INVOKE("repository_report_playback_stopped", { handle, itemId, positionMs });
|
return await TAURI_INVOKE("repository_report_playback_stopped", { handle, itemId, positionMs });
|
||||||
@@ -1422,6 +1583,20 @@ async repositoryMarkFavorite(handle: string, itemId: string) : Promise<null> {
|
|||||||
async repositoryUnmarkFavorite(handle: string, itemId: string) : Promise<null> {
|
async repositoryUnmarkFavorite(handle: string, itemId: string) : Promise<null> {
|
||||||
return await TAURI_INVOKE("repository_unmark_favorite", { handle, itemId });
|
return await TAURI_INVOKE("repository_unmark_favorite", { handle, itemId });
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Everything the viewer has favourited, across libraries, narrowed by scope.
|
||||||
|
*
|
||||||
|
* Two-phase like `repository_search`: the local answer returns immediately and
|
||||||
|
* a background server pass emits `favorites-changed` when the server's set
|
||||||
|
* differs. Without the second phase a favourite marked in another client shows
|
||||||
|
* up only on the *second* visit to the page, since the cache-first read hands
|
||||||
|
* back local rows and the refresh is invisible to the frontend.
|
||||||
|
*
|
||||||
|
* TRACES: UR-067 | DR-115, DR-120, JA-033 | UT-107
|
||||||
|
*/
|
||||||
|
async repositoryGetFavorites(handle: string, scope: SearchScope, options: GetItemsOptions | null) : Promise<SearchResult> {
|
||||||
|
return await TAURI_INVOKE("repository_get_favorites", { handle, scope, options });
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Get person details
|
* Get person details
|
||||||
*/
|
*/
|
||||||
@@ -1704,7 +1879,15 @@ storageLimit: number;
|
|||||||
/**
|
/**
|
||||||
* Only cache on WiFi
|
* Only cache on WiFi
|
||||||
*/
|
*/
|
||||||
wifiOnly: boolean }
|
wifiOnly: boolean;
|
||||||
|
/**
|
||||||
|
* How long a temporary (`download_source = 'auto'`) download lives before
|
||||||
|
* it is reclaimed, in hours. 0 disables expiry, leaving space pressure as
|
||||||
|
* the only reclaim trigger.
|
||||||
|
*
|
||||||
|
* TRACES: UR-071 | DR-127
|
||||||
|
*/
|
||||||
|
temporaryTtlHours: number }
|
||||||
/**
|
/**
|
||||||
* Cached media item returned to frontend
|
* Cached media item returned to frontend
|
||||||
*/
|
*/
|
||||||
@@ -1729,7 +1912,12 @@ itemsCached: number;
|
|||||||
/**
|
/**
|
||||||
* Libraries that failed to sync (e.g. server hiccup); best-effort.
|
* Libraries that failed to sync (e.g. server hiccup); best-effort.
|
||||||
*/
|
*/
|
||||||
librariesFailed: number }
|
librariesFailed: number;
|
||||||
|
/**
|
||||||
|
* Entries removed because the server no longer has them. Always 0 when any
|
||||||
|
* library failed, since a partial crawl cannot prove an item is gone.
|
||||||
|
*/
|
||||||
|
itemsPruned: number }
|
||||||
export type CatalogSyncStatus = {
|
export type CatalogSyncStatus = {
|
||||||
/**
|
/**
|
||||||
* RFC-3339 timestamp of the last successful sync, if any.
|
* RFC-3339 timestamp of the last successful sync, if any.
|
||||||
@@ -1812,6 +2000,26 @@ export type DownloadVideoRequest = { itemId: string; userId: string; filePath: s
|
|||||||
* Enhanced response with pre-computed stats
|
* Enhanced response with pre-computed stats
|
||||||
*/
|
*/
|
||||||
export type DownloadsResponse = { downloads: DownloadInfo[]; stats: DownloadStats }
|
export type DownloadsResponse = { downloads: DownloadInfo[]; stats: DownloadStats }
|
||||||
|
/**
|
||||||
|
* What a drain did, for logging and for the frontend's "Sync now" button.
|
||||||
|
*/
|
||||||
|
export type DrainReport = {
|
||||||
|
/**
|
||||||
|
* Rows that reached the server and are now `completed`.
|
||||||
|
*/
|
||||||
|
pushed: number;
|
||||||
|
/**
|
||||||
|
* Rows that failed and will be retried on the next reconnect.
|
||||||
|
*/
|
||||||
|
deferred: number;
|
||||||
|
/**
|
||||||
|
* Rows that exhausted `MAX_SYNC_ATTEMPTS` and were given up on.
|
||||||
|
*/
|
||||||
|
abandoned: number;
|
||||||
|
/**
|
||||||
|
* Rows still waiting afterwards (what the badge counts).
|
||||||
|
*/
|
||||||
|
remaining: number }
|
||||||
/**
|
/**
|
||||||
* Built-in equalizer presets. A preset *is* a gain curve defined by the band
|
* Built-in equalizer presets. A preset *is* a gain curve defined by the band
|
||||||
* layout above (a domain concept), not a mere label — the curve numbers live
|
* layout above (a domain concept), not a mere label — the curve numbers live
|
||||||
@@ -1837,7 +2045,14 @@ export type GetImageRequest = { itemId: string; imageType: string; maxWidth?: nu
|
|||||||
/**
|
/**
|
||||||
* Options for querying items
|
* Options for querying items
|
||||||
*/
|
*/
|
||||||
export type GetItemsOptions = { startIndex?: number | null; limit?: number | null; sortBy?: string | null; sortOrder?: string | null; includeItemTypes?: string[] | null; recursive?: boolean | null; fields?: string[] | null; genres?: string[] | null }
|
export type GetItemsOptions = { startIndex?: number | null; limit?: number | null; sortBy?: string | null; sortOrder?: string | null; includeItemTypes?: string[] | null; recursive?: boolean | null; fields?: string[] | null; genres?: string[] | null;
|
||||||
|
/**
|
||||||
|
* Restrict the listing to favourited items. Backs the per-library
|
||||||
|
* favourites toggle; composes with every other filter here.
|
||||||
|
*
|
||||||
|
* TRACES: UR-067 | DR-116 | UT-104
|
||||||
|
*/
|
||||||
|
favoritesOnly?: boolean | null }
|
||||||
/**
|
/**
|
||||||
* Image options
|
* Image options
|
||||||
*/
|
*/
|
||||||
@@ -2113,7 +2328,29 @@ itemType?: string | null;
|
|||||||
* Series ID for TV episodes. Needed alongside `item_type` so the backend can
|
* Series ID for TV episodes. Needed alongside `item_type` so the backend can
|
||||||
* look up the next episode when a background-audio track ends.
|
* look up the next episode when a background-audio track ends.
|
||||||
*/
|
*/
|
||||||
seriesId?: string | null }
|
seriesId?: string | null;
|
||||||
|
/**
|
||||||
|
* Subtitle tracks to sideload, with URLs the frontend has already resolved.
|
||||||
|
*
|
||||||
|
* Only the native backends use these: on Android they become the
|
||||||
|
* `MediaItem.SubtitleConfiguration`s ExoPlayer renders. The HTML5 path
|
||||||
|
* builds its own `<track>` children instead and ignores this list.
|
||||||
|
*
|
||||||
|
* **Order is the contract.** `player_set_subtitle_track(n)` reaches
|
||||||
|
* `JellyTauPlayer.setSubtitleTrack(n)`, which indexes into ExoPlayer's
|
||||||
|
* *text track groups* — i.e. the position of the sideloaded configuration,
|
||||||
|
* not the Jellyfin stream index (which is kept on each entry for the UI's
|
||||||
|
* benefit). So `n` must be a position in this very array, and the array
|
||||||
|
* must not be reordered or filtered between building it and sending it.
|
||||||
|
* `nativeSubtitleArrayIndex()` on the frontend computes `n` from the same
|
||||||
|
* list that is sent here, for exactly this reason.
|
||||||
|
*
|
||||||
|
* Defaulted so the background-audio handoff and the autoplay/next-episode
|
||||||
|
* callers, which have no subtitles to offer, need not send the field.
|
||||||
|
*
|
||||||
|
* TRACES: UR-020 | IR-016, JA-008 | UT-145
|
||||||
|
*/
|
||||||
|
subtitles?: SubtitleTrack[] }
|
||||||
/**
|
/**
|
||||||
* Queue context for remote transfer - what type of queue is this?
|
* Queue context for remote transfer - what type of queue is this?
|
||||||
*/
|
*/
|
||||||
@@ -2153,6 +2390,30 @@ export type PlayTracksRequest = { trackIds: string[]; startIndex: number; shuffl
|
|||||||
* over playback from a remote session so we don't restart from 0.
|
* over playback from a remote session so we don't restart from 0.
|
||||||
*/
|
*/
|
||||||
startPosition?: number | null }
|
startPosition?: number | null }
|
||||||
|
/**
|
||||||
|
* What playback facilities this platform's backend actually provides.
|
||||||
|
*
|
||||||
|
* The frontend is presentation-only and must not re-derive backend facts from
|
||||||
|
* `navigator.userAgent` — that sniffing was a second copy of the same platform
|
||||||
|
* decision Rust already makes with `cfg!`, and it drifted. These flags are the
|
||||||
|
* single source of truth; the frontend consumes them.
|
||||||
|
*
|
||||||
|
* TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
|
||||||
|
*/
|
||||||
|
export type PlaybackCapabilities = {
|
||||||
|
/**
|
||||||
|
* True when audio is rendered by a webview `<audio>` element rather than a
|
||||||
|
* native backend. Native audio exists on Linux (mpv) and Android
|
||||||
|
* (ExoPlayer); everything else (Windows, future desktops) uses the webview.
|
||||||
|
*/
|
||||||
|
usesWebviewAudio: boolean;
|
||||||
|
/**
|
||||||
|
* True when video can be rendered by a native surface composited *behind*
|
||||||
|
* a transparent webview. Android only: ExoPlayer draws into a SurfaceView
|
||||||
|
* beneath the WebView. Linux cannot do this (WebKitGTK/Wayland
|
||||||
|
* compositing), so it stays on the HTML5 element.
|
||||||
|
*/
|
||||||
|
supportsNativeVideo: boolean }
|
||||||
/**
|
/**
|
||||||
* Playback information
|
* Playback information
|
||||||
*/
|
*/
|
||||||
@@ -2649,8 +2910,63 @@ export type StreamKind = "audio" | "video" | "subtitle" |
|
|||||||
* Any stream kind we do not model explicitly (e.g. embedded image, data).
|
* Any stream kind we do not model explicitly (e.g. embedded image, data).
|
||||||
*/
|
*/
|
||||||
"other"
|
"other"
|
||||||
|
/**
|
||||||
|
* Response for a mid-playback streaming-quality change.
|
||||||
|
*
|
||||||
|
* Mirrors [`AudioTrackSwitchResponse`]: the backend decides whether the caller
|
||||||
|
* has to reload anything, so no strategy branch lives in the UI.
|
||||||
|
*
|
||||||
|
* TRACES: UR-074 | DR-162
|
||||||
|
*/
|
||||||
|
export type StreamQualityResponse =
|
||||||
|
/**
|
||||||
|
* The native backend was reloaded here; nothing left for the frontend.
|
||||||
|
*/
|
||||||
|
{ strategy: "native"; position: number } |
|
||||||
|
/**
|
||||||
|
* HTML5 must reload its element with this URL.
|
||||||
|
*/
|
||||||
|
{ strategy: "reloadStream"; new_url: string; position: number }
|
||||||
|
/**
|
||||||
|
* A ceiling on how much bandwidth a *video* stream may consume.
|
||||||
|
*
|
||||||
|
* A quality step is a bundle of concrete transcode parameters — total stream
|
||||||
|
* ceiling, the audio share of it, and the resolution that ceiling can carry —
|
||||||
|
* not just a label. Those numbers are Jellyfin encoding domain vocabulary, so
|
||||||
|
* they live here and the frontend only ever names a variant; the labels the
|
||||||
|
* picker shows are served over IPC by `player_get_streaming_qualities`.
|
||||||
|
*
|
||||||
|
* The ladder is deliberately expressed in bandwidth rather than resolution: it
|
||||||
|
* exists to fit a connection, and the resolution cap is chosen *from* the
|
||||||
|
* bitrate so the encoder does not spend a small budget on pixels it cannot
|
||||||
|
* afford. See docs/specs/streaming-bitrate-cap.md.
|
||||||
|
*
|
||||||
|
* TRACES: UR-074 | DR-162
|
||||||
|
*/
|
||||||
|
export type StreamingQuality =
|
||||||
|
/**
|
||||||
|
* No client-imposed cap — the server may direct-play the source as-is.
|
||||||
|
*/
|
||||||
|
"original" | "mbps20" | "mbps10" | "mbps8" | "mbps4" | "mbps2" | "mbps1" | "kbps720"
|
||||||
/**
|
/**
|
||||||
* Represents a subtitle track
|
* Represents a subtitle track
|
||||||
|
*
|
||||||
|
* 🔴 **Do not add `#[serde(rename_all = "camelCase")]` here.** This is the one
|
||||||
|
* struct in the player that deliberately keeps snake_case on the wire, because
|
||||||
|
* the *same* serialization feeds two consumers that both spell `mime_type`:
|
||||||
|
*
|
||||||
|
* * the JNI boundary — `player/android/mod.rs` serializes `MediaItem::subtitles`
|
||||||
|
* with `serde_json` and hands the string to `JellyTauPlayer.loadWithMetadata`,
|
||||||
|
* whose parser reads `url`, `language`, `label` and `optString("mime_type")`;
|
||||||
|
* * the IPC boundary — `PlayItemRequest::subtitles` deserializes this same type
|
||||||
|
* from the frontend, and the generated binding (`SubtitleTrack` in
|
||||||
|
* `bindings.ts`) therefore also declares `mime_type`.
|
||||||
|
*
|
||||||
|
* Renaming would not break the build and would not fail the IPC: Kotlin's
|
||||||
|
* `optString` would just fall back to its default MIME type for every track, so
|
||||||
|
* the failure would be silent. UT-146 asserts the serialized keys.
|
||||||
|
*
|
||||||
|
* TRACES: UR-020 | IR-016, JA-008 | UT-146
|
||||||
*/
|
*/
|
||||||
export type SubtitleTrack = {
|
export type SubtitleTrack = {
|
||||||
/**
|
/**
|
||||||
@@ -2670,13 +2986,22 @@ language: string | null;
|
|||||||
*/
|
*/
|
||||||
label: string | null;
|
label: string | null;
|
||||||
/**
|
/**
|
||||||
* MIME type (e.g., "text/vtt", "application/x-subrip")
|
* MIME type (e.g., "text/vtt", "application/x-subrip").
|
||||||
|
* Snake_case on purpose — see the note on the struct.
|
||||||
*/
|
*/
|
||||||
mime_type: string }
|
mime_type: string }
|
||||||
/**
|
/**
|
||||||
* Sync queue item returned to frontend
|
* Sync queue item returned to frontend
|
||||||
*/
|
*/
|
||||||
export type SyncQueueItem = { id: number; userId: string; operation: string; itemId: string | null; payload: string | null; status: string; retryCount: number; createdAt: string | null; errorMessage: string | null }
|
export type SyncQueueItem = { id: number; userId: string; operation: string; itemId: string | null; payload: string | null; status: string; retryCount: number; createdAt: string | null; errorMessage: string | null;
|
||||||
|
/**
|
||||||
|
* Cached title of the item the operation is about, when the catalog knows
|
||||||
|
* it. Resolved here rather than by a per-row frontend fetch — the queue
|
||||||
|
* list is otherwise a wall of opaque ids.
|
||||||
|
*
|
||||||
|
* TRACES: UR-025 | DR-132
|
||||||
|
*/
|
||||||
|
itemName: string | null }
|
||||||
/**
|
/**
|
||||||
* Statistics about the thumbnail cache
|
* Statistics about the thumbnail cache
|
||||||
*/
|
*/
|
||||||
@@ -2744,7 +3069,16 @@ autoPlayCountdownSeconds: number;
|
|||||||
/**
|
/**
|
||||||
* Maximum number of episodes to auto-play consecutively (0 = unlimited)
|
* Maximum number of episodes to auto-play consecutively (0 = unlimited)
|
||||||
*/
|
*/
|
||||||
autoPlayMaxEpisodes?: number }
|
autoPlayMaxEpisodes?: number;
|
||||||
|
/**
|
||||||
|
* Bandwidth ceiling applied to every video stream.
|
||||||
|
*
|
||||||
|
* `#[serde(default)]` so settings JSON persisted before this field existed
|
||||||
|
* loads as the previous behaviour (uncapped).
|
||||||
|
*
|
||||||
|
* TRACES: UR-074 | DR-162
|
||||||
|
*/
|
||||||
|
streamingQuality?: StreamingQuality }
|
||||||
/**
|
/**
|
||||||
* Volume normalization levels matching Spotify's presets
|
* Volume normalization levels matching Spotify's presets
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// 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 { JRayActor, DownloadDiskUsage } from "./bindings";
|
import type { JRayActor, DownloadDiskUsage, SearchScope } from "./bindings";
|
||||||
import type { QualityPreset } from "./quality-presets";
|
import type { QualityPreset } from "./quality-presets";
|
||||||
import type {
|
import type {
|
||||||
Library,
|
Library,
|
||||||
@@ -311,6 +311,20 @@ export class RepositoryClient {
|
|||||||
await commands.repositoryUnmarkFavorite(this.ensureHandle(), itemId);
|
await commands.repositoryUnmarkFavorite(this.ensureHandle(), itemId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything favourited, across libraries, narrowed by an opaque scope the
|
||||||
|
* backend expands into item types. The frontend never names a Jellyfin type
|
||||||
|
* here — see docs/specs/scoped-search-boundary.md.
|
||||||
|
*
|
||||||
|
* Resolves with the local answer; a later `favorites-changed` event reports
|
||||||
|
* ids the server disagreed with.
|
||||||
|
*
|
||||||
|
* TRACES: UR-067 | DR-115
|
||||||
|
*/
|
||||||
|
async getFavorites(scope: SearchScope, options?: GetItemsOptions): Promise<SearchResult> {
|
||||||
|
return commands.repositoryGetFavorites(this.ensureHandle(), scope, options ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Person Methods (via Rust) =====
|
// ===== Person Methods (via Rust) =====
|
||||||
|
|
||||||
async getPerson(personId: string): Promise<MediaItem> {
|
async getPerson(personId: string): Promise<MediaItem> {
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
<!--
|
<!--
|
||||||
Shared application header. Lifted out of the library layout so the account
|
Shared application header. Lifted out of the library layout so the account
|
||||||
menu (and desktop nav) are available on every authenticated, non-immersive
|
menu (and desktop nav) are available on every authenticated, non-immersive
|
||||||
screen, not only under /library. Routes that need in-header search (the
|
screen, not only under /library.
|
||||||
library layout) pass it in via the `search` snippet; other routes omit it.
|
|
||||||
|
The search box is owned here rather than passed in by a layout, so the same
|
||||||
|
bar renders on the library routes and on /search — searching from the header
|
||||||
|
no longer hands you to a screen with a different input.
|
||||||
|
|
||||||
TRACES: UR-054 | DR-076
|
TRACES: UR-054 | DR-076
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import AccountMenu from "$lib/components/account/AccountMenu.svelte";
|
import AccountMenu from "$lib/components/account/AccountMenu.svelte";
|
||||||
|
import HeaderSearch from "$lib/components/search/HeaderSearch.svelte";
|
||||||
let { search }: { search?: Snippet } = $props();
|
import { showHeaderSearch } from "$lib/utils/layoutShell";
|
||||||
|
|
||||||
const pathname = $derived($page.url.pathname);
|
const pathname = $derived($page.url.pathname);
|
||||||
|
const withSearch = $derived(showHeaderSearch({ pathname }));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="sticky top-0 z-50 bg-[var(--color-background)]/95 backdrop-blur border-b border-gray-800 flex-shrink-0">
|
<header class="sticky top-0 z-50 bg-[var(--color-background)]/95 backdrop-blur border-b border-gray-800 flex-shrink-0">
|
||||||
@@ -51,10 +54,10 @@
|
|||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Optional in-header search (library layout supplies it). -->
|
<!-- In-header search: the single md+ search input (library + /search). -->
|
||||||
{#if search}
|
{#if withSearch}
|
||||||
<div class="flex-1 max-w-md hidden md:block space-y-2">
|
<div class="flex-1 max-w-md hidden md:block">
|
||||||
{@render search()}
|
<HeaderSearch />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,16 @@
|
|||||||
"last row hidden behind the nav" bug. There is nothing to measure or reserve:
|
"last row hidden behind the nav" bug. There is nothing to measure or reserve:
|
||||||
the browser's flex layout does it exactly, every frame.
|
the browser's flex layout does it exactly, every frame.
|
||||||
|
|
||||||
The Android system gesture bar is cleared via `env(safe-area-inset-bottom)`.
|
The Android navigation/gesture bar is cleared via `--safe-bottom` (see
|
||||||
|
app.css). The padding sits INSIDE this element's `bg-surface` box on purpose,
|
||||||
|
so the surface colour extends behind the gesture bar instead of leaving a
|
||||||
|
strip of page background under the nav.
|
||||||
|
|
||||||
TRACES: UR-005 | DR-009
|
Never pad from a bare CSS `env()` safe-area value here: Android WebView does
|
||||||
|
not report the system bars that way, so it is always 0 and the nav ends up
|
||||||
|
under the navigation bar (UR-066).
|
||||||
|
|
||||||
|
TRACES: UR-005, UR-066 | DR-009, DR-112
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
@@ -41,7 +48,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- flex-shrink-0 so it keeps its natural height; the scroller sibling flexes. -->
|
<!-- flex-shrink-0 so it keeps its natural height; the scroller sibling flexes. -->
|
||||||
<div class="flex-shrink-0 pb-[env(safe-area-inset-bottom)] bg-[var(--color-surface)]">
|
<div class="flex-shrink-0 pb-[var(--safe-bottom)] bg-[var(--color-surface)]">
|
||||||
{#if showMiniPlayer}
|
{#if showMiniPlayer}
|
||||||
<MiniPlayer
|
<MiniPlayer
|
||||||
media={$currentMedia}
|
media={$currentMedia}
|
||||||
|
|||||||
@@ -1,27 +1,60 @@
|
|||||||
|
<!-- TRACES: UR-017, UR-068 | DR-021, DR-119 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { toggleFavorite } from "$lib/services/favorites";
|
import { toggleFavorite } from "$lib/services/favorites";
|
||||||
import { haptics } from "$lib/utils/haptics";
|
import { haptics } from "$lib/utils/haptics";
|
||||||
import { toast } from "$lib/stores/toast";
|
import { toast } from "$lib/stores/toast";
|
||||||
|
import { favoriteOverrides } from "$lib/stores/favorites";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
isFavorite?: boolean;
|
isFavorite?: boolean;
|
||||||
size?: "sm" | "md" | "lg";
|
size?: "sm" | "md" | "lg";
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/**
|
||||||
|
* "button" (default) is the standalone control used in header/hero rows;
|
||||||
|
* "overlay" is the artwork corner variant used on cards, which needs its
|
||||||
|
* own scrim to stay legible over any poster.
|
||||||
|
*/
|
||||||
|
variant?: "button" | "overlay";
|
||||||
|
/** Stop the click reaching a parent card/row that would navigate or play. */
|
||||||
|
stopPropagation?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { itemId, isFavorite = $bindable(false), size = "md", className = "" }: Props = $props();
|
let {
|
||||||
|
itemId,
|
||||||
|
isFavorite = $bindable(false),
|
||||||
|
size = "md",
|
||||||
|
className = "",
|
||||||
|
variant = "button",
|
||||||
|
stopPropagation = false,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let isAnimating = $state(false);
|
let isAnimating = $state(false);
|
||||||
|
|
||||||
|
// A toggle from any other surface (or the backend's `favorites-changed`
|
||||||
|
// refresh) wins over the prop we were mounted with — otherwise a heart tapped
|
||||||
|
// on a card would still read empty on the detail page behind it.
|
||||||
|
$effect(() => {
|
||||||
|
const override = $favoriteOverrides.get(itemId);
|
||||||
|
if (override !== undefined && override !== isFavorite) {
|
||||||
|
isFavorite = override;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
sm: "w-4 h-4",
|
sm: "w-4 h-4",
|
||||||
md: "w-5 h-5",
|
md: "w-5 h-5",
|
||||||
lg: "w-6 h-6",
|
lg: "w-6 h-6",
|
||||||
};
|
};
|
||||||
|
|
||||||
async function handleToggle() {
|
async function handleToggle(event: MouseEvent) {
|
||||||
|
// On a card the heart sits inside a clickable tile; without this, hearting
|
||||||
|
// an item would also open (or play) it.
|
||||||
|
if (stopPropagation) {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
if (isLoading) return;
|
if (isLoading) return;
|
||||||
|
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
@@ -55,8 +88,15 @@
|
|||||||
|
|
||||||
// Compute button classes
|
// Compute button classes
|
||||||
const buttonClass = $derived.by(() => {
|
const buttonClass = $derived.by(() => {
|
||||||
const baseClasses = "p-2 rounded-full transition-all";
|
const baseClasses =
|
||||||
const colorClasses = isFavorite ? "text-red-500 hover:text-red-400" : "text-gray-400 hover:text-white";
|
variant === "overlay"
|
||||||
|
? "p-1.5 rounded-full transition-all bg-black/50 backdrop-blur-sm hover:bg-black/70"
|
||||||
|
: "p-2 rounded-full transition-all";
|
||||||
|
const colorClasses = isFavorite
|
||||||
|
? "text-red-500 hover:text-red-400"
|
||||||
|
: variant === "overlay"
|
||||||
|
? "text-white/80 hover:text-white"
|
||||||
|
: "text-gray-400 hover:text-white";
|
||||||
const loadingClasses = isLoading ? "opacity-50 cursor-wait" : "";
|
const loadingClasses = isLoading ? "opacity-50 cursor-wait" : "";
|
||||||
return `${baseClasses} ${colorClasses} ${loadingClasses} ${className}`.trim();
|
return `${baseClasses} ${colorClasses} ${loadingClasses} ${className}`.trim();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,9 +3,16 @@
|
|||||||
value?: string;
|
value?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
onSearch?: (query: string) => void;
|
onSearch?: (query: string) => void;
|
||||||
|
/** Exposed so a parent can focus/position the caret (see HeaderSearch). */
|
||||||
|
inputEl?: HTMLInputElement | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { value = $bindable(""), placeholder = "Search...", onSearch }: Props = $props();
|
let {
|
||||||
|
value = $bindable(""),
|
||||||
|
placeholder = "Search...",
|
||||||
|
onSearch,
|
||||||
|
inputEl = $bindable(null),
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
@@ -39,6 +46,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
|
bind:this={inputEl}
|
||||||
type="text"
|
type="text"
|
||||||
{value}
|
{value}
|
||||||
{placeholder}
|
{placeholder}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
import LibraryGrid from "./LibraryGrid.svelte";
|
import LibraryGrid from "./LibraryGrid.svelte";
|
||||||
import TrackList from "./TrackList.svelte";
|
import TrackList from "./TrackList.svelte";
|
||||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
|
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
||||||
|
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
artist: MediaItem;
|
artist: MediaItem;
|
||||||
@@ -126,7 +128,15 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Artist Name -->
|
<!-- Artist Name -->
|
||||||
<h1 class="text-4xl font-bold text-white mb-4">{artist.name}</h1>
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<h1 class="text-4xl font-bold text-white">{artist.name}</h1>
|
||||||
|
<!-- TRACES: UR-068 | DR-119 -->
|
||||||
|
<FavoriteButton
|
||||||
|
itemId={artist.id}
|
||||||
|
isFavorite={resolveIsFavorite(artist, $favoriteOverrides)}
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Bio -->
|
<!-- Bio -->
|
||||||
{#if artist.overview}
|
{#if artist.overview}
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
<!-- TRACES: UR-048 | DR-061, DR-062 -->
|
<!--
|
||||||
|
The one and only episode surface (ux-flows §5B.1) — so it carries everything
|
||||||
|
an episode can do, not just Play. A bare Episode page used to exist alongside
|
||||||
|
it with a *different* set of affordances (download, breadcrumbs, cast), which
|
||||||
|
meant opening an episode from Continue Watching silently lost them.
|
||||||
|
|
||||||
|
TRACES: UR-048, UR-058 | DR-061, DR-062, DR-142
|
||||||
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
|
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
||||||
|
import VideoDownloadButton from "./VideoDownloadButton.svelte";
|
||||||
|
import WatchedToggleButton from "./WatchedToggleButton.svelte";
|
||||||
|
import CastSection from "./CastSection.svelte";
|
||||||
|
import GenreTags from "./GenreTags.svelte";
|
||||||
|
import RelatedItemsSection from "./RelatedItemsSection.svelte";
|
||||||
|
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
|
||||||
|
import { seasonAnchorId } from "./seriesNavigation";
|
||||||
import {
|
import {
|
||||||
isCurrentEpisode as isSameEpisode,
|
isCurrentEpisode as isSameEpisode,
|
||||||
adjacentEpisodes as computeAdjacent,
|
adjacentEpisodes as computeAdjacent,
|
||||||
@@ -12,12 +27,17 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
episode: MediaItem;
|
episode: MediaItem;
|
||||||
series: MediaItem;
|
/**
|
||||||
allEpisodes: MediaItem[];
|
* The parent series. `null` only for an episode that carries no `seriesId`
|
||||||
|
* (a deep link into a stale cache) — the view still renders, minus the
|
||||||
|
* affordances that need series context.
|
||||||
|
*/
|
||||||
|
series?: MediaItem | null;
|
||||||
|
allEpisodes?: MediaItem[];
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { episode, series, allEpisodes, onBack }: Props = $props();
|
let { episode, series = null, allEpisodes = [], onBack }: Props = $props();
|
||||||
|
|
||||||
// Pure logic lives in ./episodeStrip.ts (unit-tested). Wrap for local use.
|
// Pure logic lives in ./episodeStrip.ts (unit-tested). Wrap for local use.
|
||||||
function isCurrentEpisode(ep: MediaItem): boolean {
|
function isCurrentEpisode(ep: MediaItem): boolean {
|
||||||
@@ -26,6 +46,10 @@
|
|||||||
|
|
||||||
const adjacentEpisodes = $derived(() => computeAdjacent(episode, allEpisodes));
|
const adjacentEpisodes = $derived(() => computeAdjacent(episode, allEpisodes));
|
||||||
|
|
||||||
|
// A strip of exactly one card is the current episode talking to itself — the
|
||||||
|
// spec wants the *next* episodes, so with no siblings there is nothing to show.
|
||||||
|
const hasEpisodeStrip = $derived(adjacentEpisodes().length > 1);
|
||||||
|
|
||||||
// Compute best backdrop source (no fetch, pure derivation)
|
// Compute best backdrop source (no fetch, pure derivation)
|
||||||
const backdropSource = $derived.by(() => {
|
const backdropSource = $derived.by(() => {
|
||||||
if (episode.backdropImageTags?.[0]) {
|
if (episode.backdropImageTags?.[0]) {
|
||||||
@@ -34,12 +58,28 @@
|
|||||||
if (episode.imageId) {
|
if (episode.imageId) {
|
||||||
return { itemId: episode.id, imageType: "Primary" as const, tag: episode.imageId };
|
return { itemId: episode.id, imageType: "Primary" as const, tag: episode.imageId };
|
||||||
}
|
}
|
||||||
if (series.backdropImageTags?.[0]) {
|
if (series?.backdropImageTags?.[0]) {
|
||||||
return { itemId: series.id, imageType: "Backdrop" as const, tag: series.backdropImageTags[0] };
|
return { itemId: series.id, imageType: "Backdrop" as const, tag: series.backdropImageTags[0] };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cast and genres are the episode's own when the server sent them, else the
|
||||||
|
// series' — a list-level episode fetch often carries neither, and an empty
|
||||||
|
// Cast row on an episode of a show with a known cast reads as broken.
|
||||||
|
const people = $derived(episode.people?.length ? episode.people : series?.people ?? []);
|
||||||
|
const genres = $derived(episode.genres?.length ? episode.genres : series?.genres ?? []);
|
||||||
|
|
||||||
|
// "More Like This" on an episode means similar *shows* (UR-048), so it keys
|
||||||
|
// off the series rather than the episode.
|
||||||
|
const seriesName = $derived(series?.name ?? episode.seriesName ?? null);
|
||||||
|
const seriesHref = $derived(series ? `/library/${series.id}` : null);
|
||||||
|
const seasonHref = $derived(
|
||||||
|
series && episode.parentIndexNumber != null
|
||||||
|
? `/library/${series.id}#${seasonAnchorId(episode.parentIndexNumber)}`
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
function formatDuration(ms?: number | null): string {
|
function formatDuration(ms?: number | null): string {
|
||||||
if (!ms) return "";
|
if (!ms) return "";
|
||||||
const seconds = Math.floor(ms / 1000);
|
const seconds = Math.floor(ms / 1000);
|
||||||
@@ -64,6 +104,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleEpisodeClick(ep: MediaItem) {
|
function handleEpisodeClick(ep: MediaItem) {
|
||||||
|
if (!series) return;
|
||||||
goto(`/library/${series.id}?episode=${ep.id}`);
|
goto(`/library/${series.id}?episode=${ep.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,8 +151,19 @@
|
|||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="relative h-full flex flex-col justify-end p-8 max-w-3xl">
|
<div class="relative h-full flex flex-col justify-end p-8 max-w-3xl">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<!-- Series name -->
|
<!-- Series name — a link, so the episode page is a navigable hub
|
||||||
<p class="text-gray-300 text-lg">{series.name}</p>
|
rather than a dead end (UR-058). -->
|
||||||
|
{#if seriesName}
|
||||||
|
<p class="text-lg">
|
||||||
|
{#if seriesHref}
|
||||||
|
<a href={seriesHref} class="text-gray-300 hover:text-white hover:underline transition-colors">
|
||||||
|
{seriesName}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<span class="text-gray-300">{seriesName}</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Episode title -->
|
<!-- Episode title -->
|
||||||
<h1 class="text-4xl font-bold text-white drop-shadow-lg">
|
<h1 class="text-4xl font-bold text-white drop-shadow-lg">
|
||||||
@@ -120,9 +172,20 @@
|
|||||||
|
|
||||||
<!-- Metadata -->
|
<!-- Metadata -->
|
||||||
<div class="flex items-center gap-4 text-sm text-gray-200">
|
<div class="flex items-center gap-4 text-sm text-gray-200">
|
||||||
<span class="px-2 py-1 bg-[var(--color-jellyfin)] rounded font-semibold">
|
<!-- The badge links to the season's place in the series list —
|
||||||
{episodeLabel}
|
seasons have no page of their own (DR-103). -->
|
||||||
</span>
|
{#if seasonHref}
|
||||||
|
<a
|
||||||
|
href={seasonHref}
|
||||||
|
class="px-2 py-1 bg-[var(--color-jellyfin)] rounded font-semibold hover:brightness-110 transition-all"
|
||||||
|
>
|
||||||
|
{episodeLabel}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<span class="px-2 py-1 bg-[var(--color-jellyfin)] rounded font-semibold">
|
||||||
|
{episodeLabel}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
{#if duration}
|
{#if duration}
|
||||||
<span>{duration}</span>
|
<span>{duration}</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -166,8 +229,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Play button -->
|
<!-- Play / Download / Favourite — the full hero action row of
|
||||||
<div class="pt-2">
|
ux-flows §5B.2. TRACES: UR-058, UR-068 | DR-119, DR-142 -->
|
||||||
|
<div class="pt-2 flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onclick={handlePlay}
|
onclick={handlePlay}
|
||||||
class="px-8 py-3 bg-white text-black hover:bg-white/90 rounded-lg font-semibold text-lg flex items-center gap-2 transition-colors"
|
class="px-8 py-3 bg-white text-black hover:bg-white/90 rounded-lg font-semibold text-lg flex items-center gap-2 transition-colors"
|
||||||
@@ -177,12 +241,36 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{progress > 0 && progress < 95 ? "Resume" : "Play"}
|
{progress > 0 && progress < 95 ? "Resume" : "Play"}
|
||||||
</button>
|
</button>
|
||||||
|
<VideoDownloadButton
|
||||||
|
itemId={episode.id}
|
||||||
|
itemName={episode.name}
|
||||||
|
isMovie={false}
|
||||||
|
seriesName={seriesName ?? undefined}
|
||||||
|
seasonName={episode.seasonName ?? undefined}
|
||||||
|
seasonNumber={episode.parentIndexNumber ?? undefined}
|
||||||
|
episodeNumber={episode.indexNumber ?? undefined}
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
<WatchedToggleButton
|
||||||
|
itemId={episode.id}
|
||||||
|
watched={episode.userData?.isPlayed ?? false}
|
||||||
|
scope="episode"
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
<FavoriteButton
|
||||||
|
itemId={episode.id}
|
||||||
|
isFavorite={resolveIsFavorite(episode, $favoriteOverrides)}
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Adjacent episodes -->
|
<!-- Adjacent episodes. Nothing may be inserted between the hero and this
|
||||||
|
strip — continuation content comes before discovery content
|
||||||
|
(ux-flows §5B.2). TRACES: UR-048 | DR-061, DR-062 -->
|
||||||
|
{#if hasEpisodeStrip}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<h2 class="text-xl font-semibold text-white">More Episodes</h2>
|
<h2 class="text-xl font-semibold text-white">More Episodes</h2>
|
||||||
|
|
||||||
@@ -266,4 +354,27 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Discovery content, strictly below the episode strip (ux-flows §5B.2:
|
||||||
|
hero → strip → cast → similar). TRACES: UR-048 | DR-062, DR-142 -->
|
||||||
|
{#if genres.length}
|
||||||
|
<GenreTags {genres} maxShow={6} itemKind="episode" />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if people.length}
|
||||||
|
<CastSection {people} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- "More Like This" on an episode means similar shows, so it keys off the
|
||||||
|
series. Skipped for a series-less episode, which has nothing to match on. -->
|
||||||
|
{#if series && (series.genres?.length || series.people?.length)}
|
||||||
|
<RelatedItemsSection
|
||||||
|
currentItemId={series.id}
|
||||||
|
itemKind="series"
|
||||||
|
genres={series.genres ?? undefined}
|
||||||
|
people={series.people ?? undefined}
|
||||||
|
limit={12}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
// The Episode Focus View is the *only* episode surface (ux-flows §5B.1), so it
|
||||||
|
// has to carry everything the bare Episode page used to: download, breadcrumbs
|
||||||
|
// back to the series/season, cast and similar shows. It shipped with only Play
|
||||||
|
// and Favourite, which is why "open an episode from Continue Watching" lost the
|
||||||
|
// download affordance.
|
||||||
|
//
|
||||||
|
// TRACES: UR-048, UR-058 | DR-062, DR-142 | UT-131, UT-132, UT-133, UT-134, UT-135
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/svelte";
|
||||||
|
import type { MediaItem } from "$lib/api/types";
|
||||||
|
|
||||||
|
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 {
|
||||||
|
downloadsStore: shim({ downloads: {} as Record<string, unknown> }),
|
||||||
|
favoriteOverridesStore: shim(new Map<string, boolean>()),
|
||||||
|
getSimilarItems: vi.fn(async () => ({ items: [] as MediaItem[] })),
|
||||||
|
search: vi.fn(async () => ({ items: [] as MediaItem[] })),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("$lib/stores/downloads", () => ({
|
||||||
|
downloads: {
|
||||||
|
subscribe: h.downloadsStore.subscribe,
|
||||||
|
downloadVideo: vi.fn(),
|
||||||
|
pinItem: vi.fn(),
|
||||||
|
unpinItem: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
cancel: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("$lib/stores/favorites", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("$lib/stores/favorites")>(
|
||||||
|
"$lib/stores/favorites"
|
||||||
|
);
|
||||||
|
return { ...actual, favoriteOverrides: { subscribe: h.favoriteOverridesStore.subscribe } };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("$lib/stores/auth", () => ({
|
||||||
|
auth: {
|
||||||
|
getRepository: () => ({ getSimilarItems: h.getSimilarItems, search: h.search }),
|
||||||
|
getUserId: () => "user-1",
|
||||||
|
},
|
||||||
|
user: { subscribe: (fn: (v: unknown) => void) => (fn({ id: "user-1" }), () => {}) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// CachedImage does async repo/image work irrelevant to these tests.
|
||||||
|
vi.mock("$lib/components/common/CachedImage.svelte", async () => ({
|
||||||
|
default: (await import("./__mocks__/StubImage.svelte")).default,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import EpisodeFocusView from "./EpisodeFocusView.svelte";
|
||||||
|
|
||||||
|
const SERIES: MediaItem = {
|
||||||
|
id: "series-1",
|
||||||
|
name: "The Show",
|
||||||
|
kind: "series",
|
||||||
|
genres: ["Drama"],
|
||||||
|
people: [{ id: "p-1", name: "Lead Actor", type: "Actor" }],
|
||||||
|
} as unknown as MediaItem;
|
||||||
|
|
||||||
|
function episode(overrides: Partial<MediaItem> = {}): MediaItem {
|
||||||
|
return {
|
||||||
|
id: "ep-4",
|
||||||
|
name: "The Fourth One",
|
||||||
|
kind: "episode",
|
||||||
|
seriesId: "series-1",
|
||||||
|
seriesName: "The Show",
|
||||||
|
parentIndexNumber: 2,
|
||||||
|
indexNumber: 4,
|
||||||
|
durationMs: 2_880_000,
|
||||||
|
overview: "Something happens.",
|
||||||
|
genres: ["Drama"],
|
||||||
|
people: [{ id: "p-1", name: "Lead Actor", type: "Actor" }],
|
||||||
|
...overrides,
|
||||||
|
} as unknown as MediaItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sibling(id: string, number: number): MediaItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: `Episode ${number}`,
|
||||||
|
kind: "episode",
|
||||||
|
seriesId: "series-1",
|
||||||
|
parentIndexNumber: 2,
|
||||||
|
indexNumber: number,
|
||||||
|
} as unknown as MediaItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allEpisodes = [sibling("ep-3", 3), episode(), sibling("ep-5", 5)];
|
||||||
|
|
||||||
|
describe("EpisodeFocusView — full episode functionality (DR-142)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
h.downloadsStore.set({ downloads: {} });
|
||||||
|
h.favoriteOverridesStore.set(new Map());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers a download control in the hero", () => {
|
||||||
|
render(EpisodeFocusView, {
|
||||||
|
props: { episode: episode(), series: SERIES, allEpisodes },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByLabelText(/Download for offline playback/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("links the series name back to the series page", () => {
|
||||||
|
render(EpisodeFocusView, {
|
||||||
|
props: { episode: episode(), series: SERIES, allEpisodes },
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = screen.getByRole("link", { name: "The Show" });
|
||||||
|
expect(link.getAttribute("href")).toBe("/library/series-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("links the season badge to the season's place in the series list", () => {
|
||||||
|
render(EpisodeFocusView, {
|
||||||
|
props: { episode: episode(), series: SERIES, allEpisodes },
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = screen.getByRole("link", { name: "S2E4" });
|
||||||
|
expect(link.getAttribute("href")).toBe("/library/series-1#season-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders cast below the episode strip, never above it (DR-062)", () => {
|
||||||
|
const { container } = render(EpisodeFocusView, {
|
||||||
|
props: { episode: episode(), series: SERIES, allEpisodes },
|
||||||
|
});
|
||||||
|
|
||||||
|
const headings = [...container.querySelectorAll("h2")].map((h2) => h2.textContent?.trim());
|
||||||
|
const strip = headings.indexOf("More Episodes");
|
||||||
|
const cast = headings.findIndex((t) => t?.startsWith("Cast"));
|
||||||
|
|
||||||
|
expect(strip).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(cast).toBeGreaterThan(strip);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the episode strip when the episode has no siblings", () => {
|
||||||
|
render(EpisodeFocusView, {
|
||||||
|
props: { episode: episode(), series: SERIES, allEpisodes: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByText("More Episodes")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders without a series for an episode that carries no seriesId", () => {
|
||||||
|
render(EpisodeFocusView, {
|
||||||
|
props: {
|
||||||
|
episode: episode({ seriesId: null, seriesName: null }),
|
||||||
|
series: null,
|
||||||
|
allEpisodes: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Still a complete surface: title, play and download all present.
|
||||||
|
expect(screen.getByText("The Fourth One")).toBeTruthy();
|
||||||
|
expect(screen.getByLabelText(/Download for offline playback/i)).toBeTruthy();
|
||||||
|
expect(screen.queryByRole("link", { name: "The Show" })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
import { downloads } from "$lib/stores/downloads";
|
import { downloads } from "$lib/stores/downloads";
|
||||||
import { formatDuration } from "$lib/utils/duration";
|
import { formatDuration } from "$lib/utils/duration";
|
||||||
import VideoDownloadButton from "./VideoDownloadButton.svelte";
|
import VideoDownloadButton from "./VideoDownloadButton.svelte";
|
||||||
|
import WatchedToggleButton from "./WatchedToggleButton.svelte";
|
||||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -17,9 +18,17 @@
|
|||||||
*/
|
*/
|
||||||
current?: boolean;
|
current?: boolean;
|
||||||
onclick?: () => void;
|
onclick?: () => void;
|
||||||
|
/** Fired when the watched toggle changes, so the series page can reload. */
|
||||||
|
onWatchedChanged?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { episode, focused = false, current = false, onclick }: Props = $props();
|
let {
|
||||||
|
episode,
|
||||||
|
focused = false,
|
||||||
|
current = false,
|
||||||
|
onclick,
|
||||||
|
onWatchedChanged,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
let buttonRef: HTMLButtonElement | null = null;
|
let buttonRef: HTMLButtonElement | null = null;
|
||||||
|
|
||||||
@@ -177,6 +186,16 @@
|
|||||||
{duration}
|
{duration}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
<!-- Watched toggle - stop propagation to prevent episode play -->
|
||||||
|
<div onclick={(e) => e.stopPropagation()} role="none">
|
||||||
|
<WatchedToggleButton
|
||||||
|
itemId={episode.id}
|
||||||
|
watched={episode.userData?.isPlayed ?? false}
|
||||||
|
scope="episode"
|
||||||
|
size="sm"
|
||||||
|
onChanged={onWatchedChanged}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<!-- Download button - stop propagation to prevent episode play -->
|
<!-- Download button - stop propagation to prevent episode play -->
|
||||||
<div onclick={(e) => e.stopPropagation()} role="none">
|
<div onclick={(e) => e.stopPropagation()} role="none">
|
||||||
<VideoDownloadButton
|
<VideoDownloadButton
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import ResultsCounter from "$lib/components/common/ResultsCounter.svelte";
|
import ResultsCounter from "$lib/components/common/ResultsCounter.svelte";
|
||||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
||||||
|
import { useOfflineFilterReload } from "$lib/composables/useOfflineFilterReload";
|
||||||
import type { Genre, MediaItem, ItemType } from "$lib/api/types";
|
import type { Genre, MediaItem, ItemType } from "$lib/api/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,12 +53,16 @@
|
|||||||
let selectedGenre = $state<Genre | null>(null);
|
let selectedGenre = $state<Genre | null>(null);
|
||||||
let genreItems = $state<MediaItem[]>([]);
|
let genreItems = $state<MediaItem[]>([]);
|
||||||
let loadingItems = $state(false);
|
let loadingItems = $state(false);
|
||||||
const { markLoaded } = useServerReachabilityReload(async () => {
|
async function reloadGenreBrowse() {
|
||||||
await loadGenres();
|
await loadGenres();
|
||||||
if (selectedGenre) {
|
if (selectedGenre) {
|
||||||
await loadGenreItems(selectedGenre);
|
await loadGenreItems(selectedGenre);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
const { markLoaded } = useServerReachabilityReload(reloadGenreBrowse);
|
||||||
|
// Re-query when the offline downloaded-only gate changes. TRACES: UR-052 | DR-143
|
||||||
|
useOfflineFilterReload(reloadGenreBrowse);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await loadGenres();
|
await loadGenres();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<!-- TRACES: UR-007, UR-029, UR-030 | DR-007, DR-032, DR-033 -->
|
<!-- TRACES: UR-007, UR-029, UR-030, UR-067 | DR-007, DR-032, DR-033, DR-116 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
import BackButton from "$lib/components/common/BackButton.svelte";
|
import BackButton from "$lib/components/common/BackButton.svelte";
|
||||||
import ResultsCounter from "$lib/components/common/ResultsCounter.svelte";
|
import ResultsCounter from "$lib/components/common/ResultsCounter.svelte";
|
||||||
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
||||||
|
import { useOfflineFilterReload } from "$lib/composables/useOfflineFilterReload";
|
||||||
import type { MediaItem, Library, ItemType, SearchResult } from "$lib/api/types";
|
import type { MediaItem, Library, ItemType, SearchResult } from "$lib/api/types";
|
||||||
import LibraryGrid from "./LibraryGrid.svelte";
|
import LibraryGrid from "./LibraryGrid.svelte";
|
||||||
import TrackList from "./TrackList.svelte";
|
import TrackList from "./TrackList.svelte";
|
||||||
@@ -56,6 +57,7 @@
|
|||||||
let gridWrapper = $state<HTMLDivElement | null>(null);
|
let gridWrapper = $state<HTMLDivElement | null>(null);
|
||||||
let searchQuery = $state("");
|
let searchQuery = $state("");
|
||||||
let debouncedSearchQuery = $state("");
|
let debouncedSearchQuery = $state("");
|
||||||
|
let favoritesOnly = $state(false);
|
||||||
let sortBy = $state<string>("");
|
let sortBy = $state<string>("");
|
||||||
let sortOrder = $state<"Ascending" | "Descending">("Ascending");
|
let sortOrder = $state<"Ascending" | "Descending">("Ascending");
|
||||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -94,6 +96,11 @@
|
|||||||
await loadItems();
|
await loadItems();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Re-query when the offline downloaded-only gate changes — going offline, or
|
||||||
|
// toggling "Show all server media". Without this the listing kept whatever it
|
||||||
|
// was first loaded with and the toggle only greyed cards. TRACES: UR-052 | DR-143
|
||||||
|
useOfflineFilterReload(() => loadItems());
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await loadItems();
|
await loadItems();
|
||||||
markLoaded();
|
markLoaded();
|
||||||
@@ -140,6 +147,9 @@
|
|||||||
sortOrder,
|
sortOrder,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
limit: 10000,
|
limit: 10000,
|
||||||
|
// Narrows the listing in place; the backend owns what "favourite"
|
||||||
|
// resolves to online vs offline. TRACES: UR-067 | DR-116
|
||||||
|
favoritesOnly: favoritesOnly ? true : undefined,
|
||||||
});
|
});
|
||||||
items = excludePodcasts(result.items);
|
items = excludePodcasts(result.items);
|
||||||
}
|
}
|
||||||
@@ -154,6 +164,12 @@
|
|||||||
searchQuery = query;
|
searchQuery = query;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: UR-067 | DR-116
|
||||||
|
function toggleFavoritesOnly() {
|
||||||
|
favoritesOnly = !favoritesOnly;
|
||||||
|
loadItems();
|
||||||
|
}
|
||||||
|
|
||||||
// Debounce search input (300ms delay) - skip initial mount to avoid duplicate load
|
// Debounce search input (300ms delay) - skip initial mount to avoid duplicate load
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const _query = searchQuery; // track for reactivity
|
const _query = searchQuery; // track for reactivity
|
||||||
@@ -266,6 +282,33 @@
|
|||||||
<SearchBar value={searchQuery} placeholder={searchPlaceholder} onInput={handleSearch} />
|
<SearchBar value={searchQuery} placeholder={searchPlaceholder} onInput={handleSearch} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Favourites filter. Session-scoped on purpose: a persisted filter that
|
||||||
|
hides most of a library reads as data loss on the next launch
|
||||||
|
(ux-flows §5C.2). Hidden while searching, which has no favourites
|
||||||
|
filter of its own. TRACES: UR-067 | DR-116 -->
|
||||||
|
{#if !debouncedSearchQuery.trim()}
|
||||||
|
<button
|
||||||
|
onclick={toggleFavoritesOnly}
|
||||||
|
aria-pressed={favoritesOnly}
|
||||||
|
class="px-3 py-2 rounded-lg text-sm font-medium flex items-center gap-2 transition-colors
|
||||||
|
{favoritesOnly
|
||||||
|
? 'bg-[var(--color-jellyfin)] text-white'
|
||||||
|
: 'bg-[var(--color-surface)] text-gray-400 hover:text-white'}"
|
||||||
|
title={favoritesOnly ? "Showing favourites only" : "Show favourites only"}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4"
|
||||||
|
fill={favoritesOnly ? "currentColor" : "none"}
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||||
|
</svg>
|
||||||
|
Favourites
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Sort (only show if there are sort options) -->
|
<!-- Sort (only show if there are sort options) -->
|
||||||
{#if config.sortOptions.length > 0}
|
{#if config.sortOptions.length > 0}
|
||||||
<SortButtonGroup options={config.sortOptions} selected={sortBy} onSelect={handleSort} />
|
<SortButtonGroup options={config.sortOptions} selected={sortBy} onSelect={handleSort} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<!-- TRACES: UR-051, UR-052 | DR-068, DR-078 -->
|
<!-- TRACES: UR-051, UR-052, UR-068 | DR-068, DR-078, DR-119 -->
|
||||||
<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";
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
import { showServerCatalog } from "$lib/services/offlineCatalog";
|
import { showServerCatalog } from "$lib/services/offlineCatalog";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
|
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
||||||
|
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: MediaItem | Library;
|
item: MediaItem | Library;
|
||||||
@@ -37,9 +39,22 @@
|
|||||||
* TRACES: UR-058 | DR-087
|
* TRACES: UR-058 | DR-087
|
||||||
*/
|
*/
|
||||||
onLongPress?: () => void;
|
onLongPress?: () => void;
|
||||||
|
/**
|
||||||
|
* Show the favourite heart on the artwork. On by default for media items;
|
||||||
|
* surfaces that are not about the item itself can opt out.
|
||||||
|
* TRACES: UR-068 | DR-119
|
||||||
|
*/
|
||||||
|
showFavorite?: boolean;
|
||||||
|
/**
|
||||||
|
* Force the artwork box to a fixed aspect ratio instead of deriving one from
|
||||||
|
* the item. Use on rows that mix item kinds (e.g. the home "Your Libraries"
|
||||||
|
* strip, where square music art next to 16:9 video art would otherwise give
|
||||||
|
* the cards different heights). Artwork still fills the box via object-cover.
|
||||||
|
*/
|
||||||
|
aspect?: "square" | "video" | "poster";
|
||||||
}
|
}
|
||||||
|
|
||||||
let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick, onLongPress }: Props = $props();
|
let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick, onLongPress, showFavorite = true, aspect }: Props = $props();
|
||||||
|
|
||||||
// Long-press detection. We arm a timer on pointerdown; if it fires before the
|
// Long-press detection. We arm a timer on pointerdown; if it fires before the
|
||||||
// pointer is released (or moves too far), we treat it as a long press and set a
|
// pointer is released (or moves too far), we treat it as a long press and set a
|
||||||
@@ -120,6 +135,13 @@
|
|||||||
isMediaItem && !$isConnected && $showServerCatalog && !isDownloaded && !isActivelyDownloading
|
isMediaItem && !$isConnected && $showServerCatalog && !isDownloaded && !isActivelyDownloading
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The heart is about an item, so libraries never get one, and a greyed
|
||||||
|
// server-only card has nothing actionable to offer. TRACES: UR-068 | DR-119
|
||||||
|
const showHeart = $derived(showFavorite && isMediaItem && !isServerOnly);
|
||||||
|
const isFavorited = $derived(
|
||||||
|
isMediaItem ? resolveIsFavorite(item as MediaItem, $favoriteOverrides) : false
|
||||||
|
);
|
||||||
|
|
||||||
let queueError = $state<string | null>(null);
|
let queueError = $state<string | null>(null);
|
||||||
|
|
||||||
// Queue this item for download on next reconnect. Offline, this just persists
|
// Queue this item for download on next reconnect. Offline, this just persists
|
||||||
@@ -164,7 +186,14 @@
|
|||||||
"kind" in item && (item.kind === "track" || item.kind === "album" || item.kind === "artist" || item.kind === "playlist")
|
"kind" in item && (item.kind === "track" || item.kind === "album" || item.kind === "artist" || item.kind === "playlist")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const FIXED_ASPECT = {
|
||||||
|
square: "aspect-square",
|
||||||
|
video: "aspect-video",
|
||||||
|
poster: "aspect-[2/3]",
|
||||||
|
} as const;
|
||||||
|
|
||||||
const aspectRatio = $derived(() => {
|
const aspectRatio = $derived(() => {
|
||||||
|
if (aspect) return FIXED_ASPECT[aspect];
|
||||||
if ("kind" in item) {
|
if ("kind" in item) {
|
||||||
return isMusicType ? "aspect-square" : "aspect-[2/3]";
|
return isMusicType ? "aspect-square" : "aspect-[2/3]";
|
||||||
}
|
}
|
||||||
@@ -250,12 +279,34 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Played indicator -->
|
<!-- Top-right status stack: played tick, then the favourite heart. Grouped
|
||||||
{#if "userData" in item && item.userData?.isPlayed}
|
so the two never land on the same pixels when both apply. -->
|
||||||
<div class="absolute top-2 right-2">
|
{#if ("userData" in item && item.userData?.isPlayed) || showHeart}
|
||||||
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
<div class="absolute top-2 right-2 flex flex-col items-end gap-1">
|
||||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
{#if "userData" in item && item.userData?.isPlayed}
|
||||||
</svg>
|
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
{#if showHeart}
|
||||||
|
<!-- Always visible on touch (no hover to reveal it); on pointer
|
||||||
|
devices an unfavourited heart stays out of the way until the card
|
||||||
|
is hovered or focused. A favourited one is always shown — it is
|
||||||
|
state, not an affordance. TRACES: UR-068 | DR-119 -->
|
||||||
|
<div
|
||||||
|
class="transition-opacity {isFavorited
|
||||||
|
? ''
|
||||||
|
: 'opacity-100 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover/card:opacity-100 [@media(hover:hover)]:group-focus-within/card:opacity-100'}"
|
||||||
|
>
|
||||||
|
<FavoriteButton
|
||||||
|
itemId={item.id}
|
||||||
|
isFavorite={isFavorited}
|
||||||
|
size="sm"
|
||||||
|
variant="overlay"
|
||||||
|
stopPropagation
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
import { toast } from "$lib/stores/toast";
|
import { toast } from "$lib/stores/toast";
|
||||||
import TrackList from "./TrackList.svelte";
|
import TrackList from "./TrackList.svelte";
|
||||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
|
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
||||||
|
import { favoriteOverrides } from "$lib/stores/favorites";
|
||||||
import { formatDuration } from "$lib/utils/duration";
|
import { formatDuration } from "$lib/utils/duration";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -214,6 +216,13 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Shuffle
|
Shuffle
|
||||||
</button>
|
</button>
|
||||||
|
<!-- TRACES: UR-068 | DR-119 -->
|
||||||
|
<FavoriteButton
|
||||||
|
itemId={playlist.id}
|
||||||
|
isFavorite={$favoriteOverrides.get(playlist.id) ?? false}
|
||||||
|
size="lg"
|
||||||
|
className="self-center"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
onclick={() => showDeleteConfirm = true}
|
onclick={() => showDeleteConfirm = true}
|
||||||
class="px-4 py-2 bg-[var(--color-surface)] hover:bg-red-900/50 text-red-400 hover:text-red-300 rounded-lg font-medium flex items-center gap-2 transition-colors"
|
class="px-4 py-2 bg-[var(--color-surface)] hover:bg-red-900/50 text-red-400 hover:text-red-300 rounded-lg font-medium flex items-center gap-2 transition-colors"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import EpisodeRow from "./EpisodeRow.svelte";
|
import EpisodeRow from "./EpisodeRow.svelte";
|
||||||
import SeasonDownloadButton from "./SeasonDownloadButton.svelte";
|
import SeasonDownloadButton from "./SeasonDownloadButton.svelte";
|
||||||
import ClearHistoryButton from "./ClearHistoryButton.svelte";
|
import ClearHistoryButton from "./ClearHistoryButton.svelte";
|
||||||
|
import WatchedToggleButton from "./WatchedToggleButton.svelte";
|
||||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
import { seasonAnchorId } from "./seriesNavigation";
|
import { seasonAnchorId } from "./seriesNavigation";
|
||||||
|
|
||||||
@@ -65,18 +66,26 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Season info -->
|
<!-- Season info.
|
||||||
|
|
||||||
|
The header stacks on narrow screens and only shares a row from `sm` up.
|
||||||
|
Three action buttons and a season title cannot both fit across a phone,
|
||||||
|
and side-by-side they ended up overlapping. -->
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
|
||||||
<!-- The whole title block toggles the season open/closed. -->
|
<!-- The whole title block toggles the season open/closed. -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={onToggle}
|
onclick={onToggle}
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
aria-controls="{anchor}-episodes"
|
aria-controls="{anchor}-episodes"
|
||||||
class="flex-1 min-w-0 text-left group/season"
|
class="min-w-0 sm:flex-1 text-left group/season"
|
||||||
>
|
>
|
||||||
<h2 class="text-xl font-bold text-white flex items-center gap-2">
|
<!-- min-w-0 is load-bearing: the title span below sets `truncate`, but
|
||||||
|
a flex item will not shrink below its content width without it, so
|
||||||
|
a long season name grew the row instead of ellipsising and ran
|
||||||
|
under the buttons. -->
|
||||||
|
<h2 class="text-xl font-bold text-white flex items-center gap-2 min-w-0">
|
||||||
<svg
|
<svg
|
||||||
class="w-5 h-5 flex-shrink-0 text-gray-400 transition-transform duration-200
|
class="w-5 h-5 flex-shrink-0 text-gray-400 transition-transform duration-200
|
||||||
group-hover/season:text-white {expanded ? 'rotate-90' : ''}"
|
group-hover/season:text-white {expanded ? 'rotate-90' : ''}"
|
||||||
@@ -88,7 +97,7 @@
|
|||||||
>
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="truncate">{seasonName}</span>
|
<span class="truncate min-w-0">{seasonName}</span>
|
||||||
{#if holdsCurrentEpisode}
|
{#if holdsCurrentEpisode}
|
||||||
<span
|
<span
|
||||||
class="flex-shrink-0 px-2 py-0.5 rounded bg-yellow-400 text-black text-xs font-semibold"
|
class="flex-shrink-0 px-2 py-0.5 rounded bg-yellow-400 text-black text-xs font-semibold"
|
||||||
@@ -120,8 +129,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Per-season actions -->
|
<!-- Per-season actions. `self-start` keeps them level with the title on
|
||||||
<div class="flex-shrink-0 flex items-center gap-2">
|
wide rows; on a stacked phone layout they sit under it. -->
|
||||||
|
<div class="flex-shrink-0 flex items-center gap-2 self-start">
|
||||||
<SeasonDownloadButton
|
<SeasonDownloadButton
|
||||||
seasonId={season.id}
|
seasonId={season.id}
|
||||||
seriesName={season.seriesName || ""}
|
seriesName={season.seriesName || ""}
|
||||||
@@ -130,6 +140,13 @@
|
|||||||
{episodeCount}
|
{episodeCount}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
|
<WatchedToggleButton
|
||||||
|
itemId={season.id}
|
||||||
|
watched={watchedCount === episodeCount && episodeCount > 0}
|
||||||
|
scope="season"
|
||||||
|
size="sm"
|
||||||
|
onChanged={onHistoryCleared}
|
||||||
|
/>
|
||||||
<ClearHistoryButton
|
<ClearHistoryButton
|
||||||
itemId={season.id}
|
itemId={season.id}
|
||||||
itemName={seasonName}
|
itemName={seasonName}
|
||||||
@@ -151,6 +168,7 @@
|
|||||||
focused={episode.id === focusedEpisodeId}
|
focused={episode.id === focusedEpisodeId}
|
||||||
current={episode.id === currentEpisodeId}
|
current={episode.id === currentEpisodeId}
|
||||||
onclick={() => onEpisodeClick?.(episode)}
|
onclick={() => onEpisodeClick?.(episode)}
|
||||||
|
onWatchedChanged={onHistoryCleared}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<!--
|
||||||
|
Mark an episode, season or series watched — or unwatched again.
|
||||||
|
|
||||||
|
The backend already had both halves (`mark_played` / `clear_watch_history`,
|
||||||
|
both recursive over a container on the server) and the sync queue already
|
||||||
|
replayed the first; nothing in the UI had ever called them, so the only way to
|
||||||
|
mark something watched was to sit through it. This is that control.
|
||||||
|
|
||||||
|
Unlike ClearHistoryButton — which is the *destructive* "erase all history for
|
||||||
|
this series", confirms, and needs the server — this is an everyday toggle: no
|
||||||
|
confirmation, and it works offline by queueing, in both directions.
|
||||||
|
|
||||||
|
TRACES: UR-073 | DR-158
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { syncService } from "$lib/services/syncService";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Episode, season or series id. */
|
||||||
|
itemId: string;
|
||||||
|
/** Current watched state, as the caller knows it. */
|
||||||
|
watched: boolean;
|
||||||
|
/** What is being marked, for the tooltip wording. */
|
||||||
|
scope: "episode" | "season" | "series";
|
||||||
|
size?: "sm" | "lg";
|
||||||
|
/** Show a text label beside the icon rather than icon-only. */
|
||||||
|
showLabel?: boolean;
|
||||||
|
/** Called after a successful toggle so the caller can reload. */
|
||||||
|
onChanged?: (watched: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
itemId,
|
||||||
|
watched,
|
||||||
|
scope,
|
||||||
|
size = "lg",
|
||||||
|
showLabel = false,
|
||||||
|
onChanged,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let busy = $state(false);
|
||||||
|
|
||||||
|
// Optimistic state: the caller's `watched` prop only catches up once it has
|
||||||
|
// reloaded from the repository, which on a season means a round trip. Without
|
||||||
|
// this the button visibly ignores the first tap.
|
||||||
|
let optimistic = $state<boolean | null>(null);
|
||||||
|
const isWatched = $derived(optimistic ?? watched);
|
||||||
|
|
||||||
|
// A new item in the same slot (scrolling a virtualised list, switching series)
|
||||||
|
// must drop the previous item's optimistic state or it shows the wrong tick.
|
||||||
|
$effect(() => {
|
||||||
|
itemId;
|
||||||
|
optimistic = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const subject = $derived(
|
||||||
|
scope === "series" ? "series" : scope === "season" ? "season" : "episode"
|
||||||
|
);
|
||||||
|
const label = $derived(isWatched ? "Watched" : "Mark watched");
|
||||||
|
const title = $derived(
|
||||||
|
isWatched
|
||||||
|
? `Mark this ${subject} unwatched`
|
||||||
|
: scope === "episode"
|
||||||
|
? "Mark this episode watched"
|
||||||
|
: `Mark every episode in this ${subject} watched`
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleClick() {
|
||||||
|
if (busy) return;
|
||||||
|
|
||||||
|
const next = !isWatched;
|
||||||
|
busy = true;
|
||||||
|
optimistic = next;
|
||||||
|
try {
|
||||||
|
if (next) {
|
||||||
|
await syncService.queueMarkPlayed(itemId);
|
||||||
|
} else {
|
||||||
|
await syncService.queueMarkUnplayed(itemId);
|
||||||
|
}
|
||||||
|
onChanged?.(next);
|
||||||
|
} catch (e) {
|
||||||
|
// Put the button back where it was — the change did not happen.
|
||||||
|
optimistic = null;
|
||||||
|
console.error("Failed to change watched state:", e);
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleClick}
|
||||||
|
disabled={busy}
|
||||||
|
{title}
|
||||||
|
aria-label={title}
|
||||||
|
aria-pressed={isWatched}
|
||||||
|
class="rounded-lg font-medium flex items-center gap-2 transition-colors
|
||||||
|
disabled:opacity-40 disabled:cursor-not-allowed
|
||||||
|
{isWatched
|
||||||
|
? 'bg-[var(--color-jellyfin)]/15 text-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin)]/25'
|
||||||
|
: 'bg-[var(--color-surface)] text-gray-300 hover:bg-[var(--color-surface-hover)] hover:text-white'}
|
||||||
|
{showLabel ? (size === 'lg' ? 'px-6 py-2' : 'px-3 py-1.5 text-sm') : size === 'lg' ? 'p-2' : 'p-1.5'}"
|
||||||
|
>
|
||||||
|
{#if busy}
|
||||||
|
<div
|
||||||
|
class="border-2 border-current border-t-transparent rounded-full animate-spin
|
||||||
|
{size === 'lg' ? 'w-5 h-5' : 'w-4 h-4'}"
|
||||||
|
></div>
|
||||||
|
{:else if isWatched}
|
||||||
|
<!-- Filled check: this one is done. -->
|
||||||
|
<svg
|
||||||
|
class={size === "lg" ? "w-5 h-5" : "w-4 h-4"}
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm-1.4 14.6L6 12l1.4-1.4 3.2 3.2
|
||||||
|
6.4-6.4L18.4 8.8l-7.8 7.8z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<!-- Outline check: available, not yet done. -->
|
||||||
|
<svg
|
||||||
|
class={size === "lg" ? "w-5 h-5" : "w-4 h-4"}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12.5l2.5 2.5L16 9.5" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
{#if showLabel}
|
||||||
|
<span>{busy ? "Saving…" : label}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
|
// TRACES: UR-062 | DR-102, DR-103, DR-142 | UT-136
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
import {
|
import {
|
||||||
seasonAnchorId,
|
seasonAnchorId,
|
||||||
seasonRedirectTarget,
|
seasonRedirectTarget,
|
||||||
episodeFocusHref,
|
episodeFocusHref,
|
||||||
|
episodeRedirectTarget,
|
||||||
seriesPlayHref,
|
seriesPlayHref,
|
||||||
seriesPlayLabel,
|
seriesPlayLabel,
|
||||||
groupEpisodesBySeason,
|
groupEpisodesBySeason,
|
||||||
@@ -103,6 +105,17 @@ describe("episodeFocusHref", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("episodeRedirectTarget", () => {
|
||||||
|
it("sends a bare episode page to the episode inside its series", () => {
|
||||||
|
expect(episodeRedirectTarget(ep("s1e2", 1, 2))).toBe("/library/series-1?episode=s1e2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not redirect an episode that has no series to fall back on", () => {
|
||||||
|
const orphan = { ...ep("lone", 1, 2), seriesId: undefined } as MediaItem;
|
||||||
|
expect(episodeRedirectTarget(orphan)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("groupEpisodesBySeason", () => {
|
describe("groupEpisodesBySeason", () => {
|
||||||
it("groups episodes under their season headers, in season order", () => {
|
it("groups episodes under their season headers, in season order", () => {
|
||||||
const seasons = [seasonHeader(2), seasonHeader(1)];
|
const seasons = [seasonHeader(2), seasonHeader(1)];
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
// lives in Rust (`repository_get_series_current_episode`); this module only
|
// lives in Rust (`repository_get_series_current_episode`); this module only
|
||||||
// renders and routes around the answer.
|
// renders and routes around the answer.
|
||||||
//
|
//
|
||||||
// TRACES: UR-062 | DR-102, DR-103
|
// TRACES: UR-062 | DR-102, DR-103, DR-142
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
|
|
||||||
export interface SeasonData {
|
export interface SeasonData {
|
||||||
@@ -57,6 +57,21 @@ export function episodeFocusHref(episode: MediaItem): string {
|
|||||||
return `/library/${episode.seriesId}?episode=${episode.id}`;
|
return `/library/${episode.seriesId}?episode=${episode.id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a bare `/library/<episodeId>` should actually land — the same rule
|
||||||
|
* seasons follow (DR-103). An episode is never a page of its own, so a deep
|
||||||
|
* link, a stale bookmark, or any caller that missed `episodeFocusHref` is
|
||||||
|
* redirected into the series' Episode Focus View.
|
||||||
|
*
|
||||||
|
* Returns `null` for an episode with no `seriesId` (a deep link into a stale
|
||||||
|
* cache): there is nothing to redirect *to*, so the caller renders the Focus
|
||||||
|
* View series-less rather than stranding the user (ux-flows §5B.1).
|
||||||
|
*/
|
||||||
|
export function episodeRedirectTarget(episode: MediaItem): string | null {
|
||||||
|
if (!episode.seriesId) return null;
|
||||||
|
return episodeFocusHref(episode);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where the series hero button goes.
|
* Where the series hero button goes.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -154,8 +154,16 @@
|
|||||||
<div class="fixed inset-0 z-0 bg-[var(--color-background)]"></div>
|
<div class="fixed inset-0 z-0 bg-[var(--color-background)]"></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Content overlay -->
|
<!-- Content overlay. The blurred artwork behind it stays edge-to-edge; only
|
||||||
<div class="relative z-10 flex flex-col h-full">
|
this layer is inset, so the close button clears the status bar and the
|
||||||
|
transport controls clear the Android gesture bar. (UR-066) -->
|
||||||
|
<div
|
||||||
|
class="relative z-10 flex flex-col h-full"
|
||||||
|
style:padding-top="var(--safe-top)"
|
||||||
|
style:padding-bottom="var(--safe-bottom)"
|
||||||
|
style:padding-left="var(--safe-left)"
|
||||||
|
style:padding-right="var(--safe-right)"
|
||||||
|
>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between p-4 flex-shrink-0">
|
<div class="flex items-center justify-between p-4 flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
@@ -329,8 +337,12 @@
|
|||||||
aria-label="Close queue"
|
aria-label="Close queue"
|
||||||
></button>
|
></button>
|
||||||
|
|
||||||
<!-- Queue Panel -->
|
<!-- Queue Panel. Slides up from the very bottom, so it owns the bottom
|
||||||
<div class="absolute bottom-0 left-0 right-0 max-h-[70vh] animate-slide-up">
|
inset — its last row would otherwise sit under the gesture bar. -->
|
||||||
|
<div
|
||||||
|
class="absolute bottom-0 left-0 right-0 max-h-[70vh] animate-slide-up"
|
||||||
|
style:padding-bottom="var(--safe-bottom)"
|
||||||
|
>
|
||||||
<Queue
|
<Queue
|
||||||
items={$queueItems}
|
items={$queueItems}
|
||||||
currentIndex={$currentQueueIndex}
|
currentIndex={$currentQueueIndex}
|
||||||
|
|||||||
@@ -23,6 +23,27 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||||||
// ---- Mocks (must precede component import) --------------------------------
|
// ---- Mocks (must precede component import) --------------------------------
|
||||||
|
|
||||||
const channelHandlers: Record<string, (event: any) => void> = {};
|
const channelHandlers: Record<string, (event: any) => void> = {};
|
||||||
|
// These tests pin the **flag-off** interim behaviour: when `experimentalNativeVideo`
|
||||||
|
// is off, VideoPlayer overrides Android's native backend response to HTML5
|
||||||
|
// rendering and stops the native backend. That flag now defaults to *on*
|
||||||
|
// (DR-160, so picture-in-picture has a real surface to shrink into), so the
|
||||||
|
// default no longer selects this path and the tests have to say which path they
|
||||||
|
// are guarding rather than inherit it. (DR-161)
|
||||||
|
vi.mock("$lib/stores/nativeVideo", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("$lib/stores/nativeVideo")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
experimentalNativeVideo: {
|
||||||
|
subscribe: (run: (v: boolean) => void) => {
|
||||||
|
run(false);
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
set: () => {},
|
||||||
|
current: () => false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
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;
|
||||||
@@ -61,6 +82,10 @@ vi.mock("$lib/api/bindings", () => ({
|
|||||||
playerCancelSleepTimer: (...a: any[]) => playerCancelSleepTimer(...(a as [])),
|
playerCancelSleepTimer: (...a: any[]) => playerCancelSleepTimer(...(a as [])),
|
||||||
playerSetSubtitleTrack: vi.fn(async () => ({})),
|
playerSetSubtitleTrack: vi.fn(async () => ({})),
|
||||||
playerSwitchAudioTrack: vi.fn(async () => ({})),
|
playerSwitchAudioTrack: vi.fn(async () => ({})),
|
||||||
|
// The player loads the streaming-quality picker on mount; without these the
|
||||||
|
// mock throws and every test in the file fails before it starts.
|
||||||
|
playerGetStreamingQualities: vi.fn(async () => []),
|
||||||
|
playerGetVideoSettings: vi.fn(async () => ({ streamingQuality: "original" })),
|
||||||
storageGetSeriesAudioPreference: vi.fn(async () => null),
|
storageGetSeriesAudioPreference: vi.fn(async () => null),
|
||||||
storageSaveSeriesAudioPreference: vi.fn(async () => ({})),
|
storageSaveSeriesAudioPreference: vi.fn(async () => ({})),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040, UR-061 | DR-010, DR-023, DR-024, DR-051, DR-052, DR-092, DR-098, DR-099 -->
|
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040, UR-061 | DR-010, DR-023, DR-024, DR-051, DR-052, DR-092, DR-098, DR-099 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy, untrack } from "svelte";
|
import { onMount, onDestroy, tick, untrack } from "svelte";
|
||||||
|
import { get } from "svelte/store";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { commands } from "$lib/api/bindings";
|
import { commands } from "$lib/api/bindings";
|
||||||
import type { JRayActor } from "$lib/api/bindings";
|
import type { JRayActor, StreamingQuality } from "$lib/api/bindings";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import Hls from "hls.js";
|
import Hls from "hls.js";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
@@ -13,13 +14,37 @@
|
|||||||
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
|
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
|
||||||
import CachedImage from "../common/CachedImage.svelte";
|
import CachedImage from "../common/CachedImage.svelte";
|
||||||
import { videoFitClass } from "./videoFit";
|
import { videoFitClass } from "./videoFit";
|
||||||
|
import {
|
||||||
|
resolveSubtitleTracks,
|
||||||
|
reconcileSelectedSubtitle,
|
||||||
|
videoCrossOriginMode,
|
||||||
|
nativeSubtitleTracks,
|
||||||
|
nativeSubtitleArrayIndex,
|
||||||
|
type RenderableSubtitleTrack,
|
||||||
|
} from "./subtitleTracks";
|
||||||
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
|
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
|
||||||
import { playbackPosition } from "$lib/stores/player";
|
import { playbackPosition, playerState } from "$lib/stores/player";
|
||||||
import * as html5Adapter from "$lib/player/html5Adapter";
|
import * as html5Adapter from "$lib/player/html5Adapter";
|
||||||
import { playerController } from "$lib/player";
|
import { playerController } from "$lib/player";
|
||||||
import { Html5PlayerAdapter, type Html5ElementBridge } from "$lib/player/adapters";
|
import {
|
||||||
|
createAdapter,
|
||||||
|
Html5PlayerAdapter,
|
||||||
|
type PlayerAdapter,
|
||||||
|
type Html5ElementBridge,
|
||||||
|
} from "$lib/player/adapters";
|
||||||
import { createRustReportHost } from "$lib/player/adapters/rustReportHost";
|
import { createRustReportHost } from "$lib/player/adapters/rustReportHost";
|
||||||
import { isPipSupported, enterPip, setAutoEnterEnabled } from "$lib/utils/pictureInPicture";
|
import { experimentalNativeVideo } from "$lib/stores/nativeVideo";
|
||||||
|
import {
|
||||||
|
enableNativeVideoCompositing,
|
||||||
|
disableNativeVideoCompositing,
|
||||||
|
} from "$lib/utils/videoSurface";
|
||||||
|
import {
|
||||||
|
isPipSupported,
|
||||||
|
enterPip,
|
||||||
|
setAutoEnterEnabled,
|
||||||
|
setHtml5VideoState,
|
||||||
|
} from "$lib/utils/pictureInPicture";
|
||||||
|
import { enterImmersive, exitImmersive } from "$lib/utils/immersive";
|
||||||
import {
|
import {
|
||||||
createTapGestureState,
|
createTapGestureState,
|
||||||
registerTap,
|
registerTap,
|
||||||
@@ -42,6 +67,7 @@
|
|||||||
initialHandoffState,
|
initialHandoffState,
|
||||||
shouldEnterBackgroundAudio,
|
shouldEnterBackgroundAudio,
|
||||||
shouldExitBackgroundAudio,
|
shouldExitBackgroundAudio,
|
||||||
|
shouldResumeOnForeground,
|
||||||
type BackgroundAudioState,
|
type BackgroundAudioState,
|
||||||
} from "./backgroundAudioHandoff";
|
} from "./backgroundAudioHandoff";
|
||||||
|
|
||||||
@@ -89,8 +115,41 @@
|
|||||||
endedFired = true;
|
endedFired = true;
|
||||||
onEnded?.();
|
onEnded?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep native's picture-in-picture state in step with the `<video>` element.
|
||||||
|
*
|
||||||
|
* PiP is driven by the Activity, and it only ever knew about the native
|
||||||
|
* ExoPlayer surface — a path behind `experimentalNativeVideo`, which at the
|
||||||
|
* time defaulted to off. So in the then-shipping configuration nothing
|
||||||
|
* satisfied its "is a video playing?" check and the PiP button did nothing at
|
||||||
|
* all. Reporting the element gives it a surface it can shrink into, and still
|
||||||
|
* has to: the flag defaults to on now (DR-161) but a user who turns it off is
|
||||||
|
* back on the element. (UR-041, DR-160, DR-161)
|
||||||
|
*/
|
||||||
|
function reportPipVideoState() {
|
||||||
|
if (!useHtml5Element || !videoElement) {
|
||||||
|
setHtml5VideoState(false, 0, 0, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHtml5VideoState(
|
||||||
|
true,
|
||||||
|
videoElement.videoWidth,
|
||||||
|
videoElement.videoHeight,
|
||||||
|
isPlaying
|
||||||
|
);
|
||||||
|
}
|
||||||
let isFullscreen = $state(false);
|
let isFullscreen = $state(false);
|
||||||
let showControls = $state(true);
|
let showControls = $state(true);
|
||||||
|
/**
|
||||||
|
* True while the Activity is in picture-in-picture.
|
||||||
|
*
|
||||||
|
* On the HTML5 path the WebView *is* what PiP shows, so the page has to strip
|
||||||
|
* itself down to the video — controls, header and gradients would otherwise be
|
||||||
|
* rendered into a window a couple of inches wide. (UR-041, DR-160)
|
||||||
|
*/
|
||||||
|
let isInPip = $state(false);
|
||||||
|
let pipListenerCleanup: (() => void) | null = null;
|
||||||
let showSleepTimerModal = $state(false);
|
let showSleepTimerModal = $state(false);
|
||||||
let isBuffering = $state(false);
|
let isBuffering = $state(false);
|
||||||
let controlsTimeout: ReturnType<typeof setTimeout> | null = null;
|
let controlsTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -156,7 +215,10 @@
|
|||||||
// VideoPlayer supplies a narrow bridge for the element/HLS-coupled parts and
|
// VideoPlayer supplies a narrow bridge for the element/HLS-coupled parts and
|
||||||
// registers the adapter with the facade so control intents — from UI OR from a
|
// registers the adapter with the facade so control intents — from UI OR from a
|
||||||
// backend control event (lockscreen/remote/sleep) — reach this element.
|
// backend control event (lockscreen/remote/sleep) — reach this element.
|
||||||
let playerAdapter: Html5PlayerAdapter | null = null;
|
// Widened from Html5PlayerAdapter: the native path registers a
|
||||||
|
// NativePlayerAdapter here. Element-coupled work is guarded by
|
||||||
|
// `useHtml5Element`, not by narrowing this type.
|
||||||
|
let playerAdapter: PlayerAdapter | null = null;
|
||||||
|
|
||||||
function tearDownHls() {
|
function tearDownHls() {
|
||||||
if (hls) {
|
if (hls) {
|
||||||
@@ -184,6 +246,14 @@
|
|||||||
let showSubtitleMenu = $state(false);
|
let showSubtitleMenu = $state(false);
|
||||||
let selectedSubtitleIndex = $state<number | null>(null);
|
let selectedSubtitleIndex = $state<number | null>(null);
|
||||||
|
|
||||||
|
// Streaming bandwidth ceiling. The ladder and the current value both come from
|
||||||
|
// Rust — the frontend never encodes what a step means.
|
||||||
|
// TRACES: UR-074 | DR-162
|
||||||
|
let showQualityMenu = $state(false);
|
||||||
|
let streamingQualities = $state<[StreamingQuality, string, string][]>([]);
|
||||||
|
let selectedQuality = $state<StreamingQuality>("original");
|
||||||
|
let changingQuality = $state(false);
|
||||||
|
|
||||||
// Track duration from video element (for when media item doesn't have runTimeTicks)
|
// Track duration from video element (for when media item doesn't have runTimeTicks)
|
||||||
let videoDuration = $state(0);
|
let videoDuration = $state(0);
|
||||||
|
|
||||||
@@ -273,6 +343,61 @@
|
|||||||
return tracks;
|
return tracks;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===== Subtitle <track> sources for the HTML5 element (Linux/WebKitGTK) =====
|
||||||
|
// Resolved asynchronously into state and only then rendered. The URLs come
|
||||||
|
// from an async command, so they must never be bound to `src` directly — the
|
||||||
|
// original markup did exactly that and put "[object Promise]" on every track,
|
||||||
|
// which is why the whole block ended up commented out (and why selecting a
|
||||||
|
// subtitle did nothing: with no <track> children the element has no
|
||||||
|
// textTracks for the adapter to switch on).
|
||||||
|
// TRACES: UR-020 | DR-023 | UT-143, UT-144
|
||||||
|
let renderedSubtitleTracks = $state<RenderableSubtitleTrack[]>([]);
|
||||||
|
|
||||||
|
// The subtitle list actually handed to the native backend at load time
|
||||||
|
// (Android/ExoPlayer). Kept because `player_set_subtitle_track` takes a
|
||||||
|
// *position in this list*, not a Jellyfin stream index — see
|
||||||
|
// nativeSubtitleArrayIndex. It is written once, from onMount, before the
|
||||||
|
// play request; it is not derived, because the request is what fixed the
|
||||||
|
// backend's idea of the track order.
|
||||||
|
// TRACES: UR-020 | IR-016 | UT-147
|
||||||
|
let sentSubtitleTracks = $state<RenderableSubtitleTrack[]>([]);
|
||||||
|
|
||||||
|
// Cross-origin <track> fetches use the media element's CORS setting; see
|
||||||
|
// videoCrossOriginMode for why this is opt-in and same-origin-only.
|
||||||
|
const videoCrossOrigin = $derived(
|
||||||
|
videoCrossOriginMode(currentStreamUrl, subtitleTracks().length)
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const streams = media?.mediaStreams ?? null;
|
||||||
|
const itemId = media?.id;
|
||||||
|
const sourceId = mediaSourceId;
|
||||||
|
// Native (ExoPlayer) mode renders subtitles itself; the element has none.
|
||||||
|
if (!useHtml5Element || !itemId || !sourceId) {
|
||||||
|
renderedSubtitleTracks = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
const tracks = await resolveSubtitleTracks(streams, (index) => getSubtitleUrl(index));
|
||||||
|
if (cancelled) return;
|
||||||
|
renderedSubtitleTracks = tracks;
|
||||||
|
// Keep the menu's checkmark and the element's text tracks in agreement:
|
||||||
|
// a selection that no longer resolves collapses to "Off".
|
||||||
|
const selected = reconcileSelectedSubtitle(tracks, untrack(() => selectedSubtitleIndex));
|
||||||
|
selectedSubtitleIndex = selected;
|
||||||
|
// The <track> children were just (re)created, so re-apply the selection to
|
||||||
|
// the new TextTrack objects — otherwise a surviving selection shows nothing.
|
||||||
|
await tick();
|
||||||
|
if (!cancelled) applySubtitleToElement(selected);
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Track the last prop value to detect when parent changes the URL (vs internal seeks)
|
// Track the last prop value to detect when parent changes the URL (vs internal seeks)
|
||||||
let lastStreamUrlProp = $state("");
|
let lastStreamUrlProp = $state("");
|
||||||
|
|
||||||
@@ -525,6 +650,27 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Populate the quality menu. Deliberately its own *synchronous* onMount that
|
||||||
|
// fires the load without awaiting it: an await inside the main onMount below
|
||||||
|
// flips the component into HTML5 mode and breaks native seeking, and nothing
|
||||||
|
// about playback waits on this list.
|
||||||
|
//
|
||||||
|
// TRACES: UR-074 | DR-162
|
||||||
|
onMount(() => {
|
||||||
|
Promise.all([
|
||||||
|
commands.playerGetStreamingQualities(),
|
||||||
|
commands.playerGetVideoSettings(),
|
||||||
|
])
|
||||||
|
.then(([qualities, settings]) => {
|
||||||
|
streamingQualities = qualities;
|
||||||
|
// Optional on the wire (serde default) — absent means uncapped.
|
||||||
|
selectedQuality = settings.streamingQuality ?? "original";
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn("[VideoPlayer] Failed to load streaming qualities:", err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Set up progress reporting interval
|
// Set up progress reporting interval
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
// Background-audio lifecycle listeners MUST be registered synchronously —
|
// Background-audio lifecycle listeners MUST be registered synchronously —
|
||||||
@@ -542,28 +688,23 @@
|
|||||||
console.log("[VideoPlayer] Initializing player for:", media.name);
|
console.log("[VideoPlayer] Initializing player for:", media.name);
|
||||||
console.log("[VideoPlayer] Stream URL:", currentStreamUrl);
|
console.log("[VideoPlayer] Stream URL:", currentStreamUrl);
|
||||||
|
|
||||||
// Build subtitle tracks for native player
|
// Resolve subtitle URLs for the native (ExoPlayer) path. These must be
|
||||||
const subtitleTracks = [];
|
// in hand *before* the play request: ExoPlayer sideloads subtitles as
|
||||||
if (media.mediaStreams && mediaSourceId) {
|
// MediaItem.SubtitleConfigurations, which have to exist before
|
||||||
const subtitles = media.mediaStreams.filter(s => s.kind === "subtitle");
|
// prepare() — there is no way to add one to a loaded item afterwards.
|
||||||
for (const sub of subtitles) {
|
//
|
||||||
try {
|
// Awaiting here is safe despite the native-mode pitfall: that rule is
|
||||||
const url = await getSubtitleUrl(sub.index);
|
// about Svelte *lifecycle* calls (onMount/onDestroy) after an await,
|
||||||
if (url) {
|
// which throw lifecycle_outside_component and used to be misread as an
|
||||||
subtitleTracks.push({
|
// init failure. Nothing is registered here, and the background-audio
|
||||||
index: sub.index,
|
// subscriptions above already ran synchronously. resolveSubtitleTracks
|
||||||
url: url,
|
// fans the requests out in parallel, so this costs one round trip, not
|
||||||
language: sub.language || null,
|
// one per subtitle stream as the old serial loop did.
|
||||||
label: sub.displayTitle || sub.language || `Track ${sub.index}`,
|
// TRACES: UR-020 | IR-016, JA-008 | UT-147
|
||||||
mime_type: "text/vtt" // Jellyfin converts to WebVTT
|
sentSubtitleTracks = mediaSourceId
|
||||||
});
|
? await resolveSubtitleTracks(media.mediaStreams, (index) => getSubtitleUrl(index))
|
||||||
}
|
: [];
|
||||||
} catch (err) {
|
console.log(`[VideoPlayer] Sending ${sentSubtitleTracks.length} subtitle tracks to the backend`);
|
||||||
console.warn(`[VideoPlayer] Failed to build subtitle URL for track ${sub.index}:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`[VideoPlayer] Built ${subtitleTracks.length} subtitle tracks for native player`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call Rust backend to start playback
|
// Call Rust backend to start playback
|
||||||
// Rust will choose ExoPlayer (Android), libmpv (Linux), or tell us to use HTML5
|
// Rust will choose ExoPlayer (Android), libmpv (Linux), or tell us to use HTML5
|
||||||
@@ -574,6 +715,10 @@
|
|||||||
id: media.id,
|
id: media.id,
|
||||||
videoCodec: needsTranscoding ? "hevc" : "h264",
|
videoCodec: needsTranscoding ? "hevc" : "h264",
|
||||||
needsTranscoding: needsTranscoding,
|
needsTranscoding: needsTranscoding,
|
||||||
|
// Order matters: player_set_subtitle_track(n) is a position in this
|
||||||
|
// array. Previously this array was built and then dropped, so
|
||||||
|
// ExoPlayer got a MediaItem with no subtitles at all.
|
||||||
|
subtitles: nativeSubtitleTracks(sentSubtitleTracks),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Rust tells us which backend it's using
|
// Rust tells us which backend it's using
|
||||||
@@ -581,15 +726,16 @@
|
|||||||
backendChosen = true;
|
backendChosen = true;
|
||||||
console.log(`[VideoPlayer] Backend: ${response.backend}, useHtml5Element: ${useHtml5Element}`);
|
console.log(`[VideoPlayer] Backend: ${response.backend}, useHtml5Element: ${useHtml5Element}`);
|
||||||
|
|
||||||
// INTERIM (until the video-player API refactor lands): always render
|
// Rust reported a native backend (Android/ExoPlayer). Honour it only if
|
||||||
// through the webview HTML5 element, including Android. The native
|
// the user opted into the experimental native path; otherwise fall back
|
||||||
// ExoPlayer SurfaceView sits behind an opaque webview and has never
|
// to the webview element, which is what shipped by default.
|
||||||
// actually been visible (an init bug kept the app on the HTML5 path
|
//
|
||||||
// since the POC), so true native mode plays audio behind a frozen
|
// The flag is a suppressor, never a promoter — see createAdapter(). When
|
||||||
// picture. Stop the native backend and let the webview own playback,
|
// it is off we must also stop the native backend that player_play_item
|
||||||
// matching Linux behavior and avoiding dual audio.
|
// just started, or ExoPlayer and the <video> element both decode the
|
||||||
if (!useHtml5Element) {
|
// same stream and the audio doubles.
|
||||||
console.warn("[VideoPlayer] Native video backend reported - overriding to HTML5 rendering (native surface not visible through webview)");
|
if (!useHtml5Element && !$experimentalNativeVideo) {
|
||||||
|
console.log("[VideoPlayer] Native backend available but experimentalNativeVideo is off - using HTML5");
|
||||||
useHtml5Element = true;
|
useHtml5Element = true;
|
||||||
try {
|
try {
|
||||||
await commands.playerStop();
|
await commands.playerStop();
|
||||||
@@ -597,6 +743,14 @@
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[VideoPlayer] Failed to stop native backend:", err);
|
console.warn("[VideoPlayer] Failed to stop native backend:", err);
|
||||||
}
|
}
|
||||||
|
} else if (!useHtml5Element) {
|
||||||
|
// Native path: clear the opaque layers between the viewport and the
|
||||||
|
// ExoPlayer SurfaceView (webview widget background + page background).
|
||||||
|
// Paired with disableNativeVideoCompositing() in the teardown path —
|
||||||
|
// leaving this on renders the rest of the app over a transparent
|
||||||
|
// window.
|
||||||
|
console.log("[VideoPlayer] Using native ExoPlayer video surface");
|
||||||
|
enableNativeVideoCompositing();
|
||||||
}
|
}
|
||||||
|
|
||||||
// If using HTML5 element for non-transcoded content, stop the backend player
|
// If using HTML5 element for non-transcoded content, stop the backend player
|
||||||
@@ -615,16 +769,61 @@
|
|||||||
didStartNativePlayback = true; // Track that we need to stop backend on unmount
|
didStartNativePlayback = true; // Track that we need to stop backend on unmount
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register the HTML5 player adapter with the facade so control intents
|
// Register the adapter with the facade so control intents (UI, or a
|
||||||
// (UI or backend lockscreen/remote/sleep events) route to this element.
|
// backend lockscreen/remote/sleep event) route to whatever is actually
|
||||||
if (useHtml5Element) {
|
// rendering. Both paths need one: the native adapter forwards control
|
||||||
|
// intents to ExoPlayer over IPC.
|
||||||
|
{
|
||||||
const host = createRustReportHost(media.id, {
|
const host = createRustReportHost(media.id, {
|
||||||
onEnded: () => notifyEnded(),
|
onEnded: () => notifyEnded(),
|
||||||
onStreamUrlChanged: (u) => { currentStreamUrl = u; },
|
onStreamUrlChanged: (u) => { currentStreamUrl = u; },
|
||||||
});
|
});
|
||||||
playerAdapter = new Html5PlayerAdapter(host, adapterBridge);
|
playerAdapter = createAdapter({
|
||||||
|
backendKind: useHtml5Element ? "html5" : "native",
|
||||||
|
host,
|
||||||
|
bridge: adapterBridge,
|
||||||
|
// useHtml5Element is already the resolved decision above, so the
|
||||||
|
// flag has had its say; pass it through for the invariant check.
|
||||||
|
experimentalNativeVideo: $experimentalNativeVideo,
|
||||||
|
});
|
||||||
|
// No-op for the native adapter, which owns no DOM element.
|
||||||
playerAdapter.attach(videoElement);
|
playerAdapter.attach(videoElement);
|
||||||
playerController.setActiveAdapter(playerAdapter);
|
playerController.setActiveAdapter(playerAdapter);
|
||||||
|
|
||||||
|
// The native (ExoPlayer) path has no <video> element, so `canplay`
|
||||||
|
// never fires and the handleCanPlay initial-seek below never runs —
|
||||||
|
// resume-at-position played from the beginning on Android. Hand the
|
||||||
|
// resume point to the adapter, which issues the backend seek.
|
||||||
|
//
|
||||||
|
// HTML5 keeps its existing element-driven seek: seeking before the
|
||||||
|
// element has metadata is clamped back to 0, which is precisely what
|
||||||
|
// handleCanPlay waits for.
|
||||||
|
// TRACES: UR-005 | DR-004, DR-028
|
||||||
|
if (!useHtml5Element) {
|
||||||
|
hasPerformedInitialSeek = true; // native path owns the initial seek
|
||||||
|
lastAppliedInitialPosition = initialPosition;
|
||||||
|
await playerAdapter.load(currentStreamUrl, {
|
||||||
|
mediaId: media.id,
|
||||||
|
mediaSourceId: mediaSourceId ?? null,
|
||||||
|
needsTranscoding,
|
||||||
|
initialPosition: initialPosition ?? 0,
|
||||||
|
isLive,
|
||||||
|
audioTrackIndex: null,
|
||||||
|
knownDuration: media.durationMs ? media.durationMs / 1000 : 0,
|
||||||
|
// ExoPlayer already received these as SubtitleConfigurations via
|
||||||
|
// player_play_item; mapped to the adapter shape for the contract.
|
||||||
|
subtitleTracks: sentSubtitleTracks.map((t) => ({
|
||||||
|
index: t.streamIndex,
|
||||||
|
url: t.url,
|
||||||
|
language: t.srclang,
|
||||||
|
label: t.label,
|
||||||
|
mimeType: "text/vtt",
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
if (initialPosition && initialPosition > 0 && !isLive) {
|
||||||
|
currentTime = initialPosition;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!useHtml5Element) {
|
if (!useHtml5Element) {
|
||||||
@@ -679,6 +878,24 @@
|
|||||||
// Load series audio preference (for TV shows)
|
// Load series audio preference (for TV shows)
|
||||||
await loadSeriesAudioPreference();
|
await loadSeriesAudioPreference();
|
||||||
|
|
||||||
|
// PiP: keep native's view of the `<video>` current, and react to the window
|
||||||
|
// shrinking. The listeners are torn down in onDestroy. (DR-160)
|
||||||
|
reportPipVideoState();
|
||||||
|
const onPipEntered = () => (isInPip = true);
|
||||||
|
const onPipExited = () => (isInPip = false);
|
||||||
|
const onPipPlay = () => void videoElement?.play().catch(() => {});
|
||||||
|
const onPipPause = () => videoElement?.pause();
|
||||||
|
window.addEventListener("jellytau-pip-entered", onPipEntered);
|
||||||
|
window.addEventListener("jellytau-pip-exited", onPipExited);
|
||||||
|
window.addEventListener("jellytau-pip-play", onPipPlay);
|
||||||
|
window.addEventListener("jellytau-pip-pause", onPipPause);
|
||||||
|
pipListenerCleanup = () => {
|
||||||
|
window.removeEventListener("jellytau-pip-entered", onPipEntered);
|
||||||
|
window.removeEventListener("jellytau-pip-exited", onPipExited);
|
||||||
|
window.removeEventListener("jellytau-pip-play", onPipPlay);
|
||||||
|
window.removeEventListener("jellytau-pip-pause", onPipPause);
|
||||||
|
};
|
||||||
|
|
||||||
// Report progress every 10 seconds while playing. Live streams have no
|
// Report progress every 10 seconds while playing. Live streams have no
|
||||||
// meaningful position to report, so skip progress reporting entirely.
|
// meaningful position to report, so skip progress reporting entirely.
|
||||||
if (!isLive) {
|
if (!isLive) {
|
||||||
@@ -716,6 +933,24 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(async () => {
|
onDestroy(async () => {
|
||||||
|
// FIRST, and synchronously: restore the opaque webview/page backgrounds.
|
||||||
|
//
|
||||||
|
// This callback is async, so anything after an `await` may run a frame or
|
||||||
|
// more later. Leaving the window transparent for even that long shows the
|
||||||
|
// launcher/wallpaper through the app as the player unwinds. Unconditional
|
||||||
|
// and idempotent — a no-op when compositing was never enabled.
|
||||||
|
disableNativeVideoCompositing();
|
||||||
|
|
||||||
|
// Same reasoning for the system bars: they belong to the Activity, not to
|
||||||
|
// this component, so a player torn down while immersive would leave every
|
||||||
|
// screen behind it without a status or navigation bar. Idempotent. (UR-066)
|
||||||
|
exitImmersive();
|
||||||
|
|
||||||
|
// The `<video>` is going away, so PiP must stop being offered over it.
|
||||||
|
setHtml5VideoState(false, 0, 0, false);
|
||||||
|
pipListenerCleanup?.();
|
||||||
|
pipListenerCleanup = null;
|
||||||
|
|
||||||
// Stop RAF loop
|
// Stop RAF loop
|
||||||
stopTimeUpdates();
|
stopTimeUpdates();
|
||||||
|
|
||||||
@@ -829,6 +1064,9 @@
|
|||||||
|
|
||||||
function handleLoadedMetadata() {
|
function handleLoadedMetadata() {
|
||||||
console.log("[VideoPlayer] loadedmetadata event");
|
console.log("[VideoPlayer] loadedmetadata event");
|
||||||
|
// Intrinsic dimensions are known now, which is what PiP sizes its window
|
||||||
|
// from — before this they are 0 and the ratio would be rejected. (DR-160)
|
||||||
|
reportPipVideoState();
|
||||||
console.log("[VideoPlayer] Video element duration:", videoElement?.duration);
|
console.log("[VideoPlayer] Video element duration:", videoElement?.duration);
|
||||||
console.log("[VideoPlayer] Media item runTimeTicks:", media?.runTimeTicks);
|
console.log("[VideoPlayer] Media item runTimeTicks:", media?.runTimeTicks);
|
||||||
console.log("[VideoPlayer] Needs transcoding:", needsTranscoding);
|
console.log("[VideoPlayer] Needs transcoding:", needsTranscoding);
|
||||||
@@ -1100,6 +1338,8 @@
|
|||||||
function handlePlay() {
|
function handlePlay() {
|
||||||
isPlaying = true;
|
isPlaying = true;
|
||||||
startTimeUpdates(); // Start RAF loop for smooth time updates
|
startTimeUpdates(); // Start RAF loop for smooth time updates
|
||||||
|
// PiP's play/pause action reflects this. (DR-160)
|
||||||
|
reportPipVideoState();
|
||||||
// Mirror the DOM state into the Rust PlayerController so it is the single
|
// Mirror the DOM state into the Rust PlayerController so it is the single
|
||||||
// source of truth for HTML5 video (the <video> lives in the webview, which
|
// source of truth for HTML5 video (the <video> lives in the webview, which
|
||||||
// Rust cannot observe directly). See html5Adapter.ts.
|
// Rust cannot observe directly). See html5Adapter.ts.
|
||||||
@@ -1129,6 +1369,7 @@
|
|||||||
);
|
);
|
||||||
isPlaying = false;
|
isPlaying = false;
|
||||||
stopTimeUpdates(); // Stop RAF loop when paused
|
stopTimeUpdates(); // Stop RAF loop when paused
|
||||||
|
reportPipVideoState(); // PiP's play/pause action reflects this. (DR-160)
|
||||||
html5Adapter.reportState("paused", reportMediaId ?? null);
|
html5Adapter.reportState("paused", reportMediaId ?? null);
|
||||||
html5Adapter.reportPosition(currentTime, duration, { force: true });
|
html5Adapter.reportPosition(currentTime, duration, { force: true });
|
||||||
// Report progress when paused
|
// Report progress when paused
|
||||||
@@ -1333,7 +1574,11 @@
|
|||||||
// the position native reached, and restore play/pause.
|
// the position native reached, and restore play/pause.
|
||||||
async function exitBackgroundAudioHandoff() {
|
async function exitBackgroundAudioHandoff() {
|
||||||
if (!shouldExitBackgroundAudio(handoffState)) return;
|
if (!shouldExitBackgroundAudio(handoffState)) return;
|
||||||
const wasPlaying = handoffState.wasPlaying;
|
// Read the native player's state BEFORE exiting — the exit stops it. If the
|
||||||
|
// user hit pause on the lockscreen while backgrounded, that pause must
|
||||||
|
// survive the return to video rather than being overwritten by whatever the
|
||||||
|
// <video> was doing when we handed off.
|
||||||
|
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).
|
// Absolute position the native audio reached (base offset applied in Rust).
|
||||||
@@ -1385,12 +1630,24 @@
|
|||||||
let pendingForegroundSeek: number | null = null;
|
let pendingForegroundSeek: number | null = null;
|
||||||
let pendingForegroundPlay = false;
|
let pendingForegroundPlay = false;
|
||||||
|
|
||||||
|
// On Android the Activity owns the system bars, and requestFullscreen() cannot
|
||||||
|
// reach them — the WebView already spans the window under an edge-to-edge
|
||||||
|
// Activity, so on its own it left the status and navigation bars painted over
|
||||||
|
// the video. The native bridge is what actually makes fullscreen full screen;
|
||||||
|
// requestFullscreen() still does the work everywhere else. (UR-066, DR-157)
|
||||||
function toggleFullscreen() {
|
function toggleFullscreen() {
|
||||||
if (!document.fullscreenElement) {
|
if (!document.fullscreenElement) {
|
||||||
document.documentElement.requestFullscreen();
|
document.documentElement.requestFullscreen().catch((err) => {
|
||||||
|
// WebKitGTK rejects when the gesture isn't recognised as user-activated;
|
||||||
|
// the immersive call below is what matters on Android, so don't let a
|
||||||
|
// rejection here abort it.
|
||||||
|
console.warn("[VideoPlayer] requestFullscreen rejected:", err);
|
||||||
|
});
|
||||||
|
enterImmersive();
|
||||||
isFullscreen = true;
|
isFullscreen = true;
|
||||||
} else {
|
} else {
|
||||||
document.exitFullscreen();
|
document.exitFullscreen();
|
||||||
|
exitImmersive();
|
||||||
isFullscreen = false;
|
isFullscreen = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1450,7 +1707,9 @@
|
|||||||
toggleFullscreen();
|
toggleFullscreen();
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
if (isFullscreen) {
|
if (isFullscreen) {
|
||||||
document.exitFullscreen();
|
// Through the toggle, not document.exitFullscreen() directly: leaving
|
||||||
|
// fullscreen also has to restore the system bars and clear the flag.
|
||||||
|
toggleFullscreen();
|
||||||
} else {
|
} else {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
@@ -1660,45 +1919,104 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleQualityMenu() {
|
||||||
|
showQualityMenu = !showQualityMenu;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-open the current stream at a different bandwidth ceiling.
|
||||||
|
*
|
||||||
|
* The backend owns everything about how that happens — it decides whether the
|
||||||
|
* caller reloads (HTML5) or it reloads the native backend itself — so this
|
||||||
|
* only supplies the position to resume at and reverts the selection if the
|
||||||
|
* switch fails.
|
||||||
|
*
|
||||||
|
* TRACES: UR-074 | DR-162
|
||||||
|
*/
|
||||||
|
async function selectQuality(quality: StreamingQuality) {
|
||||||
|
showQualityMenu = false;
|
||||||
|
if (quality === selectedQuality || changingQuality) return;
|
||||||
|
|
||||||
|
const previous = selectedQuality;
|
||||||
|
selectedQuality = quality;
|
||||||
|
changingQuality = true;
|
||||||
|
try {
|
||||||
|
stopTimeUpdates();
|
||||||
|
await playerController.setStreamQuality(
|
||||||
|
quality,
|
||||||
|
videoElement ? videoElement.currentTime + seekOffset : null,
|
||||||
|
mediaSourceId ?? null,
|
||||||
|
selectedAudioTrackIndex
|
||||||
|
);
|
||||||
|
if (videoElement && !videoElement.paused) {
|
||||||
|
startTimeUpdates();
|
||||||
|
}
|
||||||
|
console.log("[VideoPlayer] Streaming quality changed:", quality);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[VideoPlayer] Failed to change streaming quality:", err);
|
||||||
|
selectedQuality = previous;
|
||||||
|
} finally {
|
||||||
|
changingQuality = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toggleSubtitleMenu() {
|
function toggleSubtitleMenu() {
|
||||||
showSubtitleMenu = !showSubtitleMenu;
|
showSubtitleMenu = !showSubtitleMenu;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectSubtitle(streamIndex: number | null, arrayIndex?: number) {
|
/**
|
||||||
console.log("[VideoPlayer] Selecting subtitle - streamIndex:", streamIndex, "arrayIndex:", arrayIndex);
|
* Show exactly one (or no) text track on the HTML5 element. `null` disables
|
||||||
|
* every track, which is what the menu's "Off" entry means.
|
||||||
|
*
|
||||||
|
* TRACES: UR-020 | DR-023
|
||||||
|
*/
|
||||||
|
function applySubtitleToElement(streamIndex: number | null) {
|
||||||
|
if (!useHtml5Element || !videoElement || !videoElement.textTracks) return;
|
||||||
|
|
||||||
|
// Disable all text tracks first, so "Off" genuinely turns subtitles off.
|
||||||
|
for (let i = 0; i < videoElement.textTracks.length; i++) {
|
||||||
|
videoElement.textTracks[i].mode = "disabled";
|
||||||
|
}
|
||||||
|
if (streamIndex === null) return;
|
||||||
|
|
||||||
|
// Find the corresponding track element by stream index.
|
||||||
|
videoElement.querySelectorAll("track").forEach((track) => {
|
||||||
|
const trackStreamIndex = parseInt(track.getAttribute("data-stream-index") || "-1");
|
||||||
|
if (trackStreamIndex === streamIndex && track.track) {
|
||||||
|
track.track.mode = "showing";
|
||||||
|
console.log("[VideoPlayer] Enabled subtitle track:", streamIndex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the menu's choice. `streamIndex` is always the Jellyfin media-stream
|
||||||
|
* index (or `null` for "Off") — the UI speaks stream indices throughout.
|
||||||
|
*
|
||||||
|
* The native backend does not: `player_set_subtitle_track(n)` reaches
|
||||||
|
* `JellyTauPlayer.setSubtitleTrack(n)`, which indexes ExoPlayer's text track
|
||||||
|
* groups, i.e. the position of the sideloaded subtitle configuration. That
|
||||||
|
* position is derived from `sentSubtitleTracks` — the exact array sent with
|
||||||
|
* the play request — and not from the menu's row number, which counts every
|
||||||
|
* subtitle *stream* including ones whose URL never resolved and so were never
|
||||||
|
* sideloaded.
|
||||||
|
*
|
||||||
|
* TRACES: UR-020 | DR-023, IR-016 | UT-147
|
||||||
|
*/
|
||||||
|
async function selectSubtitle(streamIndex: number | null) {
|
||||||
|
console.log("[VideoPlayer] Selecting subtitle - streamIndex:", streamIndex);
|
||||||
selectedSubtitleIndex = streamIndex;
|
selectedSubtitleIndex = streamIndex;
|
||||||
showSubtitleMenu = false;
|
showSubtitleMenu = false;
|
||||||
|
|
||||||
// For HTML5 video element, update the text tracks
|
// For HTML5 video element, update the text tracks
|
||||||
if (useHtml5Element && videoElement && videoElement.textTracks) {
|
if (useHtml5Element) {
|
||||||
// Disable all text tracks first
|
applySubtitleToElement(streamIndex);
|
||||||
for (let i = 0; i < videoElement.textTracks.length; i++) {
|
} else {
|
||||||
videoElement.textTracks[i].mode = "disabled";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enable the selected track if not null
|
|
||||||
if (streamIndex !== null) {
|
|
||||||
// Find the corresponding track element by stream index
|
|
||||||
const tracks = videoElement.querySelectorAll("track");
|
|
||||||
tracks.forEach((track) => {
|
|
||||||
const trackStreamIndex = parseInt(track.getAttribute("data-stream-index") || "-1");
|
|
||||||
if (trackStreamIndex === streamIndex) {
|
|
||||||
const textTrack = track.track;
|
|
||||||
if (textTrack) {
|
|
||||||
textTrack.mode = "showing";
|
|
||||||
console.log("[VideoPlayer] Enabled subtitle track:", streamIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (!useHtml5Element) {
|
|
||||||
// For native backend (Android), send command to change subtitle track
|
// For native backend (Android), send command to change subtitle track
|
||||||
try {
|
try {
|
||||||
// Use array index for ExoPlayer (0-based position in subtitle tracks array)
|
const indexToUse = nativeSubtitleArrayIndex(sentSubtitleTracks, streamIndex);
|
||||||
// If streamIndex is null (disable), pass null; otherwise use arrayIndex
|
|
||||||
const indexToUse = streamIndex === null ? null : (arrayIndex !== undefined ? arrayIndex : streamIndex);
|
|
||||||
await commands.playerSetSubtitleTrack(indexToUse);
|
await commands.playerSetSubtitleTrack(indexToUse);
|
||||||
console.log("[VideoPlayer] Native backend subtitle track changed - arrayIndex:", arrayIndex, "used:", indexToUse);
|
console.log("[VideoPlayer] Native backend subtitle track changed - streamIndex:", streamIndex, "position:", indexToUse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[VideoPlayer] Failed to set subtitle track:", error);
|
console.error("[VideoPlayer] Failed to set subtitle track:", error);
|
||||||
}
|
}
|
||||||
@@ -1737,6 +2055,7 @@
|
|||||||
<video
|
<video
|
||||||
bind:this={videoElement}
|
bind:this={videoElement}
|
||||||
src={currentStreamUrl.includes('.m3u8') && Hls.isSupported() ? '' : currentStreamUrl}
|
src={currentStreamUrl.includes('.m3u8') && Hls.isSupported() ? '' : currentStreamUrl}
|
||||||
|
crossorigin={videoCrossOrigin}
|
||||||
class={videoFitClass()}
|
class={videoFitClass()}
|
||||||
class:invisible={!isMediaReady}
|
class:invisible={!isMediaReady}
|
||||||
style="filter: brightness({brightness})"
|
style="filter: brightness({brightness})"
|
||||||
@@ -1755,18 +2074,22 @@
|
|||||||
onloadstart={handleLoadStart}
|
onloadstart={handleLoadStart}
|
||||||
onclick={handleSurfaceClick}
|
onclick={handleSurfaceClick}
|
||||||
>
|
>
|
||||||
<!-- Temporarily disabled to debug playback issues
|
<!--
|
||||||
{#each subtitleTracks() as track}
|
Subtitles for the HTML5 path. `src` is a resolved string (see
|
||||||
|
renderedSubtitleTracks); `data-stream-index` is what
|
||||||
|
Html5PlayerAdapter.selectSubtitle() matches on. No `default`
|
||||||
|
attribute: a default track auto-shows, which would contradict the
|
||||||
|
menu opening on "Off".
|
||||||
|
-->
|
||||||
|
{#each renderedSubtitleTracks as track (track.streamIndex)}
|
||||||
<track
|
<track
|
||||||
kind="subtitles"
|
kind="subtitles"
|
||||||
src={getSubtitleUrl(track.index)}
|
src={track.url}
|
||||||
srclang={track.language || "unknown"}
|
srclang={track.srclang}
|
||||||
label={track.displayTitle || track.language || `Track ${track.index}`}
|
label={track.label}
|
||||||
data-stream-index={track.index}
|
data-stream-index={track.streamIndex}
|
||||||
default={track.isDefault}
|
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
-->
|
|
||||||
</video>
|
</video>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Android ExoPlayer - video rendered natively in SurfaceView behind WebView -->
|
<!-- Android ExoPlayer - video rendered natively in SurfaceView behind WebView -->
|
||||||
@@ -1869,7 +2192,13 @@
|
|||||||
returned actors for the current timestamp. Tapping an actor with a
|
returned actors for the current timestamp. Tapping an actor with a
|
||||||
resolved Jellyfin Person id opens their library page. -->
|
resolved Jellyfin Person id opens their library page. -->
|
||||||
{#if !isPlaying && !isSeeking && jrayActors.length > 0}
|
{#if !isPlaying && !isSeeking && jrayActors.length > 0}
|
||||||
<div class="absolute top-4 right-4 max-w-xs bg-black/70 rounded-lg p-3 backdrop-blur-sm pointer-events-auto">
|
<!-- Offset by the safe-area insets so the card clears the status bar and,
|
||||||
|
in landscape, the display cutout. (UR-066) -->
|
||||||
|
<div
|
||||||
|
class="absolute top-4 right-4 max-w-xs bg-black/70 rounded-lg p-3 backdrop-blur-sm pointer-events-auto"
|
||||||
|
style:top="calc(1rem + var(--safe-top))"
|
||||||
|
style:right="calc(1rem + var(--safe-right))"
|
||||||
|
>
|
||||||
<div class="text-white/60 text-xs font-medium uppercase tracking-wide mb-2">On screen</div>
|
<div class="text-white/60 text-xs font-medium uppercase tracking-wide mb-2">On screen</div>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
{#each jrayActors as actor (actor.name + actor.jellyfin_id)}
|
{#each jrayActors as actor (actor.name + actor.jellyfin_id)}
|
||||||
@@ -1905,12 +2234,21 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Controls. `data-player-controls` marks this subtree as interactive so
|
<!-- Controls. `data-player-controls` marks this subtree as interactive so
|
||||||
container-level tap gestures ignore touches here (see DR-098). -->
|
container-level tap gestures ignore touches here (see DR-098).
|
||||||
|
|
||||||
|
The video itself deliberately fills the whole screen (edge-to-edge, under
|
||||||
|
the cutout), but every interactive control lives in here — so this box,
|
||||||
|
not the video, carries the safe-area insets. Without them the scrub bar
|
||||||
|
and the close/fullscreen buttons sit under the Android gesture bar, and
|
||||||
|
in landscape under the display cutout. (UR-066) -->
|
||||||
<div
|
<div
|
||||||
data-player-controls
|
data-player-controls
|
||||||
class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 transition-opacity duration-300"
|
class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 transition-opacity duration-300"
|
||||||
class:opacity-0={!showControls}
|
style:padding-bottom="calc(1rem + var(--safe-bottom))"
|
||||||
class:pointer-events-none={!showControls}
|
style:padding-left="calc(1rem + var(--safe-left))"
|
||||||
|
style:padding-right="calc(1rem + var(--safe-right))"
|
||||||
|
class:opacity-0={!showControls || isInPip}
|
||||||
|
class:pointer-events-none={!showControls || isInPip}
|
||||||
>
|
>
|
||||||
<!-- Title -->
|
<!-- Title -->
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
@@ -2018,6 +2356,48 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Streaming quality (bandwidth ceiling). TRACES: UR-074 | DR-162 -->
|
||||||
|
{#if streamingQualities.length > 0}
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
onclick={toggleQualityMenu}
|
||||||
|
class="text-white hover:text-gray-300 disabled:opacity-50"
|
||||||
|
disabled={changingQuality}
|
||||||
|
aria-label="Select streaming quality"
|
||||||
|
>
|
||||||
|
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM14 13v4h-4v-4H7l5-5 5 5h-3z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if showQualityMenu}
|
||||||
|
<div class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[220px] max-h-[300px] overflow-y-auto">
|
||||||
|
<div class="p-2">
|
||||||
|
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
|
||||||
|
Quality
|
||||||
|
</div>
|
||||||
|
{#each streamingQualities as [quality, label, detail]}
|
||||||
|
<button
|
||||||
|
onclick={() => selectQuality(quality)}
|
||||||
|
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedQuality === quality ? 'bg-white/20' : ''}"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-sm">{label}</span>
|
||||||
|
<span class="text-xs text-gray-400">{detail}</span>
|
||||||
|
</div>
|
||||||
|
{#if selectedQuality === quality}
|
||||||
|
<svg class="w-4 h-4 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Subtitle Selection -->
|
<!-- Subtitle Selection -->
|
||||||
{#if subtitleTracks().length > 0}
|
{#if subtitleTracks().length > 0}
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
@@ -2051,9 +2431,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<!-- Subtitle tracks -->
|
<!-- Subtitle tracks -->
|
||||||
{#each subtitleTracks() as track, i}
|
{#each subtitleTracks() as track}
|
||||||
<button
|
<button
|
||||||
onclick={() => selectSubtitle(track.index, i)}
|
onclick={() => selectSubtitle(track.index)}
|
||||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex === track.index ? 'bg-white/20' : ''}"
|
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex === track.index ? 'bg-white/20' : ''}"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ vi.mock("$lib/utils/pictureInPicture", () => ({
|
|||||||
isPipSupported: () => false,
|
isPipSupported: () => false,
|
||||||
enterPip: vi.fn(),
|
enterPip: vi.fn(),
|
||||||
setAutoEnterEnabled: vi.fn(),
|
setAutoEnterEnabled: vi.fn(),
|
||||||
|
setHtml5VideoState: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("$lib/stores/auth", () => ({
|
vi.mock("$lib/stores/auth", () => ({
|
||||||
|
|||||||
@@ -26,6 +26,27 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||||||
// ---- Mocks (must precede component import) --------------------------------
|
// ---- Mocks (must precede component import) --------------------------------
|
||||||
|
|
||||||
const channelHandlers: Record<string, (event: any) => void> = {};
|
const channelHandlers: Record<string, (event: any) => void> = {};
|
||||||
|
// These tests pin the **flag-off** interim behaviour: when `experimentalNativeVideo`
|
||||||
|
// is off, VideoPlayer overrides Android's native backend response to HTML5
|
||||||
|
// rendering and stops the native backend. That flag now defaults to *on*
|
||||||
|
// (DR-160, so picture-in-picture has a real surface to shrink into), so the
|
||||||
|
// default no longer selects this path and the tests have to say which path they
|
||||||
|
// are guarding rather than inherit it. (DR-161)
|
||||||
|
vi.mock("$lib/stores/nativeVideo", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("$lib/stores/nativeVideo")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
experimentalNativeVideo: {
|
||||||
|
subscribe: (run: (v: boolean) => void) => {
|
||||||
|
run(false);
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
set: () => {},
|
||||||
|
current: () => false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
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;
|
||||||
@@ -63,6 +84,10 @@ vi.mock("$lib/api/bindings", () => ({
|
|||||||
playerCancelSleepTimer: vi.fn(async () => ({})),
|
playerCancelSleepTimer: vi.fn(async () => ({})),
|
||||||
playerSetSubtitleTrack: vi.fn(async () => ({})),
|
playerSetSubtitleTrack: vi.fn(async () => ({})),
|
||||||
playerSwitchAudioTrack: vi.fn(async () => ({})),
|
playerSwitchAudioTrack: vi.fn(async () => ({})),
|
||||||
|
// The player loads the streaming-quality picker on mount; without these the
|
||||||
|
// mock throws and every test in the file fails before it starts.
|
||||||
|
playerGetStreamingQualities: vi.fn(async () => []),
|
||||||
|
playerGetVideoSettings: vi.fn(async () => ({ streamingQuality: "original" })),
|
||||||
storageGetSeriesAudioPreference: vi.fn(async () => null),
|
storageGetSeriesAudioPreference: vi.fn(async () => null),
|
||||||
storageSaveSeriesAudioPreference: vi.fn(async () => ({})),
|
storageSaveSeriesAudioPreference: vi.fn(async () => ({})),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
initialHandoffState,
|
initialHandoffState,
|
||||||
shouldEnterBackgroundAudio,
|
shouldEnterBackgroundAudio,
|
||||||
shouldExitBackgroundAudio,
|
shouldExitBackgroundAudio,
|
||||||
|
shouldResumeOnForeground,
|
||||||
type BackgroundAudioState,
|
type BackgroundAudioState,
|
||||||
} from "./backgroundAudioHandoff";
|
} from "./backgroundAudioHandoff";
|
||||||
|
|
||||||
@@ -58,4 +59,27 @@ describe("backgroundAudioHandoff", () => {
|
|||||||
expect(shouldExitBackgroundAudio(active)).toBe(true);
|
expect(shouldExitBackgroundAudio(active)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("shouldResumeOnForeground", () => {
|
||||||
|
it("resumes when it was playing and the native audio still is", () => {
|
||||||
|
expect(shouldResumeOnForeground(true, "playing")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays paused when the lockscreen paused the native audio", () => {
|
||||||
|
// The whole point of the lockscreen pause: coming back to the app must not
|
||||||
|
// undo it just because the video was playing when we handed off.
|
||||||
|
expect(shouldResumeOnForeground(true, "paused")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays paused when the video was already paused at handoff", () => {
|
||||||
|
expect(shouldResumeOnForeground(false, "playing")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the captured state when native state is unknown", () => {
|
||||||
|
// Loading/seeking/idle say nothing about intent — the handoff snapshot is
|
||||||
|
// the best evidence we have, so a playing video still resumes.
|
||||||
|
expect(shouldResumeOnForeground(true, "loading")).toBe(true);
|
||||||
|
expect(shouldResumeOnForeground(true, undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,3 +55,22 @@ export function shouldEnterBackgroundAudio(
|
|||||||
export function shouldExitBackgroundAudio(state: BackgroundAudioState): boolean {
|
export function shouldExitBackgroundAudio(state: BackgroundAudioState): boolean {
|
||||||
return state.active;
|
return state.active;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the `<video>` should start playing again once it reloads on foreground.
|
||||||
|
*
|
||||||
|
* `wasPlaying` is what the video was doing when we handed off, but the native
|
||||||
|
* audio player kept going after that — and the lockscreen/notification can pause
|
||||||
|
* it while backgrounded. The player is the authoritative source of play/pause,
|
||||||
|
* so an explicit `paused` from it overrides the handoff snapshot; anything less
|
||||||
|
* definite (loading, seeking, already-stopped, no state at all) falls back to
|
||||||
|
* the snapshot.
|
||||||
|
*
|
||||||
|
* TRACES: UR-040, UR-005 | DR-052 | UT-060
|
||||||
|
*/
|
||||||
|
export function shouldResumeOnForeground(
|
||||||
|
wasPlaying: boolean,
|
||||||
|
nativeStateKind: string | undefined
|
||||||
|
): boolean {
|
||||||
|
return wasPlaying && nativeStateKind !== "paused";
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import {
|
||||||
|
subtitleStreamsOf,
|
||||||
|
subtitleTrackLabel,
|
||||||
|
resolveSubtitleTracks,
|
||||||
|
reconcileSelectedSubtitle,
|
||||||
|
videoCrossOriginMode,
|
||||||
|
nativeSubtitleTracks,
|
||||||
|
nativeSubtitleArrayIndex,
|
||||||
|
type SubtitleStreamLike,
|
||||||
|
} from "./subtitleTracks";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subtitles on the Linux / WebKitGTK HTML5 `<video>` path.
|
||||||
|
*
|
||||||
|
* TRACES: UR-020 | DR-023 | UT-143, UT-144
|
||||||
|
*
|
||||||
|
* The bug this guards: VideoPlayer rendered no `<track>` children at all (the
|
||||||
|
* block was commented out "to debug playback issues"), so
|
||||||
|
* `Html5PlayerAdapter.selectSubtitle()` walked an empty `textTracks` list and
|
||||||
|
* the subtitle menu was inert on Linux. The reason it had to be disabled is
|
||||||
|
* visible in the original markup — `src={getSubtitleUrl(track.index)}` bound the
|
||||||
|
* *Promise* returned by an async function to the attribute, so every track's src
|
||||||
|
* stringified to "[object Promise]", an unloadable resource hanging off the
|
||||||
|
* media element.
|
||||||
|
*
|
||||||
|
* So the fix has two halves and both are tested here: URLs must be resolved into
|
||||||
|
* plain strings *before* they reach the markup, and the markup must actually
|
||||||
|
* render the tracks (with the `data-stream-index` the adapter matches on).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SUBS: SubtitleStreamLike[] = [
|
||||||
|
{ index: 2, kind: "subtitle", language: "eng", displayTitle: "English (SRT)", isDefault: true },
|
||||||
|
{ index: 3, kind: "subtitle", language: "fre", displayTitle: "French", isDefault: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STREAMS: SubtitleStreamLike[] = [
|
||||||
|
{ index: 0, kind: "video", language: null, displayTitle: "1080p" },
|
||||||
|
{ index: 1, kind: "audio", language: "eng", displayTitle: "English AAC" },
|
||||||
|
...SUBS,
|
||||||
|
];
|
||||||
|
|
||||||
|
const url = (i: number) => `http://jelly.example/Videos/x/Subtitles/${i}/0/subtitles.vtt?api_key=k`;
|
||||||
|
|
||||||
|
describe("subtitleStreamsOf", () => {
|
||||||
|
it("keeps only subtitle streams, in stream order", () => {
|
||||||
|
expect(subtitleStreamsOf(STREAMS).map((s) => s.index)).toEqual([2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tolerates missing media streams", () => {
|
||||||
|
expect(subtitleStreamsOf(null)).toEqual([]);
|
||||||
|
expect(subtitleStreamsOf(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("subtitleTrackLabel", () => {
|
||||||
|
it("prefers the display title, then language, then the index", () => {
|
||||||
|
expect(subtitleTrackLabel({ index: 2, displayTitle: "English (SRT)", language: "eng" })).toBe("English (SRT)");
|
||||||
|
expect(subtitleTrackLabel({ index: 2, displayTitle: null, language: "eng" })).toBe("eng");
|
||||||
|
expect(subtitleTrackLabel({ index: 2 })).toBe("Track 2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveSubtitleTracks", () => {
|
||||||
|
it("resolves real string URLs — never a Promise — for every subtitle stream", async () => {
|
||||||
|
const tracks = await resolveSubtitleTracks(STREAMS, async (i) => url(i));
|
||||||
|
|
||||||
|
expect(tracks).toHaveLength(2);
|
||||||
|
for (const track of tracks) {
|
||||||
|
expect(typeof track.url).toBe("string");
|
||||||
|
// The exact regression: a Promise bound to src stringifies to this.
|
||||||
|
expect(String(track.url)).not.toContain("[object Promise]");
|
||||||
|
expect(track.url).toContain("subtitles.vtt");
|
||||||
|
}
|
||||||
|
// The adapter matches <track> elements by data-stream-index, so the stream
|
||||||
|
// index has to survive resolution.
|
||||||
|
expect(tracks.map((t) => t.streamIndex)).toEqual([2, 3]);
|
||||||
|
expect(tracks.map((t) => t.label)).toEqual(["English (SRT)", "French"]);
|
||||||
|
expect(tracks.map((t) => t.srclang)).toEqual(["eng", "fre"]);
|
||||||
|
expect(tracks[0].isDefault).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops tracks whose URL cannot be built instead of rendering a dead src", async () => {
|
||||||
|
const tracks = await resolveSubtitleTracks(SUBS, async (i) => {
|
||||||
|
if (i === 2) throw new Error("no repository");
|
||||||
|
return url(i);
|
||||||
|
});
|
||||||
|
expect(tracks.map((t) => t.streamIndex)).toEqual([3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops empty and non-string URLs", async () => {
|
||||||
|
const tracks = await resolveSubtitleTracks(SUBS, async (i) =>
|
||||||
|
i === 2 ? " " : (undefined as unknown as string),
|
||||||
|
);
|
||||||
|
expect(tracks).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nothing when there are no subtitle streams", async () => {
|
||||||
|
expect(await resolveSubtitleTracks([STREAMS[0]], async (i) => url(i))).toEqual([]);
|
||||||
|
expect(await resolveSubtitleTracks(null, async (i) => url(i))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reconcileSelectedSubtitle", () => {
|
||||||
|
it("starts off (null) and keeps 'off' selectable", async () => {
|
||||||
|
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
|
||||||
|
expect(reconcileSelectedSubtitle(tracks, null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a selection that is still renderable", async () => {
|
||||||
|
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
|
||||||
|
expect(reconcileSelectedSubtitle(tracks, 3)).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to off when the selected track is gone (new item / failed URL)", async () => {
|
||||||
|
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
|
||||||
|
expect(reconcileSelectedSubtitle(tracks, 9)).toBeNull();
|
||||||
|
expect(reconcileSelectedSubtitle([], 3)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never auto-selects the server's default track", async () => {
|
||||||
|
// The menu opens on "Off" and a <track default> would auto-show, so the UI
|
||||||
|
// would claim subtitles are off while they are burned over the picture.
|
||||||
|
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
|
||||||
|
expect(tracks[0].isDefault).toBe(true);
|
||||||
|
expect(reconcileSelectedSubtitle(tracks, null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("videoCrossOriginMode", () => {
|
||||||
|
it("opts into CORS for a server stream that has subtitles", () => {
|
||||||
|
expect(videoCrossOriginMode("http://jelly.example/Videos/x/master.m3u8", 2)).toBe("anonymous");
|
||||||
|
expect(videoCrossOriginMode("https://jelly.example/Videos/x/stream.mp4", 1)).toBe("anonymous");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a local/offline source alone so playback cannot regress", () => {
|
||||||
|
expect(videoCrossOriginMode("asset://localhost/movie.mkv", 2)).toBeUndefined();
|
||||||
|
expect(videoCrossOriginMode("file:///home/u/movie.mkv", 2)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays out of the way when there is nothing to load", () => {
|
||||||
|
expect(videoCrossOriginMode("http://jelly.example/x.m3u8", 0)).toBeUndefined();
|
||||||
|
expect(videoCrossOriginMode("", 0)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is decided by inputs known at first render, so it cannot flip mid-load", () => {
|
||||||
|
// Same answer before and after the async URL resolution completes.
|
||||||
|
const before = videoCrossOriginMode("http://jelly.example/x.m3u8", SUBS.length);
|
||||||
|
const after = videoCrossOriginMode("http://jelly.example/x.m3u8", SUBS.length);
|
||||||
|
expect(before).toBe(after);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subtitles on the Android / ExoPlayer native path.
|
||||||
|
*
|
||||||
|
* TRACES: UR-020 | IR-016, JA-008 | UT-147
|
||||||
|
*
|
||||||
|
* The bug this guards: VideoPlayer built a fully-resolved subtitle array in
|
||||||
|
* onMount and then never sent it — `commands.playerPlayItem({...})` passed only
|
||||||
|
* streamUrl/title/id/videoCodec/needsTranscoding — so every MediaItem reached
|
||||||
|
* ExoPlayer with zero SubtitleConfigurations and `setSubtitleTrack(n)` logged
|
||||||
|
* "Invalid subtitle track index".
|
||||||
|
*
|
||||||
|
* And the second half: `setSubtitleTrack(n)` indexes ExoPlayer's *text track
|
||||||
|
* groups*, i.e. the position of the sideloaded configuration — not the Jellyfin
|
||||||
|
* stream index. The menu used to pass its own row position, which is a position
|
||||||
|
* in the *unresolved* stream list; the moment one subtitle URL failed to
|
||||||
|
* resolve, the two lists diverged and every track below the gap selected the
|
||||||
|
* wrong subtitle.
|
||||||
|
*/
|
||||||
|
describe("nativeSubtitleTracks", () => {
|
||||||
|
it("maps to the wire shape Rust deserializes and Kotlin parses", async () => {
|
||||||
|
const resolved = await resolveSubtitleTracks(SUBS, async (i) => url(i));
|
||||||
|
const payload = nativeSubtitleTracks(resolved);
|
||||||
|
|
||||||
|
expect(payload).toHaveLength(2);
|
||||||
|
// Kotlin reads url/language/label/mime_type; Rust's SubtitleTrack keeps
|
||||||
|
// snake_case for exactly that reason, and so does the generated binding.
|
||||||
|
for (const track of payload) {
|
||||||
|
expect(Object.keys(track).sort()).toEqual(
|
||||||
|
["index", "label", "language", "mime_type", "url"].sort(),
|
||||||
|
);
|
||||||
|
expect(track).not.toHaveProperty("mimeType");
|
||||||
|
expect(track.mime_type).toBe("text/vtt");
|
||||||
|
}
|
||||||
|
// Jellyfin serves every subtitle stream as WebVTT here, and the stream index
|
||||||
|
// rides along so the UI can keep talking in stream indices.
|
||||||
|
expect(payload.map((t) => t.index)).toEqual([2, 3]);
|
||||||
|
expect(payload[0].url).toContain("subtitles.vtt");
|
||||||
|
expect(payload[0].language).toBe("eng");
|
||||||
|
expect(payload[0].label).toBe("English (SRT)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves stream order, because that order is the selection index", async () => {
|
||||||
|
const resolved = await resolveSubtitleTracks(STREAMS, async (i) => url(i));
|
||||||
|
expect(nativeSubtitleTracks(resolved).map((t) => t.index)).toEqual(
|
||||||
|
resolved.map((t) => t.streamIndex),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has nothing to send when no subtitle URL resolved", async () => {
|
||||||
|
expect(nativeSubtitleTracks(await resolveSubtitleTracks(SUBS, async () => ""))).toEqual([]);
|
||||||
|
expect(nativeSubtitleTracks([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries a null language/label through rather than inventing one", () => {
|
||||||
|
const payload = nativeSubtitleTracks([
|
||||||
|
{ streamIndex: 5, url: "u.vtt", srclang: "und", label: "Track 5", isDefault: false },
|
||||||
|
]);
|
||||||
|
expect(payload[0].language).toBeNull();
|
||||||
|
expect(payload[0].label).toBe("Track 5");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("nativeSubtitleArrayIndex", () => {
|
||||||
|
it("returns the position in the list that was actually sent, not the stream index", async () => {
|
||||||
|
const resolved = await resolveSubtitleTracks(SUBS, async (i) => url(i));
|
||||||
|
expect(nativeSubtitleArrayIndex(resolved, 2)).toBe(0);
|
||||||
|
expect(nativeSubtitleArrayIndex(resolved, 3)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays aligned when a subtitle URL failed to resolve (the mis-selection bug)", async () => {
|
||||||
|
// Stream 2 has no URL, so it is not among the sideloaded configurations.
|
||||||
|
// The menu's own row for stream 3 is position 1, but ExoPlayer only has one
|
||||||
|
// text track group — position 0. Sending 1 would select nothing.
|
||||||
|
const resolved = await resolveSubtitleTracks(SUBS, async (i) => {
|
||||||
|
if (i === 2) throw new Error("no repository");
|
||||||
|
return url(i);
|
||||||
|
});
|
||||||
|
expect(resolved).toHaveLength(1);
|
||||||
|
expect(nativeSubtitleArrayIndex(resolved, 3)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps 'Off' to null so the backend disables text instead of selecting track 0", async () => {
|
||||||
|
const resolved = await resolveSubtitleTracks(SUBS, async (i) => url(i));
|
||||||
|
expect(nativeSubtitleArrayIndex(resolved, null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps a track that was never sent to null rather than to a wrong position", async () => {
|
||||||
|
const resolved = await resolveSubtitleTracks(SUBS, async (i) => url(i));
|
||||||
|
expect(nativeSubtitleArrayIndex(resolved, 99)).toBeNull();
|
||||||
|
expect(nativeSubtitleArrayIndex([], 3)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("VideoPlayer markup (the regression that made the menu inert)", () => {
|
||||||
|
const source = readFileSync(
|
||||||
|
resolve(__dirname, "VideoPlayer.svelte"),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
it("renders <track> elements instead of leaving them commented out", () => {
|
||||||
|
expect(source).not.toContain("Temporarily disabled to debug playback issues");
|
||||||
|
expect(source).toMatch(/<track\b/);
|
||||||
|
expect(source).toContain('kind="subtitles"');
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The rendered element, not a `<track>` mentioned in prose. */
|
||||||
|
const trackElement = source.slice(source.search(/<track\s/), source.search(/<track\s/) + 400);
|
||||||
|
|
||||||
|
it("keeps data-stream-index — Html5PlayerAdapter.selectSubtitle matches on it", () => {
|
||||||
|
expect(trackElement).toContain("data-stream-index");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never binds the async getSubtitleUrl() Promise to src", () => {
|
||||||
|
expect(source).not.toMatch(/src=\{\s*getSubtitleUrl\(/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mark any track default (a default track auto-shows)", () => {
|
||||||
|
expect(trackElement).not.toMatch(/\bdefault=/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The half of the Android fix that lives in the component: the resolved list has
|
||||||
|
* to actually be handed to `playerPlayItem`, and the index sent to the backend
|
||||||
|
* has to be computed from that same list.
|
||||||
|
*
|
||||||
|
* TRACES: UR-020 | IR-016 | UT-147
|
||||||
|
*/
|
||||||
|
describe("VideoPlayer -> playerPlayItem (the tracks that were built and thrown away)", () => {
|
||||||
|
const source = readFileSync(resolve(__dirname, "VideoPlayer.svelte"), "utf-8");
|
||||||
|
|
||||||
|
/** The playerPlayItem({...}) argument object. */
|
||||||
|
const playItemCall = (() => {
|
||||||
|
const start = source.indexOf("commands.playerPlayItem(");
|
||||||
|
expect(start).toBeGreaterThan(-1);
|
||||||
|
return source.slice(start, source.indexOf("});", start) + 3);
|
||||||
|
})();
|
||||||
|
|
||||||
|
it("sends the subtitle tracks it resolved", () => {
|
||||||
|
expect(playItemCall).toMatch(/\bsubtitles:/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects by position in the sent list, not by the menu's row number", () => {
|
||||||
|
expect(source).toContain("nativeSubtitleArrayIndex");
|
||||||
|
// The old code forwarded the `{#each}` index straight to the backend.
|
||||||
|
expect(source).not.toMatch(/playerSetSubtitleTrack\(\s*arrayIndex\s*\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
// Subtitle plumbing for the Linux / WebKitGTK HTML5 `<video>` playback path.
|
||||||
|
//
|
||||||
|
// Extracted from VideoPlayer.svelte so it is unit-testable, and because the
|
||||||
|
// original inline version hid a fatal mistake in plain sight: `getSubtitleUrl()`
|
||||||
|
// is async, so `src={getSubtitleUrl(track.index)}` bound a *Promise* to the
|
||||||
|
// attribute and every `<track>` pointed at "[object Promise]". The whole block
|
||||||
|
// was commented out rather than fixed, which left `<video>` with no text tracks
|
||||||
|
// at all — `Html5PlayerAdapter.selectSubtitle()` then iterated an empty
|
||||||
|
// `textTracks` list and the subtitle menu silently did nothing.
|
||||||
|
//
|
||||||
|
// The rule this module enforces: URLs are resolved to plain strings *here*, off
|
||||||
|
// the render path, and only tracks that actually resolved are handed to the
|
||||||
|
// markup.
|
||||||
|
//
|
||||||
|
// The Android / ExoPlayer native path shares this module (see
|
||||||
|
// nativeSubtitleTracks / nativeSubtitleArrayIndex at the bottom): it needs the
|
||||||
|
// exact same "resolve the URLs first, keep only what resolved" list, just handed
|
||||||
|
// to Rust instead of to `<track>` elements.
|
||||||
|
//
|
||||||
|
// TRACES: UR-020 | DR-023, IR-016 | UT-143, UT-144, UT-147
|
||||||
|
|
||||||
|
import type { SubtitleTrack } from "$lib/api/bindings";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subset of `MediaStream` (from the generated bindings) this module needs.
|
||||||
|
* Kept structural so tests do not have to build full binding objects.
|
||||||
|
*/
|
||||||
|
export interface SubtitleStreamLike {
|
||||||
|
index: number;
|
||||||
|
kind?: string | null;
|
||||||
|
language?: string | null;
|
||||||
|
displayTitle?: string | null;
|
||||||
|
isDefault?: boolean;
|
||||||
|
isForced?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A subtitle stream whose URL resolved — i.e. one we can actually render. */
|
||||||
|
export interface RenderableSubtitleTrack {
|
||||||
|
/** Jellyfin media-stream index; the adapter matches `data-stream-index`. */
|
||||||
|
streamIndex: number;
|
||||||
|
/** Fully resolved WebVTT URL. Always a string, never a Promise. */
|
||||||
|
url: string;
|
||||||
|
srclang: string;
|
||||||
|
label: string;
|
||||||
|
/** Server's "default" flag — shown in the menu, never auto-enabled. */
|
||||||
|
isDefault: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subtitle streams of a media item, in stream order. */
|
||||||
|
export function subtitleStreamsOf(
|
||||||
|
streams: readonly SubtitleStreamLike[] | null | undefined,
|
||||||
|
): SubtitleStreamLike[] {
|
||||||
|
if (!streams) return [];
|
||||||
|
return streams.filter((s) => s.kind === "subtitle");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human label for a subtitle stream, matching the menu's own fallback chain. */
|
||||||
|
export function subtitleTrackLabel(stream: SubtitleStreamLike): string {
|
||||||
|
return stream.displayTitle || stream.language || `Track ${stream.index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A src we are willing to put on a `<track>`: a non-blank plain string. */
|
||||||
|
function isRenderableUrl(url: unknown): url is string {
|
||||||
|
return typeof url === "string" && url.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve every subtitle stream's URL and return only the tracks that can be
|
||||||
|
* rendered. `resolveUrl` failures are swallowed per track: one unavailable
|
||||||
|
* subtitle must not cost the user the others, and a dead `src` on a media
|
||||||
|
* element is exactly what made this block get disabled in the first place.
|
||||||
|
*/
|
||||||
|
export async function resolveSubtitleTracks(
|
||||||
|
streams: readonly SubtitleStreamLike[] | null | undefined,
|
||||||
|
resolveUrl: (streamIndex: number) => Promise<string>,
|
||||||
|
): Promise<RenderableSubtitleTrack[]> {
|
||||||
|
const subtitles = subtitleStreamsOf(streams);
|
||||||
|
if (subtitles.length === 0) return [];
|
||||||
|
|
||||||
|
const resolved = await Promise.all(
|
||||||
|
subtitles.map(async (stream) => {
|
||||||
|
try {
|
||||||
|
const url = await resolveUrl(stream.index);
|
||||||
|
if (!isRenderableUrl(url)) return null;
|
||||||
|
return {
|
||||||
|
streamIndex: stream.index,
|
||||||
|
url,
|
||||||
|
srclang: stream.language || "und",
|
||||||
|
label: subtitleTrackLabel(stream),
|
||||||
|
isDefault: stream.isDefault === true,
|
||||||
|
} satisfies RenderableSubtitleTrack;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return resolved.filter((t): t is RenderableSubtitleTrack => t !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The selection to keep once the rendered track list changes.
|
||||||
|
*
|
||||||
|
* Subtitles are OFF unless the user turns them on: `null` in, `null` out. The
|
||||||
|
* server's `isDefault` flag is deliberately NOT promoted to a selection (and the
|
||||||
|
* markup deliberately omits the `default` attribute, which would auto-show the
|
||||||
|
* track) — the menu opens on "Off", so auto-enabling would make the UI lie about
|
||||||
|
* what is on screen, and it would change behaviour for every user who has never
|
||||||
|
* asked for subtitles.
|
||||||
|
*
|
||||||
|
* A selection that is no longer renderable (new item, or a URL that failed to
|
||||||
|
* resolve) collapses to off, so the menu's checkmark can never point at a track
|
||||||
|
* that does not exist on the element.
|
||||||
|
*/
|
||||||
|
export function reconcileSelectedSubtitle(
|
||||||
|
tracks: readonly RenderableSubtitleTrack[],
|
||||||
|
selected: number | null,
|
||||||
|
): number | null {
|
||||||
|
if (selected === null) return null;
|
||||||
|
return tracks.some((t) => t.streamIndex === selected) ? selected : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function originOf(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
||||||
|
return parsed.origin;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `crossorigin` value for the `<video>` element, or undefined for none.
|
||||||
|
*
|
||||||
|
* Text-track fetches are CORS-enabled per the HTML spec and use the *media
|
||||||
|
* element's* CORS setting, so a cross-origin `<track>` never loads unless the
|
||||||
|
* element opts in. The webview page's origin is `tauri://localhost`, so every
|
||||||
|
* subtitle served by Jellyfin is cross-origin.
|
||||||
|
*
|
||||||
|
* Opting in is only safe when the media itself comes from an http(s) server —
|
||||||
|
* the same Jellyfin that already answers hls.js' cross-origin XHRs, so we know
|
||||||
|
* it sends the headers. For a local/offline source (`file:`/`asset:`) we leave
|
||||||
|
* the attribute off: subtitles staying dark there is the status quo, whereas
|
||||||
|
* forcing CORS onto the video fetch could break playback outright.
|
||||||
|
*
|
||||||
|
* Deliberately keyed on the *count of subtitle streams* rather than on the
|
||||||
|
* resolved tracks: both inputs are known at first render, so the attribute is
|
||||||
|
* decided before the element starts loading and never flips underneath an
|
||||||
|
* in-flight media fetch.
|
||||||
|
*/
|
||||||
|
export function videoCrossOriginMode(
|
||||||
|
streamUrl: string,
|
||||||
|
subtitleStreamCount: number,
|
||||||
|
): "anonymous" | undefined {
|
||||||
|
if (subtitleStreamCount <= 0) return undefined;
|
||||||
|
return originOf(streamUrl) ? "anonymous" : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Native (Android / ExoPlayer) path ====================================
|
||||||
|
//
|
||||||
|
// The HTML5 element gets `<track>` children; the native backend instead gets the
|
||||||
|
// list *up front*, as part of the play request, because ExoPlayer sideloads
|
||||||
|
// subtitles as `MediaItem.SubtitleConfiguration`s that must exist before
|
||||||
|
// `prepare()`. There is no "add a subtitle later" — a track absent from the
|
||||||
|
// MediaItem simply does not exist as far as the player is concerned.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map resolved tracks onto the wire shape `PlayItemRequest.subtitles` carries.
|
||||||
|
*
|
||||||
|
* The element type is the *generated* `SubtitleTrack` binding on purpose, so
|
||||||
|
* `bun run check` fails if the Rust struct's field names ever move. In
|
||||||
|
* particular `mime_type` is snake_case and must stay that way: the very same
|
||||||
|
* bytes are re-serialized across JNI in `player/android/mod.rs`, and
|
||||||
|
* `JellyTauPlayer.load()` reads `optString("mime_type")`. Renaming it to
|
||||||
|
* `mimeType` would not error anywhere — Kotlin would just silently fall back to
|
||||||
|
* its default MIME type for every track.
|
||||||
|
*
|
||||||
|
* Jellyfin is asked for every subtitle stream as WebVTT (see
|
||||||
|
* `getSubtitleUrl(..., "vtt")`), so the MIME type is fixed rather than derived
|
||||||
|
* from the source subtitle codec.
|
||||||
|
*
|
||||||
|
* TRACES: UR-020 | IR-016, JA-008 | UT-147
|
||||||
|
*/
|
||||||
|
export function nativeSubtitleTracks(
|
||||||
|
tracks: readonly RenderableSubtitleTrack[],
|
||||||
|
): SubtitleTrack[] {
|
||||||
|
return tracks.map((track) => ({
|
||||||
|
index: track.streamIndex,
|
||||||
|
url: track.url,
|
||||||
|
// `srclang` carries "und" for a stream with no language, which is the right
|
||||||
|
// value for a `<track>` but is not a language the native side should claim.
|
||||||
|
language: track.srclang === "und" ? null : track.srclang,
|
||||||
|
label: track.label,
|
||||||
|
mime_type: "text/vtt",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The argument for `player_set_subtitle_track` on the native backend.
|
||||||
|
*
|
||||||
|
* 🔴 This is **not** the Jellyfin stream index.
|
||||||
|
* `JellyTauPlayer.setSubtitleTrack(n)` filters ExoPlayer's track groups down to
|
||||||
|
* `C.TRACK_TYPE_TEXT` and indexes that list with `n`, so `n` is the *position of
|
||||||
|
* the sideloaded subtitle configuration* — which is the position in the array
|
||||||
|
* that `nativeSubtitleTracks()` produced and `playerPlayItem` sent.
|
||||||
|
*
|
||||||
|
* The menu's own row number is not that position: the menu lists every subtitle
|
||||||
|
* *stream*, while only the streams whose URL resolved are sent. One failed URL
|
||||||
|
* and everything below it selects the wrong subtitle. So the index is looked up
|
||||||
|
* in the sent list instead of being passed down from the `{#each}`.
|
||||||
|
*
|
||||||
|
* `null` (the menu's "Off") stays `null`, which the backend turns into -1 and
|
||||||
|
* Kotlin turns into "disable text tracks". A stream that was never sent also
|
||||||
|
* maps to `null`: disabling subtitles is a truthful outcome, whereas guessing a
|
||||||
|
* position would show the user a different language than the one they clicked.
|
||||||
|
*
|
||||||
|
* TRACES: UR-020 | IR-016 | UT-147
|
||||||
|
*/
|
||||||
|
export function nativeSubtitleArrayIndex(
|
||||||
|
tracks: readonly RenderableSubtitleTrack[],
|
||||||
|
streamIndex: number | null,
|
||||||
|
): number | null {
|
||||||
|
if (streamIndex === null) return null;
|
||||||
|
const position = tracks.findIndex((t) => t.streamIndex === streamIndex);
|
||||||
|
return position === -1 ? null : position;
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<!--
|
||||||
|
The header search box (md+ only; below md the bottom-nav Search tab and the
|
||||||
|
/search page's own input serve that role).
|
||||||
|
|
||||||
|
One box, one results surface. The bar renders on the library routes *and on
|
||||||
|
/search itself*, so searching from the header no longer swaps you onto a
|
||||||
|
screen whose input is somewhere else: the box you typed in stays where it is
|
||||||
|
and keeps driving the results. Off /search it navigates there (the only
|
||||||
|
surface that renders results); on /search it republishes the query into the
|
||||||
|
URL, which the page consumes.
|
||||||
|
|
||||||
|
TRACES: UR-049, UR-054 | DR-063, DR-064, DR-147
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount, tick } from "svelte";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/stores";
|
||||||
|
import { library } from "$lib/stores/library";
|
||||||
|
import Search from "$lib/components/Search.svelte";
|
||||||
|
import {
|
||||||
|
isSearchRoute,
|
||||||
|
parseSearchScope,
|
||||||
|
resolveSearchScope,
|
||||||
|
searchRouteUrl,
|
||||||
|
type SearchScope,
|
||||||
|
} from "$lib/utils/searchScope";
|
||||||
|
|
||||||
|
// Seeded from the URL, then owned by the user. A navigation to /search
|
||||||
|
// remounts this component (library and root render their own AppHeader), so
|
||||||
|
// reading `?q=` here is what carries a half-typed query across that hop.
|
||||||
|
let value = $state($page.url.searchParams.get("q") ?? "");
|
||||||
|
let inputEl = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
let scope = $state<SearchScope>(
|
||||||
|
isSearchRoute($page.url.pathname)
|
||||||
|
? parseSearchScope($page.url.searchParams.get("scope"))
|
||||||
|
: resolveSearchScope($page.url.pathname)
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const url = $page.url;
|
||||||
|
if (isSearchRoute(url.pathname)) {
|
||||||
|
// On /search the scope chips own the scope and publish it in the URL, so
|
||||||
|
// the bar follows rather than overriding it on the next keystroke.
|
||||||
|
scope = parseSearchScope(url.searchParams.get("scope"));
|
||||||
|
} else if (!value.trim()) {
|
||||||
|
// Elsewhere the route seeds the scope, but only while no search is
|
||||||
|
// active: navigating must not snap a widened search back to the section
|
||||||
|
// the user happens to be in.
|
||||||
|
scope = resolveSearchScope(url.pathname);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Landing on /search with a seeded query means the user was mid-type in the
|
||||||
|
// previous route's header. That box is gone; put the caret back in this one
|
||||||
|
// so their next keystroke lands in the search field and not nowhere.
|
||||||
|
onMount(async () => {
|
||||||
|
if (!value) return;
|
||||||
|
await tick();
|
||||||
|
inputEl?.focus();
|
||||||
|
inputEl?.setSelectionRange(value.length, value.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleSearch(query: string) {
|
||||||
|
if (isSearchRoute($page.url.pathname)) {
|
||||||
|
// Already on the results surface — republish in place. replaceState keeps
|
||||||
|
// a whole session of typing to a single history entry.
|
||||||
|
await goto(searchRouteUrl(query, scope), {
|
||||||
|
replaceState: true,
|
||||||
|
keepFocus: true,
|
||||||
|
noScroll: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!query.trim()) {
|
||||||
|
library.clearSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await goto(searchRouteUrl(query, scope));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Search
|
||||||
|
bind:value
|
||||||
|
bind:inputEl
|
||||||
|
placeholder="Search your library..."
|
||||||
|
onSearch={handleSearch}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* The header search bar is the single md+ search input.
|
||||||
|
*
|
||||||
|
* Off /search it navigates there (the only surface that renders results); on
|
||||||
|
* /search it stays put and republishes the query into the URL, so the user goes
|
||||||
|
* on typing in the same box instead of being handed to a second input owned by
|
||||||
|
* the page.
|
||||||
|
*
|
||||||
|
* TRACES: UR-049, UR-054 | DR-147
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/svelte";
|
||||||
|
|
||||||
|
const { pageStore, goto, clearSearch } = vi.hoisted(() => {
|
||||||
|
const { writable } = require("svelte/store");
|
||||||
|
return {
|
||||||
|
pageStore: writable({ url: new URL("http://localhost/library/music") }),
|
||||||
|
goto: vi.fn(),
|
||||||
|
clearSearch: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("$app/stores", () => ({ page: pageStore, navigating: { subscribe: () => () => {} } }));
|
||||||
|
vi.mock("$app/navigation", () => ({ goto, afterNavigate: vi.fn(), beforeNavigate: vi.fn() }));
|
||||||
|
vi.mock("$lib/stores/library", () => ({
|
||||||
|
library: { subscribe: () => () => {}, search: vi.fn(), clearSearch },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import HeaderSearch from "./HeaderSearch.svelte";
|
||||||
|
|
||||||
|
const afterDebounce = () => new Promise((resolve) => setTimeout(resolve, 450));
|
||||||
|
|
||||||
|
function input(): HTMLInputElement {
|
||||||
|
return screen.getByPlaceholderText("Search your library...") as HTMLInputElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
pageStore.set({ url: new URL("http://localhost/library/music") });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("from a library route", () => {
|
||||||
|
it("routes to /search with the query and the route's scope", async () => {
|
||||||
|
render(HeaderSearch);
|
||||||
|
|
||||||
|
await fireEvent.input(input(), { target: { value: "jazz" } });
|
||||||
|
await afterDebounce();
|
||||||
|
|
||||||
|
expect(goto).toHaveBeenCalledWith("/search?q=jazz&scope=music");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the results rather than navigating on an empty query", async () => {
|
||||||
|
render(HeaderSearch);
|
||||||
|
|
||||||
|
await fireEvent.input(input(), { target: { value: "jazz" } });
|
||||||
|
await afterDebounce();
|
||||||
|
goto.mockClear();
|
||||||
|
|
||||||
|
await fireEvent.input(input(), { target: { value: "" } });
|
||||||
|
await afterDebounce();
|
||||||
|
|
||||||
|
expect(goto).not.toHaveBeenCalled();
|
||||||
|
expect(clearSearch).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("on /search", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
pageStore.set({ url: new URL("http://localhost/search?q=jazz&scope=music") });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries the query over from the URL and puts the caret back in the box", async () => {
|
||||||
|
render(HeaderSearch);
|
||||||
|
|
||||||
|
await waitFor(() => expect(document.activeElement).toBe(input()));
|
||||||
|
expect(input().value).toBe("jazz");
|
||||||
|
expect(input().selectionStart).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("republishes in place instead of pushing a second results screen", async () => {
|
||||||
|
render(HeaderSearch);
|
||||||
|
|
||||||
|
await fireEvent.input(input(), { target: { value: "jazzy" } });
|
||||||
|
await afterDebounce();
|
||||||
|
|
||||||
|
expect(goto).toHaveBeenCalledWith("/search?q=jazzy&scope=music", {
|
||||||
|
replaceState: true,
|
||||||
|
keepFocus: true,
|
||||||
|
noScroll: true,
|
||||||
|
});
|
||||||
|
// The box the user is typing in keeps its text — nothing re-seeds it.
|
||||||
|
expect(input().value).toBe("jazzy");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("searches with the scope the page's chips published, not the route default", async () => {
|
||||||
|
// A chip pick lands in the URL; the bar must adopt it or the next keystroke
|
||||||
|
// would silently widen the search back to All.
|
||||||
|
pageStore.set({ url: new URL("http://localhost/search?q=jazz&scope=tv") });
|
||||||
|
render(HeaderSearch);
|
||||||
|
|
||||||
|
await fireEvent.input(input(), { target: { value: "jazzy" } });
|
||||||
|
await afterDebounce();
|
||||||
|
|
||||||
|
expect(goto).toHaveBeenCalledWith(
|
||||||
|
"/search?q=jazzy&scope=tv",
|
||||||
|
expect.objectContaining({ replaceState: true })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<!--
|
||||||
|
What the "N waiting to sync" badge actually stands for.
|
||||||
|
|
||||||
|
These are outgoing changes — watch positions, watched flags — made while the
|
||||||
|
server was unreachable and still waiting to reach Jellyfin. They are *not*
|
||||||
|
downloads, which is where the badge used to send people looking: the Downloads
|
||||||
|
page lists the `downloads` table and structurally cannot show these.
|
||||||
|
|
||||||
|
Rows push themselves on reconnect (DR-131); "Sync now" only asks for that to
|
||||||
|
happen immediately.
|
||||||
|
|
||||||
|
TRACES: UR-025 | DR-132
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { commands, type SyncQueueItem } from "$lib/api/bindings";
|
||||||
|
import { syncService } from "$lib/services/syncService";
|
||||||
|
import { pendingSyncCount } from "$lib/stores/appState";
|
||||||
|
import { isConnected } from "$lib/stores/connectivity";
|
||||||
|
import {
|
||||||
|
describeOperation,
|
||||||
|
describeSubject,
|
||||||
|
isStuck,
|
||||||
|
summarize,
|
||||||
|
sortForDisplay,
|
||||||
|
} from "$lib/services/pendingSync.logic";
|
||||||
|
|
||||||
|
let items = $state<SyncQueueItem[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let syncing = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let lastResult = $state<string | null>(null);
|
||||||
|
|
||||||
|
const summary = $derived(summarize(items));
|
||||||
|
|
||||||
|
export async function refresh() {
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
items = sortForDisplay(await syncService.getPending());
|
||||||
|
pendingSyncCount.set(items.length);
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(refresh);
|
||||||
|
|
||||||
|
async function syncNow() {
|
||||||
|
try {
|
||||||
|
syncing = true;
|
||||||
|
error = null;
|
||||||
|
const report = await commands.syncProcessPending();
|
||||||
|
lastResult =
|
||||||
|
report.pushed > 0
|
||||||
|
? `Sent ${report.pushed} update${report.pushed === 1 ? "" : "s"}.`
|
||||||
|
: "Nothing could be sent — the server is still unreachable.";
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
syncing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatQueuedAt(createdAt: string | null): string {
|
||||||
|
if (!createdAt) return "";
|
||||||
|
const parsed = Date.parse(createdAt);
|
||||||
|
return Number.isNaN(parsed) ? "" : new Date(parsed).toLocaleString();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<p class="text-sm text-gray-400">
|
||||||
|
Changes made while the server was unreachable — watch positions and watched
|
||||||
|
flags — waiting to reach Jellyfin. They send themselves when the server comes
|
||||||
|
back. This is not the download queue; downloaded media lives under Downloads.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p class="py-6 text-center text-sm text-gray-400">Loading queued updates…</p>
|
||||||
|
{:else if items.length === 0}
|
||||||
|
<p class="py-6 text-center text-sm text-gray-500">Everything is synced.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
onclick={syncNow}
|
||||||
|
disabled={syncing || !$isConnected}
|
||||||
|
class="rounded-lg bg-[var(--color-jellyfin)] px-4 py-2 text-sm font-medium text-white transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
title={$isConnected ? "Send these now" : "Needs a reachable server"}
|
||||||
|
>
|
||||||
|
{syncing ? "Sending…" : "Sync now"}
|
||||||
|
</button>
|
||||||
|
{#if summary.stuck > 0}
|
||||||
|
<span class="text-xs text-amber-400">
|
||||||
|
{summary.stuck} failed and will be retried
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if lastResult}
|
||||||
|
<span class="text-xs text-gray-400">{lastResult}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="space-y-2">
|
||||||
|
{#each items as item (item.id)}
|
||||||
|
<li
|
||||||
|
class="rounded-lg border-l-4 bg-[var(--color-surface)] p-3 {isStuck(item)
|
||||||
|
? 'border-amber-500/60'
|
||||||
|
: 'border-gray-600/60'}"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate text-sm font-medium text-white">
|
||||||
|
{describeOperation(item.operation)}
|
||||||
|
</p>
|
||||||
|
<p class="truncate text-xs text-gray-400">{describeSubject(item)}</p>
|
||||||
|
{#if item.errorMessage}
|
||||||
|
<p class="mt-1 text-xs text-amber-400">
|
||||||
|
{item.errorMessage}{item.retryCount > 0 ? ` (attempt ${item.retryCount})` : ""}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<span class="shrink-0 text-[11px] text-gray-500">
|
||||||
|
{formatQueuedAt(item.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="text-sm text-red-400">{error}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<!--
|
||||||
|
Modal wrapper for the pending-sync queue, opened from the offline banner's
|
||||||
|
badge so the count is answerable where the user reads it.
|
||||||
|
|
||||||
|
TRACES: UR-025 | DR-132
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import PendingSyncList from "./PendingSyncList.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { isOpen, onClose }: Props = $props();
|
||||||
|
|
||||||
|
function handleBackdropClick(event: MouseEvent) {
|
||||||
|
if (event.target === event.currentTarget) onClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-end justify-center bg-black/60 p-0 sm:items-center sm:p-4"
|
||||||
|
onclick={handleBackdropClick}
|
||||||
|
onkeydown={(e) => { if (e.key === "Escape") onClose(); }}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="pending-sync-title"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex max-h-[80vh] w-full flex-col rounded-t-2xl bg-[var(--color-surface)] shadow-2xl sm:max-h-[70vh] sm:max-w-lg sm:rounded-2xl"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
role="none"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between border-b border-gray-800 px-6 py-4">
|
||||||
|
<h2 id="pending-sync-title" class="text-lg font-semibold text-white">
|
||||||
|
Waiting to sync
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onclick={onClose}
|
||||||
|
class="-m-2 p-2 text-gray-400 transition-colors hover:text-white"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto p-6">
|
||||||
|
<PendingSyncList />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// TRACES: UR-052 | DR-143 | UT-140
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { writable } from "svelte/store";
|
||||||
|
|
||||||
|
const h = vi.hoisted(() => ({
|
||||||
|
version: null as ReturnType<typeof import("svelte/store").writable<number>> | null,
|
||||||
|
destroyFns: [] as Array<() => void>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("svelte", () => ({
|
||||||
|
onDestroy: (fn: () => void) => h.destroyFns.push(fn),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("$lib/services/offlineCatalog", () => ({
|
||||||
|
get catalogFilterVersion() {
|
||||||
|
return h.version;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useOfflineFilterReload } from "./useOfflineFilterReload";
|
||||||
|
|
||||||
|
describe("useOfflineFilterReload (DR-143)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
h.version = writable(0);
|
||||||
|
h.destroyFns.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reload for the value the page already loaded under", () => {
|
||||||
|
const reload = vi.fn();
|
||||||
|
useOfflineFilterReload(reload);
|
||||||
|
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reloads once each time the gate settles into a new state", () => {
|
||||||
|
const reload = vi.fn();
|
||||||
|
useOfflineFilterReload(reload);
|
||||||
|
|
||||||
|
h.version!.set(1);
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
h.version!.set(2);
|
||||||
|
expect(reload).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops reloading a page that has been destroyed", () => {
|
||||||
|
const reload = vi.fn();
|
||||||
|
useOfflineFilterReload(reload);
|
||||||
|
|
||||||
|
h.destroyFns.forEach((fn) => fn());
|
||||||
|
h.version!.set(1);
|
||||||
|
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Re-query a listing when the offline "downloaded only" gate changes.
|
||||||
|
*
|
||||||
|
* The gate is a process-wide flag in Rust, consulted only while a query runs,
|
||||||
|
* so flipping it has no effect on rows already on screen. Library pages loaded
|
||||||
|
* once on mount and reloaded only on the offline → online transition
|
||||||
|
* (`useServerReachabilityReload`), which left two gaps the user sees as a broken
|
||||||
|
* filter:
|
||||||
|
*
|
||||||
|
* - going *offline* never reloaded, so the full server catalog stayed on
|
||||||
|
* screen under a now-closed gate;
|
||||||
|
* - toggling "Show all server media" never reloaded, so it only greyed the
|
||||||
|
* cards already listed instead of adding or removing any.
|
||||||
|
*
|
||||||
|
* `catalogFilterVersion` bumps once the backend has accepted the new gate, so
|
||||||
|
* the reload this triggers always queries under the intended filter.
|
||||||
|
*
|
||||||
|
* Call during component initialisation, like `useServerReachabilityReload`:
|
||||||
|
*
|
||||||
|
* ```svelte
|
||||||
|
* <script>
|
||||||
|
* useOfflineFilterReload(() => loadItems());
|
||||||
|
* </script>
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* TRACES: UR-052 | DR-143
|
||||||
|
*/
|
||||||
|
import { onDestroy } from "svelte";
|
||||||
|
import { catalogFilterVersion } from "$lib/services/offlineCatalog";
|
||||||
|
|
||||||
|
export function useOfflineFilterReload(reloadFn: () => void | Promise<void>): void {
|
||||||
|
// The value the page is already showing. Seeded from the first subscription
|
||||||
|
// callback (stores emit synchronously on subscribe) so mounting never
|
||||||
|
// triggers a redundant second load of what onMount just fetched.
|
||||||
|
let applied: number | null = null;
|
||||||
|
|
||||||
|
const unsubscribe = catalogFilterVersion.subscribe((version) => {
|
||||||
|
if (applied === null || version === applied) {
|
||||||
|
applied = version;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applied = version;
|
||||||
|
void reloadFn();
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(unsubscribe);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user