Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
58f2506966 | ||
|
|
a818fee297 | ||
|
|
a26a853f01 | ||
|
|
9d099268b9 | ||
|
|
e381d626c1 | ||
|
|
b12e99b7e1 | ||
|
|
dc8b732465 | ||
|
|
b98a530f48 | ||
|
|
b565c4ae6f | ||
|
|
79e10d7485 | ||
|
|
a2dbde5492 | ||
|
|
75cd07a5c0 | ||
|
|
64d07b8940 | ||
|
|
5b810f7fc3 | ||
|
|
1ae213ff39 | ||
|
|
98a6bca645 | ||
|
|
984e594006 | ||
|
|
f49e6e4648 | ||
|
|
105cc082ea | ||
|
|
0a3ee0791f | ||
|
|
0da0a9f16c | ||
|
|
75bae2556c | ||
|
|
48f63dd763 | ||
|
|
36ef231e2f | ||
|
|
cb79a376b3 | ||
|
|
b11188e9dd | ||
|
|
f636b6b151 | ||
|
|
37ffabee06 | ||
|
|
13e0860401 | ||
|
|
d1c01a6bc3 | ||
|
|
e5d3cc06f2 | ||
|
|
5759a97289 | ||
|
|
b9f026e215 | ||
|
|
b7a7037194 | ||
|
|
124da29fc7 | ||
|
|
5927299c0f | ||
|
|
7650efcb7f | ||
|
|
4b9350c949 | ||
|
|
d01c1216b8 | ||
|
|
fb967433f0 | ||
|
|
ee584aced2 | ||
|
|
eb76c96e94 | ||
|
|
c3ead64748 | ||
|
|
742ad88a29 | ||
|
|
d4e2cd120c | ||
|
|
c543f90ad3 | ||
|
|
589f08b873 | ||
|
|
e2c9d68311 | ||
|
|
57b24f8c74 | ||
|
|
6391720d23 | ||
|
|
90f03dd142 |
@@ -96,6 +96,12 @@ jobs:
|
||||
- name: Install dependencies
|
||||
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
|
||||
run: bun run tauri build
|
||||
env:
|
||||
@@ -121,6 +127,62 @@ jobs:
|
||||
path: dist/linux/
|
||||
retention-days: 30
|
||||
|
||||
build-windows:
|
||||
name: Build Windows
|
||||
runs-on: linux/amd64
|
||||
needs: test
|
||||
# Cross-compiled from Linux via the official Tauri path (MSVC + cargo-xwin),
|
||||
# baked into the builder image. No toolchain installs here — the image has
|
||||
# cargo-xwin, clang/clang-cl, lld, llvm, nsis and the msvc target.
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
~/.cache/cargo-xwin
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-cargo-windows-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-windows-
|
||||
|
||||
- name: Cache Node dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
node_modules
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ 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
|
||||
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
|
||||
- name: Build Windows (NSIS installer + exe)
|
||||
run: OUTPUT_DIR="$PWD/dist/windows" WIN_BUNDLES=nsis ./scripts/build-windows-cross.sh
|
||||
|
||||
- name: List Windows artifacts
|
||||
run: ls -lah dist/windows/
|
||||
|
||||
- name: Upload Windows build artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: jellytau-windows
|
||||
path: dist/windows/
|
||||
retention-days: 30
|
||||
|
||||
build-android:
|
||||
name: Build Android
|
||||
runs-on: linux/amd64
|
||||
@@ -159,48 +221,22 @@ jobs:
|
||||
- name: Install dependencies
|
||||
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
|
||||
run: |
|
||||
# On a tag build, the tag is the single source of truth for the
|
||||
# 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
|
||||
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
|
||||
- name: Initialize Android project
|
||||
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
|
||||
run: |
|
||||
# `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"
|
||||
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
|
||||
|
||||
- name: Sync custom Android sources & gradle config
|
||||
run: ./scripts/sync-android-sources.sh
|
||||
@@ -239,7 +275,7 @@ jobs:
|
||||
create-release:
|
||||
name: Create Release
|
||||
runs-on: linux/amd64
|
||||
needs: [build-linux, build-android]
|
||||
needs: [build-linux, build-windows, build-android]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||
@@ -259,6 +295,12 @@ jobs:
|
||||
name: jellytau-linux
|
||||
path: artifacts/linux/
|
||||
|
||||
- name: Download Windows artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: jellytau-windows
|
||||
path: artifacts/windows/
|
||||
|
||||
- name: Download Android artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
@@ -277,6 +319,9 @@ jobs:
|
||||
echo "- **AppImage** - Run directly on most Linux distributions" >> release_notes.md
|
||||
echo "- **DEB** - Install via \`sudo dpkg -i jellytau_*.deb\` (Ubuntu/Debian)" >> release_notes.md
|
||||
echo "" >> release_notes.md
|
||||
echo "#### Windows" >> release_notes.md
|
||||
echo "- **Installer (.exe)** - Run \`jellytau_*-setup.exe\` (NSIS). Unsigned — SmartScreen may warn on first run." >> release_notes.md
|
||||
echo "" >> release_notes.md
|
||||
echo "#### Android" >> release_notes.md
|
||||
echo "- **APK** - Install via \`adb install jellytau-release.apk\` or sideload via file manager" >> release_notes.md
|
||||
echo "- **AAB** - Upload to Google Play Console or testing platforms" >> release_notes.md
|
||||
@@ -358,7 +403,7 @@ jobs:
|
||||
fi
|
||||
echo "Release id=$RELEASE_ID"
|
||||
|
||||
for f in artifacts/android/* artifacts/linux/*; do
|
||||
for f in artifacts/android/* artifacts/linux/* artifacts/windows/*; do
|
||||
[ -f "$f" ] || continue
|
||||
echo "⬆️ Uploading $(basename "$f")"
|
||||
curl -fsS -X POST \
|
||||
|
||||
@@ -42,30 +42,45 @@ jobs:
|
||||
echo "📊 Validating requirement traceability..."
|
||||
echo ""
|
||||
|
||||
# Parse JSON
|
||||
# Denominators come from docs/requirements.md at run time — NEVER
|
||||
# hardcode them here. This step previously divided by frozen literals
|
||||
# (UR/39, IR/24, DR/48, JA/3, total 114) while the file had grown to
|
||||
# 211 requirements, so it reported 158% coverage and the threshold
|
||||
# below could never trip. See docs/specs/traceability-gate-repair.md.
|
||||
TOTAL_TRACES=$(jq '.totalTraces' traces-report.json)
|
||||
UR=$(jq '.byType.UR | length' traces-report.json)
|
||||
IR=$(jq '.byType.IR | length' traces-report.json)
|
||||
DR=$(jq '.byType.DR | length' traces-report.json)
|
||||
JA=$(jq '.byType.JA | length' traces-report.json)
|
||||
COVERED=$(jq '.coverage.covered' traces-report.json)
|
||||
TOTAL_REQS=$(jq '.coverage.total' traces-report.json)
|
||||
COVERAGE=$(jq '.coverage.percent' traces-report.json)
|
||||
|
||||
# Print coverage report
|
||||
echo "✅ TRACES Found: $TOTAL_TRACES"
|
||||
echo ""
|
||||
echo "📋 Coverage Summary:"
|
||||
echo " User Requirements (UR): $UR / 39 ($(( UR * 100 / 39 ))%)"
|
||||
echo " Integration Requirements (IR): $IR / 24 ($(( IR * 100 / 24 ))%)"
|
||||
echo " Development Requirements (DR): $DR / 48 ($(( DR * 100 / 48 ))%)"
|
||||
echo " Jellyfin API Requirements (JA): $JA / 3 ($(( JA * 100 / 3 ))%)"
|
||||
echo "📋 Coverage Summary (traced / defined):"
|
||||
for T in UR IR DR JA; do
|
||||
TRACED=$(jq --arg t "$T" '[.byType[$t][] | select(. != null)] | length' traces-report.json)
|
||||
DEFINED=$(jq --arg t "$T" '.defined[$t]' traces-report.json)
|
||||
echo " $T: $TRACED / $DEFINED"
|
||||
done
|
||||
echo ""
|
||||
|
||||
COVERED=$((UR + IR + DR + JA))
|
||||
TOTAL_REQS=114
|
||||
COVERAGE=$((COVERED * 100 / TOTAL_REQS))
|
||||
|
||||
echo "📈 Overall Coverage: $COVERED / $TOTAL_REQS ($COVERAGE%)"
|
||||
echo ""
|
||||
|
||||
# Traced IDs that requirements.md does not define (typo, or a deleted
|
||||
# requirement). These do not count toward coverage.
|
||||
ORPHANED=$(jq -c '.coverage.orphaned' traces-report.json)
|
||||
if [ "$ORPHANED" != "[]" ]; then
|
||||
echo "⚠️ Traced but not defined in requirements.md: $ORPHANED"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# A ratio above 100% means the computation is broken — the exact
|
||||
# condition that hid the stale-denominator bug. Fail loudly.
|
||||
if [ "$COVERAGE" -gt 100 ]; then
|
||||
echo "❌ ERROR: Coverage ($COVERAGE%) exceeds 100% — the gate is miscomputing."
|
||||
echo " Orphaned IDs: $ORPHANED"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check minimum threshold
|
||||
MIN_THRESHOLD=50
|
||||
if [ "$COVERAGE" -lt "$MIN_THRESHOLD" ]; then
|
||||
|
||||
@@ -64,3 +64,9 @@ src-tauri/.cargo/config.toml
|
||||
/docs/README.md
|
||||
/docs/api-redirect.md
|
||||
/docs-site/book/
|
||||
|
||||
# Arch packaging build artifacts (vendored cargo cache, makepkg workdir, output package)
|
||||
/.cargo-arch/
|
||||
/packaging/arch/pkg/
|
||||
/packaging/arch/src/
|
||||
/packaging/arch/*.pkg.tar.zst
|
||||
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to JellyTau are documented here.
|
||||
|
||||
Entries are grouped by the capability they change, not by commit. Requirement
|
||||
IDs in parentheses point at [docs/requirements.md](docs/requirements.md); the
|
||||
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
|
||||
|
||||
### ✨ Features
|
||||
|
||||
- **Audio settings now work on Android.** The equalizer, volume normalization
|
||||
and gapless playback controls in Settings › Audio previously rendered on
|
||||
Android and did nothing — `ExoPlayerBackend` was the only backend that never
|
||||
implemented `set_audio_settings`, and the trait's default silently reported
|
||||
success while applying nothing. All three now take effect:
|
||||
- **Equalizer** — the canonical 10-band ISO curve is resampled onto whatever
|
||||
bands the device's equalizer actually exposes (commonly 5), by nearest
|
||||
centre frequency.
|
||||
- **Volume normalization** — via `LoudnessEnhancer`. Note this is a gain
|
||||
stage, not a true EBU R128 normalizer like the Linux `dynaudnorm` path, so
|
||||
it approximates rather than matches Linux behaviour.
|
||||
- **Gapless playback** — honours the setting via `pauseAtEndOfMediaItems`
|
||||
(ExoPlayer is gapless by default, so this disables it when you turn it off).
|
||||
|
||||
The effects re-attach automatically when ExoPlayer rebuilds its audio sink on
|
||||
a format change, so the equalizer no longer stops applying part-way through a
|
||||
queue. (UR-027, UR-032, UR-033 → DR-030, DR-035, DR-036, IR-004)
|
||||
|
||||
⚠️ **Not yet verified on a physical device.** `AudioEffect` availability and
|
||||
band layouts vary by device and OEM ROM; where an effect is unavailable it is
|
||||
logged and skipped rather than crashing playback.
|
||||
|
||||
### 📋 Documentation
|
||||
|
||||
- **Playback backend unification investigation.** Six new specs in
|
||||
[docs/specs/](docs/specs/) record why the playback backends cannot be unified
|
||||
onto a single engine: every candidate (mpv, GStreamer, libVLC) fails the same
|
||||
webview-compositing constraint, because WebKitGTK/WebView2/Android WebView each
|
||||
own their compositor surface and native video cannot interleave with HTML.
|
||||
Audio *can* unify; video cannot. Also specifies the Android native-video spike,
|
||||
a Windows native audio backend, and the `libmpv2` migration.
|
||||
|
||||
### 🐛 Corrected requirement statuses
|
||||
|
||||
These were documented as working and were not. No behaviour changed — the docs
|
||||
were wrong.
|
||||
|
||||
- **Crossfade (UR-031, DR-034) was marked "Done (Linux only)". It is implemented
|
||||
nowhere**, and is architecturally blocked on mpv: its audio chain is
|
||||
single-stream, and FFmpeg's `acrossfade` requires two inputs. Real crossfade
|
||||
would need two libmpv instances.
|
||||
- The platform parity matrix listed crossfade as a Linux/Android gap (it is
|
||||
neither) and omitted the equalizer (which was a genuine gap, now closed).
|
||||
- `nativeAdapter.ts` cited tauri#10152 as blocking native Android video. That
|
||||
issue is a stale feature request; the capability shipped in September 2024.
|
||||
What remains unproven is SurfaceView-behind-WebView compositing, now tracked
|
||||
by a spec rather than asserted as an upstream blocker.
|
||||
|
||||
<!--
|
||||
Note: v0.1.3–v0.1.5 have no entries here. Their changes are in the git log
|
||||
and docs/traceability.md.
|
||||
-->
|
||||
|
||||
## v0.1.2
|
||||
|
||||
### ✨ Features
|
||||
|
||||
- **Search results are ordered by how well they match.** A name that *starts*
|
||||
with the query now outranks one matching mid-word — typing "parks" finds
|
||||
"Parks and Recreation" before "Sparks of Love" — and at equal match quality a
|
||||
container outranks its contents, so a series lands above its own episodes.
|
||||
Ranking is applied to the instant cached results and to the merged
|
||||
cache+server list alike, so the list no longer reshuffles when server results
|
||||
arrive. (UR-060, DR-090)
|
||||
- **Separate Shows, Episodes and People result groups.** The combined "TV Shows"
|
||||
group splits into Shows and Episodes so a show never competes with its own
|
||||
episodes for a slot, and a new People group means searching an actor's name
|
||||
reaches their bio page. Default order is Shows → Episodes → Movies → Songs →
|
||||
Albums → Artists → People; a group order saved before the split keeps the
|
||||
position it was dragged to. (UR-060, DR-091)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **The library header search bar works on every library page.** It previously
|
||||
searched in place and depended on `/library` rendering results inline, so on
|
||||
any other `/library/**` route the results were fetched and never shown.
|
||||
`/search` is now the single surface that renders results, and the header bar
|
||||
hands its query and scope over via the URL. (UR-049, DR-063)
|
||||
- **Video smaller than the window is scaled up to fit.** Sizing only ever shrank
|
||||
oversized media, so a 480p source on a 1080p display played as a small picture
|
||||
in the middle of a black frame. The picture now fits whichever axis constrains
|
||||
it, in both directions, preserving aspect ratio. (UR-005)
|
||||
|
||||
### 📋 Requirements
|
||||
|
||||
**Linux:** 64-bit, GLIBC 2.29+
|
||||
**Android:** 8.0+
|
||||
|
||||
## v0.1.1 and earlier
|
||||
|
||||
Released before this file existed — see the git history and the release notes on
|
||||
each tag.
|
||||
@@ -35,6 +35,20 @@ CI runs on **Gitea Actions** (`.gitea/workflows/`), not GitHub. Use the `gh` CLI
|
||||
only against the mirror if one exists; the canonical remote is
|
||||
`gitea.tourolle.paris`.
|
||||
|
||||
> **🔴 CI installs no system tools.** Never add an `apt-get`, `rustup`,
|
||||
> `sdkmanager`, mingw/nsis, or any other *toolchain/system-package* install to a
|
||||
> CI workflow step. Every build, test, and packaging **tool** must already live
|
||||
> in the Docker image the job runs in — the unified builder (`Dockerfile.builder`
|
||||
> → `gitea.tourolle.paris/dtourolle/jellytau-builder`) for Android/Linux/Windows,
|
||||
> or `Dockerfile.arch` for Arch. If a job needs a tool the image lacks, **add it
|
||||
> to the image, rebuild + push it** (`scripts/build-builder-image.sh`), and use
|
||||
> it from CI — do not install it at job time. This keeps builds reproducible and
|
||||
> fast, and is why the packaging stages are thin `FROM ${BUILDER_IMAGE}` layers.
|
||||
>
|
||||
> `bun install` (fetching the project's own JS deps per the lockfile) is **not**
|
||||
> a violation — that's project dependencies, not a toolchain. The rule is about
|
||||
> system tools, not npm/bun/cargo *packages* declared by the project.
|
||||
|
||||
## Before Committing
|
||||
|
||||
- Frontend: `bun run check` and `bun run test` must pass.
|
||||
@@ -169,8 +183,14 @@ and [docs/build-release.md](docs/build-release.md).
|
||||
backend expand it. Single-type presentation (`itemType: "Movie"`, "this page
|
||||
shows albums") is fine; a *category → set of types* mapping in `src/` is a leak.
|
||||
`bun run check:boundary` is the tripwire; the real gate is the spec's layer
|
||||
assignment. See [scoped-search-boundary.md](docs/specs/scoped-search-boundary.md)
|
||||
for the incident this rule came from.
|
||||
assignment. The canonical example lives in Rust:
|
||||
`SearchScope::item_types()` in `repository/types.rs` expands an opaque scope the
|
||||
frontend sends. See [scoped-search-boundary.md](docs/specs/scoped-search-boundary.md)
|
||||
for the incident this rule came from — note the tripwire missed that leak for
|
||||
months because the mapping was assigned to a named const rather than written
|
||||
inline at the query, so **a green `check:boundary` is not proof**; it flags
|
||||
item-type array literals only, not run-time-built sets or `switch`/`||`
|
||||
taxonomy.
|
||||
|
||||
## Writing specs
|
||||
|
||||
@@ -252,6 +272,23 @@ tagged responses keep the Rust field names as-is (e.g. `new_url`, not `newUrl`).
|
||||
|
||||
## Testing
|
||||
|
||||
### 🔴 Bug fixes: failing test FIRST, then the fix
|
||||
|
||||
When fixing a bug, **write a test that reproduces it and watch it fail before
|
||||
touching the fix.** Red → green, in that order:
|
||||
|
||||
1. Write a test that exercises the broken behavior and **run it — it must fail**,
|
||||
proving the test actually catches the bug (a test that passes before the fix
|
||||
proves nothing).
|
||||
2. Apply the fix.
|
||||
3. Re-run — the test now passes, and so does the rest of the suite.
|
||||
|
||||
Never fix first and backfill the test afterward: a test written against
|
||||
already-fixed code can pass for the wrong reason and silently fails to guard the
|
||||
regression. If the logic is buried in a component, extract the pure part into a
|
||||
plain `.ts` module (e.g. `episodeStrip.ts`) so it can be unit-tested — the same
|
||||
pattern as `TrackList.logic.test.ts`.
|
||||
|
||||
```bash
|
||||
# Rust
|
||||
cd src-tauri && cargo test
|
||||
|
||||
+35
@@ -1,4 +1,11 @@
|
||||
# Multi-stage build for JellyTau - Tauri Jellyfin client
|
||||
#
|
||||
# The desktop packaging stages (desktop-linux-build, windows-cross) build FROM
|
||||
# the unified registry builder image, which carries every packaging tool. Declared
|
||||
# here (before the first FROM) so it's in scope for those stages' FROM lines.
|
||||
# Override for local iteration: --build-arg BUILDER_IMAGE=jellytau-builder:latest
|
||||
ARG BUILDER_IMAGE=gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||
|
||||
FROM ubuntu:24.04 AS builder
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
@@ -108,6 +115,34 @@ RUN cd src-tauri && cargo fetch && cd .. && \
|
||||
bun run tauri android build --apk true && \
|
||||
echo "APK build complete!"
|
||||
|
||||
# Desktop packaging stages build FROM the unified registry builder image (see the
|
||||
# BUILDER_IMAGE ARG at the top), which already carries every packaging tool
|
||||
# (rpm/file for Linux, cargo-xwin + nsis + the x86_64-pc-windows-msvc rust
|
||||
# target for Windows). ONE source of dependency truth, shared with CI — no
|
||||
# per-stage apt/rustup here.
|
||||
#
|
||||
# NOTE: Windows uses the MSVC target via cargo-xwin, NOT mingw/GNU — the GNU
|
||||
# toolchain cannot bundle an NSIS installer from Linux. See
|
||||
# scripts/build-windows-cross.sh.
|
||||
|
||||
# Linux desktop packaging environment (deb + rpm; Arch is Dockerfile.arch).
|
||||
# Thin layer over the builder — the actual build runs at container-run time on
|
||||
# the bind-mounted source (see docker-compose.yml / scripts/build-desktop-linux.sh),
|
||||
# matching the `dev` service model. Run standalone with:
|
||||
# docker run --rm -v "$PWD:/app" -v "$PWD/dist:/app/dist" <img> \
|
||||
# bash -c "OUTPUT_DIR=/app/dist scripts/build-desktop-linux.sh"
|
||||
FROM ${BUILDER_IMAGE} AS desktop-linux-build
|
||||
WORKDIR /app
|
||||
CMD ["bash", "-c", "OUTPUT_DIR=/app/dist scripts/build-desktop-linux.sh"]
|
||||
|
||||
# Windows cross-compile environment (MSVC target via cargo-xwin). Video works via
|
||||
# WebView2 and audio via the webview <audio> backend; NSIS installer is produced
|
||||
# from Linux by cargo-xwin. Default bundles NSIS; override WIN_BUNDLES=none for
|
||||
# exe-only. Build runs at container-run time like above.
|
||||
FROM ${BUILDER_IMAGE} AS windows-cross
|
||||
WORKDIR /app
|
||||
CMD ["bash", "-c", "OUTPUT_DIR=/app/dist WIN_BUNDLES=${WIN_BUNDLES:-nsis} scripts/build-windows-cross.sh"]
|
||||
|
||||
# Final output stage
|
||||
FROM ubuntu:24.04 AS final
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# JellyTau Arch Linux package builder.
|
||||
#
|
||||
# Tauri has no pacman bundle target, so we build a real .pkg.tar.zst with makepkg
|
||||
# from packaging/arch/PKGBUILD. makepkg refuses to run as root, so we create a
|
||||
# non-root `builder` user with passwordless sudo (for `makepkg -s` pacman calls).
|
||||
#
|
||||
# docker build -f Dockerfile.arch -t jellytau-arch .
|
||||
# docker run --rm -v "$PWD/dist:/out" jellytau-arch
|
||||
FROM archlinux:latest
|
||||
|
||||
RUN pacman -Syu --noconfirm \
|
||||
base-devel git sudo \
|
||||
rust cargo nodejs \
|
||||
webkit2gtk-4.1 mpv gtk3 libayatana-appindicator \
|
||||
libsoup3 pkgconf openssl \
|
||||
&& pacman -Scc --noconfirm
|
||||
|
||||
# Bun is not in the official repos; install the upstream binary.
|
||||
RUN curl -fsSL https://bun.sh/install | bash && \
|
||||
ln -s /root/.bun/bin/bun /usr/local/bin/bun
|
||||
|
||||
# Non-root build user with passwordless sudo for makepkg's dependency step.
|
||||
RUN useradd -m builder && \
|
||||
echo 'builder ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/builder && \
|
||||
ln -sf /root/.bun/bin/bun /usr/local/bin/bun
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN chown -R builder:builder /app
|
||||
|
||||
USER builder
|
||||
ENV OUTPUT_DIR=/out
|
||||
RUN mkdir -p /out
|
||||
VOLUME ["/out"]
|
||||
|
||||
# Default: build the package. Output lands in /out (mount it to collect the pkg).
|
||||
CMD ["bash", "-c", "OUTPUT_DIR=/out scripts/build-arch.sh"]
|
||||
+49
-1
@@ -1,5 +1,9 @@
|
||||
# JellyTau Builder Image
|
||||
# Pre-built image with all dependencies for building and testing
|
||||
# Pre-built image with all dependencies for building, testing, and packaging:
|
||||
# - Android APK (SDK/NDK), Linux desktop (deb/rpm),
|
||||
# - Windows cross via the official Tauri path: MSVC target + cargo-xwin + NSIS
|
||||
# Arch packages build in a separate archlinux image (Dockerfile.arch) since
|
||||
# makepkg is Arch-specific.
|
||||
# Push to your registry: docker build -f Dockerfile.builder -t gitea.tourolle.paris/dtourolle/jellytau-builder:latest .
|
||||
|
||||
FROM ubuntu:24.04
|
||||
@@ -83,6 +87,50 @@ RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
|
||||
# Set NDK environment variable
|
||||
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
|
||||
# or changing a packaging tool doesn't invalidate the expensive apt/rust/Android
|
||||
# layers above (a tool tweak becomes a ~1-2 min rebuild, not ~15). Covers Linux
|
||||
# (deb/rpm) and Windows cross (MSVC via cargo-xwin + NSIS).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# Linux desktop packaging: rpmbuild for the .rpm bundle (deb needs nothing extra)
|
||||
rpm \
|
||||
file \
|
||||
# Windows cross-compile (official Tauri path: MSVC target via cargo-xwin).
|
||||
# clang provides clang-cl, the MSVC-compatible C compiler cc-rs uses to build
|
||||
# C deps (bundled sqlite, ring, ...); lld = linker; llvm = llvm-lib/ar etc;
|
||||
# nsis = installer generator.
|
||||
clang \
|
||||
lld \
|
||||
llvm \
|
||||
nsis \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
# Ubuntu's clang package ships clang but NOT the clang-cl alias that cc-rs
|
||||
# invokes for MSVC targets. clang-cl is the same binary in MSVC-compat mode,
|
||||
# so provide it as a symlink.
|
||||
&& ln -sf /usr/bin/clang /usr/local/bin/clang-cl
|
||||
|
||||
# Windows rust target + cargo-xwin (downloads the MSVC CRT/SDK at build time).
|
||||
RUN . $HOME/.cargo/env && \
|
||||
rustup target add x86_64-pc-windows-msvc && \
|
||||
cargo install --locked cargo-xwin
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENTRYPOINT ["/bin/bash"]
|
||||
|
||||
@@ -33,6 +33,58 @@ services:
|
||||
ports:
|
||||
- "5172:5172" # In case you want to run dev server
|
||||
|
||||
# Linux desktop packages - deb + rpm + pacman into ./dist
|
||||
desktop-linux-build:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: desktop-linux-build
|
||||
args:
|
||||
# Defaults to the registry builder (Dockerfile's ARG). Point at a locally
|
||||
# built builder with: BUILDER_IMAGE=jellytau-builder:latest docker compose ...
|
||||
BUILDER_IMAGE: ${BUILDER_IMAGE:-gitea.tourolle.paris/dtourolle/jellytau-builder:latest}
|
||||
container_name: jellytau-desktop-linux-build
|
||||
volumes:
|
||||
- .:/app
|
||||
- cargo-cache:/root/.cargo
|
||||
- bun-cache:/root/.bun
|
||||
environment:
|
||||
- RUST_BACKTRACE=1
|
||||
- OUTPUT_DIR=/app/dist
|
||||
command: bash -c "OUTPUT_DIR=/app/dist scripts/build-desktop-linux.sh"
|
||||
|
||||
# Arch Linux package (.pkg.tar.zst via makepkg) into ./dist
|
||||
arch-build:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.arch
|
||||
container_name: jellytau-arch-build
|
||||
volumes:
|
||||
- ./dist:/out
|
||||
environment:
|
||||
- RUST_BACKTRACE=1
|
||||
- OUTPUT_DIR=/out
|
||||
|
||||
# Windows cross-compile (MSVC via cargo-xwin). Emits NSIS installer + .exe to
|
||||
# ./dist. Override WIN_BUNDLES=none for exe-only.
|
||||
windows-cross:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: windows-cross
|
||||
args:
|
||||
BUILDER_IMAGE: ${BUILDER_IMAGE:-gitea.tourolle.paris/dtourolle/jellytau-builder:latest}
|
||||
container_name: jellytau-windows-cross
|
||||
volumes:
|
||||
- .:/app
|
||||
- cargo-cache:/root/.cargo
|
||||
- bun-cache:/root/.bun
|
||||
environment:
|
||||
- RUST_BACKTRACE=1
|
||||
- OUTPUT_DIR=/app/dist
|
||||
- WIN_BUNDLES=${WIN_BUNDLES:-nsis}
|
||||
command: bash -c "OUTPUT_DIR=/app/dist WIN_BUNDLES=${WIN_BUNDLES:-nsis} scripts/build-windows-cross.sh"
|
||||
|
||||
# Development container - for interactive development
|
||||
dev:
|
||||
build:
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Desktop packaging (Linux, Arch, Windows)
|
||||
|
||||
How to produce distributable desktop packages for JellyTau. All three flows can
|
||||
run in Docker so no host toolchain setup is required. Outputs land in `./dist`.
|
||||
|
||||
## One builder image (shared with CI)
|
||||
|
||||
The deb/rpm and Windows-cross flows build on the **unified registry builder**
|
||||
([../Dockerfile.builder](../Dockerfile.builder) →
|
||||
`gitea.tourolle.paris/dtourolle/jellytau-builder`), the same image CI uses. It
|
||||
carries every packaging tool: Android SDK/NDK, `rpm`/`file` (Linux bundler),
|
||||
`cargo-xwin` + `lld` + `llvm` + `nsis` + the `x86_64-pc-windows-msvc` rust target
|
||||
(Windows). There is **one** dependency source of truth — no per-stage tool
|
||||
installs.
|
||||
|
||||
The desktop stages in [../Dockerfile](../Dockerfile) are thin `FROM
|
||||
${BUILDER_IMAGE}` environments; the actual build runs at container-run time on
|
||||
your bind-mounted source (like the `dev` service), so source edits need no image
|
||||
rebuild.
|
||||
|
||||
**If you changed `Dockerfile.builder`** (e.g. added a tool), rebuild and push it
|
||||
first, or the packaging flows use the stale registry image:
|
||||
|
||||
```bash
|
||||
scripts/build-builder-image.sh # build + push :latest to the registry
|
||||
# ...or iterate locally without pushing:
|
||||
docker build -f Dockerfile.builder -t jellytau-builder:latest .
|
||||
BUILDER_IMAGE=jellytau-builder:latest bun run docker:build:windows
|
||||
```
|
||||
|
||||
Arch uses a separate `archlinux` image ([../Dockerfile.arch](../Dockerfile.arch))
|
||||
because `makepkg` is Arch-specific — it is not part of the unified builder.
|
||||
|
||||
| Target | Format | Docker command | Functional? |
|
||||
|--------|--------|----------------|-------------|
|
||||
| Debian/Ubuntu, Fedora | `.deb`, `.rpm` | `bun run docker:build:linux` | ✅ yes |
|
||||
| Arch Linux | `.pkg.tar.zst` | `bun run docker:build:arch` | ✅ yes |
|
||||
| Windows | NSIS installer + `.exe` | `bun run docker:build:windows` | ✅ yes (unsigned) |
|
||||
|
||||
## Linux: deb + rpm
|
||||
|
||||
Tauri's bundler produces these natively. The build runs on the existing Ubuntu
|
||||
builder image ([../Dockerfile](../Dockerfile), `desktop-linux-build` stage):
|
||||
|
||||
```bash
|
||||
bun run docker:build:linux # deb + rpm -> ./dist
|
||||
# or, on a host with the Tauri Linux deps installed:
|
||||
BUNDLES="deb,rpm" scripts/build-desktop-linux.sh
|
||||
```
|
||||
|
||||
Runtime dependency: the app links libmpv (audio) and WebKitGTK (webview + HTML5
|
||||
transcoded video). The deb/rpm declare these.
|
||||
|
||||
> Note: `appimage` is also a valid Tauri target if you want a portable bundle —
|
||||
> add it to `BUNDLES`.
|
||||
|
||||
## Arch Linux: pacman package
|
||||
|
||||
**Tauri has no `pacman` bundle target** (as of tauri-cli 2.9.x — valid targets
|
||||
are deb/rpm/appimage/msi/nsis/app/dmg). So we ship a hand-written PKGBUILD in
|
||||
[../packaging/arch/PKGBUILD](../packaging/arch/PKGBUILD) and build it with
|
||||
`makepkg` on an Arch base image ([../Dockerfile.arch](../Dockerfile.arch)):
|
||||
|
||||
```bash
|
||||
bun run docker:build:arch # .pkg.tar.zst -> ./dist
|
||||
```
|
||||
|
||||
The PKGBUILD is AUR-ready: swap its `source=()` for a release tarball/VCS URL to
|
||||
publish. Runtime deps: `webkit2gtk-4.1`, `mpv`, `gtk3`, `libayatana-appindicator`.
|
||||
|
||||
`makepkg` refuses to run as root, so the Docker stage builds as a non-root
|
||||
`builder` user. Because the image `COPY`s the source at build time, the
|
||||
`arch-build` compose service does **not** bind-mount the repo — rebuild the image
|
||||
to pick up source changes.
|
||||
|
||||
## Windows: NSIS installer cross-compiled from Linux
|
||||
|
||||
Produces a working (unsigned) NSIS installer + `.exe` via the official Tauri
|
||||
cross-compile path — the `x86_64-pc-windows-msvc` target driven by `cargo-xwin`.
|
||||
Video plays via WebView2 and audio via the webview `<audio>` backend. See
|
||||
[build-windows.md](build-windows.md) for the full explanation.
|
||||
|
||||
```bash
|
||||
bun run docker:build:windows # NSIS installer + .exe -> ./dist
|
||||
WIN_BUNDLES=none bun run docker:build:windows # exe only, skip bundling
|
||||
```
|
||||
|
||||
The Docker `windows-cross` stage is a thin layer over the builder, which carries
|
||||
`cargo-xwin` + `lld` + `llvm` + `nsis` + the `x86_64-pc-windows-msvc` target.
|
||||
Cross-compilation is Tauri's "last resort" path (less tested than building on
|
||||
Windows); a `windows-latest` CI job is the fallback if it misbehaves.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Windows build
|
||||
|
||||
JellyTau targets Linux and Android primarily, but a working Windows build —
|
||||
including an **NSIS installer cross-compiled from Linux** — is produced by the
|
||||
Docker tooling. It is not yet a first-class release target (no code signing / CI
|
||||
job / SMTC lockscreen), but it runs and plays media.
|
||||
|
||||
## How playback works on Windows
|
||||
|
||||
- **Video** — renders through the webview HTML5 `<video>` element (hls.js) on
|
||||
*every* platform; on Windows that is WebView2 (Chromium/Edge), which plays HLS +
|
||||
h264 fine. No Windows-specific code.
|
||||
- **Audio-only (music)** — the native audio backends are libmpv (Linux) and
|
||||
ExoPlayer (Android); neither exists on Windows. Instead
|
||||
`create_player_backend()` in [../src-tauri/src/lib.rs](../src-tauri/src/lib.rs)
|
||||
uses `WebviewAudioBackend` on non-Linux/non-Android targets: it hands the stream
|
||||
URL to a webview `<audio>` element (see
|
||||
[../src/lib/services/webviewAudio.ts](../src/lib/services/webviewAudio.ts)),
|
||||
which reports state back through the same `player_report_*` round-trip the video
|
||||
path uses. Pure Rust + Tauri events.
|
||||
|
||||
## Cross-compiling from Linux (MSVC + cargo-xwin)
|
||||
|
||||
We use the [official Tauri cross-compile path](https://v2.tauri.app/distribute/windows-installer/):
|
||||
the **MSVC** target (`x86_64-pc-windows-msvc`) driven by
|
||||
[`cargo-xwin`](https://github.com/rust-cross/cargo-xwin), which downloads the MSVC
|
||||
CRT / Windows SDK headers and links with `lld`. MSVC is the target Tauri
|
||||
officially supports for Windows (mingw/GNU is not), and — unlike GNU — it lets the
|
||||
Tauri CLI bundle the **NSIS installer from a Linux host**.
|
||||
|
||||
> Why not mingw/GNU? The GNU target *does* link a valid `.exe`, but the Tauri CLI
|
||||
> gates `--bundles` by the host OS unless it recognizes a real Windows build.
|
||||
> `--runner cargo-xwin --target x86_64-pc-windows-msvc` is what flips it into
|
||||
> Windows mode and enables the `nsis`/`msi` bundlers on Linux.
|
||||
|
||||
The builder image ([../Dockerfile.builder](../Dockerfile.builder)) bakes in the
|
||||
whole toolchain: the `x86_64-pc-windows-msvc` rust target, `cargo-xwin`, `lld`,
|
||||
`llvm`, and `nsis`.
|
||||
|
||||
```bash
|
||||
bun run docker:build:windows # NSIS installer + .exe -> ./dist
|
||||
WIN_BUNDLES=none bun run docker:build:windows # exe only, skip bundling
|
||||
```
|
||||
|
||||
Or directly on a host that has the toolchain:
|
||||
|
||||
```bash
|
||||
scripts/build-windows-cross.sh # nsis installer + exe
|
||||
WIN_BUNDLES=none scripts/build-windows-cross.sh # exe only
|
||||
```
|
||||
|
||||
Under the hood the build runs:
|
||||
|
||||
```bash
|
||||
tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis
|
||||
```
|
||||
|
||||
Outputs:
|
||||
- `.exe` — `src-tauri/target/x86_64-pc-windows-msvc/release/jellytau.exe`
|
||||
- NSIS installer — `.../release/bundle/nsis/*-setup.exe`
|
||||
|
||||
(both copied to `./dist` when `OUTPUT_DIR` is set).
|
||||
|
||||
## Caveats
|
||||
|
||||
- **Cross-compilation is a last resort** per Tauri's own docs — it's less tested
|
||||
than building on Windows. If it misbehaves, a `windows-latest` CI job or a
|
||||
Windows VM building natively (`tauri build --bundles nsis`) is the fallback.
|
||||
- **Code signing is not wired up** — the installer is unsigned, so Windows
|
||||
SmartScreen will warn on first run.
|
||||
|
||||
## Outstanding for a first-class Windows release
|
||||
|
||||
1. Gapless/crossfade + SMTC (lockscreen) — currently no-ops in the webview audio
|
||||
path.
|
||||
2. Downloaded (`Local` source) file playback needs `convertFileSrc` on the
|
||||
frontend; streaming works today.
|
||||
3. Code signing + a Windows packaging CI job.
|
||||
+235
-40
@@ -37,11 +37,11 @@ For a narrative overview of the system design, see
|
||||
| UR-024 | View recently added content on server | Medium | Done |
|
||||
| UR-025 | Sync watch history and progress back to Jellyfin | High | Done |
|
||||
| UR-026 | Sleep timer for audio and video playback (roller UI, time/track/episode modes) | Low | Done |
|
||||
| UR-027 | Audio equalizer for sound customization | Low | Planned |
|
||||
| UR-027 | Audio equalizer for sound customization | Low | Done (Linux only) |
|
||||
| UR-028 | Navigate to artist/album by tapping names in now playing view | High | Done |
|
||||
| UR-029 | Toggle between grid and list view in library | Medium | Done |
|
||||
| UR-030 | Quick genre browsing and filtering | Medium | Done |
|
||||
| UR-031 | Crossfade between audio tracks | Low | Done (Linux only) |
|
||||
| UR-031 | Crossfade between audio tracks | Low | Not implemented (blocked — see DR-034) |
|
||||
| UR-032 | Gapless playback for seamless album listening | Medium | Done (Linux only) |
|
||||
| UR-033 | Volume normalization to prevent volume jumps between tracks | Low | Done (Linux only) |
|
||||
| UR-034 | Rich home screen with hero banners, carousels, and personalized sections | High | Done |
|
||||
@@ -62,12 +62,26 @@ For a narrative overview of the system design, see
|
||||
| UR-049 | Search is scoped by where it was started — inside a library it searches that library, from Home/library-root/search-tab it searches everything — with the scope shown as filter chips under the search bar that preselect from context and can be changed without retyping (see [ux-flows.md §6.1](ux-flows.md)) | High | Implemented |
|
||||
| UR-050 | Reorder search result groups (Songs, Albums, Artists, Movies, TV Shows) by drag and drop in settings, so the media a user cares about most appears first (see [ux-flows.md §6.3](ux-flows.md)) | Medium | Implemented |
|
||||
| UR-051 | Browse library pages in a consistent layout where card shape signals media type (square music, poster video, thumbnail episode), ordinal content stays listed, and the grid/list preference persists across pages (see [ux-flows.md §5A](ux-flows.md)) | Medium | Partial (implemented; toggle not reachable from settings) |
|
||||
| UR-052 | While offline, library pages show only media available on the device by default, with an opt-in toggle that additionally reveals the cached server catalog as greyed-out entries which can be queued for download on the next reconnect | High | Broken (toggle does not gate the listing; see issue #10) |
|
||||
| UR-052 | While offline, library pages show only media available on the device by default, with an opt-in toggle that additionally reveals the cached server catalog as greyed-out entries which can be queued for download on the next reconnect | High | Done |
|
||||
| UR-053 | Restrict media downloads to unmetered networks via a "WiFi Only" setting: when enabled, queued downloads are held while the device is on cellular or a metered connection (including metered WiFi hotspots) and resume automatically once an unmetered network is available | Medium | Done (pending device verification) |
|
||||
| UR-054 | Reach account actions (Settings, Downloads, Display preferences, Sign out) from every authenticated screen via a single account menu anchored to the user's name, identical on desktop and mobile (see [ux-flows.md §1.2](ux-flows.md)) | High | Done |
|
||||
| UR-055 | Browse downloaded media as an offline-scoped library — reusing the same library grids, cards, and detail pages as online browsing, showing only libraries/containers with downloaded content — with the transfer-progress list demoted to a secondary "Transfers" view (see [ux-flows.md §7.2](ux-flows.md)) | High | Planned |
|
||||
| UR-056 | See how much disk each downloaded item/album/series consumes, in familiar rounded units shown on the card and detail page, with a device total on the Downloaded surface and a reclaim amount stated at the point of removal (see [ux-flows.md §7.3.1](ux-flows.md)) | Medium | Planned |
|
||||
| UR-055 | Browse downloaded media as an offline-scoped library — reusing the same library grids, cards, and detail pages as online browsing, showing only libraries/containers with downloaded content — with the transfer-progress list demoted to a secondary "Transfers" view (see [ux-flows.md §7.2](ux-flows.md)) | High | Done |
|
||||
| UR-056 | See how much disk each downloaded item/album/series consumes, in familiar rounded units shown on the card and detail page, with a device total on the Downloaded surface and a reclaim amount stated at the point of removal (see [ux-flows.md §7.3.1](ux-flows.md)) | Medium | Done |
|
||||
| UR-057 | Settings apply the instant a control is changed — no "Save" button and no save/dirty state — so leaving the page never loses a change; sliders show a live readout while dragging but persist on release (see [ux-flows.md §8.1](ux-flows.md)) | Medium | Done |
|
||||
| UR-058 | On the home screen, a tap on a media card opens the item (movie/episode detail page, or the series Episode Focus View for episodes) rather than starting playback; a long-press starts "play now" after a confirm; an episode detail/focus page links back to its parent series and season (see [ux-flows.md §5B.5](ux-flows.md) and [§5B.1](ux-flows.md)) | Medium | Done |
|
||||
| UR-059 | Skipping to the next episode records the episode left behind as **fully watched** rather than saving a mid-episode resume point — skipping means "done with this one", not "stopped here" — and Continue Watching hides episodes the viewer has already moved past (a partial position behind that series' next-up episode), so the row only ever offers genuinely unfinished media | Medium | Done |
|
||||
| UR-060 | Search results are ordered by how well they match: a name that *starts* with the query outranks one matching mid-word (typing "parks" finds "Parks and Recreation" before "Sparks of Love"), and at equal match quality a container outranks its contents (a series before its episodes). Results are grouped into distinct categories — TV Shows, Episodes, Movies, Songs, Albums, Artists and People — so a show never competes with its own episodes for the same slot, and searching an actor's name reaches their bio | High | Done |
|
||||
| UR-061 | Double tapping the video skips within it — right half jumps **forward 30 seconds**, left half jumps **back 10 seconds** — with an on-screen indicator naming the amount. A double tap leaves the play state unchanged — playing jumps and keeps playing, paused jumps and stays paused — because the second tap re-toggles what the first tap toggled (see DR-098); the skip lands relative to the position the player actually reports, and repeated double taps accumulate rather than all skipping from the same spot | Medium | 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-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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -99,7 +113,7 @@ External system integrations and platform-specific implementations.
|
||||
| IR-017 | Jellyfin API client for transcoding parameters | API | UR-022 | Planned |
|
||||
| IR-018 | libmpv subtitle rendering and selection | Playback | UR-020 | Planned |
|
||||
| IR-019 | libmpv audio track selection | Playback | UR-021 | Planned |
|
||||
| IR-020 | libmpv/ExoPlayer equalizer integration | Playback | UR-027 | Planned |
|
||||
| IR-020 | libmpv/ExoPlayer equalizer integration | Playback | UR-027 | Done (Linux/MPV; Android parity pending) |
|
||||
| IR-022 | Jellyfin API client for person/cast data | API | UR-035, UR-036 | Done |
|
||||
| IR-023 | Database schema for person/cast caching | Storage | UR-035, UR-036 | Done |
|
||||
| IR-024 | Jellyfin API client for home screen data (featured, continue watching) | API | UR-034 | Done |
|
||||
@@ -108,6 +122,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-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-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
|
||||
|
||||
@@ -147,6 +164,9 @@ API endpoints and data contracts required for Jellyfin integration.
|
||||
| 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-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
|
||||
|
||||
@@ -185,11 +205,11 @@ Internal architecture, components, and application logic.
|
||||
| DR-029 | Sleep timer with roller UI, time/track/episode modes, and auto-stop (audio + video players) | Player | UR-026 | Done |
|
||||
| DR-049 | Auto-play episode limit (configurable max episodes per session) | Player | UR-023 | Done |
|
||||
| DR-050 | Reusable scroll picker (roller) component | UI | UR-026 | Done |
|
||||
| DR-030 | Equalizer UI with presets and custom bands | UI | UR-027 | Planned |
|
||||
| DR-030 | Equalizer UI with presets and custom bands | UI | UR-027 | Done |
|
||||
| DR-031 | Clickable artist/album links in now playing view | UI | UR-028 | Done |
|
||||
| DR-032 | List view option for library browsing (albums, artists) | UI | UR-029 | Done |
|
||||
| DR-033 | Genre browsing screen with quick filters | UI | UR-030 | Done |
|
||||
| DR-034 | Crossfade engine with configurable duration (0-12s) | Player | UR-031 | Done (Linux only) |
|
||||
| DR-034 | Crossfade engine with configurable duration (0-12s) | Player | UR-031 | Not implemented (blocked on MPV: single-stream audio chain; `acrossfade` needs 2 inputs — see docs/specs/playback-backend-unification.md) |
|
||||
| DR-035 | Gapless playback between sequential tracks | Player | UR-032 | Done (Linux only) |
|
||||
| DR-036 | Volume normalization with preset levels (Loud/Normal/Quiet) | Player | UR-033 | Done (Linux only) |
|
||||
| DR-037 | Remote session browser and control UI | UI | UR-010 | Done |
|
||||
@@ -216,10 +236,10 @@ Internal architecture, components, and application logic.
|
||||
| DR-060 | Multi-server store and active-account selection: save/get/delete server, save/get user, set/get active user (per-server), active-session resolution | Storage | UR-047 | Partial (store done; server-switcher UI pending) |
|
||||
| DR-061 | Episode Focus View: episode hero followed *immediately* by the "More Episodes" strip — a forward-biased window (~3 before / ~6 after) around the current episode, spanning season boundaries in series order, with the current episode present and badged, per-card resume progress and watched state, and click-to-swap focus (no playback) | UI | UR-048 | Done |
|
||||
| DR-062 | Detail-page section ordering: continuation content precedes discovery content — Episode Focus View renders hero → episode strip → cast → similar; Series renders hero → seasons/episodes → cast → similar | UI | UR-048 | Done |
|
||||
| DR-063 | Search scope resolver mapping the originating route to an `includeItemTypes` set (All / Music / Movies / TV), defaulting to All for Home, `/library`, and the search tab | UI | UR-049 | Implemented |
|
||||
| DR-063 | Search scope taxonomy owned by Rust: `SearchScope` (All / Music / Movies / TV) crosses IPC as an opaque enum and `SearchScope::item_types()` expands it to Jellyfin item types, resolved once in `repository_search` before the cache and server paths diverge so online and offline filter identically; `All` expands to *no* filter rather than the union of the other scopes (which would drop People and folders). The frontend maps the originating route to a scope (`resolveSearchScope`, presentation) and never names an item type for search | Backend | UR-049 | Implemented |
|
||||
| DR-064 | Scope chip row rendered under the search bar on both the search page and the in-library header search: preselected from context, horizontally scrollable, re-runs the search preserving the query on change | UI | UR-049 | Implemented |
|
||||
| DR-065 | Thread `SearchOptions.includeItemTypes` through `library.search()` so the global/header search honours scope (backend online + offline paths already support it) | UI | UR-049 | Implemented |
|
||||
| DR-066 | Persisted search result group order with a drag-and-drop settings list, keyboard-accessible reordering, a shipped default (Songs → Albums → Artists → Movies → TV Shows), and empty-group omission | Settings | UR-050 | Implemented |
|
||||
| DR-066 | Persisted search result group order with a drag-and-drop settings list, keyboard-accessible reordering, a shipped default (see DR-091 for the current group set and order), and empty-group omission | Settings | UR-050 | Implemented |
|
||||
| DR-067 | `SearchResults` renders groups in the user-configured order rather than hardcoded markup order, without altering intra-group ranking | UI | UR-050 | Implemented |
|
||||
| DR-068 | Library card shape by media type: 1:1 square for music (circular mask for artists), 2:3 poster for movies/series/seasons, 16:9 for episodes and collection folders | UI | UR-051 | Done |
|
||||
| DR-069 | Responsive library grid (2/3/4/5/6 columns across base→xl) with two-line truncated card text and artwork-overlay progress/watched state | UI | UR-051 | Done |
|
||||
@@ -227,16 +247,83 @@ Internal architecture, components, and application logic.
|
||||
| DR-075 | Shared `AccountMenu` component: identity header (user + server), Downloads / Settings / Display entries, divider, Sign out last; anchored to the username/avatar trigger and identical on desktop and mobile | UI | UR-054 | Done |
|
||||
| DR-076 | App shell exposes the header (and therefore the account menu) on every authenticated non-immersive route, including `/`, `/search`, and `/downloads`; only `/player/*` and `/login` remain chrome-free | UI | UR-054 | Done |
|
||||
| DR-077 | Display section in Settings binding the existing persisted grid/list `viewMode` store, giving the preference a discoverable home | Settings | UR-054, UR-029 | Done |
|
||||
| DR-078 | Catalog-visibility gate spanning the "Show all server media" toggle → `set_show_server_catalog` → `INCLUDE_CATALOG_BROWSE` → the synced-catalog UNION branch of offline `get_items`. Visibility resolves to `serverReachable \|\| showServerCatalog`, so offline with the toggle off lists downloaded/local media only | Storage | UR-052, UR-002 | Partial (gate implemented and unit-tested; defeated upstream by DR-079 and by the repository fallback in DR-080) |
|
||||
| DR-079 | `isConnected` derives from backend-reported server reachability alone; `navigator.onLine` is advisory and may only trigger a recheck, never force or clear the offline state (a reachable LAN server while the browser reports offline, and an unreachable server on a live link, must both resolve correctly) | Connectivity | UR-052, UR-043 | Broken (`isConnected` ANDs in `navigator.onLine`, so a live link with an unreachable server never enters offline listing) |
|
||||
| DR-080 | With the catalog-browse gate off, an empty offline `get_items` result is authoritative "no downloads here" and must be returned as-is; the hybrid repository must not treat it as a cache miss and fall through to the server | Storage | UR-052, UR-013 | Broken (`has_content()` cache-hit test in `HybridRepository::get_items`/`parallel_race` falls through to the server on an intentionally empty result) |
|
||||
| DR-078 | Catalog-visibility gate spanning the "Show all server media" toggle → `set_show_server_catalog` → `INCLUDE_CATALOG_BROWSE` → the synced-catalog UNION branch of offline `get_items`. Visibility resolves to `serverReachable \|\| showServerCatalog`, so offline with the toggle off lists downloaded/local media only | Storage | UR-052, UR-002 | Done |
|
||||
| DR-079 | `isConnected` derives from backend-reported server reachability alone; `navigator.onLine` is advisory and may only trigger a recheck, never force or clear the offline state (a reachable LAN server while the browser reports offline, and an unreachable server on a live link, must both resolve correctly) | Connectivity | UR-052, UR-043 | Done |
|
||||
| DR-080 | With the catalog-browse gate off, an empty offline `get_items` result is authoritative "no downloads here" and must be returned as-is; the hybrid repository must not treat it as a cache miss and fall through to the server | Storage | UR-052, UR-013 | Done |
|
||||
| DR-074 | WiFi-only download gate: `NetworkState`/`NetworkType` transport model reported from the platform via `set_network_state`, checked in `pump_download_queue` before starting any pending row (cellular/metered/unknown fail closed, WiFi and Ethernet require `NOT_METERED`); blocked rows stay `pending` and re-pump on network change, with a `waitingForNetwork` event driving the "Waiting for WiFi" notice. Also wires the previously inert Smart Caching / Queue Pre-caching / WiFi Only settings toggles to `CacheConfig` | Downloads | UR-053 | Done (pending device verification) |
|
||||
| DR-081 | `/downloads` split into a default **Downloaded** browse view and a secondary **Transfers** activity view, with a view switch and a Transfers badge shown only while transfers are active | UI | UR-055 | Planned |
|
||||
| DR-082 | Offline-scoped browse entry point in the repository client: browse downloaded content only (offline repository `get_items`/`get_libraries` — downloaded items plus their containers) independent of server reachability, without merging server catalog | Storage | UR-055 | Planned |
|
||||
| DR-083 | Downloaded browse reuses library grids, cards, and detail pages via the offline-scoped source; omits libraries/containers with no downloaded content; badges partially- vs fully-downloaded containers; play uses the local file; remove available at item/album/season/series level | UI | UR-055 | Planned |
|
||||
| DR-084 | Transfers view renders only in-flight rows (downloading/queued/paused/failed/waiting-for-WiFi) with Pause/Resume/Cancel/Retry; completed transfers leave the view and appear in Downloaded | UI | UR-055 | Planned |
|
||||
| DR-085 | Per-item on-disk size: stat downloaded files, aggregate to album/season/series subtotals and a device total, format in consistent rounded human units; surface size on cards and detail pages, the device total on the Downloaded surface, and a reclaim figure in the remove confirmation | Downloads | UR-056 | Planned |
|
||||
| DR-081 | `/downloads` split into a default **Downloaded** browse view and a secondary **Transfers** activity view, with a view switch and a Transfers badge shown only while transfers are active | UI | UR-055 | Done |
|
||||
| DR-082 | Offline-scoped browse entry point in the repository client: browse downloaded content only (offline repository `get_items`/`get_libraries` — downloaded items plus their containers) independent of server reachability, without merging server catalog | Storage | UR-055 | Done |
|
||||
| DR-083 | Downloaded browse reuses library grids, cards, and detail pages via the offline-scoped source; omits libraries/containers with no downloaded content; badges partially- vs fully-downloaded containers; play uses the local file; remove available at item/album/season/series level | UI | UR-055 | Done |
|
||||
| DR-084 | Transfers view renders only in-flight rows (downloading/queued/paused/failed/waiting-for-WiFi) with Pause/Resume/Cancel/Retry; completed transfers leave the view and appear in Downloaded | UI | UR-055 | Done |
|
||||
| DR-085 | Per-item on-disk size: stat downloaded files, aggregate to album/season/series subtotals and a device total, format in consistent rounded human units; surface size on cards and detail pages, the device total on the Downloaded surface, and a reclaim figure in the remove confirmation | Downloads | UR-056 | Done |
|
||||
| DR-086 | Settings page persists each control on change via per-group writers (`playerSetAudioSettings` / `playerSetVideoSettings` / `updateCacheConfig`) rather than a batch Save action; slider controls persist on `change` (pointer release) not each `input` tick; no Save button, `saving`, or `saveMessage` state | Settings | UR-057 | Done |
|
||||
| DR-087 | `MediaCard` gains an `onLongPress` prop with pointer-based long-press detection (~500 ms hold, cancelled on >10 px move so carousel scroll is unaffected, trailing click suppressed); home carousels wire tap→detail/focus routing and long-press→confirm→player; episode taps route to `/library/<seriesId>?episode=<id>`; the bare-episode detail page links to its parent series/season | UI | UR-058 | Done |
|
||||
| DR-088 | Skip-to-next-episode marks the outgoing episode played (`markAsPlayed`) instead of reporting a stop position, and arms a one-shot suppression consumed by the player's stop handler so `VideoPlayer`'s post-navigation unmount stop report cannot overwrite the 100% progress with the partial position | UI | UR-059 | Done |
|
||||
| DR-089 | Continue Watching suppresses resume entries superseded by Next Up: an in-progress episode whose series has a next-up entry strictly later in series order (season, then episode) is dropped from the Home and TV rows; movies, series without a next-up entry, and items with unknown/mixed episode ordering are always kept | UI | UR-059 | Done |
|
||||
| DR-090 | Relevance ranking in Rust (`domain/search_rank.rs`): results sort by match position (prefix → word-start → mid-word substring → no name match) then by media kind (containers before their contents), stably so the backend's own relevance breaks ties. Applied in `repository_search` to both the instant cache result and the merged cache+server union, so the list does not reshuffle when server results land | Backend | UR-060 | Done |
|
||||
| DR-091 | Search result groups split TV into separate Shows and Episodes groups and add a People group (default order: Shows → Episodes → Movies → Songs → Albums → Artists → People); a stored `tvShows` order from before the split expands in place to shows+episodes so an upgrading user keeps their arrangement | UI | UR-060 | Done |
|
||||
| DR-092 | Video tap gestures resolve in `tapGestures.ts` (pure, unit-tested) rather than inline in `VideoPlayer.svelte`: `registerTap` classifies each tap and the component acts on it immediately — `togglePlayPause` for a first tap, or `seek` (+30 s right / −10 s left) plus a re-toggle for a second tap inside `DOUBLE_TAP_WINDOW_MS` (300 ms). A consumed pair resets the state, and a swipe forgets the tap. The deferral this originally used was removed in DR-098, which also covers suppressing the compatibility `click` the browser synthesizes after a touch tap. `resolveSeekTarget` converts the delta to the absolute position the facade requires, clamped per DR-095 and chained off a still-in-flight `pendingSeekTarget` so back-to-back skips accumulate instead of all resolving against a not-yet-updated position | UI | UR-061 | Done |
|
||||
| DR-094 | Frontend boundary tripwire (`scripts/check-frontend-boundary.sh`) detects Jellyfin item-type array literals **anywhere** in `src/` rather than only inline at an `includeItemTypes:` query site, so a category→type mapping cannot evade the check by being assigned to a named const (the evasion that let the `scoped-search` leak pass CI); requires two adjacent type literals so single-type presentation and `item.type ===` inspection stay legal, and caps the allowlist to force taxonomy into Rust instead of accumulating exceptions | Tooling | - | Done |
|
||||
| DR-098 | Video tap gestures act **immediately** — no deferral, no timer, and only first/second taps exist. A first tap toggles play/pause; a second tap inside `DOUBLE_TAP_WINDOW_MS` seeks *and* toggles again, so the two toggles cancel and a double tap preserves the play state (playing → jump and keep playing; paused → jump and stay paused). This replaces a design that deferred the first tap behind a 300 ms timer so a second tap could cancel it: the timer cleared its own handle *before* invoking the toggle, which reopened the `tapTimeout !== null` guard in `handleVideoClick` meant to suppress the compatibility `click` Android's WebView synthesizes after a touch — the late click then toggled a second time, producing a pause/unpause loop (long-press was unaffected, which is what identified the tap path). Click suppression no longer depends on the timer: `handleVideoClick` ignores `detail === 0` *and* any click within `TOUCH_CLICK_SUPPRESS_MS` of a touch tap. A swipe undoes the touchstart toggle exactly once (latched on `swipeGestureActive`) so brightness swipes never change play state. Click suppression is shared by **every** click target layered over the video via `isSynthesizedTouchClick`, not just the `<video>`: pausing renders a full-screen play-overlay button, so the synthesized click lands on *that* and an unguarded handler there resumed immediately — pausing appeared impossible while unpausing worked, because unpausing removes the overlay | UI | UR-061 | Done |
|
||||
| DR-099 | The video seek bar is usable by touch. Two Android-only defects made dragging or tapping it move the thumb without moving playback. (a) *Gesture hijack*: the container-level gesture layer skips `touchstart` on a control (DR-098) but kept handling `touchmove`, so a seek-bar drag was measured against the **previous** gesture's start point — a huge bogus vertical delta that read as a brightness swipe, dimmed the screen to the 0.3 floor, and fired a spurious play/pause "correction" mid-drag. A gesture is now latched at `touchstart` (`playerGestureActive`) and `touchmove` ignores anything not latched, since re-checking the move target cannot recover a start point that was never recorded. (b) *Commit signal*: the seek was committed **only** from `change`, which Android's WebView does not reliably fire for a touch interaction on a range input — the thumb moved to the tapped position and no seek ever ran. `touchend`/`mouseup` now commit as well; `input` arms a one-shot latch so whichever release signal arrives first commits and the other is a no-op. `seekRelative` shares the same `commitSeek` entry point instead of fabricating a synthetic `change` event | UI | UR-005, UR-061 | Done |
|
||||
| DR-097 | Transport authority (play/pause/toggle) lives in Rust for **webview-rendered** media, not just native. The controller tracks the state the HTML5 element reports (`html5_playing`, fed by `report_html5_state`, which now *stores* rather than only re-emitting); `play`/`pause`/`toggle_playback` consult it and drive the element by emitting a `ControlCommand` that `playerEvents.handleControlCommand` executes against the active adapter. A `stopped`/`idle` report clears it so the native backend (MPV/ExoPlayer) regains authority for music. The frontend facade no longer short-circuits transport into the adapter: `adapter.toggle()` previously decided play-vs-pause by reading `el.paused` off the DOM, a value that flips transiently while an element buffers or settles a seek — so two intents ~150 ms apart read *different* values, performed *opposing* actions, and self-sustained a play/pause loop needing no further input (observed on Android with a fully-buffered `readyState=4 networkState=1` element). Same "backend decides, adapter executes the primitive" split as `player_seek_video` | Player | UR-005 | Done |
|
||||
| DR-096 | `Html5PlayerAdapter.play()` is resilient to stall recovery: an in-flight attempt is memoised so concurrent callers (UI plus hls.js gap-controller recovery) share one `element.play()` instead of stacking calls, and an `AbortError` ("play() request was interrupted by a call to pause()") is logged at debug rather than pushed to `host.onError`. The browser raises it whenever a pending play promise is superseded by a pause/seek/source change, which hls.js does routinely while nudging past a stall — reporting it surfaced a player error roughly once per second for the whole stall and left the UI stuck showing paused | Player | UR-005 | Done |
|
||||
| DR-095 | Seek targets clamp strictly *inside* the media (`clampSeekTarget`, `END_SEEK_MARGIN_SECONDS` = 6 s ≈ one HLS segment) instead of to the exact `duration`. Landing on the duration makes hls.js request the segment whose start time lies past the end of the media (e.g. a 6330.324 s item → segment 1055 starting at 6336.33 s), which Jellyfin never produces; the fetch times out and hls.js' gap-controller stalls at the last buffered position, presenting as "unpausing or skipping bounces straight back to paused". Applied on both seek paths — the relative-skip `resolveSeekTarget` and the seek-bar drag, whose range input `max` is the duration itself — and floored at 0 so media shorter than the margin still seeks to the start | UI | UR-061 | Done |
|
||||
| DR-100 | Leaving a video and re-entering it renders the **video** player, never the audio one. Both halves of the `/player/[id]` decision are pure and unit-tested in `playerSurface.ts`. (a) `shouldReuseActivePlayback` excludes video: the "already playing, just show the UI" shortcut (added for expanding the audio mini player) returns *before* a stream URL is fetched, which is fine for audio — the backend owns the stream and the route only mirrors it — but leaves `<VideoPlayer>` with nothing to render. Closing a webview-rendered video deliberately emits no `stopped` state (that would break the autoplay handoff, see DR-047), so the Rust controller still reports that movie/episode as its loaded media and re-entering the same item hit the shortcut. (b) `resolvePlayerSurface` maps video-without-a-stream-URL to `pending` (spinner) instead of falling through to `<AudioPlayer>`, so no future path can put video content in the audio surface. Video now always takes the full load path, which fetches the stream URL and applies the stored resume position | UI | UR-005 | Done |
|
||||
| DR-101 | "Where is this viewer in this series" is resolved in **Rust**, not the frontend. `repository_get_series_episodes` performs the season fan-out (`get_items(series_id)` → seasons → `get_items(season_id)`, plus the flat-series fallback for shows whose children are episodes rather than season folders) and returns them in series order — season index ascending, episode index ascending, specials (season 0) after every numbered season. `repository_get_series_current_episode` layers the pure policy `pick_current_episode` over that list: an **in-progress** episode wins (earliest in series order on a tie — it is literally where playback stopped, and Next Up would skip past it), then the server's **Next Up** for that series, then the **first unwatched** episode, then the first. The third rung is the offline path, not dead code: `OfflineRepository::get_next_up_episodes` returns an empty vec, so without it the feature would be online-only. A failing Next Up or resume lookup degrades to empty rather than failing the call. `repository_get_next_up_episodes` had accepted a `series_id` since it was written and **no caller had ever passed one** | Repository | UR-062 | Done |
|
||||
| DR-102 | The series detail page anchors on that answer. It calls `repositoryGetSeriesEpisodes` once instead of fanning out over seasons in TypeScript (the fan-out *and* its flat-series fallback were domain knowledge in the presentation layer), groups the returned episodes under season headers by `parentIndexNumber`, and passes the resolved current episode to `SeasonSection` → `EpisodeRow`, which renders a highlight ring and scrolls itself into view. The hero button navigates to `/library/<seriesId>?episode=<currentId>` — the Episode Focus View, where an explicit Play/Resume commits — per ux-flows §5B.5: Play on a *container* is navigation, Play on a *leaf* commits. It previously resolved `$libraryItems[0]`, the first **season** by `SortName`, and navigated to `/player/<seasonId>`, which the player route bounced back to `/library/<seasonId>` — so Play on a series played nothing and landed on the season-1 page | UI | UR-062 | Done |
|
||||
| DR-103 | A season is not a destination. `/library/<seasonId>` redirects to `/library/<seriesId>#season-<indexNumber>`, the anchor `SeasonSection` renders, so a season link scrolls the series' continuous episode list rather than opening a page. Every inbound link follows: the episode breadcrumb, `handleItemClick case "season"`, the TV landing page's `case "Season"`, and `DownloadedBrowse`. A season carrying no `seriesId` (deep link into a stale cache) still renders the generic view so the user is never stranded. This removes a surface that had no route of its own — it fell through the detail page's `kind` chain to the generic "Contents" poster grid, contradicting ux-flows §5A.2 (episodes must be a row list), and clicking an episode there opened a bare Episode page, which §5B.1 forbids | UI | UR-062 | Done |
|
||||
| DR-104 | The "More Episodes" strip spans the **whole series** in series order, per ux-flows §5B.2's cross-season continuity rule: at the end of a season the window runs on into the next season's first episodes instead of dead-ending. `adjacentEpisodes` previously filtered the pool to `parentIndexNumber === current.parentIndexNumber` and sorted by `indexNumber` alone, so the window could never leave the current season — and, when episodes of several seasons did reach it, sorting by episode number alone interleaved them. Cards crossing a season boundary are labelled `SxEy` rather than a bare episode number so the jump is legible | UI | UR-062 | 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-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-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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -249,7 +336,7 @@ Internal architecture, components, and application logic.
|
||||
| UR-001 | IR-001, IR-002 | - |
|
||||
| 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-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 |
|
||||
| UR-005 | - | DR-001, DR-005, DR-009 |
|
||||
| UR-006 | IR-005, IR-006, IR-007, IR-008 | - |
|
||||
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
|
||||
@@ -270,7 +357,7 @@ Internal architecture, components, and application logic.
|
||||
| UR-022 | IR-017 | DR-025 |
|
||||
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049 |
|
||||
| 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-027 | IR-020 | DR-030 |
|
||||
| UR-028 | - | DR-031 |
|
||||
@@ -285,7 +372,7 @@ Internal architecture, components, and application logic.
|
||||
| UR-037 | IR-010 | DR-042 |
|
||||
| UR-038 | IR-010 | DR-043 |
|
||||
| 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 |
|
||||
| UR-041 | IR-026 | DR-053 |
|
||||
| UR-042 | IR-009, IR-014 | DR-054 |
|
||||
| UR-043 | IR-027 | DR-055 |
|
||||
@@ -293,16 +380,29 @@ Internal architecture, components, and application logic.
|
||||
| UR-045 | - | DR-057 |
|
||||
| UR-046 | IR-028 | DR-058 |
|
||||
| UR-047 | IR-013 | DR-060 |
|
||||
| UR-048 | - | DR-061, DR-062 |
|
||||
| UR-049 | IR-010 | DR-063, DR-064, DR-065 |
|
||||
| UR-048 | - | DR-061, DR-062, DR-142 |
|
||||
| UR-049 | IR-010 | DR-063, DR-064, DR-065, DR-147 |
|
||||
| UR-050 | - | DR-066, DR-067 |
|
||||
| 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-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-056 | - | DR-085 |
|
||||
| UR-057 | - | DR-086 |
|
||||
| UR-058 | - | DR-087, DR-142 |
|
||||
| UR-060 | - | DR-090, DR-091, DR-111 |
|
||||
| UR-061 | - | DR-092 |
|
||||
| UR-062 | - | DR-101, DR-102, DR-103, DR-104, DR-107 |
|
||||
| UR-063 | - | DR-105 |
|
||||
| UR-064 | - | DR-106 |
|
||||
| UR-065 | IR-030 | DR-108, DR-109, DR-110, DR-111 |
|
||||
| UR-066 | IR-031 | DR-112 |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -373,11 +473,92 @@ Internal architecture, components, and application logic.
|
||||
| UT-059 | Audio-only stream URL builder for a video item (selected audio-stream index) | JA-032, DR-052 | Pending |
|
||||
| UT-060 | Background-audio handoff state machine (background→audio, foreground→video; no dual audio) | DR-052 | Pending |
|
||||
| UT-061 | Background-audio Tauri command param naming (camelCase) | DR-052 | Pending |
|
||||
| UT-062 | `setBackgroundAudioEnabled` reports whether the native bridge was actually reached (missing bridge, stale proxy, throwing method) so a dead bridge cannot look armed | UR-040, IR-025, DR-051 | Done |
|
||||
| UT-067 | Offline `get_items` gates the synced-catalog UNION on the catalog-browse flag (downloads only when off, full catalog when on) | DR-078 | Done |
|
||||
| UT-068 | Catalog visibility resolves to `serverReachable \|\| showServerCatalog`, and is pushed to the backend on every change of either input | DR-078, DR-079 | Pending |
|
||||
| UT-069 | `isConnected` follows backend reachability alone: false when the server is unreachable on a live link, true for a reachable server while `navigator.onLine` is false | DR-079 | Pending |
|
||||
| UT-070 | Hybrid `get_items` returns an empty offline result as-is when the catalog-browse gate is off, without querying the server | DR-080 | Pending |
|
||||
| UT-068 | Catalog visibility resolves to `serverReachable \|\| showServerCatalog`, and is pushed to the backend on every change of either input | DR-078, DR-079 | Done |
|
||||
| UT-069 | `isConnected` follows backend reachability alone: false when the server is unreachable on a live link, true for a reachable server while `navigator.onLine` is false | DR-079 | Done |
|
||||
| UT-070 | Hybrid `get_items` returns an empty offline result as-is when the catalog-browse gate is off, without querying the server | DR-080 | Done |
|
||||
| UT-066 | WiFi-only download gate: cellular and metered WiFi blocked, unmetered WiFi/Ethernet allowed, unknown/none fail closed, desktop default ungated; plus the frontend network reporter (transport reporting, change subscription, teardown, fail-open queries) | DR-074 | Done |
|
||||
| UT-071 | Byte-size formatter: zero/negative/non-finite → "0 B"; decimal unit thresholds; 2–3 significant-figure banding; trailing-zero trimming; largest-unit cap | DR-085 | Done |
|
||||
| UT-072 | Downloaded-only browse returns a downloaded leaf and its container, filtered to the requested album parent; a non-downloaded sibling is omitted | DR-082, DR-083 | Done |
|
||||
| UT-073 | An empty downloaded-only browse is authoritative — no rows, no error — regardless of the catalog-browse flag | DR-082 | Done |
|
||||
| UT-074 | Only libraries with downloaded content are listed; an empty one is omitted | DR-082 | Done |
|
||||
| UT-075 | Disk usage reports a leaf's own size, a container's summed descendants, and reconciles the device total with the sum of leaves | DR-085 | Done |
|
||||
| UT-076 | Downloaded library browse lists album containers, not their individual tracks; drilling into the album returns the tracks | DR-082, DR-083 | Done |
|
||||
| UT-077 | Downloaded TV library browse lists the series, not seasons/episodes; drilling returns the season then the episode | DR-082, DR-083 | Done |
|
||||
| UT-078 | A downloaded leaf with no cached container (e.g. a movie) still surfaces at the library level | DR-082, DR-083 | Done |
|
||||
| UT-079 | Each EQ preset returns a 10-band gain curve within range; Flat is all zeros; Bass Boost lifts lows and leaves highs flat | DR-030 | Done |
|
||||
| UT-080 | `with_equalizer_normalised` clamps out-of-range gains and forces the band vector to exactly 10 entries (pad short, truncate long) | DR-030 | Done |
|
||||
| UT-081 | Old persisted AudioSettings JSON without EQ fields loads as disabled + flat | DR-030 | Done |
|
||||
| UT-082 | EQ fields serialize as camelCase (`equalizerEnabled`/`equalizerBands`) and round-trip | DR-030 | Done |
|
||||
| UT-083 | EQ filter entries are empty when disabled or when the curve is flat (clears the `af` filter) | IR-020 | Done |
|
||||
| UT-084 | Enabled EQ builds one peaking `equalizer` per non-zero band at the right frequency and gain inside a single `lavfi` chain | IR-020 | Done |
|
||||
| UT-085 | A first tap resolves to `togglePlayPause` immediately — no deferral and no timer | DR-092, DR-098 | Done |
|
||||
| UT-086 | A second tap inside the window seeks (+30 s right half, −10 s left half) with the matching feedback side **and** re-toggles play/pause, so the two toggles cancel and the play state is unchanged by a double tap | DR-092, DR-098 | Done |
|
||||
| UT-087 | A tap after the window, and the tap following a consumed pair, are each fresh first taps that toggle (there is no third-tap case); repeated double taps keep seeking; `cancel()` makes the next tap a first tap so an interpreted swipe cannot seek | DR-092, DR-098 | Done |
|
||||
| UT-088 | `resolveSeekTarget` applies the delta to the reported position, clamps into `[0, duration - END_SEEK_MARGIN_SECONDS]`, chains off an in-flight pending target so rapid skips accumulate, and ignores that target once the player reports past it | DR-092, DR-095 | Done |
|
||||
| UT-089 | A touch drag on the video seek bar seeks to the dragged position, never toggles play/pause, and never alters brightness — the container gesture layer stays out of a control drag entirely | DR-098, DR-099 | Done |
|
||||
| UT-090 | The seek bar commits its seek on `touchend` even when the engine never fires `change`, and commits exactly once when both signals arrive | DR-099 | 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-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-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
|
||||
|
||||
@@ -396,8 +577,8 @@ Internal architecture, components, and application logic.
|
||||
| IT-011 | Resume playback from server position | IR-015, UR-019 | Pending |
|
||||
| IT-012 | Equalizer bands via libmpv | IR-020, UR-027 | Pending |
|
||||
| IT-013 | Background-audio handoff on Android: background/lock continues audio via native service and stops video decode; foreground resumes video at position | IR-025, UR-040 | Pending |
|
||||
| IT-016 | Offline library listing end-to-end: with the server unreachable, a library page lists only downloaded media with the toggle off, and additionally reveals greyed-out cached catalog entries with the toggle on | UR-052, DR-078, DR-079, DR-080 | Pending |
|
||||
| IT-017 | A download queued from a greyed-out offline catalog entry persists and is resolved and started on reconnect | UR-052, UR-011 | Pending |
|
||||
| IT-016 | Offline library listing end-to-end: with the server unreachable, a library page lists only downloaded media with the toggle off, and additionally reveals greyed-out cached catalog entries with the toggle on | UR-052, DR-078, DR-079, DR-080 | Done |
|
||||
| IT-017 | A download queued from a greyed-out offline catalog entry persists and is resolved and started on reconnect | UR-052, UR-011 | Done |
|
||||
|
||||
---
|
||||
|
||||
@@ -467,22 +648,36 @@ The `PlayerBackend` trait defines optional audio settings methods with default e
|
||||
| Basic playback | ✅ | ✅ | Parity |
|
||||
| Volume control | ✅ | ✅ | Parity |
|
||||
| Seek | ✅ | ✅ | Parity |
|
||||
| Crossfade | ✅ | ❌ | Gap |
|
||||
| Gapless playback | ✅ | ❌ | Gap |
|
||||
| Volume normalization | ✅ | ❌ | Gap |
|
||||
| Crossfade | ❌ | ❌ | Not implemented (blocked on MPV) |
|
||||
| Gapless playback | ✅ | ⚠️ | Implemented, pending on-device verification |
|
||||
| Volume normalization | ✅ | ⚠️ | Implemented (LoudnessEnhancer — gain stage, approximate vs MPV's dynaudnorm), pending on-device verification |
|
||||
| Equalizer (10-band) | ✅ | ⚠️ | Implemented (resampled onto device bands), pending on-device verification |
|
||||
| Position updates | 250ms | On-demand | Inconsistent |
|
||||
|
||||
**Future Fix**:
|
||||
1. Implement `set_audio_settings()` in `ExoPlayerBackend`
|
||||
2. Add Kotlin-side ExoPlayer configuration for crossfade (using `ConcatenatingMediaSource` or `DefaultMediaSourceFactory`)
|
||||
3. Implement gapless via ExoPlayer's built-in gapless support
|
||||
4. Add volume normalization via ExoPlayer's `LoudnessEnhancer` or audio processor
|
||||
5. Standardize position update frequency across platforms
|
||||
**Status** (see docs/specs/android-audio-settings-parity.md):
|
||||
1. ✅ `set_audio_settings()` implemented in `ExoPlayerBackend` (JSON over JNI)
|
||||
2. ✅ Gapless via ExoPlayer's `pauseAtEndOfMediaItems`
|
||||
3. ✅ Volume normalization via `LoudnessEnhancer`
|
||||
4. ✅ Equalizer via `android.media.audiofx.Equalizer`, canonical 10 bands
|
||||
resampled onto the device's band centres
|
||||
5. ⬜ **Not yet verified on a physical device** — the EQ/normalization effects
|
||||
depend on device-specific `AudioEffect` availability and band layouts
|
||||
6. ⬜ Flip the trait's `set_audio_settings` default from `Ok(())` to
|
||||
`Err(not_implemented())` so a backend that omits it fails loudly instead of
|
||||
silently reporting success. Deferred until (5) confirms the Android path works
|
||||
7. ⬜ Standardize position update frequency across platforms
|
||||
|
||||
Crossfade is deliberately absent: it is unimplemented on every platform and
|
||||
architecturally blocked on MPV, so building it on Android alone would invert the
|
||||
parity gap. (The previously suggested `ConcatenatingMediaSource` is also
|
||||
deprecated in current Media3.)
|
||||
|
||||
**Impact**:
|
||||
- Medium - Android users lack audio enhancement features advertised in requirements
|
||||
- User experience differs between platforms
|
||||
- UR-031 (Crossfade), UR-032 (Gapless), UR-033 (Normalization) only work on Linux
|
||||
- UR-032 (Gapless), UR-033 (Normalization) and UR-027 (Equalizer) are now
|
||||
implemented on Android as well as Linux, pending on-device verification
|
||||
- UR-031 (Crossfade) works nowhere — see DR-034
|
||||
|
||||
**Traces To**: IR-004, UR-031, UR-032, UR-033, DR-034, DR-035, DR-036
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
# Spec: Android audio settings parity (EQ, normalization, gapless)
|
||||
|
||||
**Status:** Proposed
|
||||
**Requirements:** UR-031, UR-032, UR-033, UR-027 → DR-034, DR-035, DR-036, DR-030; IR-004
|
||||
**UX spec:** n/a — no UI change; Settings › Audio already renders these controls
|
||||
**Supersedes / revises:** closes the audio half of the parity gap recorded in [playback-backend-unification.md](playback-backend-unification.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Implement `set_audio_settings` / `audio_settings` on `ExoPlayerBackend` so the
|
||||
equalizer, volume normalization, and gapless playback settings actually take
|
||||
effect on Android. Today the Settings › Audio panel renders these controls on
|
||||
Android and they silently do nothing — `ExoPlayerBackend` is the only backend
|
||||
that does not override the trait's no-op defaults.
|
||||
|
||||
Crossfade is explicitly **not** included; see Out of scope.
|
||||
|
||||
## Motivation
|
||||
|
||||
`PlayerBackend` declares `set_audio_settings` with a default `Ok(())` body.
|
||||
`MpvBackend`, `NullBackend`, and `WebviewAudioBackend` all override it;
|
||||
`ExoPlayerBackend` does not. The settings are persisted, pushed to the backend on
|
||||
every track load, and displayed in the UI — and then dropped on the floor.
|
||||
|
||||
This is the single most user-visible platform divergence in the app: a user who
|
||||
sets a "Rock" EQ preset on Android sees the sliders move and hears no change.
|
||||
|
||||
The backend-unification investigation ruled out fixing this by swapping engines
|
||||
(video cannot be unified; see the sibling spec), so the fix is to implement the
|
||||
trait methods where they are missing.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Band count, centre frequencies, gain range, preset→curve map | Rust (existing) | Already domain-owned in `settings.rs` per [audio-equalizer.md](audio-equalizer.md). Android must consume the same `AudioSettings`, not define its own bands. Duplicating the band layout in Kotlin would be a taxonomy leak of exactly the kind `check:boundary` guards against. |
|
||||
| Mapping `AudioSettings` → Android audio-effect parameters | Rust → JNI boundary | Platform playback detail, the direct analogue of `build_af_filter` in `mpv_backend.rs`. Belongs with the other `set_audio_settings` code. |
|
||||
| Attaching/detaching `Equalizer` and `LoudnessEnhancer` to the ExoPlayer audio session | Kotlin (`JellyTauPlayer.kt`) | Android platform API mechanics; needs the live `audioSessionId`, which only the Kotlin layer holds. |
|
||||
| Normalization preset (Loud/Normal/Quiet) → target gain | Rust (existing) | `VolumeLevel` is domain vocabulary; the same preset must mean the same loudness on every platform. |
|
||||
| Rendering sliders / preset chips | Frontend (existing) | Pure presentation; unchanged by this spec. |
|
||||
|
||||
Borderline row: attaching the effects could arguably be driven entirely from
|
||||
Rust via JNI property calls. It goes to Kotlin because `AudioEffect` construction
|
||||
requires the audio session id and must be re-attached when ExoPlayer rebuilds its
|
||||
audio sink — lifecycle state that lives in `JellyTauPlayer.kt`. Rust still owns
|
||||
*what* the values are; Kotlin owns *when* the effect objects exist.
|
||||
|
||||
## Design
|
||||
|
||||
### Rust — `ExoPlayerBackend` (`src-tauri/src/player/android/mod.rs`)
|
||||
|
||||
Override the two defaulted methods, mirroring the shape of the existing
|
||||
`set_audio_track` JNI call:
|
||||
|
||||
```rust
|
||||
fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
|
||||
let s = settings.clone().with_crossfade_clamped().with_equalizer_normalised();
|
||||
// Serialize as JSON — the same pattern load() already uses for subtitles,
|
||||
// avoiding a 6-arg JNI signature that has to change every time a field lands.
|
||||
let json = serde_json::to_string(&s).map_err(|e| PlayerError { message: e.to_string() })?;
|
||||
// Kotlin: fun setAudioSettings(json: String)
|
||||
self.call_player_method_string("setAudioSettings", &json)?;
|
||||
self.shared_state.lock_safe().audio_settings = s;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn audio_settings(&self) -> AudioSettings {
|
||||
self.shared_state.lock_safe().audio_settings.clone()
|
||||
}
|
||||
```
|
||||
|
||||
`ExoPlayerState` gains an `audio_settings: AudioSettings` field. Note
|
||||
`ExoPlayerBackend` currently holds no such state — `position`/`state`/`volume` are
|
||||
all pushed in by JNI callbacks — so this is the first *pull*-side field. That is
|
||||
correct: audio settings are commanded downward, never reported upward.
|
||||
|
||||
### Kotlin — `JellyTauPlayer.kt`
|
||||
|
||||
```kotlin
|
||||
fun setAudioSettings(json: String) {
|
||||
val s = JSONObject(json)
|
||||
applyEqualizer(s.getBoolean("equalizerEnabled"), s.getJSONArray("equalizerBands"))
|
||||
applyNormalization(s.getBoolean("normalizeVolume"), s.getString("volumeLevel"))
|
||||
exoPlayer.pauseAtEndOfMediaItems = !s.getBoolean("gaplessPlayback")
|
||||
}
|
||||
```
|
||||
|
||||
Three independent mechanisms:
|
||||
|
||||
- **Gapless** — nearly free. ExoPlayer is gapless by default for compatible
|
||||
formats; honouring the setting means *disabling* it when the user turns it off,
|
||||
via `pauseAtEndOfMediaItems`. Note this only applies within a loaded playlist;
|
||||
our queue loads one item at a time, so verify behaviour before claiming DR-035
|
||||
on Android (see Testing).
|
||||
- **Equalizer** — `android.media.audiofx.Equalizer` bound to
|
||||
`exoPlayer.audioSessionId`. Android's EQ exposes a device-dependent band count
|
||||
(commonly 5) at fixed centre frequencies, which will **not** match our 10-band
|
||||
ISO layout. Rust owns the canonical 10 bands; Kotlin resamples them onto the
|
||||
device's bands by nearest-centre-frequency interpolation. Gains are in
|
||||
millibels (`setBandLevel` takes mB, we store dB → ×100), clamped to the
|
||||
device's reported `getBandLevelRange()`.
|
||||
- **Normalization** — `android.media.audiofx.LoudnessEnhancer`, also bound to the
|
||||
audio session, `setTargetGain(mB)` derived from `VolumeLevel`. This is a gain
|
||||
booster, not a true EBU R128 normalizer like MPV's `dynaudnorm`; parity is
|
||||
approximate and should be documented as such rather than overclaimed.
|
||||
|
||||
Lifecycle: build the effects lazily on first use, release them in `release()`,
|
||||
and re-attach on `onAudioSessionIdChanged` — ExoPlayer can rebuild its audio sink
|
||||
(e.g. on a format change), which invalidates effects bound to the old session.
|
||||
|
||||
### Make the silent-failure mode impossible
|
||||
|
||||
The trait's default is the root cause of this whole class of bug:
|
||||
|
||||
```rust
|
||||
// backend.rs:85 — reports success while doing nothing
|
||||
fn set_audio_settings(&mut self, _settings: &AudioSettings) -> Result<(), PlayerError> {
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
Android inherits this, so every EQ/normalization change on Android returns `Ok`
|
||||
and silently does nothing — the UI ships and has no effect, with no error anywhere.
|
||||
|
||||
Once `ExoPlayerBackend` implements the methods, **change the trait default to
|
||||
`Err(PlayerError::not_implemented())`**, matching how `set_audio_track` /
|
||||
`set_subtitle_track` already behave. Any future backend that forgets to implement
|
||||
audio settings then fails loudly instead of lying.
|
||||
|
||||
Check the call sites before flipping it: `NullBackend` overrides both methods, so
|
||||
the graceful-degradation path is unaffected, but confirm nothing treats a
|
||||
`set_audio_settings` error as fatal to playback.
|
||||
|
||||
### Re-application on track load
|
||||
|
||||
`PlayerController` already re-pushes `AudioSettings` per track on the platforms
|
||||
that implement it; the Android path inherits that for free once the trait methods
|
||||
exist. No controller change.
|
||||
|
||||
### 🔴 Threading note
|
||||
|
||||
`setAudioSettings` is invoked from Rust on whatever thread the command lands on.
|
||||
`AudioEffect` construction must not happen on the ExoPlayer application thread
|
||||
from inside a player callback — that is the re-entrancy hazard CLAUDE.md warns
|
||||
about, and the same shape as the `AutoplayDecision` deadlock. Post the work to
|
||||
the player's handler rather than doing it inline in a listener.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Crossfade (UR-031 / DR-034).** Not implemented on *any* platform today, and
|
||||
architecturally blocked on MPV (single-stream audio chain; `acrossfade` needs
|
||||
two inputs). Implementing it on Android alone would invert the parity gap. It
|
||||
needs its own spec and probably two player instances.
|
||||
- True EBU R128 normalization. `LoudnessEnhancer` is a gain stage; matching
|
||||
`dynaudnorm` exactly is out of reach without a custom `AudioProcessor`.
|
||||
- Windows audio settings — see [windows-native-audio-backend.md](windows-native-audio-backend.md).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `ExoPlayerBackend` overrides `set_audio_settings` and `audio_settings`.
|
||||
- [ ] EQ preset change on Android audibly changes playback; setting persists across track changes and app restart.
|
||||
- [ ] Normalization toggle audibly changes level; the three presets are ordered Loud > Normal > Quiet.
|
||||
- [ ] Disabling gapless produces a gap between consecutive tracks; enabling it does not.
|
||||
- [ ] Effects are released on `release()` and survive an audio-session rebuild.
|
||||
- [ ] `requirements.md` parity matrix updated: EQ and normalization ✅ Android.
|
||||
- [ ] `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 if Rust types changed.
|
||||
|
||||
## Testing
|
||||
|
||||
**Rust** (`cargo test`): `set_audio_settings` stores the sanitized settings and
|
||||
`audio_settings()` returns them — assert clamping/normalisation is applied
|
||||
(crossfade clamped to 12s, band vector normalised to `EQ_BANDS.len()`). The JNI
|
||||
call itself is not unit-testable; extract the JSON serialization into a pure
|
||||
function and test that its shape matches what the Kotlin parser expects. That
|
||||
serialization contract is the part most likely to silently break.
|
||||
|
||||
**Kotlin**: the band-resampling function (10 canonical bands → N device bands) is
|
||||
pure arithmetic — extract it and unit-test it, including the degenerate cases of
|
||||
a 5-band device and a device reporting 10 bands.
|
||||
|
||||
**Manual, on device** (these are the ones that actually prove it):
|
||||
1. Set Bass Boost, play a track, confirm audible change.
|
||||
2. Toggle normalization mid-track; confirm level change without a playback stall.
|
||||
3. Queue two gapless-encoded tracks, toggle the setting, confirm the gap appears/disappears.
|
||||
4. Force a format change (44.1kHz → 48kHz track) and confirm the EQ still applies afterwards — this exercises the session-rebuild re-attach.
|
||||
|
||||
## TRACES
|
||||
|
||||
- `ExoPlayerBackend::set_audio_settings` → `// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036`
|
||||
- Kotlin `setAudioSettings` / `applyEqualizer` / `applyNormalization` → same IDs
|
||||
- Band-resampling helper + its tests → `DR-030 | UT-xxx`
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Read [audio-equalizer.md](audio-equalizer.md) first — it defines the canonical
|
||||
band layout and the preset→curve rule this spec consumes. Do not redefine bands
|
||||
in Kotlin.
|
||||
- Android source edits go in `src-tauri/android/src` (canonical tree), then run
|
||||
`scripts/sync-android-sources.sh`. Never edit the `gen/` tree.
|
||||
- There is a **stale duplicate** `JellyTauPlayer.kt` (285 lines) at
|
||||
`src-tauri/android/app/src/main/java/com/dtourolle/jellytau/player/` alongside
|
||||
the real 1103-line file at `src-tauri/android/src/main/java/...`. Edit the
|
||||
latter. Consider deleting the former as a separate change.
|
||||
- A parallel Claude session may be active — `git diff` before "repairing"
|
||||
unexpected changes.
|
||||
@@ -0,0 +1,282 @@
|
||||
# Spec: Android native video — transparent-webview spike
|
||||
|
||||
**Status:** Spike succeeded — native video confirmed working on a physical
|
||||
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
|
||||
**Supersedes / revises:** acts on finding 2 of [playback-backend-unification.md](playback-backend-unification.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Test whether ExoPlayer's existing `SurfaceView` video path can be composited
|
||||
behind a transparent Tauri WebView on Android. If it works, Android regains
|
||||
hardware video decoding (MediaCodec) and libass-quality ASS/SSA subtitles, both
|
||||
of which the current webview path lacks. If it does not, we document why and
|
||||
delete the dead code.
|
||||
|
||||
This is a **spike**, not a feature commitment. The deliverable is a yes/no answer
|
||||
with evidence, plus either a working path behind a flag or a removal.
|
||||
|
||||
## Motivation
|
||||
|
||||
`createAdapter()` hardcodes `const effectiveKind = "html5"` and does
|
||||
`void backendKind`, discarding the `use_html5_element` value Rust computes in
|
||||
`get_player_status`. As a result:
|
||||
|
||||
- `NativePlayerAdapter` is dead code.
|
||||
- `JellyTauPlayer.kt`'s `getOrCreateSurfaceView()` — which already calls
|
||||
`setZOrderMediaOverlay(false)` and wires `setVideoSurfaceHolder` — is
|
||||
unreachable.
|
||||
- Android video decodes in the WebView instead of via MediaCodec, despite
|
||||
`CodecDetector.kt` going to the trouble of reporting hardware codec
|
||||
capabilities back to Rust for DeviceProfile generation.
|
||||
|
||||
The code comment in `nativeAdapter.ts:11-14` justifies this by citing
|
||||
tauri#10152 as an upstream blocker. **That justification is stale.**
|
||||
|
||||
### Why the blocker no longer holds
|
||||
|
||||
- tauri#10152 is open but **dead since 2024-07-01**, and it is a *feature
|
||||
request* ("Support transparent webviews on mobile"), not a bug report about
|
||||
compositing.
|
||||
- The capability shipped in tauri commit `27d01834` (2024-09-02) — a clippy
|
||||
cleanup that moved `transparent()` out of the desktop-gated impl block, fencing
|
||||
only the tao call behind `#[cfg(desktop)]`. Because it landed as unrelated
|
||||
cleanup, nobody closed the issue.
|
||||
- The black/white-screen reports (tauri#8381, tauri#9408) were a real but
|
||||
*different* bug: a broken JNI signature for `setBackgroundColor`, fixed in
|
||||
**wry 0.39.4** (PR #1237). We ship wry 0.55.x.
|
||||
- Current wry calls `setBackgroundColor(0)` unconditionally on Android when
|
||||
transparency is requested.
|
||||
|
||||
### The honest caveat
|
||||
|
||||
**Nobody has demonstrated SurfaceView-behind-WebView on Tauri Android.** A search
|
||||
of both `tauri-apps/tauri` and `tauri-apps/wry` issues for `surfaceview` returns
|
||||
zero results, and the one native-video Tauri plugin
|
||||
(`YeonV/tauri-plugin-videoplayer`) sidesteps compositing by launching a separate
|
||||
fullscreen Activity. Nothing upstream blocks this; nothing upstream proves it.
|
||||
Hence: spike, not feature.
|
||||
|
||||
Note this is the *Android* question only. The equivalent Linux compositing
|
||||
problem is maintainer-declared unfixable and is **not** in scope — see the
|
||||
unification spec.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Which video backend this platform uses | Rust (existing) | `get_player_status` already computes `use_html5_element`. The frontend must *consume* it, not decide it. Restoring that is the point of the spike. |
|
||||
| Surface creation, z-ordering, `setVideoSurfaceHolder` lifecycle | Kotlin | Android platform mechanics; already written in `JellyTauPlayer.kt`. |
|
||||
| Seek/audio-track *strategy* | Rust (existing) | Already returned by `player_seek_video` / `player_switch_audio_track`; `NativePlayerAdapter` executes the chosen primitive. Unchanged — this is exactly what the `PlayerAdapter` contract was built for. |
|
||||
| Positioning the surface under the video viewport | Frontend | Pure presentation/layout. **This is the risk area** — see Design. |
|
||||
|
||||
## Design
|
||||
|
||||
### Phase 1 — prove compositing (no app changes)
|
||||
|
||||
Before touching the adapter factory, verify the primitive works at all:
|
||||
|
||||
1. Set `"transparent": true` in `tauri.conf.json` for the Android build, plus
|
||||
`html, body { background: transparent; }`.
|
||||
2. Confirm the WebView is genuinely transparent (a native view behind it is
|
||||
visible) and that the app does not regress to a black/white screen.
|
||||
|
||||
If this fails, stop — everything downstream is moot, and the finding is that
|
||||
Tauri Android transparency is still broken in practice despite the shipped fix.
|
||||
|
||||
### Phase 2 — un-hardcode the factory
|
||||
|
||||
```ts
|
||||
// src/lib/player/adapters/index.ts
|
||||
export function createAdapter({ backendKind, host, bridge }: CreateAdapterArgs): PlayerAdapter {
|
||||
return backendKind === "native"
|
||||
? new NativePlayerAdapter(host)
|
||||
: new Html5PlayerAdapter(host, bridge);
|
||||
}
|
||||
```
|
||||
|
||||
`backendKind` comes from `get_player_status` (`VideoBackend::Native` on Android).
|
||||
Gate behind a setting — `experimentalNativeVideo`, default **off** — so a broken
|
||||
spike cannot ship as a regression. Rust already owns this decision; the flag only
|
||||
suppresses it.
|
||||
|
||||
**Also in scope: remove the user-agent sniffing in
|
||||
`src/lib/services/webviewAudio.ts:30-41`.** It re-derives which audio backend the
|
||||
platform has from `navigator.userAgent` ("matching the Rust cfg gate", per its own
|
||||
comment) — the frontend deciding a backend fact it should be told. Same root cause
|
||||
as the hardcode above, same fix: consume the value Rust already computes. Fold it
|
||||
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
|
||||
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
|
||||
|
||||
The hard part, and where this most likely fails. The webview's `<video>` element
|
||||
occupies a laid-out box; the `SurfaceView` must be positioned to match it, and
|
||||
kept matched through scroll, rotation, and mini-player transitions.
|
||||
|
||||
Approach: the video view reports its `getBoundingClientRect()` to Rust, which
|
||||
forwards the rect to Kotlin to position the `SurfaceView`. This is the same
|
||||
"faking it" technique the ecosystem uses on desktop — acceptable here *only if*
|
||||
the video is effectively fullscreen on Android, which it is in the player route.
|
||||
|
||||
**Explicit failure criterion**: if the surface cannot be kept aligned during
|
||||
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.
|
||||
|
||||
**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
|
||||
|
||||
- **Hardware decode via MediaCodec** — `CodecDetector.kt` already reports
|
||||
capabilities; the DeviceProfile would finally match what actually plays.
|
||||
- **ASS/SSA subtitles** are *not* automatic. ExoPlayer cannot render them; that
|
||||
would require libmpv, which is a separate and much larger decision (see the
|
||||
unification spec's engine comparison). Scope this spike to hardware decode
|
||||
only, and do not claim subtitle improvements from it.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Linux native video. Maintainer-declared unfixable on WebKitGTK/Wayland.
|
||||
- Replacing ExoPlayer with libmpv on Android.
|
||||
- Windows native video.
|
||||
- Removing the HTML5 path. It stays as the default and the fallback.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
The spike is **complete** when one of these is true:
|
||||
|
||||
**Success path**
|
||||
- [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`).
|
||||
- [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.
|
||||
- [x] `webviewAudio.ts` no longer inspects `navigator.userAgent`; the platform's audio backend is read from Rust (`player_get_capabilities` → `usesWebviewAudio`).
|
||||
- [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.
|
||||
- [ ] 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.
|
||||
- [ ] 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.
|
||||
|
||||
**Failure path**
|
||||
- [ ] The blocking behaviour is documented in this spec with evidence.
|
||||
- [ ] `NativePlayerAdapter` and the unreachable `SurfaceView` code are deleted, or explicitly retained with a *correct* comment.
|
||||
- [ ] `nativeAdapter.ts:11-14` no longer cites tauri#10152.
|
||||
|
||||
Either way:
|
||||
- [x] `bun run check` (0 errors), `bun run test` (892 passed), `bun run check:boundary` pass.
|
||||
- [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
|
||||
|
||||
Adapter-selection logic is pure and testable without a device: assert
|
||||
`createAdapter` returns `NativePlayerAdapter` for `backendKind: "native"` with
|
||||
the flag on, and `Html5PlayerAdapter` in every other combination — including that
|
||||
the flag off forces HTML5 even when Rust says native. That last case is the
|
||||
regression guard.
|
||||
|
||||
Everything else is manual on-device; there is no meaningful way to unit-test
|
||||
surface compositing. Test on at least two devices — compositing behaviour varies
|
||||
by OEM and Android version.
|
||||
|
||||
Per CLAUDE.md, if the spike turns into a bug fix (e.g. seek breaks under the
|
||||
native adapter), write the failing test first.
|
||||
|
||||
## TRACES
|
||||
|
||||
- `createAdapter` → `// TRACES: UR-003, UR-004 | DR-004, DR-150 | UT-149`
|
||||
- Adapter-selection tests → `UT-xxx`
|
||||
- No new requirement IDs; this spike either satisfies existing IR-004 expectations or documents why it cannot.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- **Do not skip Phase 1.** If transparency does not work, phases 2 and 3 are
|
||||
wasted effort.
|
||||
- `VideoPlayer.svelte` has a documented hazard: no lifecycle calls after an
|
||||
`await` in `onMount` — it flips to HTML5 mode and breaks Android seek. The
|
||||
adapter swap touches exactly this code path.
|
||||
- tauri-specta tagged responses keep Rust field names (`new_url`, not `newUrl`).
|
||||
- Android source edits go in `src-tauri/android/src`, then run
|
||||
`scripts/sync-android-sources.sh`.
|
||||
- A parallel Claude session may be active — `git diff` first.
|
||||
@@ -0,0 +1,176 @@
|
||||
# Spec: Audio equalizer
|
||||
|
||||
**Status:** Accepted
|
||||
**Requirements:** UR-027 → DR-030 (EQ UI), IR-020 (MPV EQ integration).
|
||||
**UX spec:** n/a (extends the Settings › Audio section, ux-flows §8.1 instant-apply).
|
||||
**Supersedes / revises:** —
|
||||
**Revised by:** [android-audio-settings-parity.md](android-audio-settings-parity.md) — lifts the "Android is a no-op" limitation below.
|
||||
|
||||
## Summary
|
||||
|
||||
Add a graphic audio equalizer to playback. Users pick a preset (Flat, Rock,
|
||||
Pop, Jazz, Classical, Bass Boost, Treble Boost, Vocal) or set custom per-band
|
||||
gains, from a new block in Settings › Audio. On Linux the gains apply live via
|
||||
MPV's audio-filter chain; the settings persist and re-apply on the next track
|
||||
and at startup, exactly like crossfade/gapless/normalize do today. Android is a
|
||||
no-op for now (documented parity gap, same as those three features).
|
||||
|
||||
## Motivation
|
||||
|
||||
UR-027 is one of the few still-unbuilt audio features. The audio-settings
|
||||
pipeline it needs already exists — `AudioSettings` + `set_audio_settings` on the
|
||||
`PlayerBackend` trait, the `player_set_audio_settings` command, and the Settings
|
||||
› Audio UI with instant-apply. Crossfade, gapless, and volume normalization all
|
||||
ride that pipeline. The equalizer is the same shape: N more fields on
|
||||
`AudioSettings`, an `af` filter on the MPV backend, one more block in the
|
||||
settings panel. No new command, no new state machine.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| EQ band count, centre frequencies, gain range/clamping | Rust | Domain of the audio engine; the bands must match what the MPV filter expects. Changing the DSP must not require a frontend change. |
|
||||
| Preset name → per-band gain curve | Rust | A preset *is* a domain gain curve, not a label. It changes with the audio engine's band layout, never with the UI. Placing it in the frontend would be the scoped-search taxonomy mistake again (values that look like config but are domain data). |
|
||||
| Translating gains → MPV `af` filter string | Rust | Platform playback detail; lives with the other `set_audio_settings` filter code in `mpv_backend.rs`. |
|
||||
| Persisting the chosen settings, re-pushing on load | Rust/existing | Same path crossfade/etc. already use; the controller re-applies `AudioSettings` per track. |
|
||||
| Rendering band sliders, the preset chips, live readouts | Frontend | Pure presentation; changes only if the settings UI is redesigned. |
|
||||
| Which preset chip is highlighted; instant-apply on change | Frontend | Presentation/input handling (UR-057), the same as the normalize preset picker. |
|
||||
|
||||
Tie-breaker note: the preset→curve map is the one tempting boundary leak. It goes
|
||||
in Rust because a preset is a set of band gains defined *by the band layout*,
|
||||
which is an engine property. The frontend only ever names a preset and renders
|
||||
the resulting gains; it never defines them.
|
||||
|
||||
## Design
|
||||
|
||||
### `AudioSettings` (Rust, `settings.rs`)
|
||||
|
||||
Add two fields (both `#[serde(rename_all = "camelCase")]` via the existing
|
||||
struct attribute):
|
||||
|
||||
```rust
|
||||
/// Equalizer enabled. When false, no `af` EQ filter is applied.
|
||||
pub equalizer_enabled: bool,
|
||||
/// Per-band gains in dB, one per FIXED band (see EQ_BANDS). Length is
|
||||
/// validated/normalised to EQ_BANDS.len(); clamped to [-12, +12] dB.
|
||||
pub equalizer_bands: Vec<f32>,
|
||||
```
|
||||
|
||||
Fixed 10-band ISO layout (domain constant in `settings.rs`):
|
||||
|
||||
```rust
|
||||
pub const EQ_BANDS: [f32; 10] =
|
||||
[31.0, 62.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0];
|
||||
pub const EQ_GAIN_MIN: f32 = -12.0;
|
||||
pub const EQ_GAIN_MAX: f32 = 12.0;
|
||||
```
|
||||
|
||||
- `Default`: `equalizer_enabled: false`, `equalizer_bands: vec![0.0; 10]` (flat).
|
||||
- New `with_equalizer_normalised(self)` clamps each gain to `[EQ_GAIN_MIN,
|
||||
EQ_GAIN_MAX]` and pads/truncates the vec to 10 bands. Applied in the command
|
||||
alongside `with_crossfade_clamped` (add that call too — it's currently missing).
|
||||
- Backward compat: both fields `#[serde(default)]` so old persisted JSON loads.
|
||||
|
||||
### Presets (Rust, `settings.rs`)
|
||||
|
||||
```rust
|
||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum EqPreset { Flat, Rock, Pop, Jazz, Classical, BassBoost, TrebleBoost, Vocal }
|
||||
|
||||
impl EqPreset {
|
||||
/// The 10-band gain curve (dB) for this preset.
|
||||
pub fn gains(&self) -> [f32; 10] { /* table */ }
|
||||
}
|
||||
```
|
||||
|
||||
Preset selection is a *frontend* convenience: tapping a chip sets
|
||||
`equalizer_bands = preset.gains()` and pushes settings. The curve tables live in
|
||||
Rust; the frontend reads them via a tiny `player_get_eq_presets` command
|
||||
returning `Vec<(EqPreset, Vec<f32>)>` (or a map), so the frontend never encodes
|
||||
the numbers. (If exposing the whole table is awkward through specta, expose
|
||||
`player_eq_preset_gains(preset) -> Vec<f32>` instead — pick at implement time.)
|
||||
|
||||
### MPV application (Rust, `mpv_backend.rs::set_audio_settings`)
|
||||
|
||||
Build an `equalizer` / `anequalizer` filter from the bands and set the `af`
|
||||
property. When `equalizer_enabled` is false or all gains are 0, clear the EQ
|
||||
filter (leave any other `af` entries intact). Use `af add`/`af remove` or a
|
||||
rebuilt `af` string; keep it isolated so it doesn't stomp a future crossfade
|
||||
filter. Errors map to `PlayerError` like the gapless code.
|
||||
|
||||
### No new persistence table
|
||||
|
||||
`AudioSettings` is already round-tripped by the frontend settings store and
|
||||
re-pushed via `player_set_audio_settings` on change and on load. The two new
|
||||
fields ride along. `NullBackend`/Android inherit the trait default (no-op).
|
||||
|
||||
### Wire summary
|
||||
|
||||
- Command names unchanged: `player_set_audio_settings`,
|
||||
`player_get_audio_settings` (now carry the EQ fields).
|
||||
- New (optional) read-only command for preset curves — kebab n/a (it's a
|
||||
command): `player_get_eq_presets` (or `player_eq_preset_gains`).
|
||||
- Regenerate `bindings.ts` from the Rust types; never hand-edit.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Android/ExoPlayer EQ (parity gap tracked with crossfade/gapless/normalize).
|
||||
**Now specified in [android-audio-settings-parity.md](android-audio-settings-parity.md)**,
|
||||
which implements `set_audio_settings` on `ExoPlayerBackend`. The canonical band
|
||||
layout and preset→curve map defined here remain authoritative; the Android side
|
||||
resamples those bands onto the device equalizer rather than defining its own.
|
||||
- Per-track or per-library EQ profiles — one global profile only.
|
||||
- Automatic loudness/room correction; only manual bands + presets.
|
||||
- Changing the crossfade/normalize TODOs in `set_audio_settings` beyond wiring
|
||||
the missing `with_crossfade_clamped` call.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Settings › Audio has an Equalizer block: enable toggle, preset chips, 10
|
||||
band sliders with live dB readouts, instant-apply (no Save button).
|
||||
- [ ] Choosing a preset sets the bands from the Rust-defined curve; editing a
|
||||
band switches the highlighted preset to "Custom" (frontend-only label).
|
||||
- [ ] Gains clamp to [-12, +12] dB; the band vector always normalises to 10.
|
||||
- [ ] On Linux, enabling EQ audibly changes output and persists across tracks
|
||||
and app restart; disabling clears the filter without affecting other audio.
|
||||
- [ ] Old persisted settings (no EQ fields) load without error, defaulting flat.
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
||||
- [ ] `bun run check:boundary` passes (no preset curve numbers in the frontend).
|
||||
- [ ] New requirement-implementing code carries `// TRACES:` comments.
|
||||
- [ ] `bindings.ts` regenerated.
|
||||
|
||||
## Testing
|
||||
|
||||
- Rust (`settings.rs`): default is flat + disabled; `with_equalizer_normalised`
|
||||
clamps out-of-range gains and pads/truncates band length; serialization
|
||||
round-trips the camelCase fields; backward-compat load of pre-EQ JSON; each
|
||||
preset returns a 10-length curve; Flat is all zeros.
|
||||
- Rust IPC param naming for any new command (camelCase rule per CLAUDE.md).
|
||||
- Frontend (`settings` page or an extracted helper): selecting a preset sets the
|
||||
expected band array; editing a band flips the label to Custom; enable toggle
|
||||
gates the sliders. Keep DSP untested on the frontend (it's Rust's).
|
||||
|
||||
## TRACES
|
||||
|
||||
- `AudioSettings` EQ fields + normalise + presets: `UR-027 | DR-030` (+ unit tests)
|
||||
- MPV EQ filter application: `UR-027 | IR-020`
|
||||
- Settings EQ UI block: `UR-027 | DR-030`
|
||||
- Preset-curve command: `UR-027 | DR-030`
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- A parallel Claude session is active in this repo (it has touched
|
||||
`tauri.conf.json`, `Dockerfile`, `package.json`, home components, and added
|
||||
build scripts, and the Rust build is currently broken by its
|
||||
`tauri.conf.json` bundle-target change). `git diff` before "repairing"
|
||||
anything you didn't write; keep EQ changes isolated to `settings.rs`,
|
||||
`mpv_backend.rs`, `backend.rs` (trait default already covers it),
|
||||
`commands/player/settings.rs`, and the settings page.
|
||||
- Mirror the volume-normalization block in the settings page for the toggle +
|
||||
preset-picker pattern; mirror the gapless code in `set_audio_settings` for the
|
||||
MPV property handling.
|
||||
- Confirm the exact MPV filter name available in the linked libmpv
|
||||
(`equalizer` vs `anequalizer`/`superequalizer`) before committing the filter
|
||||
string; gate cleanly if unavailable.
|
||||
@@ -0,0 +1,215 @@
|
||||
# Spec: Harden the frontend boundary tripwire
|
||||
|
||||
**Status:** Implemented
|
||||
**Requirements:** DR-094
|
||||
**UX spec:** n/a — developer tooling.
|
||||
**Supersedes / revises:** revises the detection rule in
|
||||
[scripts/check-frontend-boundary.sh](../../scripts/check-frontend-boundary.sh);
|
||||
the boundary *policy* in [scoped-search-boundary.md](scoped-search-boundary.md)
|
||||
is unchanged.
|
||||
|
||||
## Summary
|
||||
|
||||
`bun run check:boundary` passes on a tree that contains the exact leak it was
|
||||
built to catch. It matches a multi-type array only when written **inline at the
|
||||
query site**, so assigning the same array to a named const evades it entirely —
|
||||
which is how [searchScope.ts](../../src/lib/utils/searchScope.ts) has kept a
|
||||
category→item-type mapping through every green CI run. This spec broadens the
|
||||
match to item-type array literals anywhere in `src/`, and resolves the handful
|
||||
of legitimate hits that broadening surfaces.
|
||||
|
||||
## Motivation
|
||||
|
||||
The current pattern is anchored to the `includeItemTypes:` key:
|
||||
|
||||
```sh
|
||||
PATTERN='includeItemTypes:[[:space:]]*\[[^]]*,[^]]*\]'
|
||||
```
|
||||
|
||||
The live leak is not written that way:
|
||||
|
||||
```ts
|
||||
// src/lib/utils/searchScope.ts:29 — invisible to the tripwire
|
||||
const SCOPE_ITEM_TYPES = { music: ["MusicAlbum", "MusicArtist", "Audio", "Playlist"], … };
|
||||
```
|
||||
|
||||
The taxonomy and the query are one indirection apart, and the grep only sees the
|
||||
query. The script's own header is admirably honest that it is "a TRIPWIRE, NOT A
|
||||
PROOF" — but the gap here is not a subtle judgment call it was designed to
|
||||
defer to human review. It is the *crudest form* of the violation, one `const`
|
||||
away from the shape it does match, in the very file the founding incident was
|
||||
written about.
|
||||
|
||||
Broadening the pattern to any item-type array literal finds it, with a
|
||||
manageable number of other hits (measured, not estimated):
|
||||
|
||||
| Site | Verdict |
|
||||
|------|---------|
|
||||
| `searchScope.ts:30,32` | 🔴 The leak. Removed by [scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md). |
|
||||
| `PersonDetailView.svelte:30` | Already allowlisted, with a recorded reason. |
|
||||
| `DownloadedBrowse.svelte:95` | Borderline — `["MusicAlbum","Series","Season","BoxSet"].includes(item.type)` as an "is this a container?" predicate. |
|
||||
| `GenericMediaListPage.svelte:298` | Borderline — `["MusicAlbum","MusicArtist","Audio","Playlist"].includes(config.itemType)` as a music-styling predicate. |
|
||||
| 6 hits in `*.test.ts` | Excluded; tests legitimately name types. |
|
||||
|
||||
Four non-test sites total. This is a tractable change, not a boil-the-ocean one.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
Tooling only — no application logic, nothing crosses IPC. The two borderline
|
||||
*application* sites do get a layer decision, below.
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Detecting item-type array literals in `src/` | Build tooling (`scripts/`) | Static analysis of repo source; belongs beside the existing check. |
|
||||
| "Is this item a container?" (`DownloadedBrowse`) | **Rust** (recommended) | Containers-vs-leaves is Jellyfin structure, and the set grows when Jellyfin adds a container type — the litmus test's "yes". Prefer a `MediaItem.isContainer` boolean from the backend over a type-set predicate in a component. |
|
||||
| "Is this music content?" (`GenericMediaListPage`) | **Frontend, allowlisted** | Selects a grid *style*. It reads `config.itemType`, a value the page already declares about itself, and changes only if the UI is redesigned — the litmus test's "no". Single-type presentation is explicitly not the target of the rule. |
|
||||
|
||||
`DownloadedBrowse` defaults to Rust per the checklist's borderline rule; see
|
||||
Out of scope for why the migration itself is deferred rather than bundled.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Broaden the pattern
|
||||
|
||||
Replace the key-anchored pattern with one matching an array literal of two or
|
||||
more known Jellyfin item types, wherever it appears:
|
||||
|
||||
```sh
|
||||
# Two or more adjacent item-type string literals inside a bracket.
|
||||
TYPES='Movie|Series|Episode|Audio|MusicAlbum|MusicArtist|MusicVideo|Season|BoxSet|Playlist|Book|AudioBook|Video|Person|Folder|CollectionFolder|TvChannel|LiveTvChannel'
|
||||
PATTERN="\[[[:space:]]*\"($TYPES)\"[[:space:]]*,[[:space:]]*\"($TYPES)\""
|
||||
```
|
||||
|
||||
Properties worth stating, because each is a deliberate trade:
|
||||
|
||||
- **Not anchored to any key**, so a named const, a function return, a `Record`
|
||||
value, or an inline query all match equally.
|
||||
- **Requires two adjacent type literals**, preserving the existing and correct
|
||||
carve-out that single-type presentation (`itemType: "Movie"`) is legitimate.
|
||||
- **Requires string literals**, so `item.type === "Audio"` (display inspection)
|
||||
still does not match.
|
||||
- **Explicit type list**, not `[A-Z][a-z]+`, so arbitrary string arrays
|
||||
(`["High","Low"]`, `["Songs","Albums"]`) do not produce noise.
|
||||
|
||||
Keep `grep -rInE`, the `*.test.*` exclusion, and the allowlist mechanism as they
|
||||
are — all three work.
|
||||
|
||||
### 2. Resolve the surfaced sites
|
||||
|
||||
- `PersonDetailView.svelte` — already allowlisted; entry unchanged.
|
||||
- `GenericMediaListPage.svelte` — **add to the allowlist** with the reason from
|
||||
the layer table (grid styling over a self-declared `itemType`).
|
||||
- `DownloadedBrowse.svelte` — **add to the allowlist with a `TODO` naming the
|
||||
preferred fix** (backend `isContainer`). An allowlist entry that records a
|
||||
known-borderline decision is honest; silently broadening the pattern to miss
|
||||
it would not be.
|
||||
- `searchScope.ts` — **not allowlisted.** It is the leak, and
|
||||
[scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md)
|
||||
deletes it.
|
||||
|
||||
### 3. 🔴 Sequencing
|
||||
|
||||
**This spec must land after Stage 1 of
|
||||
[scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md).**
|
||||
Hardening the tripwire first turns `master` red on a violation with no fix
|
||||
available, and the only ways out are reverting the hardening or allowlisting the
|
||||
leak — the second of which is exactly how a boundary rule dies.
|
||||
|
||||
### 4. Keep the allowlist honest
|
||||
|
||||
The script already warns that a growing allowlist means the boundary is eroding.
|
||||
This change takes it from 1 entry to 3, which is close to that line. Add a hard
|
||||
cap so drift is caught mechanically rather than by whoever notices:
|
||||
|
||||
```sh
|
||||
MAX_ALLOWLIST=4
|
||||
if [ "${#ALLOWLIST[@]}" -gt "$MAX_ALLOWLIST" ]; then
|
||||
echo "❌ Allowlist has ${#ALLOWLIST[@]} entries (max $MAX_ALLOWLIST)."
|
||||
echo " Push taxonomy into Rust instead of appending here."
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
The cap is deliberately just above the current count: the next exception forces
|
||||
a conversation instead of a one-line append.
|
||||
|
||||
### 5. Restate the limits
|
||||
|
||||
The header's "tripwire, not a proof" caveat stays and gets sharper. The broadened
|
||||
pattern still cannot see:
|
||||
|
||||
- a type set built at run time (`[...musicTypes, "Playlist"]`),
|
||||
- types split across variables (`const A = "Audio"; [A, B]`),
|
||||
- taxonomy expressed as a `switch` or chained `||` rather than an array.
|
||||
|
||||
The spec-review checklist remains the real gate. This raises the floor; it does
|
||||
not close the class.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Migrating `DownloadedBrowse` to a backend `isContainer` flag.** It touches
|
||||
`MediaItem`, `bindings.ts`, and the offline path — its own spec. Allowlisted
|
||||
with a TODO here so it is recorded, not forgotten.
|
||||
- The scoped-search fix itself — [scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md).
|
||||
- Detecting the run-time-construction cases listed above.
|
||||
- Extending the check to Rust or Kotlin (the rule is about `src/`).
|
||||
- Changing the boundary *policy* in CLAUDE.md.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] With `searchScope.ts` reverted to its leaking form, `bun run check:boundary`
|
||||
**fails** and names `src/lib/utils/searchScope.ts`. This is the criterion
|
||||
that proves the fix — verify it explicitly before landing.
|
||||
- [ ] On the post-fix tree, `bun run check:boundary` passes.
|
||||
- [ ] A newly introduced `const X = ["Movie", "Series"]` in any non-test `src/`
|
||||
file fails the check (regression test for the const-indirection evasion).
|
||||
- [ ] `itemType: "Movie"` and `item.type === "Audio"` do **not** trip the check.
|
||||
- [ ] `["High", "Low"]` and other non-item-type arrays do **not** trip it.
|
||||
- [ ] Test files are still excluded (the 6 known test hits stay silent).
|
||||
- [ ] The allowlist has exactly 3 entries, each with a written reason; a 5th
|
||||
entry fails the check via `MAX_ALLOWLIST`.
|
||||
- [ ] The script header still states it is a tripwire, not a proof, and names the
|
||||
evasions it cannot see.
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
- [ ] `bun run test:all` passes.
|
||||
|
||||
## Testing
|
||||
|
||||
The script is bash and has no test harness. Verify by construction — each is a
|
||||
temporary edit, run, revert:
|
||||
|
||||
1. Reintroduce the `SCOPE_ITEM_TYPES` const → **must fail**.
|
||||
2. Add `const T = ["Movie","Series"]` to a scratch `.svelte` file → **must fail**.
|
||||
3. Add the same to a `.test.ts` file → **must pass** (exclusion holds).
|
||||
4. Add `itemType: "Movie"` → **must pass**.
|
||||
5. Add a 5th allowlist entry → **must fail** on the cap.
|
||||
|
||||
Record the five results in the PR description. A grep-based gate that has never
|
||||
been observed failing is indistinguishable from one that cannot fail — which is
|
||||
the precise condition this whole spec exists to correct.
|
||||
|
||||
## TRACES
|
||||
|
||||
Allocate in `requirements.md`:
|
||||
|
||||
- **DR-094** — "Frontend boundary tripwire detects Jellyfin item-type array
|
||||
literals anywhere in `src/` (not only inline at an `includeItemTypes:` query
|
||||
site), so a category→type mapping cannot evade the check via a named const;
|
||||
allowlist is capped to force taxonomy into Rust rather than accumulating
|
||||
exceptions." Category: Tooling. Status: Done on merge.
|
||||
|
||||
Shell scripts carry no `TRACES:` comment convention in this repo; reference
|
||||
DR-094 in the script header comment instead.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- A parallel Claude session may be active in this repo — `git diff` before
|
||||
"repairing" unexpected changes (CLAUDE.md §Gotchas).
|
||||
- **Land after Stage 1 of the scoped-search fix** — see §3. This is the one
|
||||
ordering constraint that will break `master` if ignored.
|
||||
- Test the regex against the current tree *before* committing:
|
||||
`grep -rInE "$PATTERN" src/ | grep -v '\.test\.'` should return exactly the
|
||||
four sites in the Motivation table.
|
||||
- The `TYPES` list will need occasional extension as Jellyfin adds types.
|
||||
That is acceptable for a tripwire — an unlisted type produces a false
|
||||
negative, never a false positive, so the check degrades safely.
|
||||
@@ -0,0 +1,250 @@
|
||||
# Spec: Build provenance (git describe + build profile)
|
||||
|
||||
**Status:** Proposed
|
||||
**Requirements:** new DR-093 (build provenance surfaced in-app and in logs); no UR — this is a diagnostic capability, not a user feature
|
||||
**UX spec:** n/a — adds an About block to Settings; no new flow
|
||||
**Supersedes / revises:** —
|
||||
|
||||
## Summary
|
||||
|
||||
Make every build say exactly what it is. Today a running JellyTau reports no
|
||||
version at all — not in the UI, not in the logs — and the only version string in
|
||||
the tree is the hand-maintained `0.2.0` duplicated across three files.
|
||||
|
||||
This adds a `build.rs`-generated provenance string (`git describe` + short SHA +
|
||||
dirty flag + debug/release profile), exposes it over IPC, and renders it in a new
|
||||
Settings › About block. It also removes one of the three hand-bumped version
|
||||
files.
|
||||
|
||||
## Motivation
|
||||
|
||||
The concrete problem: when a user reports "the equalizer does nothing on my
|
||||
device" — which is a live risk for v0.2.0, whose Android audio settings are not
|
||||
yet device-verified — there is currently no way to tell which build they are
|
||||
running. Tag? Master? A local debug build from three weeks ago? The bug report
|
||||
cannot distinguish them.
|
||||
|
||||
Two smaller irritations this also fixes:
|
||||
|
||||
- **Debug builds masquerade as releases.** `0.2.0` is `0.2.0` whether it came
|
||||
from a tagged release or `bun run tauri dev`.
|
||||
- **Three files carry the version.** `package.json`, `src-tauri/Cargo.toml` and
|
||||
`src-tauri/tauri.conf.json` must be bumped in lockstep; the release checklist
|
||||
exists partly to stop them drifting.
|
||||
|
||||
### What this deliberately does *not* do
|
||||
|
||||
**The canonical version stays hand-bumped in `Cargo.toml`.** Cargo requires a
|
||||
literal semver string at manifest-parse time and cannot derive it from git. The
|
||||
same is true of `tauri.conf.json`. Attempting to source the *release* version
|
||||
from a tag trades a scripted, reviewable bump for a fragile build-time
|
||||
dependency that breaks in exactly the environment we care most about (CI, in
|
||||
Docker, from a shallow clone).
|
||||
|
||||
So: **the release version is authored; the build provenance is derived.** They
|
||||
answer different questions — "what release is this?" versus "what commit is this
|
||||
binary actually built from?" — and only the second benefits from git.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Capturing git describe / SHA / dirty state at compile time | Rust (`build.rs`) | Only the Rust build has a compile step that can shell out to git and bake the result into the binary. A frontend equivalent would report the *dev server's* state, not the shipped binary's. |
|
||||
| Degrading to a sentinel when git is unavailable | Rust (`build.rs`) | Build-environment concern. Must never fail the build — CI runs in Docker from a shallow clone. |
|
||||
| Release version (`0.2.0`) | Rust (`Cargo.toml`, authored) | Domain fact about the product, not derivable from the environment. |
|
||||
| Deciding *what a build is* (release / dev / dirty) | Rust | Domain classification. The frontend must not infer "this is a dev build" from a string shape — it renders what it is told. |
|
||||
| Rendering the About block, copy-to-clipboard | Frontend | Pure presentation. |
|
||||
|
||||
Borderline row: the release/dev/dirty classification could be done in the
|
||||
frontend by pattern-matching the describe string. It goes to Rust because that is
|
||||
a *rule about what constitutes a release build*, and it would have to change if
|
||||
the tagging scheme changed — the litmus test in the template puts that in Rust.
|
||||
Send a typed enum, not a string for the frontend to parse.
|
||||
|
||||
## Design
|
||||
|
||||
### `build.rs`
|
||||
|
||||
```rust
|
||||
fn main() {
|
||||
emit_build_provenance();
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
fn emit_build_provenance() {
|
||||
let describe = std::process::Command::new("git")
|
||||
.args(["describe", "--tags", "--always", "--dirty"])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
println!("cargo:rustc-env=JELLYTAU_GIT_DESCRIBE={describe}");
|
||||
|
||||
// Rebuild when HEAD moves or a ref is written, so the string does not go
|
||||
// stale across commits. Guarded: these paths do not exist in a git-less
|
||||
// source tarball, and emitting rerun-if-changed for a missing path would
|
||||
// force a rebuild every time.
|
||||
for p in [".git/HEAD", ".git/refs"] {
|
||||
if std::path::Path::new("../").join(p).exists() {
|
||||
println!("cargo:rerun-if-changed=../{p}");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
🔴 **`build.rs` must never fail the build.** Every git call is
|
||||
`.ok()`-swallowed; a missing git binary, a shallow clone, or a source tarball all
|
||||
yield `"unknown"`. A build that breaks because git is absent would be a worse bug
|
||||
than the one this fixes.
|
||||
|
||||
Note the `../` prefixes: `build.rs` runs with CWD at `src-tauri/`, so the repo's
|
||||
`.git` is one level up.
|
||||
|
||||
### The provenance type
|
||||
|
||||
```rust
|
||||
/// TRACES: DR-093
|
||||
#[derive(specta::Type, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildInfo {
|
||||
/// Authored release version (Cargo.toml).
|
||||
pub version: String,
|
||||
/// `git describe --tags --always --dirty`, or "unknown".
|
||||
pub git_describe: String,
|
||||
/// What kind of build this is — classified in Rust, not inferred by the UI.
|
||||
pub kind: BuildKind,
|
||||
}
|
||||
|
||||
/// TRACES: DR-093
|
||||
#[derive(specta::Type, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum BuildKind {
|
||||
/// Built from a clean, exactly-tagged commit in release mode.
|
||||
Release,
|
||||
/// Release-mode build that is not on a clean tag (e.g. master, or dirty).
|
||||
Untagged,
|
||||
/// debug_assertions build.
|
||||
Development,
|
||||
/// Git state unavailable at build time.
|
||||
Unknown,
|
||||
}
|
||||
```
|
||||
|
||||
Classification:
|
||||
|
||||
```rust
|
||||
let kind = if cfg!(debug_assertions) {
|
||||
BuildKind::Development
|
||||
} else if describe == "unknown" {
|
||||
BuildKind::Unknown
|
||||
} else if describe.contains('-') { // "v0.2.0-3-gcb79a37" or "...-dirty"
|
||||
BuildKind::Untagged
|
||||
} else {
|
||||
BuildKind::Release
|
||||
};
|
||||
```
|
||||
|
||||
### Command
|
||||
|
||||
```rust
|
||||
/// TRACES: DR-093
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn get_build_info() -> BuildInfo { … }
|
||||
```
|
||||
|
||||
No parameters, so the camelCase param rule does not apply; the struct fields do
|
||||
need `#[serde(rename_all = "camelCase")]` (above). Regenerate `bindings.ts`.
|
||||
|
||||
Also log the provenance once at startup, next to the existing init logging —
|
||||
that is what makes a user-submitted log file self-identifying, which is most of
|
||||
the value.
|
||||
|
||||
### Settings › About
|
||||
|
||||
A new block at the bottom of `src/routes/settings/+page.svelte`, rendering
|
||||
version, describe string, and a badge for non-release builds. One
|
||||
copy-to-clipboard button that yields a paste-ready block for bug reports:
|
||||
|
||||
```
|
||||
JellyTau 0.2.0 (v0.2.0-3-gcb79a37-dirty, development)
|
||||
linux x86_64
|
||||
```
|
||||
|
||||
Platform/arch come from the existing Tauri APIs; do not shell out.
|
||||
|
||||
### Removing one version file
|
||||
|
||||
`tauri.conf.json`'s `"version"` field can be omitted, in which case Tauri falls
|
||||
back to the Cargo version. That takes the bump from three files to two.
|
||||
|
||||
**Verify before adopting**: confirm the Android `versionName`/`versionCode` and
|
||||
the NSIS installer version still resolve correctly with the field absent —
|
||||
Android packaging in particular reads the Tauri config. If either regresses,
|
||||
keep the field and drop this part; it is a convenience, not the point of the
|
||||
spec.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Deriving the *release* version from git tags (see Motivation).
|
||||
- A build-time timestamp. It defeats reproducible builds and adds little over
|
||||
the commit SHA.
|
||||
- CI provenance/attestation, SBOM, signing.
|
||||
- Displaying the Jellyfin server version (separate concern, already available
|
||||
from `/System/Info`).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `cargo build` succeeds with git absent, from a shallow clone, and from a source tarball with no `.git` — yielding `"unknown"` in each case, never a build failure.
|
||||
- [ ] A tagged clean release build reports `BuildKind::Release`; `bun run tauri dev` reports `Development`; a dirty tree reports `Untagged` (release mode) with `-dirty` in the describe string.
|
||||
- [ ] The describe string changes after a new commit without a manual `cargo clean` (rerun-if-changed works).
|
||||
- [ ] Provenance is logged once at startup.
|
||||
- [ ] Settings › About renders version + describe + build-kind badge, with working copy-to-clipboard.
|
||||
- [ ] 🔴 CI checkouts that build a shippable artifact set `fetch-depth: 0`, or their artifacts are knowingly stamped `unknown`. Currently only `publish-docs.yml` sets it; `build-release.yml` has five checkouts and `build-and-test.yml` two, all of which would report `unknown` as-is.
|
||||
- [ ] **No toolchain installed in CI** — git is already present in the builder image; nothing new is added.
|
||||
- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
||||
- [ ] `bindings.ts` regenerated.
|
||||
- [ ] DR-093 allocated in `requirements.md`; new code carries `// TRACES:`.
|
||||
|
||||
## Testing
|
||||
|
||||
**Rust**: the classification is pure and must be extracted from the command as
|
||||
`classify_build(describe: &str, debug: bool) -> BuildKind` so it can be tested
|
||||
directly. Cover: `"v0.2.0"` → `Release`; `"v0.2.0-3-gcb79a37"` → `Untagged`;
|
||||
`"v0.2.0-dirty"` → `Untagged`; `"unknown"` → `Unknown`; `debug = true` → always
|
||||
`Development` regardless of describe.
|
||||
|
||||
`build.rs` itself is not unit-testable. Verify its failure path manually by
|
||||
building with `PATH` stripped of git, and from a `git archive` tarball — both
|
||||
must succeed with `"unknown"`.
|
||||
|
||||
**Frontend**: assert the About block renders each `BuildKind` correctly, and that
|
||||
it renders the backend-supplied kind rather than re-deriving it from the string
|
||||
(a test that passes a `Release` kind with a `-dirty` describe and asserts the
|
||||
badge follows the *kind* would catch that regression).
|
||||
|
||||
## TRACES
|
||||
|
||||
- `build.rs` provenance emission → `// TRACES: | DR-093`
|
||||
- `BuildInfo` / `BuildKind` / `classify_build` → `// TRACES: | DR-093`
|
||||
- `get_build_info` command → `// TRACES: | DR-093`
|
||||
- Settings About block → `// TRACES: | DR-093`
|
||||
- `classify_build` tests → `UT-BUILD-1`
|
||||
- Allocate **DR-093** in `requirements.md` ("Build provenance: git describe and
|
||||
build profile surfaced in-app and in logs"). Next free DR at time of writing
|
||||
is DR-093.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Do the `build.rs` + command + logging first; the About UI is the smaller half
|
||||
and the logging alone delivers most of the diagnostic value.
|
||||
- The `fetch-depth: 0` change is the easiest part to forget and the one that
|
||||
makes CI artifacts useless if missed — it is why that acceptance box is
|
||||
flagged. Weigh it per workflow: test-only jobs do not need it.
|
||||
- Do not add a build timestamp "while you are in there" — see Out of scope.
|
||||
- A parallel Claude session may be active — `git diff` before "repairing"
|
||||
unexpected changes.
|
||||
@@ -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,169 @@
|
||||
# Spec: Migrate to libmpv2 and declare the project licence
|
||||
|
||||
**Status:** Proposed
|
||||
**Requirements:** UR-003 → IR-003 (revises the MPV integration); no new user-facing behaviour
|
||||
**UX spec:** n/a
|
||||
**Supersedes / revises:** dependency and licensing housekeeping identified in [playback-backend-unification.md](playback-backend-unification.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Two related pieces of housekeeping that block or complicate later work:
|
||||
|
||||
1. Replace the abandoned `libmpv` crate (pinned to a git branch) with the
|
||||
maintained `libmpv2`.
|
||||
2. Add a `LICENSE` file. The project has none, which leaves its legal status
|
||||
undefined while it links GPL-licensed libmpv.
|
||||
|
||||
Neither changes user-visible behaviour. Both are prerequisites for
|
||||
[windows-native-audio-backend.md](windows-native-audio-backend.md).
|
||||
|
||||
## Motivation
|
||||
|
||||
### The dependency is dead
|
||||
|
||||
```toml
|
||||
# src-tauri/Cargo.toml
|
||||
libmpv = { git = "https://github.com/ParadoxSpiral/libmpv-rs.git", branch = "master" }
|
||||
```
|
||||
|
||||
- crates.io `libmpv` 2.0.1 was published **2020-09-29**.
|
||||
- The upstream repo's last commit was **2023-01-08**; nothing since was released.
|
||||
- We pin a git *branch*, so builds are not reproducible — the same lockfile-less
|
||||
checkout can resolve differently over time, and CI has no protection if the
|
||||
branch moves or the repo disappears.
|
||||
|
||||
`libmpv2` (kohsine/libmpv2-rs) is a maintained fork of exactly this crate:
|
||||
6.0.0 released **2026-05-12**, ~23.5k recent downloads against the original's
|
||||
~1.1k, releases roughly quarterly since 2024.
|
||||
|
||||
### The project has no licence
|
||||
|
||||
There is no `LICENSE`/`COPYING` file and `src-tauri/Cargo.toml` has no `license`
|
||||
field. The project is open source and will never be commercial, so this is purely
|
||||
an omission — but it matters because we link libmpv, and "no licence" defaults to
|
||||
*all rights reserved*, which is incompatible with distributing a GPL-derived
|
||||
work.
|
||||
|
||||
## Design
|
||||
|
||||
### Part 1 — licence
|
||||
|
||||
**Use GPLv3.** This is forced, not chosen:
|
||||
|
||||
- mpv's default build is **GPLv2-or-later**, so the combined work must be
|
||||
GPL-compatible.
|
||||
- Apache-2.0 is **GPLv2-incompatible** (patent-termination and indemnification
|
||||
clauses) but GPLv3-compatible.
|
||||
- A scan of the dependency tree found Apache-2.0-**only** crates with no
|
||||
alternative arm — most importantly **`tao`** (Tauri's own windowing crate),
|
||||
plus `sync_wrapper`, `gethostname`, and `ring` (Apache-2.0 AND ISC).
|
||||
|
||||
`tao` is unavoidable in a Tauri app, so GPLv2 is unavailable. Exercising mpv's
|
||||
"or later" option puts the combination at **GPLv3**.
|
||||
|
||||
Actions:
|
||||
- Add `LICENSE` containing the GPLv3 text.
|
||||
- Add `license = "GPL-3.0-or-later"` to `src-tauri/Cargo.toml` and `license` to
|
||||
`package.json`.
|
||||
- Note in the README that the binary links libmpv (GPLv2+) and FFmpeg.
|
||||
|
||||
Because the project is open source, we use mpv's **default GPL build** — no
|
||||
`-Dgpl=false`, no LGPL FFmpeg build, and none of the LGPL §6 relinking analysis
|
||||
that a proprietary app would need. We keep VAAPI/VDPAU/X11 and every GPL FFmpeg
|
||||
filter.
|
||||
|
||||
🔴 Never build FFmpeg with `--enable-nonfree` — that produces a binary that is
|
||||
**unredistributable under any licence**, open source or not.
|
||||
|
||||
### Part 2 — libmpv → libmpv2
|
||||
|
||||
```toml
|
||||
# Linux (and later Windows, per the Windows audio spec)
|
||||
libmpv2 = "=6.0.0"
|
||||
```
|
||||
|
||||
Pin exactly: `libmpv2` has broken its API in **every** major release.
|
||||
|
||||
Breaking changes to expect, from the changelog:
|
||||
|
||||
| Version | Change | Impact here |
|
||||
|---|---|---|
|
||||
| 4.0.0 | Removed command helper methods — call `mpv.command(...)` directly | Low; we already use `command`/`set_property` |
|
||||
| 5.0.0 | Removed `mpv_node` support entirely (properties return strings; parse JSON yourself); `EventContext` folded into `Mpv`; `ProtocolContext` → `Protocol` | **Medium** — `start_event_loop` uses `create_event_context()`; check whether that call still exists |
|
||||
| 6.0.0 | `RenderContext::new()` → `Mpv::create_render_context()`; `'static` bound on `OpenGLInitParams`; render context now borrows `Mpv` (fixes a use-after-free) | **None** — we do not use the render API |
|
||||
|
||||
The last row matters: we run mpv audio-only (`video = no`), so the entire render
|
||||
surface is irrelevant to us. Consider disabling the default `render` feature to
|
||||
reduce build surface.
|
||||
|
||||
The main porting work is the event loop in `mpv_backend.rs` — `wait_event`,
|
||||
`disable_deprecated_events`, and the `FileLoaded` / `PlaybackRestart` /
|
||||
`PropertyChange` / `EndFile` handling, given 5.0.0 folded `EventContext` into
|
||||
`Mpv`.
|
||||
|
||||
Everything else — `set_property` calls, the `af` filter graph, the 250ms position
|
||||
thread, the seek-suppression window — should port unchanged.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
No logic moves. This is a dependency swap plus a licence file; the
|
||||
`PlayerBackend` trait boundary is untouched.
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| mpv event → `PlayerStatusEvent` mapping | Rust (unchanged) | Already correct; only the binding API beneath it changes. |
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Any behaviour change. If playback behaves differently after this, that is a bug.
|
||||
- Windows support — separate spec, but this must land first.
|
||||
- Adopting the render API. We are audio-only on mpv.
|
||||
- Re-licensing decisions beyond adding the file the project already implies.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `LICENSE` (GPLv3) present; `license` field set in `Cargo.toml` and `package.json`.
|
||||
- [ ] A full dependency-licence audit has been run (`cargo install cargo-license && cargo license`) and confirms no GPLv3-incompatible dependency. *(The scan behind this spec resolved 441 of 575 crates from the local registry cache; the remaining 134 are unverified.)*
|
||||
- [ ] `libmpv` git dependency removed; `libmpv2` pinned to an exact version.
|
||||
- [ ] Linux audio playback works identically: play/pause/seek/volume, queue advance, gapless, EQ, normalization, sleep timer.
|
||||
- [ ] Position updates still arrive at 250ms; the 150ms post-seek suppression still prevents the jump-to-zero glitch.
|
||||
- [ ] `EndFile` still emits `PlaybackEnded` only for EOF (not STOP/QUIT/ERROR) — autoplay depends on this.
|
||||
- [ ] Builder image updated if the libmpv dev package requirement changed; **no toolchain install added to any CI step**.
|
||||
- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
||||
|
||||
## Testing
|
||||
|
||||
The existing `mpv_backend_test.rs` plus the `build_af_filter`,
|
||||
`eq_filter_entries`, and `normalize_filter_entry` tests are the regression net —
|
||||
they must pass unchanged, since none of them touch the binding API.
|
||||
|
||||
The event loop has no unit tests and is where the risk concentrates. Verify
|
||||
manually on Linux:
|
||||
|
||||
1. Play → pause → play; confirm position does not flash to 0:00 (the known
|
||||
playing-event regression).
|
||||
2. Seek mid-track; confirm no jump-to-zero within 150ms.
|
||||
3. Let a track end naturally; confirm autoplay advances (exercises `EndFile` EOF).
|
||||
4. Press stop; confirm autoplay does **not** advance.
|
||||
5. Sleep-timer expiry; confirm it stops without triggering autoplay.
|
||||
|
||||
Cases 3–5 are the ones most likely to break silently, and each corresponds to a
|
||||
bug already fixed once in this codebase.
|
||||
|
||||
## TRACES
|
||||
|
||||
- `MpvBackend` construction / event loop → existing `// TRACES: UR-003 | IR-003`, unchanged
|
||||
- No new requirement IDs; this is a dependency migration.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Do this **before** the Windows audio backend.
|
||||
- Read the 4.0/5.0/6.0 changelogs before writing code — the crate has broken API
|
||||
in every major release, most recently two months before this spec.
|
||||
- The crates.io `repository` field for `libmpv2` points at `kohsine/libmpv-rs`,
|
||||
but the repo was renamed to **`libmpv2-rs`**; the old raw URLs 404.
|
||||
- `libmpv2-sys` ships pregenerated bindings and vendored headers, so no libclang
|
||||
is needed at build time — relevant to keeping the builder image thin.
|
||||
- A parallel Claude session may be active — `git diff` before "repairing"
|
||||
unexpected changes.
|
||||
@@ -0,0 +1,226 @@
|
||||
# Spec: Playback backend unification — findings and strategy
|
||||
|
||||
**Status:** Accepted (analysis; no code changes)
|
||||
**Requirements:** IR-004, UR-031, UR-032, UR-033 — revises the "Platform Playback Backend Parity" issue in requirements.md
|
||||
**UX spec:** n/a
|
||||
**Supersedes / revises:** informs [android-native-video-spike.md](android-native-video-spike.md), [android-audio-settings-parity.md](android-audio-settings-parity.md), [windows-native-audio-backend.md](windows-native-audio-backend.md)
|
||||
|
||||
## Summary
|
||||
|
||||
This spec records the outcome of an investigation into unifying JellyTau's
|
||||
playback backends (Linux/MPV, Android/ExoPlayer, Windows/webview) onto a single
|
||||
engine with hardware acceleration everywhere. **The conclusion is that video
|
||||
cannot be unified onto a native engine, and should not be attempted.** Audio
|
||||
*can* be, and that is where the remaining specs direct effort.
|
||||
|
||||
No code changes follow from this spec directly. It exists so the decision is
|
||||
written down with its evidence, and so a future session does not re-run the same
|
||||
investigation.
|
||||
|
||||
## Motivation
|
||||
|
||||
The requirements doc carries a "Platform Playback Backend Parity" issue noting
|
||||
that audio settings work on Linux but not Android, and proposing eventual
|
||||
convergence. The natural next question — "should we just run one engine
|
||||
everywhere?" — needed answering before spending effort on per-backend patches.
|
||||
|
||||
The investigation also surfaced that several statements in requirements.md and in
|
||||
code comments are factually wrong. Those corrections are part of the deliverable.
|
||||
|
||||
## Findings
|
||||
|
||||
### 1. The current architecture is not what the docs describe
|
||||
|
||||
| Platform | Audio | Video |
|
||||
|----------|-------|-------|
|
||||
| Linux | MPV (native, **audio-only**) | webview `<video>` + hls.js |
|
||||
| Android | ExoPlayer (native) | **webview `<video>` + hls.js** |
|
||||
| Windows | webview `<audio>` | webview `<video>` + hls.js |
|
||||
|
||||
Two surprises:
|
||||
|
||||
- **MPV never decodes video.** `mpv_backend.rs` sets `video = no` and
|
||||
`audio-display = no` at construction. Linux video has always been the webview.
|
||||
Correspondingly, `player_play_item` deliberately does *not* load into MPV on
|
||||
Linux (it calls `set_current_item`, which only updates the queue).
|
||||
- **Android video is also the webview.** `createAdapter()` in
|
||||
`src/lib/player/adapters/index.ts` hardcodes `const effectiveKind = "html5"`
|
||||
and does `void backendKind`, discarding the `use_html5_element` signal that
|
||||
`get_player_status` computes in Rust. `NativePlayerAdapter` is dead code, and
|
||||
ExoPlayer's `SurfaceView` path in `JellyTauPlayer.kt` is unreachable.
|
||||
|
||||
So video is *already* unified — on HTML5, everywhere, by accident of that
|
||||
hardcode — and on the path without hardware decoding on Android.
|
||||
|
||||
### 2. Native video cannot be composited with a Tauri webview
|
||||
|
||||
This is the load-bearing finding. It is **not** an mpv limitation; it defeats
|
||||
every candidate engine identically:
|
||||
|
||||
- **mpv**: `tauri-plugin-libmpv`'s own platform table reads Linux ⚠️
|
||||
*"Experimental. Window embedding is not working."*
|
||||
- **GStreamer** (wry discussion #284, 2024): *"Gstreamer was rendering above the
|
||||
surface and covering all html elements."*
|
||||
- **libVLC** (tauri discussion #6343, 2024): *"I had to render the webview in a
|
||||
child window though because vlc kept rendering on top of it."*
|
||||
|
||||
Root cause, from Tauri maintainer amrbashir (tauri#9220, 2024-03-30):
|
||||
|
||||
> "we are limited to using Webkit2GTK on Linux and that requires a GTK window.
|
||||
> While possible to add a GTK widget as a child X11 window inside raw X11 window,
|
||||
> this is however a bit hacky and **it is not possible on Wayland at all**."
|
||||
|
||||
WebKitGTK, WebView2, and Android WebView each draw into their own compositor
|
||||
surface. A native video surface is either entirely above or entirely below the
|
||||
webview; it cannot interleave with HTML. Every working example in the ecosystem
|
||||
is the same hack — a separate child window position-synced to a
|
||||
`getBoundingClientRect()` div — which breaks on resize, scroll, and any UI drawn
|
||||
over the video. For JellyTau that means the controls, subtitle overlay, and
|
||||
mini-player.
|
||||
|
||||
The most recent comment on tauri#6343 (2026-05-23) confirms it is still unsolved:
|
||||
|
||||
> "I'm faking it and the window is not truly embedded, basically when the parent
|
||||
> moves or resizes I reset the position and size of the libmpv window to align it
|
||||
> with an HTML div."
|
||||
|
||||
**The principle to carry forward: audio can unify on a native engine; video
|
||||
cannot, because video needs a surface and the webview owns the surface.**
|
||||
|
||||
### 3. mpv would regress streaming quality
|
||||
|
||||
mpv has **no adaptive bitrate**. It delegates HLS to FFmpeg's demuxer, which
|
||||
selects one variant at open time and never adapts; mpv#3548 (2016) requested ABR
|
||||
and it never landed. `--hls-bitrate` is a static picker defaulting to `max`.
|
||||
|
||||
The webview path already has real ABR via hls.js. Moving video to mpv would be a
|
||||
**downgrade** on every platform — no graceful degradation on weak networks, and
|
||||
quality changes requiring teardown and reload.
|
||||
|
||||
### 4. Crossfade is architecturally blocked on mpv
|
||||
|
||||
mpv's audio chain is single-stream. FFmpeg's `acrossfade` is an `N→A` filter
|
||||
requiring two input streams, so there is no second input to feed it. Real
|
||||
crossfade needs **two libmpv instances** with manually ramped volumes. Upstream
|
||||
maintainer response (mpv#4512, closed three minutes after opening):
|
||||
|
||||
> "No. I also find crossfading stupid and complex, so the likeliness of that
|
||||
> happening is low."
|
||||
|
||||
GStreamer *could* do it via `audiomixer`. mpv cannot, at any reasonable cost.
|
||||
|
||||
### 5. Engine comparison summary
|
||||
|
||||
| Criterion | mpv | GStreamer | libVLC |
|
||||
|-----------|-----|-----------|--------|
|
||||
| Webview compositing | ❌ Linux broken | ❌ same wall | ❌ same wall |
|
||||
| Adaptive bitrate HLS | ❌ none | ✅ adaptivedemux2 | ✅ adaptive module |
|
||||
| Rust bindings | ⚠️ `libmpv2` active; our pin is dead | ✅ `gstreamer-rs` excellent | ❌ `vlc-rs` abandoned (2018) |
|
||||
| Windows cross-MSVC | ⚠️ prebuilt DLL | ❌ pkg-config vs cargo-xwin | ❌ no better |
|
||||
| Android packaging | ✅ Maven AAR (used by Findroid) | ⚠️ Cerbero/NDK, painful | ✅ mature AAR |
|
||||
| ASS/SSA subtitles | ✅ libass built in | ✅ libass | ✅ libass |
|
||||
| Crossfade | ❌ impossible | ✅ `audiomixer` | ⚠️ unclear |
|
||||
|
||||
Every candidate fails the first row, which is the disqualifying one.
|
||||
|
||||
### 6. Two further options ruled out
|
||||
|
||||
**Webview `<audio>`/`<video>` everywhere** (i.e. delete the native audio backends
|
||||
too) is dead on Android: `navigator.mediaSession` is *deliberately compiled out*
|
||||
of Android WebView (Chromium CL 2613133003), so lockscreen/media-notification
|
||||
control would be impossible. Chromium has also never shipped `audioTracks`. It
|
||||
remains fine for Windows *video*, which is what we already do.
|
||||
|
||||
**FFmpeg-direct / Rust-native** (`ffmpeg-next`, `rsmpeg`, Symphonia) is not
|
||||
close: the safe bindings do not expose hardware decode at all, `ffmpeg-next` is
|
||||
self-declared maintenance-only, and Symphonia lacks HE-AAC and gapless AAC. This
|
||||
is a multi-person-year path to reach parity with what we already have.
|
||||
|
||||
### 7. If libmpv is ever revisited on Android
|
||||
|
||||
Recorded so the next investigation starts from evidence rather than repeating the
|
||||
search. The `dev.jdtech.mpv:libmpv` AAR — maintained by Findroid's author, i.e.
|
||||
another Jellyfin Android client — was inspected directly:
|
||||
|
||||
- `libmpv.so` exports the full 54-function `mpv_*` C API with **zero `Java_`
|
||||
symbols**; JNI is a separate optional ~19 KB `libplayer.so`. So it is drivable
|
||||
from Rust without a Java shim. (This is precisely what disqualifies libVLC,
|
||||
whose Android video path hard-requires a Java `AWindow` jobject.)
|
||||
- ~23 MB/ABI, versus libVLC's ~46 MB/ABI.
|
||||
- 🔴 **The published AAR is built `--enable-gpl --enable-version3` — it is
|
||||
GPLv3**, not LGPL. Fine for us (see [libmpv2-migration.md](libmpv2-migration.md)),
|
||||
but it would be a hard constraint for anyone shipping closed source, and an
|
||||
LGPL rebuild would be your own build to own.
|
||||
- Top unverified risk if anyone tries this: whether `libmpv2-sys` can
|
||||
cross-compile for `aarch64-linux-android` against that prebuilt `.so`. No
|
||||
working example of `libmpv2` on Android was found.
|
||||
|
||||
None of this changes the verdict — the cost is the MediaSession/foreground-service
|
||||
rewrite, not the bindings.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **Do not unify video onto a native engine.** Video stays in the webview with
|
||||
hls.js on all platforms. This is not a compromise — it is the configuration
|
||||
that falls out of the compositing constraint, and it is the only one that
|
||||
gives us ABR for free.
|
||||
2. **Android native video is worth a bounded spike anyway** — not for
|
||||
unification, but because ExoPlayer's `SurfaceView` path already exists and
|
||||
would restore hardware decode plus ASS/SSA subtitles. See
|
||||
[android-native-video-spike.md](android-native-video-spike.md).
|
||||
3. **Audio parity is the real gap** and is achievable without touching any of the
|
||||
above. See [android-audio-settings-parity.md](android-audio-settings-parity.md)
|
||||
and [windows-native-audio-backend.md](windows-native-audio-backend.md).
|
||||
4. **Migrate the dead libmpv pin** regardless of any of this. See
|
||||
[libmpv2-migration.md](libmpv2-migration.md).
|
||||
|
||||
## Corrections to existing docs
|
||||
|
||||
These are factual errors found during the investigation. Fixing them is in scope
|
||||
for this spec.
|
||||
|
||||
| Location | Says | Actually |
|
||||
|----------|------|----------|
|
||||
| `requirements.md` UR-031 (line ~44) | "Done (Linux only)" | Not implemented on any platform. |
|
||||
| `requirements.md` DR-034 (line ~196) | "Done (Linux only)" | Not implemented anywhere — `mpv_backend.rs` has a bare `// TODO: Implement crossfade via MPV audio filters if needed`. Architecturally blocked on mpv (finding 4). |
|
||||
| `requirements.md` parity matrix | Crossfade ✅ Linux / ❌ Android | ❌ / ❌ |
|
||||
| `requirements.md` parity matrix | (no EQ row) | EQ is also Linux-only — `build_af_filter`/`eq_filter_entries` exist only in `mpv_backend.rs`. Same root cause, same fix. |
|
||||
| `nativeAdapter.ts:11-14` | Native Android video "blocked upstream by tauri#10152" | tauri#10152 is a stale *feature request*, dead since 2024-07-01. The capability shipped in tauri commit `27d01834` (2024-09-02). Not a blocker. |
|
||||
|
||||
## Layer assignment
|
||||
|
||||
No new logic. The one boundary observation worth recording:
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Which video backend a platform uses (`use_html5_element`) | Rust | Already correctly computed in `get_player_status`. The frontend currently *discards* it — that is the bug, not the design. Restoring it means the frontend consumes a backend decision rather than making its own. |
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Any code change. This spec is analysis; the sibling specs carry the work.
|
||||
- iOS/macOS. Not current targets.
|
||||
- Replacing hls.js.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `requirements.md` DR-034 status corrected; parity matrix updated (crossfade ❌/❌, EQ row added).
|
||||
- [ ] Stale tauri#10152 comment in `nativeAdapter.ts` corrected.
|
||||
- [ ] The four sibling specs exist and are linked from here.
|
||||
|
||||
## Testing
|
||||
|
||||
n/a — documentation only.
|
||||
|
||||
## TRACES
|
||||
|
||||
No new code. Requirement text changes only; DR-034's status line is the one
|
||||
substantive edit.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- The evidence above was gathered in July 2026. The compositing constraint has
|
||||
been stable since 2021 (wry#284) and is maintainer-declared unfixable, so it is
|
||||
unlikely to change soon — but if someone revisits this, tauri#6343 and wry#284
|
||||
are the threads to re-read first.
|
||||
- A parallel Claude session may be active in this repo — `git diff` before
|
||||
"repairing" unexpected changes.
|
||||
@@ -0,0 +1,198 @@
|
||||
# Spec: Playback documentation corrections
|
||||
|
||||
**Status:** Proposed
|
||||
**Requirements:** revises the status of DR-034; corrects the parity matrix in [requirements.md](../requirements.md)
|
||||
**UX spec:** n/a
|
||||
**Supersedes / revises:** implements the "Corrections to existing docs" section of [playback-backend-unification.md](playback-backend-unification.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Fix four factual errors in the requirements doc and the player source comments,
|
||||
all found while investigating backend unification. Each claims something the code
|
||||
does not do. Small change, but they are actively misleading: two of them assert a
|
||||
feature is implemented when it is implemented nowhere, and one cites an upstream
|
||||
blocker that no longer exists.
|
||||
|
||||
Documentation and comments only — no behaviour change.
|
||||
|
||||
## Motivation
|
||||
|
||||
These errors compound. DR-034 reads "Done (Linux only)", so a future session
|
||||
planning Android parity would reasonably assume crossfade exists on Linux and
|
||||
only needs porting — when in fact it is unimplemented everywhere *and*
|
||||
architecturally blocked on the engine it supposedly runs on. Likewise the
|
||||
tauri#10152 comment has been discouraging work on Android native video since the
|
||||
upstream capability shipped in September 2024.
|
||||
|
||||
## The corrections
|
||||
|
||||
### 1. DR-034 status is wrong
|
||||
|
||||
`requirements.md` line ~196:
|
||||
|
||||
```
|
||||
| DR-034 | Crossfade engine with configurable duration (0-12s) | Player | UR-031 | Done (Linux only) |
|
||||
```
|
||||
|
||||
The code:
|
||||
|
||||
```rust
|
||||
// src-tauri/src/player/mpv_backend.rs, in set_audio_settings
|
||||
// TODO: Implement crossfade via MPV audio filters if needed
|
||||
```
|
||||
|
||||
That is the entire crossfade implementation. `crossfade_duration` is plumbed
|
||||
through `AudioSettings` and clamped to 0–12s, but no backend ever acts on it.
|
||||
|
||||
**Change to:** `Not implemented (blocked on MPV — see playback-backend-unification.md)`
|
||||
|
||||
Worth stating *why* in the requirements entry, because it is not a scheduling
|
||||
gap: mpv's audio chain is single-stream, and FFmpeg's `acrossfade` is an `N→A`
|
||||
filter needing two inputs. Real crossfade requires two libmpv instances with
|
||||
manually ramped volumes. Upstream declined the feature (mpv#4512).
|
||||
|
||||
### 1b. UR-031 status is wrong for the same reason
|
||||
|
||||
`requirements.md` line ~44:
|
||||
|
||||
```
|
||||
| UR-031 | Crossfade between audio tracks | Low | Done (Linux only) |
|
||||
```
|
||||
|
||||
Same error one level up: the *user* requirement is also marked done. Since no
|
||||
backend implements crossfade, UR-031 is not satisfied on any platform.
|
||||
|
||||
**Change to:** `Not implemented (blocked — see DR-034)`
|
||||
|
||||
Note line ~517 of the same file (`UR-031 (Crossfade), UR-032 (Gapless),
|
||||
UR-033 (Normalization) only work on Linux`) inherits the error — crossfade works
|
||||
nowhere, so it should read UR-032/UR-033 only.
|
||||
|
||||
### 2. Parity matrix crossfade row is wrong
|
||||
|
||||
```
|
||||
| Crossfade | ✅ | ❌ | Gap |
|
||||
```
|
||||
|
||||
**Change to** `| Crossfade | ❌ | ❌ | Not implemented |` — it is not a
|
||||
platform-parity gap, it is an unbuilt feature.
|
||||
|
||||
### 3. Parity matrix is missing the equalizer
|
||||
|
||||
The matrix lists crossfade, gapless, and normalization but omits the EQ, which
|
||||
has the same Linux-only shape and the same root cause (`ExoPlayerBackend` not
|
||||
overriding `set_audio_settings`). `build_af_filter` and `eq_filter_entries` exist
|
||||
only in `mpv_backend.rs`; there is no equalizer code in the Android tree.
|
||||
|
||||
**Add:** `| Equalizer (10-band) | ✅ | ❌ | Gap |`
|
||||
|
||||
### 4. `nativeAdapter.ts` cites a stale blocker
|
||||
|
||||
`src/lib/player/adapters/nativeAdapter.ts:11-14` states native Android video is
|
||||
blocked upstream by tauri#10152 (transparent webview / SurfaceView compositing).
|
||||
|
||||
tauri#10152 is open but **dead since 2024-07-01**, and it is a *feature request*
|
||||
that `WebviewWindowBuilder::transparent` was desktop-only — not a report that
|
||||
compositing is broken. The capability shipped in tauri commit `27d01834`
|
||||
(2024-09-02), which moved `transparent()` into the cross-platform impl block with
|
||||
only the tao call `#[cfg(desktop)]`-fenced. It landed as a clippy cleanup, so the
|
||||
issue was never closed. Separately, the black/white-screen bug (tauri#8381,
|
||||
tauri#9408) was a broken JNI signature for `setBackgroundColor`, fixed in wry
|
||||
0.39.4; we ship wry 0.55.x.
|
||||
|
||||
**Change to:** a comment stating the adapter is currently unreachable because
|
||||
`createAdapter` hardcodes the HTML5 kind, that transparency is no longer an
|
||||
upstream blocker, and that
|
||||
[android-native-video-spike.md](android-native-video-spike.md) tracks whether
|
||||
SurfaceView compositing actually works. Be explicit that *nobody has
|
||||
demonstrated* SurfaceView-behind-WebView on Tauri Android — nothing upstream
|
||||
blocks it, and nothing upstream proves it.
|
||||
|
||||
### 5. Platform capability is signalled three incompatible ways
|
||||
|
||||
Not a doc error — a real inconsistency found during the same investigation, worth
|
||||
recording here even though fixing it needs its own change.
|
||||
|
||||
Which backend a platform uses is currently expressed three ways:
|
||||
|
||||
1. Rust `#[cfg]` gates in `player/mod.rs` and `create_player_backend` — the truth.
|
||||
2. The `useHtml5Element` / `VideoBackend` value from `get_player_status` — which
|
||||
the frontend discards (see the spike spec).
|
||||
3. **Frontend user-agent sniffing** in `src/lib/services/webviewAudio.ts:30-41`:
|
||||
|
||||
```ts
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
const isAndroid = ua.includes("android");
|
||||
const isLinux = ua.includes("linux") && !isAndroid;
|
||||
return !isAndroid && !isLinux;
|
||||
```
|
||||
|
||||
The comment says it is "matching the Rust cfg gate" — i.e. the frontend
|
||||
re-derives a backend decision from the user-agent string and hopes it stays in
|
||||
sync. That is the frontend deciding *which backend exists*, which is domain
|
||||
knowledge, not presentation. It also breaks silently the moment a new target is
|
||||
added or a webview's UA changes.
|
||||
|
||||
**This is a boundary leak of the same family the spec-review checklist exists to
|
||||
catch**, even though `check:boundary`'s tripwire (item-type arrays) does not
|
||||
match it. Rust already computes the answer; the frontend should consume it.
|
||||
|
||||
Not fixed by this spec — it is behavioural, not documentation. It should be
|
||||
folded into the spike spec's factory rework, where the same
|
||||
"consume Rust's decision instead of re-deriving it" change is already in scope.
|
||||
|
||||
### Also worth fixing while here
|
||||
|
||||
`requirements.md` IR-004 reads "In Progress (basic playback works, audio settings
|
||||
missing)". That stays accurate until
|
||||
[android-audio-settings-parity.md](android-audio-settings-parity.md) lands, but
|
||||
the "Future Fix" list in the parity issue proposes
|
||||
`ConcatenatingMediaSource` for crossfade — **deprecated in current Media3**. Drop
|
||||
that suggestion; the modern approach is a custom `AudioProcessor`.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
No logic. Documentation and comments only.
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| — | — | No logic introduced or moved by this spec. |
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Implementing crossfade. This spec only stops claiming it exists.
|
||||
- Implementing Android audio settings — see the parity spec.
|
||||
- Running the Android video spike — see that spec.
|
||||
- Rewriting the architecture docs. `docs/architecture/05-platform-backends.md`
|
||||
should be re-read for the same class of error, but that is a larger pass.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] DR-034 status corrected, with the blocking reason stated.
|
||||
- [ ] UR-031 status corrected (line ~44), and the "only work on Linux" line (~517) no longer lists crossfade.
|
||||
- [ ] Parity matrix: crossfade ❌/❌; equalizer row added.
|
||||
- [ ] `ConcatenatingMediaSource` suggestion removed from the "Future Fix" list.
|
||||
- [ ] `nativeAdapter.ts` comment corrected and pointing at the spike spec.
|
||||
- [ ] `bun run check` and `bun run test` pass (a comment change still touches TS).
|
||||
- [ ] `bun run traces:markdown` re-run if requirement text changed.
|
||||
|
||||
No Rust changes, so the `cargo` gates do not apply.
|
||||
|
||||
## Testing
|
||||
|
||||
None beyond the standard gates — no behaviour changes. Confirm
|
||||
`bun run traces:markdown` regenerates cleanly, since DR-034's row is referenced
|
||||
by the traceability matrix.
|
||||
|
||||
## TRACES
|
||||
|
||||
No code implementing requirements changes; no TRACES comments to add or update.
|
||||
The DR-034 row in `docs/traceability.md` will regenerate with the corrected text.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Do **not** silently delete DR-034. The requirement (UR-031 crossfade) is still
|
||||
wanted; it is the *status* that is wrong. Keeping the row with an honest status
|
||||
and a reason is the point.
|
||||
- A parallel Claude session may be active — `git diff` before "repairing"
|
||||
unexpected changes.
|
||||
@@ -0,0 +1,258 @@
|
||||
# Spec: Enforce the unified player boundary
|
||||
|
||||
**Status:** Proposed
|
||||
**Requirements:** DR-095 (new); relates to UR-005 and the unified-player-boundary
|
||||
principle in CLAUDE.md and [02-svelte-frontend.md](../architecture/02-svelte-frontend.md)
|
||||
**UX spec:** n/a — refactor, no user-visible change.
|
||||
**Supersedes / revises:** n/a
|
||||
|
||||
## Summary
|
||||
|
||||
The stated principle is that UI controls playback **only** through
|
||||
`playerController` ([src/lib/player/index.ts](../../src/lib/player/index.ts)),
|
||||
never by calling `commands.player*` directly. There are **52 direct call sites
|
||||
outside** that facade. This spec routes the genuine playback-control calls
|
||||
through the facade, narrows the principle's wording so it stops forbidding
|
||||
things it never meant to forbid, and adds the lint rule that keeps it true —
|
||||
because this rule is the one design principle in the audit with **no automated
|
||||
check at all**, and it is also the one that drifted furthest.
|
||||
|
||||
## Motivation
|
||||
|
||||
Direct `commands.player*` usage outside `src/lib/player/`, by file:
|
||||
|
||||
| File | Sites |
|
||||
|---|---|
|
||||
| [queue.ts](../../src/lib/stores/queue.ts) | 10 |
|
||||
| [player/[id]/+page.svelte](../../src/routes/player/[id]/+page.svelte) | 9 |
|
||||
| [VideoPlayer.svelte](../../src/lib/components/player/VideoPlayer.svelte) | 8 |
|
||||
| [settings/+page.svelte](../../src/routes/settings/+page.svelte) | 5 |
|
||||
| [sleepTimer.ts](../../src/lib/stores/sleepTimer.ts) / [auth.ts](../../src/lib/stores/auth.ts) / [autoplay.ts](../../src/lib/api/autoplay.ts) | 4 each |
|
||||
| [preload.ts](../../src/lib/services/preload.ts) | 3 |
|
||||
| [library/[id]](../../src/routes/library/[id]/+page.svelte), [playerEvents.ts](../../src/lib/services/playerEvents.ts), [playbackMode.ts](../../src/lib/stores/playbackMode.ts) | 1–2 each |
|
||||
|
||||
These are **not** equivalent violations, and treating them as one number is why
|
||||
the rule has been easy to ignore. Three distinct groups:
|
||||
|
||||
**(a) Genuine violations — playback control with a facade method that already
|
||||
exists.** `playerStop` ×6, `playerPlayTracks` ×4, `playerSeek` ×2,
|
||||
`playerPlayAlbumTrack` ×2, `playerNext`, `playerPrevious`, `playerSkipTo`,
|
||||
`playerToggleShuffle`, `playerCycleRepeat`, `playerRemoveFromQueue`,
|
||||
`playerMoveInQueue`, `playerAddTrackById`, `playerAddTracksByIds`,
|
||||
`playerSetSubtitleTrack`, `playerPlayItem`. The facade exposes `stop()`,
|
||||
`seek()`, `next()`, `previous()`, `skipTo()`, `toggleShuffle()`,
|
||||
`cycleRepeat()`, `removeFromQueue()`, `moveInQueue()`, `addTrackById()`,
|
||||
`addTracksByIds()`, `setSubtitleTrack()`, `playTracks()`, `playAlbumTrack()`,
|
||||
`playItem()` — every one of these has a facade equivalent that is simply not
|
||||
being called. `queue.ts` is the starkest case: it imports `commands` directly
|
||||
and re-implements ten methods the facade already provides.
|
||||
|
||||
**(b) Playback control with no facade method.** `playerPlayQueue`,
|
||||
`playerGetQueue`, `playerGetStatus`, `playerEnterBackgroundAudio`,
|
||||
`playerExitBackgroundAudio`, `playerSetSleepTimer`, `playerCancelSleepTimer`,
|
||||
`playerPlayNextEpisode`, `playerCancelAutoplayCountdown`. In scope for the
|
||||
principle, but currently *impossible* to comply with — the facade has no surface
|
||||
for them. A rule that cannot be followed is not being broken so much as it is
|
||||
unfinished.
|
||||
|
||||
**(c) Not playback control.** `playerConfigureJellyfin` ×3,
|
||||
`playerDisableJellyfin`, `playerGet/SetAudioSettings`,
|
||||
`playerGet/SetVideoSettings`, `playerGetEqPresets`,
|
||||
`playerGet/SetAutoplaySettings`, `playerGet/SetCacheConfig`,
|
||||
`playerPreloadUpcoming`. These are configuration and lifecycle calls that happen
|
||||
to live under the `player_` command prefix. The principle is about *who is
|
||||
authoritative for playback state* — settings CRUD isn't that.
|
||||
|
||||
The audit's read: the rule as written is violated 52 times, which makes real
|
||||
drift indistinguishable from acceptable usage, and that ambiguity is what lets
|
||||
group (a) persist. Note also that the principle **is** well-honoured where it
|
||||
matters most — the read side is clean, with UI reading state exclusively from
|
||||
the facade's re-exported stores. The write side is what drifted.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
Frontend-internal refactor. No domain logic moves and nothing new crosses IPC —
|
||||
the same Rust commands are called, through one module instead of many.
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Playback command dispatch (adapter routing: native vs HTML5) | Frontend — `src/lib/player/` **only** | Presentation-layer plumbing, but must be centralised: the facade picks between the native backend and the HTML5 `<video>` adapter. A caller bypassing it silently skips that routing. |
|
||||
| Playback *authority* (position, pause, rate, track changes) | **Rust / the player** | Unchanged. The player is authoritative; UI is a consumer. This spec does not touch that direction. |
|
||||
| Queue mutation commands | Frontend facade → Rust | Rust owns queue state; the facade is the single call path to it. |
|
||||
| Player settings CRUD (EQ, video, autoplay, cache) | Frontend, **outside** the facade | Configuration, not playback control — read/written on a settings page with no adapter routing. Explicitly carved out below. |
|
||||
| Backend→frontend event handling | `playerEvents.ts` | Already correct. It is the facade's own plumbing, not a bypassing consumer. |
|
||||
|
||||
No Jellyfin taxonomy is involved, so no boundary-leak risk.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Narrow the principle to what it actually means
|
||||
|
||||
Amend CLAUDE.md and [02-svelte-frontend.md](../architecture/02-svelte-frontend.md):
|
||||
|
||||
> **Unified player boundary.** UI controls **playback** — transport, queue
|
||||
> mutation, track selection, playback initiation — *only* through
|
||||
> `playerController`. Player **configuration** commands (`player_*_settings`,
|
||||
> `player_configure_jellyfin`, `player_*_cache_config`, `player_preload_upcoming`)
|
||||
> are ordinary IPC and may be called directly from settings surfaces.
|
||||
|
||||
This is a clarification, not a relaxation: it makes group (c) explicitly fine so
|
||||
that a violation count means something. A rule with 52 nominal violations, most
|
||||
of them acceptable, provides no signal.
|
||||
|
||||
### 2. Fill the facade gaps (group b)
|
||||
|
||||
Add to `playerController`, each a thin pass-through preserving current
|
||||
behaviour:
|
||||
|
||||
```ts
|
||||
playQueue, getQueue, getStatus,
|
||||
enterBackgroundAudio, exitBackgroundAudio,
|
||||
setSleepTimer, cancelSleepTimer,
|
||||
playNextEpisode, cancelAutoplayCountdown,
|
||||
```
|
||||
|
||||
Do this **first** — group (a) cannot be fully migrated while callers still need
|
||||
a direct import for a neighbouring call, and a file that imports `commands` for
|
||||
one reason will keep using it for others.
|
||||
|
||||
### 3. Migrate group (a)
|
||||
|
||||
Mechanical: replace `commands.playerX(...)` with `playerController.x(...)`.
|
||||
Highest-value first: `queue.ts` (10 sites, all direct facade equivalents), then
|
||||
`player/[id]/+page.svelte`, `VideoPlayer.svelte`, `sleepTimer.ts`,
|
||||
`playbackMode.ts`, `library/[id]/+page.svelte`.
|
||||
|
||||
Two sites need care rather than substitution:
|
||||
|
||||
- **`playerEvents.ts`** (`playerOnPlaybackEnded`, `playerStop` in the error
|
||||
path). This module *is* the facade's event plumbing — the counterpart to
|
||||
`index.ts`, inside the boundary conceptually though not by directory. Treat
|
||||
`src/lib/services/playerEvents.ts` as **inside** the boundary and exempt it,
|
||||
rather than making it call the facade that calls back into it. Record this in
|
||||
the lint config with the reason.
|
||||
- **`VideoPlayer.svelte`** — registers its own adapter via `setActiveAdapter`.
|
||||
Its `playerStop`/`playerPlayItem` calls interact with adapter lifecycle, and
|
||||
CLAUDE.md's gotcha ("no lifecycle calls after an `await` in `onMount`") applies.
|
||||
Migrate this file **last and on its own**, so an Android seek regression is
|
||||
bisectable to one commit.
|
||||
|
||||
### 4. Add the lint rule (the part that makes it stick)
|
||||
|
||||
The audit's finding was that principles with working checks held up and
|
||||
principles without them drifted. This principle has no check. Add
|
||||
`scripts/check-player-boundary.sh`, wired as `bun run check:player-boundary` and
|
||||
into `test-all.sh`:
|
||||
|
||||
```sh
|
||||
# Playback-control commands that MUST go through the facade.
|
||||
CONTROL='player(Play|Pause|Toggle|Stop|Seek|Next|Previous|SkipTo|ToggleShuffle|CycleRepeat|RemoveFromQueue|MoveInQueue|SetVolume|ToggleMute|SetSubtitleTrack|SeekVideo|SwitchAudioTrack|PlayTracks|PlayAlbumTrack|PlayItem|PlayQueue|AddTrackById|AddTracksByIds|GetQueue|GetStatus|EnterBackgroundAudio|ExitBackgroundAudio|SetSleepTimer|CancelSleepTimer|PlayNextEpisode|CancelAutoplayCountdown|OnPlaybackEnded)'
|
||||
|
||||
# Inside the boundary: the facade and its event plumbing.
|
||||
EXEMPT='^src/lib/player/|^src/lib/services/playerEvents\.ts$'
|
||||
```
|
||||
|
||||
Flag `commands.$CONTROL` in non-test `src/` files outside `EXEMPT`. Config
|
||||
commands are deliberately absent from the list, matching §1 — so the check
|
||||
encodes the narrowed rule rather than the aspirational one.
|
||||
|
||||
An ESLint `no-restricted-syntax` rule would give better editor feedback, but the
|
||||
project has no ESLint config; a shell check matches the existing
|
||||
`check:boundary` precedent and adds no dependency.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Changing playback *behaviour* — pure refactor.
|
||||
- The one-directional state principle (audited clean; UI reads from facade
|
||||
stores only).
|
||||
- Moving settings CRUD behind the facade (§1 explicitly carves it out).
|
||||
- Introducing ESLint.
|
||||
- Refactoring `VideoPlayer.svelte`'s 2079 lines generally, beyond its facade
|
||||
call sites.
|
||||
- The `commands.player*` calls **inside** `src/lib/player/` — that is the
|
||||
facade doing its job.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `playerController` exposes the group-(b) methods listed in §2.
|
||||
- [ ] `grep -rn "commands\.player" src/ --include='*.ts' --include='*.svelte' | grep -v '^src/lib/player/' | grep -v 'playerEvents\.ts' | grep -v '\.test\.' | grep -v bindings.ts`
|
||||
returns **only** configuration commands per §1 — no transport, queue, or
|
||||
playback-initiation call.
|
||||
- [ ] `queue.ts` no longer imports `commands` from bindings.
|
||||
- [ ] `bun run check:player-boundary` exists, is wired into `test-all.sh`, and
|
||||
passes.
|
||||
- [ ] The check **fails** when a `commands.playerStop()` is added to a non-exempt
|
||||
file — verify explicitly, as with the other gates in this batch.
|
||||
- [ ] The check does **not** fail on `commands.playerSetAudioSettings()` in
|
||||
`settings/+page.svelte` (the §1 carve-out works).
|
||||
- [ ] CLAUDE.md and `02-svelte-frontend.md` carry the narrowed wording, including
|
||||
the config carve-out and the `playerEvents.ts` exemption with its reason.
|
||||
- [ ] **No behavioural change**: audio and video playback, queue reorder,
|
||||
shuffle/repeat, sleep timer, background audio, and autoplay all behave as
|
||||
before on **both Linux and Android**.
|
||||
- [ ] Android seek and `onMount` lifecycle still correct after the
|
||||
`VideoPlayer.svelte` migration (the known-fragile path).
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
- [ ] `bun run check:boundary` passes.
|
||||
- [ ] Changed code carries `// TRACES:` comments.
|
||||
- [ ] No Rust change, so no `bindings.ts` regeneration.
|
||||
|
||||
## Testing
|
||||
|
||||
**Frontend** (`bun run test`):
|
||||
- Extend the existing facade tests to cover each new group-(b) method: it
|
||||
forwards to the right command with the right arguments, and routes to the
|
||||
active adapter where applicable.
|
||||
- `queue.ts` tests: assert calls land on `playerController`, not `commands`. Mock
|
||||
the facade — a test that mocks `commands` would pass either way and guard
|
||||
nothing.
|
||||
- Keep `tauriIntegration.test.ts` and the other IPC param-naming tests green;
|
||||
they cover the camelCase rule this refactor must not disturb.
|
||||
|
||||
**Manual** (no automated coverage for these paths):
|
||||
- Linux: play/pause/seek/next/prev, queue reorder, shuffle, repeat, sleep timer,
|
||||
transcoded video (HLS), background audio enter/exit.
|
||||
- Android: the same, plus lockscreen/MediaSession controls, and **seek after
|
||||
entering the player** — the specific regression CLAUDE.md warns about.
|
||||
|
||||
Because this is a pure refactor, the strongest signal is that no test *changes
|
||||
expectation*. A test needing its assertions rewritten means behaviour moved —
|
||||
investigate rather than update it.
|
||||
|
||||
## TRACES
|
||||
|
||||
Allocate in `requirements.md`:
|
||||
|
||||
- **DR-095** — "UI playback control is routed exclusively through the
|
||||
`playerController` facade (`src/lib/player/`), with `playerEvents.ts` inside
|
||||
the boundary as its event plumbing and player *configuration* commands
|
||||
explicitly outside it; enforced by `scripts/check-player-boundary.sh`."
|
||||
Category: Player. Traces to UR-005. Status: Done on merge.
|
||||
|
||||
```typescript
|
||||
// src/lib/player/index.ts
|
||||
// TRACES: UR-005 | DR-095
|
||||
```
|
||||
|
||||
New facade tests take `@req-test: UT-089` onward (next free UT is **UT-089**;
|
||||
coordinate if landing alongside the sibling specs, which draw from the same
|
||||
pool).
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- A parallel Claude session may be active in this repo — `git diff` before
|
||||
"repairing" unexpected changes (CLAUDE.md §Gotchas).
|
||||
- **Order matters**: §2 (fill gaps) → §3 (migrate, `VideoPlayer.svelte` last and
|
||||
alone) → §4 (add the check). Adding the check first turns `master` red.
|
||||
- 🔴 **`VideoPlayer.svelte`**: no lifecycle calls after an `await` in `onMount` —
|
||||
it flips to HTML5 mode and breaks Android seek. Do not let a mechanical
|
||||
substitution introduce an `await` before a lifecycle call.
|
||||
- The facade's `requireHandle()` may throw where a raw `commands` call did not.
|
||||
Check each migrated call site's error handling rather than assuming the
|
||||
try/catch still covers the same cases.
|
||||
- `playbackMode.ts` interacts with remote-mode routing (`play_on_session` vs
|
||||
local MPV). Verify remote casting still works after migrating its
|
||||
`playerPlayTracks` call.
|
||||
- This spec is deliberately the *lowest* priority of the audit batch: it is the
|
||||
largest diff and the only one carrying real regression risk, while the
|
||||
traceability gate is a few lines and restores a dead safety net.
|
||||
@@ -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,161 @@
|
||||
# Spec: Remove the broken `check-req-coverage.sh`
|
||||
|
||||
**Status:** Implemented
|
||||
**Requirements:** supports DR-093 (see [traceability-gate-repair.md](traceability-gate-repair.md))
|
||||
**UX spec:** n/a — developer tooling.
|
||||
**Supersedes / revises:** n/a
|
||||
|
||||
## Summary
|
||||
|
||||
`scripts/check-req-coverage.sh` is broken, orphaned, and actively misleading: it
|
||||
reports `Total Requirements: 1`, zeros in every category, and then prints
|
||||
**"✨ All requirements have implementations!"**. Nothing references it — not CI,
|
||||
not `package.json`, not the docs. This spec deletes it, with a narrowly-scoped
|
||||
alternative (repair it) documented and rejected below.
|
||||
|
||||
## Motivation
|
||||
|
||||
Running it today produces:
|
||||
|
||||
```
|
||||
Category Breakdown:
|
||||
UR: 0 requirements
|
||||
IR: 0 requirements
|
||||
DR: 0 requirements
|
||||
JA: 0 requirements
|
||||
|
||||
Summary:
|
||||
Total Requirements: 1
|
||||
✅ Fully Implemented: 0 (0%)
|
||||
|
||||
✨ All requirements have implementations!
|
||||
```
|
||||
|
||||
Every number is wrong (the real totals are UR 61, IR 29, DR 89, JA 32), and the
|
||||
concluding message is the *opposite* of a warning — a developer running this to
|
||||
sanity-check coverage is told everything is fine.
|
||||
|
||||
This is worse than having no script. It is a trap, and it sits in `scripts/`
|
||||
next to tools that do work, with nothing marking it as dead.
|
||||
|
||||
Verification that it is genuinely orphaned:
|
||||
|
||||
```console
|
||||
$ grep -rn "check-req-coverage" . --include='*.yml' --include='*.json' \
|
||||
--include='*.sh' --include='*.md' | grep -v node_modules
|
||||
(no output)
|
||||
```
|
||||
|
||||
## Layer assignment
|
||||
|
||||
Developer tooling only; no application logic and nothing crosses the IPC
|
||||
boundary.
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Requirement-coverage reporting | Build tooling — `extract-traces.ts` | One tool should own coverage analysis. A second, divergent implementation is how the two answers ("1 requirement" vs "211") came to disagree unnoticed. |
|
||||
|
||||
## Design
|
||||
|
||||
**Delete `scripts/check-req-coverage.sh`.**
|
||||
|
||||
Coverage reporting is owned by [scripts/extract-traces.ts](../../scripts/extract-traces.ts),
|
||||
which is correct, is what CI runs, and gains a first-class local coverage mode
|
||||
in [traceability-gate-repair.md](traceability-gate-repair.md):
|
||||
|
||||
```bash
|
||||
bun run traces:coverage # the supported way to check coverage locally
|
||||
```
|
||||
|
||||
Then check the sibling scripts for the same rot. `scripts/` also contains
|
||||
`check-test-coverage.sh` and `find-req-implementations.sh`, neither of which is
|
||||
referenced from `package.json`. An unreferenced script is never run and so rots
|
||||
silently — that is the actual failure mode being fixed here, and fixing only the
|
||||
one instance found by audit leaves the others to be rediscovered later.
|
||||
|
||||
### Findings (investigation, 2026-07)
|
||||
|
||||
All three scripts turned out to share a **single root cause**, and all three are
|
||||
deleted:
|
||||
|
||||
| Script | Defect |
|
||||
|---|---|
|
||||
| `check-req-coverage.sh` | Reads `README.md`, which has held **zero** requirement rows since they moved to `docs/requirements.md` → `total_reqs=1`, every category 0, "✨ All requirements have implementations!" Also greps `src-tauri/` unscoped. |
|
||||
| `check-test-coverage.sh` | Greps `src-tauri/` unscoped — including **40 GB** of `target/` build artifacts. Hangs indefinitely; produces no output at all. |
|
||||
| `find-req-implementations.sh` | Same unscoped `src-tauri/` grep. Same hang. |
|
||||
|
||||
So none of them were subtly wrong — two could never terminate, and the third
|
||||
inverted its own conclusion.
|
||||
|
||||
They were nonetheless *salvageable*: scoping the greps to `src-tauri/src` and
|
||||
repointing at `docs/requirements.md` would be a few lines, and the `@req:` /
|
||||
`@req-test:` tags they read are still present in the tree (**146** and **76**
|
||||
occurrences).
|
||||
|
||||
**Decision: delete all three anyway.** The tags are an undocumented parallel
|
||||
convention — `@req:` appears in no doc, and CLAUDE.md describes only `TRACES:`.
|
||||
Repairing the scripts would re-establish a second traceability system to keep in
|
||||
sync with the first, which is the same two-sources-of-truth condition that let
|
||||
"1 requirement" and "211 requirements" coexist unnoticed. `TRACES:` plus the
|
||||
repaired coverage engine ([traceability-gate-repair.md](traceability-gate-repair.md))
|
||||
already cover this ground.
|
||||
|
||||
The existing `@req:` / `@req-test:` comments are left in place: they are
|
||||
harmless as prose, several encode genuinely useful test intent, and stripping
|
||||
222 comments across the tree is a large diff with no functional gain. They are
|
||||
simply no longer read by any tool.
|
||||
|
||||
### Alternative considered: repair rather than delete
|
||||
|
||||
Rejected. The script's output format duplicates what `traces:markdown` already
|
||||
generates, it has no tests, no caller, and no documented purpose distinct from
|
||||
`extract-traces.ts`. Repairing it recreates the two-sources-of-truth condition
|
||||
that produced the contradiction. If a shell-based coverage check is ever wanted,
|
||||
it should shell out to `traces:json` and `jq` rather than re-parse
|
||||
`requirements.md` independently.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- The CI workflow denominators — [traceability-gate-repair.md](traceability-gate-repair.md).
|
||||
- Any change to `extract-traces.ts`'s output (that spec owns it).
|
||||
- Auditing scripts that *are* referenced from `package.json` — they run
|
||||
regularly and would fail visibly.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `scripts/check-req-coverage.sh` no longer exists.
|
||||
- [ ] `grep -rn "check-req-coverage" .` (excluding `node_modules` and this spec)
|
||||
returns nothing — no dangling reference in CI, docs, or `package.json`.
|
||||
- [ ] `scripts/check-test-coverage.sh` and `find-req-implementations.sh` have each
|
||||
been run and either wired into `package.json` or deleted; the decision and
|
||||
reason are recorded in `scripts/README.md`. **Outcome: all three deleted —
|
||||
see Findings.**
|
||||
- [ ] `scripts/README.md` documents `bun run traces:coverage` as the supported
|
||||
way to check requirement coverage locally.
|
||||
- [ ] `bun run test:all` passes (confirms nothing invoked the deleted script).
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
- [ ] `bun run check:boundary` passes.
|
||||
|
||||
## Testing
|
||||
|
||||
No unit tests — this is a deletion. Verification is the grep in the acceptance
|
||||
criteria plus a green `bun run test:all`, which exercises the script paths that
|
||||
actually run.
|
||||
|
||||
## TRACES
|
||||
|
||||
No new requirement. The deletion is covered by **DR-093**
|
||||
([traceability-gate-repair.md](traceability-gate-repair.md)), which establishes
|
||||
`extract-traces.ts` as the single owner of coverage reporting. Note the removal
|
||||
in that DR's text when both land.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- A parallel Claude session may be active in this repo — `git diff` before
|
||||
"repairing" unexpected changes (CLAUDE.md §Gotchas).
|
||||
- Land this **after** or alongside [traceability-gate-repair.md](traceability-gate-repair.md),
|
||||
so `bun run traces:coverage` exists before the broken script is removed and
|
||||
developers are never left without a coverage command.
|
||||
- Check `docs/traceability-ci.md` and `docs/traces-quick-ref.md` for prose
|
||||
references to the deleted script; the grep above covers `.md`, but read the
|
||||
surrounding sentence rather than deleting the line mechanically.
|
||||
@@ -0,0 +1,254 @@
|
||||
# Spec: Land the scoped-search boundary fix (implementation)
|
||||
|
||||
**Status:** Stage 1 Implemented — Stage 2 (result-side grouping) outstanding
|
||||
**Requirements:** UR-049, UR-050 | DR-063, DR-066, DR-067 (existing — no new IDs)
|
||||
**UX spec:** n/a — zero user-visible change is the point (see Acceptance criteria).
|
||||
**Supersedes / revises:** implements [scoped-search-boundary.md](scoped-search-boundary.md),
|
||||
which specified this fix but was never built. That spec remains the **design
|
||||
authority**; this one is the delivery plan and status correction.
|
||||
|
||||
## Summary
|
||||
|
||||
[scoped-search-boundary.md](scoped-search-boundary.md) diagnosed a domain-taxonomy
|
||||
leak, specified the fix in full detail, and became the justification for the
|
||||
project's boundary rule in CLAUDE.md, the `check:boundary` tripwire, and the
|
||||
spec-review checklist. **The fix was never implemented.** The leak it describes
|
||||
is still live in `main`. This spec exists to close that gap and to correct the
|
||||
record — the codebase currently enforces a rule against a violation it still
|
||||
contains.
|
||||
|
||||
## Motivation
|
||||
|
||||
The mapping the rule forbids is present and in use:
|
||||
|
||||
```ts
|
||||
// src/lib/utils/searchScope.ts:29-32
|
||||
const SCOPE_ITEM_TYPES: Record<Exclude<SearchScope, "all">, string[]> = {
|
||||
music: ["MusicAlbum", "MusicArtist", "Audio", "Playlist"],
|
||||
movies: ["Movie"],
|
||||
tv: ["Series", "Episode"],
|
||||
};
|
||||
```
|
||||
|
||||
This is not dead code. [library.ts:262](../../src/lib/stores/library.ts#L262)
|
||||
calls `scopeItemTypes(scope)` and puts the result straight into
|
||||
`options.includeItemTypes`. Meanwhile there is **no `SearchScope` anywhere in
|
||||
`src-tauri/`**:
|
||||
|
||||
```console
|
||||
$ grep -rn "SearchScope" src-tauri/src --include='*.rs'
|
||||
(no output)
|
||||
```
|
||||
|
||||
Three things make this the highest-value item found in the design-principles
|
||||
audit:
|
||||
|
||||
1. **The rule's own founding incident is unremediated.** CLAUDE.md cites this
|
||||
spec as "the incident this rule came from." A rule whose originating
|
||||
violation is still shipping is not credible.
|
||||
2. **The tripwire cannot see it.** `bun run check:boundary` passes — it greps for
|
||||
a multi-type array literal *at the query site*, and this one is assigned to a
|
||||
named const and dereferenced elsewhere. Broadening the tripwire is specified
|
||||
separately in [boundary-tripwire-hardening.md](boundary-tripwire-hardening.md);
|
||||
note that hardening it **without** landing this fix would turn `master` red.
|
||||
3. **The spec's own acceptance criterion fails today.** "Adding a hypothetical
|
||||
new type to a scope requires editing only Rust" — adding a type to the Music
|
||||
scope right now requires editing `searchScope.ts`.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
Unchanged from [scoped-search-boundary.md](scoped-search-boundary.md) §Design;
|
||||
restated so this spec is reviewable on its own.
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Scope → Jellyfin item types (`music` → `MusicAlbum`, `MusicArtist`, `Audio`, `Playlist`) | **Rust** | Domain vocabulary. Changes if Jellyfin adds/renames an item type — the litmus test's "yes" case. This is the leak being fixed. |
|
||||
| Result item → search group bucketing | **Rust** | Same taxonomy, result side. Classifying a `MediaItem` as a Song vs Album is Jellyfin vocabulary, not layout. |
|
||||
| `All` sends no filter at all (≠ union of enumerated types) | **Rust** | A query-shaping rule with a correctness consequence (Person/folder results would be silently dropped). Belongs with the expansion it qualifies. |
|
||||
| Group display order, labels, reordering, persistence | Frontend | Pure presentation — changes only if the UI is redesigned. Explicitly retained frontend-side. |
|
||||
| `resolveSearchScope(pathname)` — route → initial scope | Frontend | Routing/navigation, no Jellyfin vocabulary. Stays exactly as-is. |
|
||||
| Chip labels (`SCOPE_LABELS`), scope order (`SEARCH_SCOPES`) | Frontend | Display strings over an opaque enum. |
|
||||
| `GROUP_SCOPE` (which group belongs to which scope) | **Delete** | Borderline taxonomy, made redundant: once Rust filters by scope, out-of-scope groups arrive empty and drop via the empty-omit rule. Borderline defaults to Rust; here it defaults to *gone*. |
|
||||
|
||||
The `SearchScope` and `SearchGroupId` **types** come to the frontend from
|
||||
generated `bindings.ts`. Naming an opaque enum variant is not taxonomy; knowing
|
||||
what item types it expands to is.
|
||||
|
||||
## Design
|
||||
|
||||
**Follow [scoped-search-boundary.md](scoped-search-boundary.md) §Design as
|
||||
written** — `SearchScope` enum + `item_types()` in `repository/types.rs`,
|
||||
`SearchOptions.scope`, `SearchGroupId`/`SearchGroup`/`GroupedSearchResult`,
|
||||
scope-wins precedence, `All` → `None` → no filter. It is not restated here;
|
||||
duplicating it would create two drifting copies of the same design.
|
||||
|
||||
This spec adds only the delivery sequencing that the original left implicit.
|
||||
|
||||
### Staging: land it in two reviewable pieces
|
||||
|
||||
The original bundles the query side and the result side into one change. That is
|
||||
a large diff touching Rust types, `bindings.ts`, the store, and a component, with
|
||||
the `search-event` dual-payload hazard in the middle. Split it:
|
||||
|
||||
**Stage 1 — query side (closes the leak).**
|
||||
`SearchScope` enum, `SearchOptions.scope`, command resolves scope →
|
||||
`include_item_types` in Rust, `library.ts` sends `{ scope }`, delete
|
||||
`SCOPE_ITEM_TYPES` and `scopeItemTypes()`. Result grouping stays as it is.
|
||||
|
||||
After Stage 1 the actual boundary violation is gone and
|
||||
[boundary-tripwire-hardening.md](boundary-tripwire-hardening.md) can land safely.
|
||||
|
||||
**Stage 2 — result side.** `SearchGroupId`/`SearchGroup`/`GroupedSearchResult`,
|
||||
Rust bucketing, both payloads converted, `composeSearchGroups()` shrunk,
|
||||
`GROUP_ITEM_TYPES`/`groupItemTypes()`/`GROUP_SCOPE` deleted.
|
||||
|
||||
Both stages are required for the original spec's acceptance criteria to pass;
|
||||
Stage 1 alone leaves `GROUP_ITEM_TYPES` in the frontend. **Stage 1 is not a
|
||||
stopping point** — it is a review boundary. Do not mark the parent spec
|
||||
Implemented until Stage 2 lands.
|
||||
|
||||
### Stage 1 — delivered (July 2026)
|
||||
|
||||
- `SearchScope` enum + `item_types()` in [repository/types.rs](../../src-tauri/src/repository/types.rs);
|
||||
`All` → `None` → no filter.
|
||||
- `SearchOptions.scope` with `resolve_scope()`; scope wins over
|
||||
`include_item_types`, which stays for the non-search `get_items` callers.
|
||||
- `repository_search` resolves the scope **once, before** the cache/server split,
|
||||
so both phases filter identically.
|
||||
- `SCOPE_ITEM_TYPES` and `scopeItemTypes()` deleted; `searchScope.ts` now
|
||||
re-exports `SearchScope` from the generated bindings instead of a hand-written
|
||||
union.
|
||||
- [library.ts](../../src/lib/stores/library.ts) sends `{ scope }`.
|
||||
- 8 Rust tests (`search_scope_tests`); the frontend suite now asserts the
|
||||
*opaque scope* is sent rather than an item-type list.
|
||||
|
||||
Verified: adding `"AudioBook"` to the Music scope changed **zero** files under
|
||||
`src/` — the criterion that failed before this work.
|
||||
|
||||
**Stage 2 remains open**: `GROUP_ITEM_TYPES` / `groupItemTypes()` (result-side
|
||||
bucketing, single-type-per-group) are still in `searchScope.ts`, and both search
|
||||
payloads still carry a flat `MediaItem[]` rather than `GroupedSearchResult`.
|
||||
|
||||
### 🔴 The `search-event` dual payload (Stage 2)
|
||||
|
||||
The original flags this as "the single largest part of the change and the
|
||||
easiest to half-do." Restating because it is the one thing that silently breaks:
|
||||
search resolves **twice** — the command returns instant cache results, then the
|
||||
merged cache+server union arrives via `search-event`. Both payloads must carry
|
||||
`GroupedSearchResult`. Convert one and the UI flickers between shapes as server
|
||||
results land.
|
||||
|
||||
Write the failing test for the *event* payload first — the command return is the
|
||||
obvious half, the event is the half that gets forgotten.
|
||||
|
||||
### Note on `SearchOptions.scope` and specta
|
||||
|
||||
`SearchOptions` is already `#[serde(rename_all = "camelCase")]` with
|
||||
`skip_serializing_if = "Option::is_none"`. Add `scope: Option<SearchScope>`
|
||||
following that pattern so `All`/absent omits the key. Regenerate `bindings.ts`
|
||||
— `SearchOptions` there is currently
|
||||
`{ limit?, includeItemTypes?, searchTerm? }` and must gain `scope?`. Never
|
||||
hand-edit it.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Redesigning anything in [scoped-search-boundary.md](scoped-search-boundary.md).
|
||||
If implementation shows the design wrong, revise **that** spec, don't fork it.
|
||||
- Online/offline `include_item_types` **filtering** — already correct; only the
|
||||
source of the type list moves.
|
||||
- Ranking within or across groups (DR-090 territory).
|
||||
- Chip UX, scope persistence, group-order persistence — unchanged.
|
||||
- The two lesser type-set sites in `DownloadedBrowse.svelte` and
|
||||
`GenericMediaListPage.svelte`, handled in
|
||||
[boundary-tripwire-hardening.md](boundary-tripwire-hardening.md).
|
||||
- Broadening the tripwire itself — same sibling spec.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
Inherits every criterion from [scoped-search-boundary.md](scoped-search-boundary.md)
|
||||
§Acceptance criteria. Additionally:
|
||||
|
||||
- [ ] `grep -rn "SearchScope" src-tauri/src --include='*.rs'` returns matches —
|
||||
the enum exists in Rust (it does not today).
|
||||
- [ ] `grep -n "SCOPE_ITEM_TYPES\|scopeItemTypes\|GROUP_ITEM_TYPES\|groupItemTypes" src/lib/utils/searchScope.ts`
|
||||
returns nothing.
|
||||
- [ ] `grep -rn "scopeItemTypes" src/` returns nothing — including the
|
||||
`library.ts` import and call site.
|
||||
- [ ] `SearchOptions` in `bindings.ts` includes `scope`; regenerated, not
|
||||
hand-edited.
|
||||
- [ ] **Behaviour is byte-identical for the user**: same scoping, same groups,
|
||||
same order, same empty-group omission, offline included. This spec is a
|
||||
pure refactor — any visible change is a defect.
|
||||
- [ ] `All` scope sends no `includeItemTypes` (asserted in a Rust test, not by
|
||||
inspection).
|
||||
- [ ] Adding a type to the Music scope requires editing **only** Rust —
|
||||
demonstrate by making the edit and confirming no `src/` file changes.
|
||||
- [ ] `scoped-search-boundary.md` status flips to **Implemented**, and
|
||||
`scoped-search.md`'s "frontend only, no Rust changes" framing gets a
|
||||
banner pointing at the corrected design.
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
||||
- [ ] `bun run check:boundary` passes.
|
||||
- [ ] Changed code carries `// TRACES:` comments (IDs below).
|
||||
|
||||
## Testing
|
||||
|
||||
Follow [scoped-search-boundary.md](scoped-search-boundary.md) §Testing. Emphases:
|
||||
|
||||
**Rust** (`cargo test`):
|
||||
- `SearchScope::item_types()` per scope; `All` → `None`.
|
||||
- Scope resolution happens **before** the online/offline split, so both paths
|
||||
get the same filter — a regression here is invisible until someone searches
|
||||
offline.
|
||||
- `scope` set + `include_item_types` set → scope wins (the documented
|
||||
precedence; assert it rather than trusting the doc).
|
||||
- Stage 2: mixed `Vec<MediaItem>` buckets correctly; unknown types dropped;
|
||||
canonical group order; **the `search-event` payload is the grouped shape**.
|
||||
|
||||
**Frontend** (`bun run test`):
|
||||
- `resolveSearchScope()` tests in `searchScope.test.ts` must pass **unchanged** —
|
||||
they cover the part that is not moving, and are the regression net proving the
|
||||
refactor didn't disturb routing.
|
||||
- `library.ts` sends `{ scope }` and never `includeItemTypes` for search.
|
||||
- `composeSearchGroups()` over fixture `SearchGroup[]` with no `.type`
|
||||
inspection in the implementation.
|
||||
|
||||
**Offline parity:** run a scoped search with the server unreachable and confirm
|
||||
identical grouping. The offline repository path honours `include_item_types`
|
||||
independently, and this is the case most likely to be missed.
|
||||
|
||||
## TRACES
|
||||
|
||||
No new requirement IDs — this implements existing ones. Retag as the code moves:
|
||||
|
||||
```rust
|
||||
// src-tauri/src/repository/types.rs
|
||||
/// TRACES: UR-049 | DR-063
|
||||
pub enum SearchScope { … }
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/lib/utils/searchScope.ts — keep the file header; it retains
|
||||
// resolveSearchScope + group-order presentation logic.
|
||||
// TRACES: UR-049, UR-050 | DR-063, DR-066, DR-067
|
||||
```
|
||||
|
||||
Update DR-063's text in `requirements.md` to state that scope expansion is owned
|
||||
by Rust, so the requirement stops describing the leaked design. New Rust tests
|
||||
take `@req-test: UT-089` onward (next free UT is **UT-089**).
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- A parallel Claude session may be active in this repo — `git diff` before
|
||||
"repairing" unexpected changes (CLAUDE.md §Gotchas).
|
||||
- **Read [scoped-search-boundary.md](scoped-search-boundary.md) first.** This
|
||||
spec is deliberately thin on design; that one is the authority.
|
||||
- Sequence with the sibling specs: **Stage 1 here → then
|
||||
[boundary-tripwire-hardening.md](boundary-tripwire-hardening.md)**. Hardening
|
||||
the tripwire first turns `master` red on a known-unfixed violation.
|
||||
- `git log --oneline -- docs/specs/scoped-search-boundary.md` is worth a look
|
||||
before starting — understanding why the fix stalled may surface a constraint
|
||||
the spec didn't record.
|
||||
- The user-visible-change count for this spec is zero. If QA reports a
|
||||
difference in search results, that is a bug in the refactor, not an
|
||||
improvement.
|
||||
@@ -8,8 +8,14 @@
|
||||
> is being moved into Rust. The **user-facing behaviour and UX in this spec are
|
||||
> unchanged**; only where the scope→item-type mapping and result bucketing live
|
||||
> changes. Read the boundary spec before touching search code.
|
||||
>
|
||||
> **Progress:** the scope→item-type mapping now lives in Rust
|
||||
> (`SearchScope::item_types()`); the frontend sends an opaque scope. Result-side
|
||||
> bucketing (`GROUP_ITEM_TYPES`) is still frontend-side — see
|
||||
> [scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md)
|
||||
> §Stage 2.
|
||||
|
||||
**Status:** Implemented (boundary revision pending — see banner above)
|
||||
**Status:** Implemented (boundary revision: query side done, result side pending)
|
||||
**Scope:** Frontend only. No Rust changes required. *(Revised — see banner.)*
|
||||
**Requirements:** UR-049 → DR-063, DR-064, DR-065; UR-050 → DR-066, DR-067
|
||||
(see [requirements.md](../requirements.md)).
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
# Spec: series navigation lands on the current episode
|
||||
|
||||
**Status:** Accepted
|
||||
**Requirements:** UR-062 → DR-101, DR-102, DR-103, DR-104, DR-107; UR-063 → DR-105; UR-064 → DR-106
|
||||
**UX spec:** [ux-flows.md §5B.1](../ux-flows.md), [§5B.2](../ux-flows.md), [§5B.4](../ux-flows.md), [§5B.5](../ux-flows.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Opening a TV series lands you where you actually are in it: the seasons render
|
||||
as collapsible sections with **only the current season expanded**, the current
|
||||
episode highlighted and scrolled into view, and the hero button opens that
|
||||
episode's focus view (labelled `Resume S2E4` / `Play S1E1`) instead of the first
|
||||
season. A season stops being a destination of its own — every route that used to
|
||||
land on `/library/<seasonId>` now lands on the series with that season in view,
|
||||
so the full cross-season episode list is always reachable in one place. Watch
|
||||
history can be erased per series and per season. Separately, each video library
|
||||
collapses from three routes (landing, all-titles, genres) to one route with
|
||||
in-page tabs.
|
||||
|
||||
## Motivation
|
||||
|
||||
Two problems, reported together.
|
||||
|
||||
**1. Series navigation dead-ends at season 1.** The series detail page's Play
|
||||
button resolved its target as `$libraryItems[0]` — the first *season* child,
|
||||
ordered by `SortName` — and navigated to `/player/<seasonId>`. The player route
|
||||
classifies `season` as a container kind and bounces it back to
|
||||
`/library/<seasonId>`. So Play on a series played nothing; it navigated you to
|
||||
the season-1 page. Opening a series without pressing Play rendered every season
|
||||
stacked but scrolled to the top, so a viewer 4 seasons deep had to scroll past
|
||||
everything they had already watched.
|
||||
|
||||
The backend has been able to answer "where is this viewer in this show" the
|
||||
whole time: `repository_get_next_up_episodes(handle, series_id, limit)` is wired
|
||||
end-to-end to `/Shows/NextUp?SeriesId=`. **Both frontend call sites pass
|
||||
`undefined` for `series_id`** — the per-series capability existed and was never
|
||||
used.
|
||||
|
||||
**2. Seasons are an accidental page.** There is no season route. `/library/
|
||||
<seasonId>` falls through the detail page's `kind` chain into the generic
|
||||
"Contents" poster grid, which contradicts ux-flows §5A.2 (episodes in a season
|
||||
must render as a row list). Worse, clicking an episode from that grid opens a
|
||||
*bare* Episode page, which §5B.1 explicitly forbids. Four call sites fed it: the
|
||||
episode breadcrumb, `handleItemClick case "season"`, the TV landing page, and
|
||||
the broken Play button above.
|
||||
|
||||
**3. Too many video library routes.** Seven routes serve two media types, and the
|
||||
naming does not even agree with itself: `/library/tv` + `/library/tv/shows` +
|
||||
`/library/shows/genres` versus `/library/movies` + `/library/movies/all` +
|
||||
`/library/movies/genres`. The genre routes do not share a prefix, which
|
||||
`searchScope.ts:45` carries an apologetic comment about. The two "all" pages are
|
||||
27-line config wrappers over the same `GenericMediaListPage`.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Which episode is "current" for a series (resume → next-up → first unwatched → first) | **Rust** | Domain policy over Jellyfin user-data semantics. It changes if Jellyfin changes what `UserData.is_played` means, if Next Up's rules change, or if we decide a 98%-watched episode counts as finished. It does not change if the UI is redesigned. |
|
||||
| Gathering a series' episodes across all seasons in broadcast order | **Rust** | Jellyfin's shape (episodes hang off season folders, except when a series is flat and they hang off the series) is provider vocabulary. The frontend already reimplemented this fan-out *and* its flat-series fallback; that is domain knowledge that leaked. |
|
||||
| Ordering rule for "series order" (season index, then episode index, specials last) | **Rust** | Season 0 = specials is a Jellyfin convention, not a layout choice. |
|
||||
| Scrolling the current episode into view; the highlight ring and `Up next` badge | Frontend | Pure presentation. Changes only if the page is redesigned. |
|
||||
| Which seasons start expanded | Frontend | Consumes the backend's answer (`currentEpisode`) to decide layout. The *decision* about where the viewer is stays in Rust; only "and therefore this section opens" is here. |
|
||||
| What "erase watch history" means (played flag + resume position, recursive over a container) | **Rust** | Jellyfin user-data semantics. Changes if the server's mark-unplayed behaviour changes; unaffected by any UI redesign. |
|
||||
| Refusing to clear history while offline | **Rust** | A data-integrity rule, not a disabled button: history cleared only locally would be undone by the next sync. The UI disabling the button is a courtesy on top. |
|
||||
| Play button *label* (`Resume S2E4` vs `Play S1E1`) | Frontend | Rendering a decision the backend already made (the returned episode plus its resume position). |
|
||||
| Which route Play navigates to | Frontend | Navigation is presentation. |
|
||||
| Redirecting `/library/<seasonId>` to the series anchor | Frontend | Route topology. |
|
||||
| Episode-strip window size (3 before / 6 after) | Frontend | A layout constant; §5B.2 owns it. |
|
||||
| Library page tabs and the `?view=` param | Frontend | View preference and route topology. |
|
||||
|
||||
Borderline row — **the strip's cross-season *ordering*** is Rust (it is series
|
||||
order, above), but the *window* taken from that ordered list is frontend. The
|
||||
tie-breaker: the list handed to the frontend is already correct and complete;
|
||||
choosing how much of it fits on screen is layout.
|
||||
|
||||
## Design
|
||||
|
||||
### Rust: the current-episode policy
|
||||
|
||||
Two new pieces, split so the policy is unit-testable without a repository.
|
||||
|
||||
**Pure policy** — `src-tauri/src/repository/series_progress.rs`:
|
||||
|
||||
```rust
|
||||
/// Series order: season index asc, then episode index asc. Specials (season 0)
|
||||
/// sort after every numbered season rather than before season 1.
|
||||
pub fn sort_series_order(episodes: &mut [MediaItem]);
|
||||
|
||||
/// The episode a viewer should land on, given everything already fetched.
|
||||
/// Order: in-progress episode → Next Up → first unwatched → first episode.
|
||||
pub fn pick_current_episode(
|
||||
episodes: &[MediaItem], // series order
|
||||
next_up: &[MediaItem],
|
||||
resume: &[MediaItem],
|
||||
) -> Option<MediaItem>;
|
||||
```
|
||||
|
||||
Why that order:
|
||||
|
||||
- **In-progress wins** because a partially-watched episode is literally where
|
||||
the viewer stopped; Next Up would skip past it. Ties break toward the earliest
|
||||
in series order, so a viewer who dipped into a later episode still resumes the
|
||||
one they are actually working through.
|
||||
- **Next Up second** because it is the server's own answer, and it accounts for
|
||||
history we do not cache.
|
||||
- **First unwatched third** — the offline repository returns an empty vec for
|
||||
Next Up (`offline.rs:1247`), so without this fallback the whole feature would
|
||||
be online-only. This is the offline path, not dead code.
|
||||
- **First episode last** so a never-watched series lands on S1E1 rather than
|
||||
nothing.
|
||||
|
||||
A `resume`/`next_up` entry that is not among `episodes` is still honoured — it
|
||||
comes from the same server and may carry an id the season fan-out missed — but
|
||||
it must belong to this series.
|
||||
|
||||
**Fetch + command** — `src-tauri/src/commands/repository.rs`:
|
||||
|
||||
```rust
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_series_episodes(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
series_id: String,
|
||||
) -> Result<Vec<MediaItem>, String>
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_series_current_episode(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
series_id: String,
|
||||
) -> Result<Option<MediaItem>, String>
|
||||
```
|
||||
|
||||
Frontend params are camelCase (`{ handle, seriesId }`) per the Tauri v2 rule.
|
||||
|
||||
`repository_get_series_episodes` performs the fan-out the frontend used to do:
|
||||
`get_items(series_id)` → seasons → `get_items(season_id)` per season, plus the
|
||||
flat-series fallback (a series whose children are episodes, not seasons), then
|
||||
`sort_series_order`. `repository_get_series_current_episode` calls it, adds
|
||||
`get_next_up_episodes(Some(series_id), Some(1))` and
|
||||
`get_resume_items(Some(series_id), Some(10))`, and applies `pick_current_episode`.
|
||||
Both tolerate a failing Next Up (offline) by treating it as empty rather than
|
||||
failing the whole call.
|
||||
|
||||
### Frontend: series page
|
||||
|
||||
- `loadItem()` calls `repositoryGetSeriesEpisodes` once instead of fanning out
|
||||
over seasons itself, and `repositoryGetSeriesCurrentEpisode` for the anchor.
|
||||
Season *headers* still come from `get_items(seriesId)`; the page groups the
|
||||
returned episodes under them by `parentIndexNumber`.
|
||||
- No `?episode=` param → series view, `SeasonSection` receives
|
||||
`currentEpisodeId`, `EpisodeRow` renders the highlight and scrolls itself into
|
||||
view (`scrollIntoView({ block: "center" })`, the existing `focused` mechanism,
|
||||
now distinguishing *focused* from *current*).
|
||||
- Seasons are collapsible and **only the current season is expanded**
|
||||
(`initialExpandedSeasons`). Without this a ten-season show renders every
|
||||
episode of every season at once and buries the one the viewer came for. A
|
||||
collapsed season still shows its episode count and watched count, so progress
|
||||
is legible without expanding. Toggle state is local and not persisted — it is
|
||||
a reading position, not a preference.
|
||||
- Hero Play → `goto(/library/<seriesId>?episode=<currentId>)`, i.e. the Episode
|
||||
Focus View, where an explicit Play/Resume starts playback. This follows
|
||||
ux-flows §5B.5's "tap opens, never commits" rule: Play on a *container* is
|
||||
navigation; Play on a *leaf* (the focus view, a movie) commits.
|
||||
- Clicking an episode in a season section → `?episode=` swap, not
|
||||
`/player/<id>`. §5B.1.
|
||||
|
||||
### Frontend: seasons are not a destination
|
||||
|
||||
`/library/<seasonId>` resolves the season's `seriesId` and redirects to
|
||||
`/library/<seriesId>#season-<indexNumber>`; `SeasonSection` renders that anchor
|
||||
id. A season with no `seriesId` (deep link into a stale cache) keeps the old
|
||||
generic rendering as a fallback so the user is never stranded. Inbound links
|
||||
updated: episode breadcrumb, `handleItemClick case "season"`, the TV landing
|
||||
page's `case "Season"`, and `DownloadedBrowse`.
|
||||
|
||||
### Erasing watch history
|
||||
|
||||
```rust
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_clear_watch_history(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
item_id: String,
|
||||
) -> Result<(), String>
|
||||
```
|
||||
|
||||
`OnlineRepository` maps it to `DELETE /Users/{userId}/PlayedItems/{itemId}` —
|
||||
Jellyfin's mark-unplayed, which clears the played flag *and* zeroes the resume
|
||||
position, and which the server applies recursively to a folder. One call
|
||||
therefore handles a whole series or a single season; no per-episode fan-out.
|
||||
`OfflineRepository` returns `RepoError::Offline` rather than clearing locally,
|
||||
because divergent local history is undone by the next sync.
|
||||
|
||||
`ClearHistoryButton` is shared by the series hero (`scope="series"`) and each
|
||||
`SeasonSection` header (`scope="season"`). It confirms first — there is no undo —
|
||||
disables itself while the server is unreachable, and reloads the page on success
|
||||
so the recomputed current episode is what the viewer sees. Clearing a whole
|
||||
series therefore returns it to S1E1, which is the same path a never-watched
|
||||
series takes through `pick_current_episode`.
|
||||
|
||||
### Frontend: one route per video library
|
||||
|
||||
`/library/tv` and `/library/movies` each gain `?view=browse|all|genres` tabs,
|
||||
rendering the existing `GenericMediaListPage` / `GenericGenreBrowser` components
|
||||
inline. `?view=` is omitted for `browse` (the default) to keep URLs clean —
|
||||
the same convention `searchRouteUrl` uses for the `all` scope.
|
||||
|
||||
The four legacy routes become redirect-only `+page.ts` loads:
|
||||
|
||||
| Legacy | Redirects to |
|
||||
|--------|--------------|
|
||||
| `/library/tv/shows` | `/library/tv?view=all` |
|
||||
| `/library/shows/genres` | `/library/tv?view=genres` |
|
||||
| `/library/movies/all` | `/library/movies?view=all` |
|
||||
| `/library/movies/genres` | `/library/movies?view=genres` |
|
||||
|
||||
They are kept (rather than deleted) because `GenreTags` builds links to them and
|
||||
users may 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 — the tabs
|
||||
replace it, and the tiles were a second navigation affordance to the same two
|
||||
destinations the carousels' "Show all" links already reach.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Cross-season autoplay.** `player/mod.rs:fetch_next_episode_for_item` is
|
||||
still season-bounded, so autoplay stops at a season boundary. Fixing it should
|
||||
reuse `repository_get_series_episodes`, but it touches the playback state
|
||||
machine and the Android JNI advance path (see the `AutoplayDecision` deadlock
|
||||
note in CLAUDE.md) and belongs in its own change.
|
||||
- **Music library routes.** `/library/music/*` has five sub-routes with the same
|
||||
shape; the same consolidation applies but is not done here.
|
||||
- **Marking a series' progress** (mark-watched / mark-unwatched from the series
|
||||
page).
|
||||
@@ -0,0 +1,238 @@
|
||||
# Spec: Repair the traceability coverage gate
|
||||
|
||||
**Status:** Implemented
|
||||
**Requirements:** DR-093 → supports the traceability practice described in CLAUDE.md
|
||||
**UX spec:** n/a — developer tooling, no user-facing surface.
|
||||
**Supersedes / revises:** n/a
|
||||
|
||||
## Summary
|
||||
|
||||
The CI traceability gate has been passing unconditionally for an unknown length
|
||||
of time because it divides traced-requirement counts by **hardcoded denominators
|
||||
that no longer match [requirements.md](../requirements.md)**. It currently
|
||||
reports **158% overall coverage** (and `JA 24 / 3 = 800%`), so the 50% threshold
|
||||
is mathematically unreachable and the job cannot fail. This spec makes the gate
|
||||
derive its denominators from `requirements.md` at run time, so it reports the
|
||||
real number (**85%** today) and can actually fail again.
|
||||
|
||||
## Motivation
|
||||
|
||||
`.gitea/workflows/traceability-check.yml` hardcodes `UR/39, IR/24, DR/48, JA/3`
|
||||
and `TOTAL_REQS=114`. The real counts are **UR 61, IR 29, DR 89, JA 32 — 211
|
||||
total**. Requirements were added over time; the divisors were never updated.
|
||||
|
||||
The consequence is not a cosmetic reporting bug. The gate is the *only*
|
||||
automated defence for the traceability practice, and it is dead:
|
||||
|
||||
```
|
||||
CI today: 181 / 114 = 158% → threshold 50% can never trip
|
||||
Reality: 181 / 211 = 85% → healthy, but unguarded
|
||||
```
|
||||
|
||||
Coverage could collapse to 30% and CI would still print a green
|
||||
"✅ Coverage is acceptable". An audit of the design principles found that every
|
||||
principle with a *working* automated check is in good shape, and the ones that
|
||||
drifted are exactly the ones whose checks were broken or too narrow — this is
|
||||
the clearest instance.
|
||||
|
||||
A second, related defect is handled in a sibling spec: `scripts/check-req-coverage.sh`
|
||||
is separately broken and orphaned (see
|
||||
[req-coverage-script-removal.md](req-coverage-script-removal.md)).
|
||||
|
||||
## Layer assignment
|
||||
|
||||
This spec touches only CI/build tooling — no application logic crosses the
|
||||
Rust/Svelte boundary. The table is filled in for completeness.
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Counting requirement IDs defined in `requirements.md` | Build tooling (`scripts/`) | Neither runtime layer; it is repo metadata analysis. Belongs beside `extract-traces.ts`, not in the workflow YAML, so it is runnable and testable locally. |
|
||||
| Counting *traced* requirement IDs | Build tooling — existing `extract-traces.ts` | Already implemented and correct; this spec consumes it rather than duplicating it. |
|
||||
| Threshold policy (the 50% number) | CI workflow | Deployment policy, not analysis. Keeping it in YAML lets it be tuned without touching the script. |
|
||||
|
||||
No frontend or Rust logic is added, so no taxonomy leak is possible.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Denominators come from `requirements.md`, not literals
|
||||
|
||||
`requirements.md` defines requirements in markdown tables with a stable leading
|
||||
cell, e.g.:
|
||||
|
||||
```
|
||||
| DR-001 | Player state machine (idle, loading, …) | Player | UR-005 | Done |
|
||||
| UR-002 | Access media when online or offline | High | Done |
|
||||
```
|
||||
|
||||
Extend [scripts/extract-traces.ts](../../scripts/extract-traces.ts) to also emit
|
||||
the *defined* counts, so one tool owns both sides of the fraction and CI does no
|
||||
arithmetic on stale literals. Add a `defined` key to the JSON report:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"byType": { "UR": [...], "IR": [...], "DR": [...], "JA": [...] }, // traced (existing)
|
||||
"defined": { "UR": 61, "IR": 29, "DR": 89, "JA": 32 }, // NEW
|
||||
"coverage": { "covered": 181, "total": 211, "percent": 85 }, // NEW
|
||||
"requirements": { ... }, // existing
|
||||
"totalTraces": 318, "totalFiles": …, "timestamp": "…" // existing
|
||||
}
|
||||
```
|
||||
|
||||
Parsing rule for a *defined* requirement: a line in `docs/requirements.md`
|
||||
matching `^\|\s*(UR|IR|DR|JA)-\d{3}\s*\|` — the ID must be the table's first
|
||||
cell. This deliberately does **not** count IDs mentioned in the `Traces To`
|
||||
column or in prose, which is why a naive `grep -o` over the whole file
|
||||
overcounts.
|
||||
|
||||
`defined` counts IDs that exist in the spec; `byType` counts IDs that appear in
|
||||
a `TRACES:` comment somewhere in the source. Coverage is
|
||||
`|byType ∩ defined| / |defined|`.
|
||||
|
||||
> **Intersection, not raw length.** A `TRACES:` comment naming an ID that
|
||||
> `requirements.md` does not define (a typo, or a requirement later deleted)
|
||||
> must **not** inflate the numerator — that is how a ratio exceeds 100% in the
|
||||
> first place. Such IDs are reported separately as `orphaned` so they get fixed
|
||||
> rather than silently counted or silently dropped.
|
||||
|
||||
```jsonc
|
||||
"orphaned": ["DR-097"] // traced in code but not defined in requirements.md
|
||||
```
|
||||
|
||||
### 2. The workflow consumes the computed number
|
||||
|
||||
Replace the arithmetic in `.gitea/workflows/traceability-check.yml` (lines
|
||||
46–76) with reads of the precomputed fields:
|
||||
|
||||
```sh
|
||||
COVERAGE=$(jq '.coverage.percent' traces-report.json)
|
||||
COVERED=$(jq '.coverage.covered' traces-report.json)
|
||||
TOTAL_REQS=$(jq '.coverage.total' traces-report.json)
|
||||
|
||||
for T in UR IR DR JA; do
|
||||
TRACED=$(jq --arg t "$T" '.byType[$t] | length' traces-report.json)
|
||||
DEFINED=$(jq --arg t "$T" '.defined[$t]' traces-report.json)
|
||||
echo " $T: $TRACED / $DEFINED"
|
||||
done
|
||||
|
||||
MIN_THRESHOLD=50
|
||||
[ "$COVERAGE" -lt "$MIN_THRESHOLD" ] && { echo "❌ …"; exit 1; }
|
||||
```
|
||||
|
||||
No hardcoded denominator survives anywhere in the workflow.
|
||||
|
||||
### 3. A self-check so this cannot silently rot again
|
||||
|
||||
The root cause was a number that drifted with nothing watching it. Add a
|
||||
guard that fails the job on an arithmetically impossible result:
|
||||
|
||||
```sh
|
||||
if [ "$COVERAGE" -gt 100 ]; then
|
||||
echo "❌ Coverage > 100% — the gate is miscomputing; orphaned IDs: $(jq -c '.orphaned' traces-report.json)"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
A >100% reading is now a hard failure rather than a green tick.
|
||||
|
||||
### 4. Local parity
|
||||
|
||||
Add a script so the gate is runnable outside CI:
|
||||
|
||||
```jsonc
|
||||
"traces:coverage": "bun run scripts/extract-traces.ts --format coverage"
|
||||
```
|
||||
|
||||
Prints the same table CI prints and exits non-zero below threshold.
|
||||
|
||||
### Threshold
|
||||
|
||||
Keep `MIN_THRESHOLD=50` in this spec. Real coverage is 85%, so raising the bar
|
||||
is tempting, but doing it in the same change that repairs the gate conflates
|
||||
"restore the safety net" with "tighten the policy" — if the build then fails, it
|
||||
is ambiguous which change caused it. Ratcheting is deliberately deferred to
|
||||
follow-up work once the honest number has been observed on `master` for a few
|
||||
builds.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Raising `MIN_THRESHOLD` above 50 (see above).
|
||||
- Fixing/removing `scripts/check-req-coverage.sh` — [req-coverage-script-removal.md](req-coverage-script-removal.md).
|
||||
- Adding TRACES comments to raise the actual coverage number.
|
||||
- Changing the `TRACES:` comment format or the extractor's parsing of it.
|
||||
- The PR "modified files missing TRACES" step (lines 78–126), which is advisory
|
||||
by design and stays advisory.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `bun run traces:json` emits `defined`, `coverage`, and `orphaned` keys.
|
||||
- [ ] `coverage.total` equals the count of requirement IDs defined in
|
||||
`requirements.md` (**211** at time of writing), not a literal.
|
||||
- [ ] `coverage.percent` reports **85** (±1 for rounding) on the current tree —
|
||||
i.e. the honest number, not 158.
|
||||
- [ ] No hardcoded requirement denominator (`39`, `24`, `48`, `3`, `114`) remains
|
||||
in `.gitea/workflows/traceability-check.yml`. Verify:
|
||||
`grep -nE '/ *(39|24|48|3|114)\b' .gitea/workflows/traceability-check.yml`
|
||||
returns nothing.
|
||||
- [ ] Adding a new requirement row to `requirements.md` **lowers** reported
|
||||
coverage until it is traced (proves the denominator is live).
|
||||
- [ ] A `TRACES:` comment naming an undefined ID appears in `orphaned` and does
|
||||
**not** raise `coverage.percent`.
|
||||
- [ ] The job fails if coverage is forced below 50% (test by temporarily raising
|
||||
`MIN_THRESHOLD` to 99 locally) — proving the gate can fail again.
|
||||
- [ ] The job fails if coverage computes >100%.
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
- [ ] `bun run check:boundary` passes.
|
||||
- [ ] New requirement-implementing code carries `// TRACES:` comments.
|
||||
- [ ] No Rust types changed, so no `bindings.ts` regeneration needed.
|
||||
|
||||
## Testing
|
||||
|
||||
`extract-traces.ts` currently has no test coverage. Add
|
||||
`scripts/extract-traces.test.ts` (vitest) over fixture strings rather than the
|
||||
live `requirements.md`, so the tests do not change meaning as requirements are
|
||||
added:
|
||||
|
||||
- **UT:** counts a well-formed table row as a defined requirement.
|
||||
- **UT:** does **not** count an ID appearing only in the `Traces To` column or
|
||||
in prose — the specific overcounting bug this parse rule avoids.
|
||||
- **UT:** coverage is the intersection — a traced-but-undefined ID lands in
|
||||
`orphaned` and does not inflate the numerator.
|
||||
- **UT:** coverage of an empty trace set is 0%, not a divide-by-zero.
|
||||
- **UT:** all-traced fixture reports exactly 100%, never above.
|
||||
|
||||
CI behaviour is verified by the acceptance criteria above (the forced-failure
|
||||
check is the important one — a gate nobody has watched fail is not known to
|
||||
work).
|
||||
|
||||
## TRACES
|
||||
|
||||
Allocate in `requirements.md`:
|
||||
|
||||
- **DR-093** — "Traceability coverage gate derives requirement denominators from
|
||||
`requirements.md` at run time (not hardcoded literals), computes coverage as
|
||||
the intersection of traced and defined IDs, reports IDs traced but undefined
|
||||
as orphaned, and fails on an impossible >100% result." Category: Tooling.
|
||||
Status: Done on merge.
|
||||
|
||||
Tag:
|
||||
|
||||
```typescript
|
||||
// scripts/extract-traces.ts
|
||||
// TRACES: | DR-093
|
||||
```
|
||||
|
||||
Tests carry `@req-test: UT-089 …` onward (next free UT is **UT-089**).
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- A parallel Claude session may be active in this repo — run `git diff` before
|
||||
"repairing" unexpected changes (CLAUDE.md §Gotchas).
|
||||
- **Do not add tooling to the CI image for this.** `jq` and `bun` are already in
|
||||
`jellytau-builder`; this spec needs nothing else. Installing a system package
|
||||
in a workflow step violates the hard CI rule in CLAUDE.md.
|
||||
- Keep `traces:json`'s existing keys intact — `release-notes.ts` and
|
||||
`traces:markdown` consume the same report, and the CI workflow uploads it as
|
||||
an artifact. This is an additive change.
|
||||
- The `head -50 docs/traceability.md` and artifact-upload steps are unaffected.
|
||||
- Expect the first green build after this change to print a *lower* number than
|
||||
before (85% vs 158%). That is the fix working, not a regression.
|
||||
@@ -0,0 +1,209 @@
|
||||
# Spec: Windows native audio backend
|
||||
|
||||
**Status:** Proposed
|
||||
**Requirements:** UR-003, UR-027, UR-032, UR-033 → DR-030, DR-035, DR-036; new IR-030
|
||||
**UX spec:** n/a — Settings › Audio already renders the controls
|
||||
**Supersedes / revises:** acts on the "audio can unify, video cannot" conclusion in [playback-backend-unification.md](playback-backend-unification.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Give Windows a real native audio backend instead of the current webview
|
||||
`<audio>` shim. Windows is the only platform where audio playback has no decoder
|
||||
of its own: `WebviewAudioBackend` hands a URL to a frontend `<audio>` element and
|
||||
relays transport commands. It cannot set volume, cannot apply any audio setting,
|
||||
and reports state only via DOM events.
|
||||
|
||||
Audio needs no rendering surface, so **none of the webview-compositing problems
|
||||
that block unified video apply here.** This is the cleanest available win.
|
||||
|
||||
## Motivation
|
||||
|
||||
`WebviewAudioBackend` was a deliberate stopgap ("audio-only playback for
|
||||
platforms without a native audio backend"), and it works — but it has a hard
|
||||
functional gap. From `webview_audio_backend.rs`:
|
||||
|
||||
```rust
|
||||
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
|
||||
// ...stores locally only; there is no ControlCommand action for volume
|
||||
}
|
||||
```
|
||||
|
||||
So volume changes never reach the element; the frontend has to observe the player
|
||||
store and apply volume itself. `set_audio_settings` likewise stores values that
|
||||
nothing consumes — EQ, normalization, and gapless are all inert on Windows.
|
||||
|
||||
Meanwhile the backend-unification investigation established that a native *audio*
|
||||
engine is unproblematic on Windows specifically: `tauri-plugin-libmpv` lists
|
||||
Windows as its **fully tested** platform (in contrast to Linux, where embedding
|
||||
is broken — but that is a *video surface* problem, which audio does not have).
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Decoding and playing the audio stream | Rust | Playback is domain logic; every other platform already decodes in Rust or a native player. The webview shim is the anomaly. |
|
||||
| Applying `AudioSettings` (EQ/normalize/gapless) | Rust | Same `AudioSettings` contract as MPV/ExoPlayer; band layout and presets stay canonical in `settings.rs`. |
|
||||
| Position/state reporting | Rust | Restores the project's core principle — the player is the authoritative source of state. Today Windows inverts this: the DOM element is authoritative and Rust mirrors it. |
|
||||
| Volume | Rust | Currently broken precisely because it is split across the boundary. |
|
||||
| Rendering the player UI | Frontend | Unchanged. |
|
||||
|
||||
The strongest argument for this change is the third row. CLAUDE.md states
|
||||
playback state is one-directional with the player authoritative; on Windows that
|
||||
is currently false, and the `player_report_*` round-trip exists to paper over it.
|
||||
|
||||
## Design
|
||||
|
||||
### Engine choice
|
||||
|
||||
Two viable options; **libmpv is recommended** for consistency with the Linux
|
||||
audio backend.
|
||||
|
||||
| | libmpv | GStreamer |
|
||||
|---|---|---|
|
||||
| Windows status | ✅ `tauri-plugin-libmpv` reports fully tested | ✅ works, but… |
|
||||
| Rust bindings | `libmpv2` 6.0.0, active | `gstreamer-rs` 0.25.x, excellent |
|
||||
| Cross-MSVC from Linux | ⚠️ needs prebuilt DLL + import lib | ❌ `gstreamer-sys` uses pkg-config, fights `cargo-xwin` |
|
||||
| Code reuse | ✅ `MpvBackend` logic is directly reusable | ❌ a second engine to learn |
|
||||
| Crossfade capable | ❌ single-stream chain | ✅ `audiomixer` |
|
||||
|
||||
libmpv wins on reuse: `MpvBackend`'s `set_audio_settings` — the `af` lavfi graph
|
||||
built by `build_af_filter`, `eq_filter_entries`, `normalize_filter_entry` — is
|
||||
platform-independent and would apply unchanged.
|
||||
|
||||
The one reason to prefer GStreamer is crossfade (UR-031), which mpv structurally
|
||||
cannot do. If crossfade becomes a priority, revisit; it would then argue for
|
||||
GStreamer on *both* Linux and Windows, which is a much larger change.
|
||||
|
||||
### Structure
|
||||
|
||||
Rename the cfg gate so `MpvBackend` is no longer Linux-only:
|
||||
|
||||
```rust
|
||||
// src-tauri/src/player/mod.rs
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
pub mod mpv_backend;
|
||||
```
|
||||
|
||||
`MpvBackend::new` needs one platform-specific branch: `detect_audio_system()`
|
||||
currently probes `pactl`/`pw-cli`/`/proc/asound/cards` to pick an `ao`. On
|
||||
Windows the equivalent is `wasapi` (mpv's default), so the detection is a
|
||||
`#[cfg]` returning `"wasapi"` — no probing needed.
|
||||
|
||||
Everything else — the event loop, the 250ms position thread, the seek-suppression
|
||||
window, the `af` filter graph — is unchanged.
|
||||
|
||||
`WebviewAudioBackend` stays for other targets (macOS and anything else hitting
|
||||
the `not(any(...))` arm) and as the fallback if libmpv fails to initialize. The
|
||||
existing `emit_backend_init_failed` path already handles that gracefully.
|
||||
|
||||
### Build
|
||||
|
||||
`libmpv2-sys` is well-suited to cross-compilation: no pkg-config, vendored
|
||||
headers, pregenerated bindings (no libclang). It emits `cargo:rustc-link-lib=mpv`
|
||||
unconditionally, so the build must supply a linkable import library for
|
||||
`x86_64-pc-windows-msvc`.
|
||||
|
||||
Keep the `build_libmpv` feature **off** — its Unix path shells out to mpv-build
|
||||
and explicitly rejects cross-compilation.
|
||||
|
||||
🔴 Per CLAUDE.md, the prebuilt libmpv **must be added to the builder image**
|
||||
(`Dockerfile.builder` → rebuild + push via `scripts/build-builder-image.sh`), not
|
||||
installed at CI job time. `libmpv-2.dll` must also be bundled into the NSIS
|
||||
installer via `tauri.conf.json`'s resources.
|
||||
|
||||
### Verified build mechanics
|
||||
|
||||
The cross-compile path was tested hands-on from Linux (July 2026), not inferred:
|
||||
|
||||
- Neither shinchiro nor zhongfly ships an `mpv.def` or MSVC `mpv.lib` — only a
|
||||
MinGW `libmpv.dll.a`. (Several online sources claim otherwise; they are wrong.)
|
||||
- An MSVC-style import lib can be generated locally with LLVM tools only:
|
||||
`llvm-readobj --coff-exports libmpv-2.dll` → synthesize `mpv.def` →
|
||||
`llvm-dlltool -m i386:x86-64 -d mpv.def -l mpv.lib`. `llvm-lib /def:` produces a
|
||||
byte-identical result.
|
||||
- A real `lld-link` link against that import lib **succeeds**, and the resulting
|
||||
import table resolves `mpv_client_api_version` from `libmpv-2.dll`. `lld-link`
|
||||
is the linker `cargo-xwin` uses, so this is the load-bearing step.
|
||||
- Linking directly against the shipped MinGW `libmpv.dll.a` **also** succeeds, so
|
||||
def-generation may be skippable — but that relies on lld's GNU-archive
|
||||
tolerance rather than a documented contract. Keep `llvm-dlltool` as the
|
||||
fallback.
|
||||
- MinGW origin is not an ABI problem: libmpv exports a pure C ABI, and the x86-64
|
||||
Windows calling convention is platform-defined. The upstream note that MSVC
|
||||
cannot *build* mpv is frequently misread as "MSVC cannot *link* libmpv" — that
|
||||
is not what it says.
|
||||
- 🔴 Never free/realloc across the DLL boundary — use `mpv_free`.
|
||||
|
||||
Build wiring is ordinary: `cargo:rustc-link-lib=dylib=mpv` plus
|
||||
`cargo:rustc-link-search`. Nothing about libmpv conflicts with `cargo-xwin`.
|
||||
|
||||
### Size and shipping
|
||||
|
||||
Measured uncompressed: **93 MiB** (zhongfly `mpv-dev-lgpl-x86_64`) vs **112 MiB**
|
||||
(shinchiro, full GPL build); ~26–30 MB compressed in the `.7z`.
|
||||
|
||||
**Ship the zhongfly LGPL build** — smaller, and there is no reason to pull the
|
||||
GPL variant in for an audio-only use.
|
||||
|
||||
Import-table inspection confirms **no companion DLLs are needed**: every
|
||||
dependency is a system DLL (`KERNEL32`, `USER32`, `d2d1`, `DWrite`, `OPENGL32`,
|
||||
`vulkan-1`, UCRT `api-ms-win-*`). One file to bundle.
|
||||
|
||||
93 MiB is still substantial against a Tauri app's usual few MB. Since we use mpv
|
||||
audio-only, investigate whether a pruned build (no video decoders, no libplacebo)
|
||||
is worth producing for the builder image — but treat that as an optimization,
|
||||
not a blocker.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Windows *video*. Stays in WebView2 + hls.js — it works and has ABR.
|
||||
- Crossfade (UR-031/DR-034) — not implemented anywhere; needs its own spec.
|
||||
- Replacing `WebviewAudioBackend` for macOS.
|
||||
- MPRIS/SMTC media-key integration — worth a follow-up, not this spec.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Windows build produces a `MpvBackend`-backed player; `backend-init-failed` is emitted (not a crash) if libmpv is unavailable.
|
||||
- [ ] Volume control works from the UI — the current hard gap.
|
||||
- [ ] EQ, normalization, and gapless audibly take effect on Windows.
|
||||
- [ ] Position/state originate in Rust; the `<audio>` element is no longer in the audio path.
|
||||
- [ ] Seek, next/previous, and queue advance work; sleep timer stops playback.
|
||||
- [ ] `libmpv-2.dll` ships in the NSIS installer and the app runs on a clean Windows VM with no mpv installed.
|
||||
- [ ] Builder image carries the Windows libmpv artefacts; **no toolchain install added to any CI step**.
|
||||
- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
||||
- [ ] New requirement-implementing code carries `// TRACES:` comments.
|
||||
|
||||
## Testing
|
||||
|
||||
**Rust**: the existing `mpv_backend_test.rs` and the `build_af_filter` /
|
||||
`normalize_filter_entry` / `eq_filter_entries` unit tests already cover the
|
||||
filter-graph logic and are platform-independent — they should pass unchanged
|
||||
under a Windows `cargo check`/test. Add a test asserting `detect_audio_system()`
|
||||
returns `wasapi` under `cfg(windows)`.
|
||||
|
||||
**Manual, on Windows**: volume, EQ preset change, normalization toggle, gapless
|
||||
between two tracks, seek, queue advance, sleep timer. Then the packaging test —
|
||||
install the NSIS output on a clean VM and confirm it launches and plays.
|
||||
|
||||
Per CLAUDE.md, the volume gap is a *bug fix*: write a failing test for
|
||||
"`set_volume` reaches the backend" before implementing.
|
||||
|
||||
## TRACES
|
||||
|
||||
- Windows `MpvBackend` construction in `create_player_backend` → `// TRACES: UR-003 | IR-030`
|
||||
- `detect_audio_system` Windows branch → `IR-030`
|
||||
- Existing `set_audio_settings` gains Windows coverage → `UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036`
|
||||
- Allocate **IR-030** in `requirements.md` ("libmpv integration for Windows audio playback").
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Do this **after** [libmpv2-migration.md](libmpv2-migration.md) — porting the
|
||||
current dead `libmpv` git pin to a second platform would double the migration
|
||||
work.
|
||||
- `libmpv2` has broken its API in every major release (4.0 removed command
|
||||
helpers, 5.0 removed `mpv_node`, 6.0 changed `RenderContext` ownership). Pin an
|
||||
exact version.
|
||||
- Only the `render`-feature parts of `libmpv2` concern video; audio-only use does
|
||||
not need it, and disabling the default `render` feature may shrink the build.
|
||||
- A parallel Claude session may be active — `git diff` first.
|
||||
+26
-12
@@ -43,14 +43,26 @@ Extracts all TRACES comments from:
|
||||
|
||||
### 2. Coverage Thresholds
|
||||
The workflow checks:
|
||||
- **Minimum overall coverage:** 50% (57+ requirements traced)
|
||||
- **Requirements by type:**
|
||||
- UR (User): 23+ of 39
|
||||
- IR (Integration): 5+ of 24
|
||||
- DR (Development): 28+ of 48
|
||||
- JA (Jellyfin API): 0+ of 3
|
||||
- **Minimum overall coverage:** 50%
|
||||
|
||||
If coverage drops below threshold, the workflow **fails** and blocks merge.
|
||||
Denominators are **derived from `docs/requirements.md` at run time** — they are
|
||||
never hardcoded here or in the workflow. Run `bun run traces:coverage` for the
|
||||
current per-type breakdown; any number written into this document is a snapshot
|
||||
that will drift.
|
||||
|
||||
> **Why this matters.** The workflow used to divide by frozen literals
|
||||
> (UR/39, IR/24, DR/48, JA/3, total 114) while `requirements.md` had grown past
|
||||
> 200. It reported **158%** coverage, so the 50% threshold was unreachable and
|
||||
> the job could not fail regardless of how far coverage dropped. See
|
||||
> [specs/traceability-gate-repair.md](specs/traceability-gate-repair.md).
|
||||
|
||||
Coverage is the *intersection* of traced and defined IDs: an ID that appears in
|
||||
a `TRACES:` comment but is not defined in `requirements.md` is reported as
|
||||
**orphaned** and does not count toward coverage. UT/IT test identifiers are a
|
||||
separate taxonomy and are excluded entirely.
|
||||
|
||||
The workflow **fails** and blocks merge if coverage drops below 50% — or if it
|
||||
computes above 100%, which can only mean the gate is miscounting.
|
||||
|
||||
### 3. Modified File Checking
|
||||
On pull requests, the workflow:
|
||||
@@ -153,11 +165,13 @@ cat docs/traceability.md
|
||||
## Coverage Goals
|
||||
|
||||
### Current Status
|
||||
- Overall: 51% (56/114)
|
||||
- UR: 59% (23/39)
|
||||
- IR: 21% (5/24)
|
||||
- DR: 58% (28/48)
|
||||
- JA: 0% (0/3)
|
||||
|
||||
Run `bun run traces:coverage` — it prints the live figure and exits non-zero
|
||||
below threshold. Numbers are deliberately not pinned here; the previous snapshot
|
||||
in this section (51%, 56/114) was stale by roughly 100 requirements and was what
|
||||
made the broken CI arithmetic look plausible for so long.
|
||||
|
||||
As of July 2026 overall coverage is ~86% (182/212).
|
||||
|
||||
### Targets
|
||||
- **Short term** (Sprint): Maintain ≥50% overall
|
||||
|
||||
+5781
-1779
File diff suppressed because it is too large
Load Diff
+192
-8
@@ -346,10 +346,12 @@ flowchart TB
|
||||
**User Interaction:**
|
||||
- **Tap screen:** Controls reappear for 3 seconds
|
||||
- **Double tap left side:** Rewind 10 seconds (shows animated feedback with "-10" indicator)
|
||||
- **Double tap right side:** Forward 10 seconds (shows animated feedback with "+10" indicator)
|
||||
- **Double tap right side:** Forward 30 seconds (shows animated feedback with "+30" indicator)
|
||||
- **Single tap play/pause is deferred** by the 300 ms double-tap window, so a double tap
|
||||
skips without also toggling pause (UR-061)
|
||||
- **Swipe up/down on left side:** Adjust brightness (0.3-1.7x, shows brightness indicator with progress bar)
|
||||
- **Swipe up/down on right side:** Adjust volume (0-100%, shows volume indicator with progress bar)
|
||||
- **Keyboard arrows:** ← rewind 10s, → forward 10s (desktop/external keyboard)
|
||||
- **Keyboard arrows:** ← rewind 10s, → forward 30s (desktop/external keyboard)
|
||||
- **Keyboard space/K:** Toggle play/pause
|
||||
- **Keyboard F:** Toggle fullscreen
|
||||
- **Pinch:** Zoom (planned)
|
||||
@@ -609,9 +611,12 @@ flowchart TB
|
||||
```
|
||||
|
||||
An episode is **never** browsed as a bare `Episode` item page. Clicking an
|
||||
episode anywhere navigates to `/library/<seriesId>?episode=<episodeId>`, so the
|
||||
episode is always shown in the context of its series and the series' full
|
||||
episode list is already loaded.
|
||||
episode anywhere — a series' season list, a Home carousel (§5B.5), etc. —
|
||||
navigates to `/library/<seriesId>?episode=<episodeId>`, so the episode is always
|
||||
shown in the context of its series and the series' full episode list is already
|
||||
loaded. Should an episode ever arrive without a `seriesId` (deep link, stale
|
||||
cache), the bare Episode page renders as a fallback and links back to its parent
|
||||
series and season by title so the user is never stranded.
|
||||
|
||||
### 5B.2 Episode Focus View — section order
|
||||
|
||||
@@ -629,7 +634,7 @@ episode strip.
|
||||
│ │ S2E4 • 48m • ★8.1 │ │
|
||||
│ │ Overview… │ │
|
||||
│ │ ▓▓▓▓▓░░░░░ 32m left │ │
|
||||
│ │ [▶ Play] │ │
|
||||
│ │ [▶ Play] [⬇] [♡] │ │
|
||||
│ └───────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ More Episodes │ ← 2. EPISODE STRIP
|
||||
@@ -683,10 +688,10 @@ A movie has no continuation set, so cast follows the hero directly.
|
||||
### 5B.4 Series detail — section order
|
||||
|
||||
```
|
||||
Hero (poster, title, metadata, Play / Download)
|
||||
Hero (poster, title, metadata, Resume SxEy / Download / Favorite / Clear history)
|
||||
→ Crew links
|
||||
→ Genre tags
|
||||
→ Seasons + episodes (per-season sections)
|
||||
→ Seasons (collapsible; only the current season expanded)
|
||||
→ Cast
|
||||
→ More Like This
|
||||
```
|
||||
@@ -695,6 +700,185 @@ The same principle as §5B.2: **episodes come before cast and similar shows.**
|
||||
The reason a user opens a series page is to pick an episode; discovery content
|
||||
is secondary and sits underneath.
|
||||
|
||||
**Rules for the seasons block** *(UR-062, UR-064)*:
|
||||
|
||||
- **The page opens where the viewer is.** The backend resolves the current
|
||||
episode — in progress, else Next Up, else first unwatched, else the premiere —
|
||||
and the page scrolls it into view with an `Up next` badge and a highlight ring.
|
||||
Never season 1 by default, unless season 1 *is* where the viewer is.
|
||||
- **Seasons collapse; only the current one is expanded.** A ten-season show
|
||||
otherwise renders hundreds of rows and buries the episode the viewer came for.
|
||||
A collapsed season still names its episode count and watched count, so
|
||||
progress is readable without expanding it.
|
||||
- **The hero button opens, it does not play.** It reads `Resume S2E4` /
|
||||
`Play S1E1` — naming its target — and navigates to that episode's Focus View,
|
||||
where Play commits. Play on a *container* is navigation (§5B.5); Play on a
|
||||
*leaf* is the commitment.
|
||||
- **A season is never its own page.** `/library/<seasonId>` redirects to
|
||||
`/library/<seriesId>#season-N`. Every affordance that names a season — the
|
||||
episode breadcrumb, a season card in a grid, a Downloads drill-in — lands on
|
||||
the series with that season in view, so the episodes of all seasons stay one
|
||||
browsable list.
|
||||
- **Watch history is erasable** per series (hero) and per season (season
|
||||
header). It confirms first, cannot be undone, and needs the server. Clearing a
|
||||
whole series returns it to S1E1 by the same path a never-watched show takes.
|
||||
|
||||
### 5B.5 Home-card interaction — tap opens, long-press plays
|
||||
|
||||
Cards on the Home screen carousels (Next Movie, Next Episode, Continue
|
||||
Watching, Recently Added, …) **do not play on tap.** A plain tap opens the
|
||||
item; playback is the deliberate, second gesture.
|
||||
|
||||
| Card kind | Tap (short) | Long-press (~500 ms hold) |
|
||||
|-----------|-------------|---------------------------|
|
||||
| Movie | Movie detail page (`/library/<id>`) | Confirm → play now (`/player/<id>`) |
|
||||
| Episode | Series Episode Focus View (`/library/<seriesId>?episode=<id>`, per §5B.1) | Confirm → play now (`/player/<id>`) |
|
||||
| Series / Season / Album / Artist / Playlist / Folder | Detail page (`/library/<id>`) | Same as tap (no single "play now" target) |
|
||||
| Channel / live leaf | Player (`/player/<id>`) — no detail page exists | Confirm → play now |
|
||||
|
||||
Rationale and rules:
|
||||
|
||||
- **Tap is navigation, not commitment.** Previously a tap on a movie/episode
|
||||
jumped straight into the player, which made it easy to lose your place in a
|
||||
half-watched item or start a stream you only meant to inspect. Tap now lands
|
||||
on the detail/focus page, where Play is an explicit button.
|
||||
- **Long-press is the shortcut for "just play it."** It surfaces a native
|
||||
confirm (`Play "<name>" now?`) before starting playback, so an accidental
|
||||
hold never blows away a resume position silently.
|
||||
- **The long-press must not fight the carousel.** Detection cancels if the
|
||||
pointer moves more than ~10 px (a horizontal scroll of the row), so holding
|
||||
to scroll never triggers play.
|
||||
- **Episodes still obey §5B.1** — a home tap on an episode opens the series
|
||||
Focus View, never a bare Episode page, so the series context loads.
|
||||
|
||||
This behavior lives in `MediaCard` (`onLongPress` prop + pointer-based
|
||||
detection) so any surface can opt in; today the Home carousels are the only
|
||||
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
|
||||
|
||||
+10
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellytau",
|
||||
"version": "0.0.16",
|
||||
"version": "0.4.8",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.5",
|
||||
@@ -20,16 +20,25 @@
|
||||
"check:boundary": "bash scripts/check-frontend-boundary.sh",
|
||||
"android:build": "./scripts/build-android.sh",
|
||||
"android:build:release": "./scripts/build-android.sh release",
|
||||
"android:build:device": "./scripts/build-android.sh --device",
|
||||
"android:build:release:device": "./scripts/build-android.sh release --device",
|
||||
"android:build:clean": "rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target && bun install && bun run build",
|
||||
"android:deploy": "./scripts/deploy-android.sh",
|
||||
"android:dev": "./scripts/build-and-deploy.sh",
|
||||
"android:check": "./scripts/check-android.sh",
|
||||
"android:logs": "./scripts/logcat.sh",
|
||||
"desktop:build:linux": "./scripts/build-desktop-linux.sh",
|
||||
"desktop:build:arch": "./scripts/build-arch.sh",
|
||||
"desktop:build:windows": "./scripts/build-windows-cross.sh",
|
||||
"docker:build:linux": "docker compose run --rm desktop-linux-build",
|
||||
"docker:build:arch": "docker compose run --rm arch-build",
|
||||
"docker:build:windows": "docker compose run --rm windows-cross",
|
||||
"clean": "./scripts/clean.sh",
|
||||
"tauri": "tauri",
|
||||
"traces": "bun run scripts/extract-traces.ts",
|
||||
"traces:json": "bun run scripts/extract-traces.ts --format json",
|
||||
"traces:markdown": "bun run scripts/extract-traces.ts --format markdown > docs/traceability.md",
|
||||
"traces:coverage": "bun run scripts/extract-traces.ts --format coverage",
|
||||
"release:notes": "bun run scripts/release-notes.ts"
|
||||
},
|
||||
"license": "MIT",
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Maintainer: Duncan Tourolle <duncan@tourolle.paris>
|
||||
#
|
||||
# JellyTau — a cross-platform Jellyfin client (Tauri + SvelteKit).
|
||||
#
|
||||
# This PKGBUILD builds from the local source tree by default (see the `dev`
|
||||
# convenience below), which is what scripts/build-arch.sh uses inside the Arch
|
||||
# Docker stage. For AUR distribution, replace the `source=()` line with a release
|
||||
# tarball/VCS URL and drop the local-copy prepare() step.
|
||||
|
||||
pkgname=jellytau
|
||||
pkgver=0.0.18
|
||||
pkgrel=1
|
||||
pkgdesc="A cross-platform Jellyfin client"
|
||||
arch=('x86_64')
|
||||
url="https://gitea.tourolle.paris/dtourolle/jellytau"
|
||||
license=('MIT')
|
||||
# Runtime: libmpv for audio, webkit2gtk for the webview + HTML5 transcoded video.
|
||||
depends=('webkit2gtk-4.1' 'mpv' 'gtk3' 'libayatana-appindicator')
|
||||
makedepends=('rust' 'cargo' 'bun' 'nodejs' 'pkgconf' 'libsoup3')
|
||||
options=('!strip' '!lto')
|
||||
|
||||
# Populated from the working tree by scripts/build-arch.sh (SRC env var).
|
||||
_srcdir="${JELLYTAU_SRC:-$startdir/../..}"
|
||||
|
||||
build() {
|
||||
cd "$_srcdir"
|
||||
export CARGO_HOME="${CARGO_HOME:-$srcdir/cargo-home}"
|
||||
bun install --frozen-lockfile || bun install
|
||||
bun run build
|
||||
# Only the raw binary is needed; packaging is done in package() below so we
|
||||
# control the Arch filesystem layout ourselves rather than via tauri-bundler.
|
||||
(cd src-tauri && cargo build --release --locked)
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$_srcdir"
|
||||
|
||||
install -Dm755 "src-tauri/target/release/jellytau" \
|
||||
"$pkgdir/usr/bin/jellytau"
|
||||
|
||||
# Desktop entry
|
||||
install -Dm644 "packaging/arch/jellytau.desktop" \
|
||||
"$pkgdir/usr/share/applications/jellytau.desktop"
|
||||
|
||||
# Icons (hicolor)
|
||||
install -Dm644 "src-tauri/icons/32x32.png" \
|
||||
"$pkgdir/usr/share/icons/hicolor/32x32/apps/jellytau.png"
|
||||
install -Dm644 "src-tauri/icons/128x128.png" \
|
||||
"$pkgdir/usr/share/icons/hicolor/128x128/apps/jellytau.png"
|
||||
install -Dm644 "src-tauri/icons/128x128@2x.png" \
|
||||
"$pkgdir/usr/share/icons/hicolor/256x256/apps/jellytau.png"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=JellyTau
|
||||
Comment=A cross-platform Jellyfin client
|
||||
Exec=jellytau
|
||||
Icon=jellytau
|
||||
Terminal=false
|
||||
Categories=AudioVideo;Player;Audio;Video;
|
||||
StartupWMClass=jellytau
|
||||
+17
-1
@@ -69,13 +69,29 @@ Extract requirement IDs (TRACES) from source code and generate a traceability ma
|
||||
bun run traces # Generate markdown report
|
||||
bun run traces:json # Generate JSON report
|
||||
bun run traces:markdown # Save to docs/traceability.md
|
||||
bun run traces:coverage # Coverage gate — exits non-zero below 50%
|
||||
```
|
||||
|
||||
The script scans all TypeScript, Svelte, and Rust files looking for `TRACES:` comments and generates a comprehensive mapping of:
|
||||
The script scans all TypeScript, Svelte, and Rust files (plus `scripts/`)
|
||||
looking for `TRACES:` comments and generates a comprehensive mapping of:
|
||||
- Which code files implement which requirements
|
||||
- Line numbers and code context
|
||||
- Coverage summary by requirement type (UR, IR, DR, JA)
|
||||
|
||||
**`bun run traces:coverage` is the supported way to check requirement coverage
|
||||
locally** — it runs the same computation CI does. Coverage denominators are
|
||||
derived from `docs/requirements.md` at run time; they are never hardcoded. An ID
|
||||
that appears in a `TRACES:` comment but is not defined in `requirements.md` is
|
||||
reported as *orphaned* and does not count toward coverage (see DR-093).
|
||||
|
||||
> **Removed:** `check-req-coverage.sh`, `check-test-coverage.sh`, and
|
||||
> `find-req-implementations.sh` were deleted in July 2026. They read an
|
||||
> undocumented `@req:` tag convention parallel to `TRACES:`, grepped `src-tauri/`
|
||||
> unscoped (hanging on ~40 GB of `target/` artifacts), and in one case reported
|
||||
> "all requirements implemented" from an empty result set. `extract-traces.ts` is
|
||||
> the single source of truth for requirement coverage. See
|
||||
> [docs/specs/req-coverage-script-removal.md](../docs/specs/req-coverage-script-removal.md).
|
||||
|
||||
Example TRACES comment in code:
|
||||
```typescript
|
||||
// TRACES: UR-005, UR-026 | DR-029
|
||||
|
||||
@@ -18,15 +18,50 @@ echo ""
|
||||
# Parse args: build type (debug/release) and optional --clean flag.
|
||||
# By default the build is INCREMENTAL — Cargo and Vite reuse their caches.
|
||||
# Pass --clean (or CLEAN=1) to wipe all caches for a from-scratch build.
|
||||
#
|
||||
# ABI selection: by default Tauri builds all four ABIs (arm64/arm/x86/x86_64),
|
||||
# which is what a distributable universal APK needs — but for an on-device test
|
||||
# it means three wasted Rust compiles. Pass --device (or ABI=aarch64) to build
|
||||
# only the connected device's architecture; --abi <t> targets one explicitly.
|
||||
BUILD_TYPE="debug"
|
||||
CLEAN="${CLEAN:-0}"
|
||||
ABI="${ABI:-}"
|
||||
next_is_abi=0
|
||||
for arg in "$@"; do
|
||||
if [ "$next_is_abi" = "1" ]; then
|
||||
ABI="$arg"
|
||||
next_is_abi=0
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
--clean) CLEAN=1 ;;
|
||||
--abi) next_is_abi=1 ;;
|
||||
--device) ABI="device" ;;
|
||||
debug|release) BUILD_TYPE="$arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Resolve --device to the attached device's Rust target triple.
|
||||
if [ "$ABI" = "device" ]; then
|
||||
device_abi="$(adb shell getprop ro.product.cpu.abi 2>/dev/null | tr -d '\r\n')"
|
||||
case "$device_abi" in
|
||||
arm64-v8a) ABI="aarch64" ;;
|
||||
armeabi-v7a) ABI="armv7" ;;
|
||||
x86_64) ABI="x86_64" ;;
|
||||
x86) ABI="i686" ;;
|
||||
*)
|
||||
echo "⚠️ Could not detect device ABI (got '${device_abi:-none}') — building all targets."
|
||||
ABI=""
|
||||
;;
|
||||
esac
|
||||
[ -n "$ABI" ] && echo "🎯 Device ABI $device_abi → building only '$ABI'"
|
||||
fi
|
||||
|
||||
TARGET_ARGS=()
|
||||
if [ -n "$ABI" ]; then
|
||||
TARGET_ARGS=(--target "$ABI")
|
||||
fi
|
||||
|
||||
# Step 0: Optionally clear build caches for a fully fresh build.
|
||||
if [ "$CLEAN" = "1" ]; then
|
||||
echo "🧹 Clearing build caches (clean build)..."
|
||||
@@ -48,10 +83,10 @@ if [ "$BUILD_TYPE" = "release" ]; then
|
||||
# after sync-android-sources.sh, since gen/android is (re)generated there.
|
||||
./scripts/write-keystore-properties.sh
|
||||
echo "📦 Building release APK..."
|
||||
bun run tauri android build --apk true
|
||||
bun run tauri android build --apk true "${TARGET_ARGS[@]}"
|
||||
else
|
||||
echo "📦 Building debug APK..."
|
||||
bun run tauri android build --apk true --debug
|
||||
bun run tauri android build --apk true --debug "${TARGET_ARGS[@]}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
# Build an Arch Linux package (.pkg.tar.zst) for JellyTau via makepkg.
|
||||
#
|
||||
# Tauri's bundler has no pacman target (as of tauri-cli 2.9.x), so we ship a
|
||||
# hand-written PKGBUILD in packaging/arch/ and build it with makepkg. This must
|
||||
# run on an Arch host / the `arch-build` Docker stage — makepkg is Arch-specific
|
||||
# and refuses to run as root, so run it as a non-root user with sudo for deps.
|
||||
#
|
||||
# Usage (typically inside the arch-build Docker stage as a non-root user):
|
||||
# scripts/build-arch.sh
|
||||
# OUTPUT_DIR=/app/dist scripts/build-arch.sh
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$REPO_ROOT/packaging/arch"
|
||||
|
||||
echo "🏛️ Building JellyTau Arch package"
|
||||
echo "=================================="
|
||||
|
||||
# Point the PKGBUILD at the working tree and give cargo/bun a writable home.
|
||||
export JELLYTAU_SRC="$REPO_ROOT"
|
||||
export CARGO_HOME="${CARGO_HOME:-$REPO_ROOT/.cargo-arch}"
|
||||
|
||||
# -s installs missing deps (needs sudo/root privileges for pacman), -f overwrites.
|
||||
makepkg -sf --noconfirm
|
||||
|
||||
echo ""
|
||||
echo "✅ Built Arch package(s):"
|
||||
ls -1 ./*.pkg.tar.zst
|
||||
|
||||
if [[ -n "${OUTPUT_DIR:-}" ]]; then
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
cp -v ./*.pkg.tar.zst "$OUTPUT_DIR/"
|
||||
echo ""
|
||||
echo "📦 Copied Arch package(s) to $OUTPUT_DIR"
|
||||
fi
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/bin/bash
|
||||
# Build Linux desktop packages (deb + rpm) for JellyTau.
|
||||
#
|
||||
# Produces bundles under src-tauri/target/release/bundle/{deb,rpm}.
|
||||
# Runs on the existing Ubuntu builder image. NOTE: Tauri has no pacman bundle
|
||||
# target — the Arch package is built separately with makepkg (scripts/build-arch.sh
|
||||
# / Dockerfile.arch). `appimage` is also available if you want a portable bundle.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/build-desktop-linux.sh # deb + rpm
|
||||
# BUNDLES="deb,appimage" scripts/build-desktop-linux.sh # subset / add appimage
|
||||
# OUTPUT_DIR=/app/dist scripts/build-desktop-linux.sh # copy bundles out
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
BUNDLES="${BUNDLES:-deb,rpm}"
|
||||
|
||||
echo "🐧 Building JellyTau Linux desktop packages"
|
||||
echo "==========================================="
|
||||
echo "Bundles: $BUNDLES"
|
||||
echo ""
|
||||
|
||||
bun install --frozen-lockfile 2>/dev/null || bun install
|
||||
bun run build
|
||||
|
||||
# --bundles overrides tauri.conf.json bundle.targets so this script controls
|
||||
# exactly which Linux formats are produced (never NSIS here).
|
||||
bun run tauri build --bundles "$BUNDLES"
|
||||
|
||||
BUNDLE_ROOT="src-tauri/target/release/bundle"
|
||||
echo ""
|
||||
echo "✅ Built packages:"
|
||||
find "$BUNDLE_ROOT" -maxdepth 2 -type f \
|
||||
\( -name '*.deb' -o -name '*.rpm' -o -name '*.AppImage' \) -print
|
||||
|
||||
if [[ -n "${OUTPUT_DIR:-}" ]]; then
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
find "$BUNDLE_ROOT" -maxdepth 2 -type f \
|
||||
\( -name '*.deb' -o -name '*.rpm' -o -name '*.AppImage' \) \
|
||||
-exec cp -v {} "$OUTPUT_DIR/" \;
|
||||
echo ""
|
||||
echo "📦 Copied bundles to $OUTPUT_DIR"
|
||||
fi
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
#!/bin/bash
|
||||
# Cross-compile JellyTau for Windows from Linux, producing an NSIS installer.
|
||||
#
|
||||
# Uses the OFFICIAL Tauri cross-compile path (https://v2.tauri.app/distribute/
|
||||
# windows-installer/): the MSVC target driven by cargo-xwin, which downloads the
|
||||
# MSVC CRT/Windows SDK headers and links with lld. This is the target Tauri
|
||||
# officially supports for Windows (the mingw/GNU target is not), and unlike GNU
|
||||
# it can bundle the NSIS installer from a Linux host.
|
||||
#
|
||||
# Playback on Windows: video renders via WebView2 and audio via the webview
|
||||
# <audio> backend (WebviewAudioBackend) — see docs/build-windows.md.
|
||||
#
|
||||
# Requirements (present in the Docker windows-cross target / unified builder):
|
||||
# - rustup target x86_64-pc-windows-msvc
|
||||
# - cargo-xwin (cargo install --locked cargo-xwin)
|
||||
# - lld, llvm (linker + llvm-lib used by cargo-xwin)
|
||||
# - nsis (makensis) (installer generator)
|
||||
#
|
||||
# Usage:
|
||||
# scripts/build-windows-cross.sh # exe + NSIS installer
|
||||
# WIN_BUNDLES=none scripts/build-windows-cross.sh # exe only, skip bundling
|
||||
# OUTPUT_DIR=/app/dist scripts/build-windows-cross.sh
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
TARGET="x86_64-pc-windows-msvc"
|
||||
WIN_BUNDLES="${WIN_BUNDLES:-nsis}"
|
||||
|
||||
echo "🪟 Cross-compiling JellyTau for Windows ($TARGET, via cargo-xwin)"
|
||||
echo "================================================================"
|
||||
echo "Video plays via WebView2; audio via the webview <audio> backend."
|
||||
echo "Bundles: $WIN_BUNDLES"
|
||||
echo ""
|
||||
|
||||
bun install --frozen-lockfile 2>/dev/null || bun install
|
||||
bun run build
|
||||
|
||||
# --runner cargo-xwin + the MSVC target is what makes the Tauri CLI treat this as
|
||||
# a real Windows build and enable the nsis/msi bundlers on a Linux host.
|
||||
#
|
||||
# IMPORTANT: do NOT pass `--bundles nsis` here. tauri-cli 2.9.x validates the
|
||||
# `--bundles` flag against a static clap enum gated by the HOST OS (Linux allows
|
||||
# only deb/rpm/appimage) *before* it considers --target/--runner, so `--bundles
|
||||
# nsis` is rejected at arg-parse time. Instead the Windows bundle targets come
|
||||
# from tauri.conf.json (bundle.targets includes "nsis"), which is not subject to
|
||||
# that CLI validation — the bundler then picks nsis once it knows the target is
|
||||
# Windows.
|
||||
if [[ "$WIN_BUNDLES" == "none" ]]; then
|
||||
bun run tauri build --runner cargo-xwin --target "$TARGET" --no-bundle
|
||||
else
|
||||
bun run tauri build --runner cargo-xwin --target "$TARGET"
|
||||
fi
|
||||
|
||||
BIN_DIR="src-tauri/target/$TARGET/release"
|
||||
echo ""
|
||||
echo "✅ Built Windows artifacts:"
|
||||
find "$BIN_DIR" -maxdepth 1 -name '*.exe' -print
|
||||
find "$BIN_DIR/bundle" -type f \( -name '*.exe' -o -name '*.msi' \) -print 2>/dev/null || true
|
||||
|
||||
if [[ -n "${OUTPUT_DIR:-}" ]]; then
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
find "$BIN_DIR" -maxdepth 1 -name 'jellytau.exe' -exec cp -v {} "$OUTPUT_DIR/" \;
|
||||
# NSIS setup installers land in bundle/nsis/*-setup.exe; MSI in bundle/msi/*.msi.
|
||||
find "$BIN_DIR/bundle" -type f \( -name '*-setup.exe' -o -name '*.msi' \) \
|
||||
-exec cp -v {} "$OUTPUT_DIR/" \; 2>/dev/null || true
|
||||
echo ""
|
||||
echo "📦 Copied Windows artifacts to $OUTPUT_DIR"
|
||||
fi
|
||||
@@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Boundary tripwire: flag domain-taxonomy leaks in the Svelte frontend.
|
||||
#
|
||||
# Implements DR-094 (see docs/requirements.md).
|
||||
#
|
||||
# The project rule (CLAUDE.md, docs/architecture/02-svelte-frontend.md) is that
|
||||
# the frontend is presentation-only and the Rust backend owns domain logic —
|
||||
# including Jellyfin's item-type *taxonomy* (what the category "Music" means as a
|
||||
@@ -9,18 +11,29 @@
|
||||
#
|
||||
# ⚠️ This is a TRIPWIRE, NOT A PROOF. A grep cannot distinguish taxonomy-as-policy
|
||||
# (a leak) from taxonomy-as-display (legitimate: "is this a music card?"). It
|
||||
# targets the one machine-detectable signature of the leak class — a *query* that
|
||||
# names a multi-type category — and defers everything subtler to the human
|
||||
# spec-review checklist (docs/specs/SPEC-REVIEW-CHECKLIST.md). A clean run here
|
||||
# does not mean the boundary is respected; it means the crudest violation isn't
|
||||
# present.
|
||||
# targets the machine-detectable signature of the leak class and defers
|
||||
# everything subtler to the human spec-review checklist
|
||||
# (docs/specs/SPEC-REVIEW-CHECKLIST.md). A clean run here does not mean the
|
||||
# boundary is respected; it means the crudest violation isn't present.
|
||||
#
|
||||
# What it flags: an `includeItemTypes: [ ... , ... ]` array literal with two or
|
||||
# more types — i.e. the frontend deciding that a *category* maps to a *set* of
|
||||
# Jellyfin types, which is domain knowledge the backend should own. Single-type
|
||||
# query arrays (`includeItemTypes: ["Movie"]`) are a page saying "I show movies"
|
||||
# and are allowed. Type *inspection* (`item.type === "Audio"`) is display logic
|
||||
# and is not matched.
|
||||
# What it flags: an array literal naming two or more Jellyfin item types,
|
||||
# ANYWHERE in src/ — i.e. the frontend deciding that a *category* maps to a *set*
|
||||
# of Jellyfin types, which is domain knowledge the backend should own.
|
||||
# Single-type arrays (`includeItemTypes: ["Movie"]`) are a page saying "I show
|
||||
# movies" and are allowed. Type *inspection* (`item.type === "Audio"`) is display
|
||||
# logic and is not matched.
|
||||
#
|
||||
# 🔴 What it still CANNOT see (do not read a green run as proof):
|
||||
# - a type set built at run time: [...musicTypes, "Playlist"]
|
||||
# - types split across variables: const A = "Audio"; [A, B]
|
||||
# - taxonomy as control flow: switch (t) { case "Audio": … }
|
||||
# t === "Audio" || t === "MusicAlbum"
|
||||
# - an item type absent from ITEM_TYPES below (false negative by design)
|
||||
#
|
||||
# This check was hardened in July 2026 after the audit found it passing on the
|
||||
# very leak it was written for: the original pattern was anchored to
|
||||
# `includeItemTypes:` at the query site, so assigning the same array to a named
|
||||
# const evaded it entirely. See docs/specs/boundary-tripwire-hardening.md (DR-094).
|
||||
#
|
||||
# Escaping a genuine exception: add the file+reason to the ALLOWLIST below.
|
||||
|
||||
@@ -28,7 +41,7 @@ set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Files permitted to contain a multi-type includeItemTypes query, with the reason.
|
||||
# Files permitted to contain a multi-type item-type array, with the reason.
|
||||
# Keep this SHORT. A growing allowlist means the boundary is eroding — that is a
|
||||
# signal to push taxonomy into Rust, not to keep appending here.
|
||||
ALLOWLIST=(
|
||||
@@ -36,8 +49,31 @@ ALLOWLIST=(
|
||||
# two-type filmography query with no category-configuration behind it. Tracked
|
||||
# as acceptable pending any person-scope work; revisit if it grows.
|
||||
"src/lib/components/library/PersonDetailView.svelte"
|
||||
|
||||
# Grid styling predicate over `config.itemType`, a value the page already
|
||||
# declares about itself. Selects a *look*, issues no query, and would only
|
||||
# change if the UI were redesigned — presentation, not taxonomy-as-policy.
|
||||
"src/lib/components/library/GenericMediaListPage.svelte"
|
||||
|
||||
# "Is this item a container?" predicate for downloads browsing.
|
||||
# BORDERLINE — leans domain: the container set grows when Jellyfin adds a
|
||||
# container type. TODO: replace with a backend-supplied `MediaItem.isContainer`
|
||||
# flag and remove this entry. Tracked in
|
||||
# docs/specs/boundary-tripwire-hardening.md §Out of scope.
|
||||
"src/lib/components/downloads/DownloadedBrowse.svelte"
|
||||
)
|
||||
|
||||
# Hard cap so erosion is caught mechanically rather than by whoever notices.
|
||||
# Deliberately just above the current count: the next exception forces a
|
||||
# conversation instead of a one-line append.
|
||||
MAX_ALLOWLIST=4
|
||||
|
||||
if [[ "${#ALLOWLIST[@]}" -gt "$MAX_ALLOWLIST" ]]; then
|
||||
echo "❌ Allowlist has ${#ALLOWLIST[@]} entries (max $MAX_ALLOWLIST)."
|
||||
echo " Push taxonomy into Rust instead of appending here."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
is_allowed() {
|
||||
local file="$1"
|
||||
for allowed in "${ALLOWLIST[@]}"; do
|
||||
@@ -46,11 +82,28 @@ is_allowed() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Multi-element includeItemTypes array: `includeItemTypes: [ <x> , <y> ... ]`.
|
||||
# The comma inside the brackets is what makes it multi-type.
|
||||
PATTERN='includeItemTypes:[[:space:]]*\[[^]]*,[^]]*\]'
|
||||
# Two or more adjacent Jellyfin item-type string literals inside a bracket.
|
||||
#
|
||||
# NOT anchored to `includeItemTypes:` — that was the original rule, and it missed
|
||||
# the real leak: `searchScope.ts` assigned the same array to a named const and
|
||||
# dereferenced it one indirection away from the query, so the grep never saw it
|
||||
# while CI stayed green. Matching the array literal itself catches a const, a
|
||||
# Record value, a function return, and an inline query alike.
|
||||
#
|
||||
# Deliberate limits:
|
||||
# - requires TWO adjacent types, so single-type presentation
|
||||
# (`itemType: "Movie"`) stays legal — the rule targets *category* taxonomy;
|
||||
# - requires string literals, so `item.type === "Audio"` (display inspection)
|
||||
# does not match;
|
||||
# - uses an explicit type list rather than a generic capitalised-word pattern,
|
||||
# so unrelated string arrays (`["High","Low"]`) produce no noise.
|
||||
#
|
||||
# An item type missing from this list is a false *negative*, never a false
|
||||
# positive — the check degrades safely as Jellyfin adds types.
|
||||
ITEM_TYPES='Movie|Series|Episode|Audio|MusicAlbum|MusicArtist|MusicVideo|Season|BoxSet|Playlist|Book|AudioBook|Video|Person|Folder|CollectionFolder|TvChannel|LiveTvChannel'
|
||||
PATTERN="\[[[:space:]]*\"($ITEM_TYPES)\"[[:space:]]*,[[:space:]]*\"($ITEM_TYPES)\""
|
||||
|
||||
echo "🔎 Checking frontend for domain-taxonomy leaks (multi-type query arrays)…"
|
||||
echo "🔎 Checking frontend for domain-taxonomy leaks (item-type array literals)…"
|
||||
|
||||
# Collect hits, excluding tests and the allowlist.
|
||||
violations=""
|
||||
@@ -69,9 +122,11 @@ done < <(grep -rInE "$PATTERN" src/ 2>/dev/null || true)
|
||||
|
||||
if [[ -n "$violations" ]]; then
|
||||
echo ""
|
||||
echo "❌ Frontend boundary violation: a multi-type includeItemTypes query defines"
|
||||
echo " a category in the presentation layer. That taxonomy belongs in Rust —"
|
||||
echo " send an opaque scope and let the backend expand it to item types."
|
||||
echo "❌ Frontend boundary violation: an item-type array literal defines a"
|
||||
echo " category in the presentation layer. That taxonomy belongs in Rust —"
|
||||
echo " send an opaque scope/enum and let the backend expand it to item types"
|
||||
echo " (see SearchScope::item_types() in src-tauri/src/repository/types.rs)."
|
||||
echo " Assigning the array to a const does not make it presentation."
|
||||
echo " See docs/specs/scoped-search-boundary.md and CLAUDE.md."
|
||||
echo ""
|
||||
echo "$violations" | sed 's/^/ /'
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Requirements Coverage Checker
|
||||
# Extracts @req tags from codebase and compares with README.md
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
REQUIREMENTS_FILE="README.md"
|
||||
SOURCE_DIRS="src-tauri/ src/"
|
||||
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo " Requirements Coverage Report"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
|
||||
# Extract requirement IDs from README.md (UR-, IR-, DR-, JA-)
|
||||
echo "📊 Scanning requirements from $REQUIREMENTS_FILE..."
|
||||
requirements=$(grep -E "^\| (UR|IR|DR|JA)-[0-9]+" "$REQUIREMENTS_FILE" | \
|
||||
sed -E 's/^\| ([A-Z]+-[0-9]+).*/\1/' | \
|
||||
sort -u)
|
||||
|
||||
total_reqs=$(echo "$requirements" | wc -l)
|
||||
implemented=0
|
||||
partial=0
|
||||
planned=0
|
||||
missing=0
|
||||
|
||||
echo ""
|
||||
echo "Category Breakdown:"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
for category in UR IR DR JA; do
|
||||
cat_count=$(echo "$requirements" | grep "^$category-" | wc -l)
|
||||
printf "%-4s %3d requirements\n" "$category:" "$cat_count"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Implementation Status:"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
for req in $requirements; do
|
||||
# Count full implementations
|
||||
full_count=$(grep -r "@req: $req" $SOURCE_DIRS 2>/dev/null | grep -v "@req-partial" | grep -v "@req-planned" | wc -l)
|
||||
|
||||
# Count partial implementations
|
||||
partial_count=$(grep -r "@req-partial: $req" $SOURCE_DIRS 2>/dev/null | wc -l)
|
||||
|
||||
# Count planned
|
||||
planned_count=$(grep -r "@req-planned: $req" $SOURCE_DIRS 2>/dev/null | wc -l)
|
||||
|
||||
if [ "$full_count" -gt 0 ]; then
|
||||
echo "✅ $req: $full_count implementation(s)"
|
||||
((implemented++))
|
||||
elif [ "$partial_count" -gt 0 ]; then
|
||||
echo "🔶 $req: $partial_count partial implementation(s)"
|
||||
((partial++))
|
||||
elif [ "$planned_count" -gt 0 ]; then
|
||||
echo "📋 $req: Planned (not yet implemented)"
|
||||
((planned++))
|
||||
else
|
||||
echo "❌ $req: No implementation found"
|
||||
((missing++))
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Summary:"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
printf "Total Requirements: %3d\n" "$total_reqs"
|
||||
printf "✅ Fully Implemented: %3d (%.0f%%)\n" "$implemented" "$(echo "scale=0; $implemented * 100 / $total_reqs" | bc)"
|
||||
printf "🔶 Partially Implemented: %3d (%.0f%%)\n" "$partial" "$(echo "scale=0; $partial * 100 / $total_reqs" | bc)"
|
||||
printf "📋 Planned: %3d (%.0f%%)\n" "$planned" "$(echo "scale=0; $planned * 100 / $total_reqs" | bc)"
|
||||
printf "❌ Missing: %3d (%.0f%%)\n" "$missing" "$(echo "scale=0; $missing * 100 / $total_reqs" | bc)"
|
||||
echo ""
|
||||
|
||||
# Exit code based on missing critical requirements
|
||||
if [ "$missing" -gt 0 ]; then
|
||||
echo "⚠️ Warning: $missing requirements have no implementation"
|
||||
exit 1
|
||||
else
|
||||
echo "✨ All requirements have implementations!"
|
||||
exit 0
|
||||
fi
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Test Coverage Report
|
||||
# Links test requirements to implementations
|
||||
#
|
||||
|
||||
echo "Test Coverage Report"
|
||||
echo "===================="
|
||||
echo ""
|
||||
|
||||
test_reqs=$(grep -rh "@req-test:" src-tauri/ 2>/dev/null | \
|
||||
sed 's/.*@req-test: \([A-Z][A-Z]-[0-9]*\).*/\1/' | \
|
||||
sort -u)
|
||||
|
||||
total_tests=0
|
||||
covered=0
|
||||
uncovered=0
|
||||
|
||||
for req in $test_reqs; do
|
||||
test_count=$(grep -r "@req-test: $req" src-tauri/ 2>/dev/null | wc -l)
|
||||
impl_count=$(grep -r "@req: $req" src-tauri/ src/ 2>/dev/null | wc -l)
|
||||
|
||||
((total_tests++))
|
||||
|
||||
if [ "$test_count" -gt 0 ] && [ "$impl_count" -gt 0 ]; then
|
||||
echo "✅ $req: $test_count test(s), $impl_count implementation(s)"
|
||||
((covered++))
|
||||
elif [ "$impl_count" -eq 0 ]; then
|
||||
echo "⚠️ $req: $test_count test(s) but no implementation"
|
||||
((uncovered++))
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Summary:"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
printf "Total Test Requirements: %3d\n" "$total_tests"
|
||||
printf "✅ With Implementation: %3d (%.0f%%)\n" "$covered" "$(echo "scale=0; $covered * 100 / $total_tests" | bc)"
|
||||
printf "⚠️ No Implementation: %3d (%.0f%%)\n" "$uncovered" "$(echo "scale=0; $uncovered * 100 / $total_tests" | bc)"
|
||||
echo ""
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Tests for the traceability coverage computation.
|
||||
*
|
||||
* These run over fixture strings rather than the live docs/requirements.md, so
|
||||
* their meaning does not drift as requirements are added.
|
||||
*
|
||||
* Background: the CI gate divided traced-requirement counts by hardcoded
|
||||
* denominators (UR/39, IR/24, DR/48, JA/3, total 114) that had fallen out of
|
||||
* date, reporting 158% coverage and making the 50% threshold unreachable. These
|
||||
* tests pin the parsing and arithmetic that replace those literals.
|
||||
*
|
||||
* @req-test: UT-089 - Requirement definitions parsed from requirements.md
|
||||
* @req-test: UT-090 - Coverage is the intersection of traced and defined IDs
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { countDefinedRequirements, computeCoverage } from "./extract-traces";
|
||||
|
||||
describe("countDefinedRequirements", () => {
|
||||
it("counts a well-formed table row as a defined requirement", () => {
|
||||
const md = `
|
||||
| ID | Requirement | Priority | Status |
|
||||
|----|-------------|----------|--------|
|
||||
| UR-001 | Run the app on multiple platforms | High | In Progress |
|
||||
| UR-002 | Access media when online or offline | High | Done |
|
||||
`;
|
||||
const defined = countDefinedRequirements(md);
|
||||
expect(defined.UR).toBe(2);
|
||||
expect(defined.DR).toBe(0);
|
||||
});
|
||||
|
||||
it("does not count IDs that appear only in the Traces To column", () => {
|
||||
// The bug this rule avoids: a naive grep for /DR-\d{3}/ over the whole file
|
||||
// counts DR-001 here as "defined", inflating the denominator with IDs that
|
||||
// are merely referenced.
|
||||
const md = `
|
||||
| DR-001 | Player state machine | Player | UR-005 | Done |
|
||||
| DR-002 | MediaItem struct | Player | UR-003, UR-004 | Done |
|
||||
`;
|
||||
const defined = countDefinedRequirements(md);
|
||||
expect(defined.DR).toBe(2);
|
||||
// UR-005/UR-003/UR-004 are referenced, never defined here.
|
||||
expect(defined.UR).toBe(0);
|
||||
});
|
||||
|
||||
it("does not count IDs mentioned in prose", () => {
|
||||
const md = `
|
||||
Some prose explaining that UR-005 relates to DR-001 and JA-002.
|
||||
|
||||
| UR-005 | Control media playback | High | Done |
|
||||
`;
|
||||
const defined = countDefinedRequirements(md);
|
||||
expect(defined.UR).toBe(1);
|
||||
expect(defined.DR).toBe(0);
|
||||
expect(defined.JA).toBe(0);
|
||||
});
|
||||
|
||||
it("deduplicates an ID listed in both the spec table and the traceability matrix", () => {
|
||||
// requirements.md lists every UR twice: once in §1 (definition) and again in
|
||||
// §3 (traceability matrix), both as a leading table cell. Counting rows
|
||||
// instead of unique IDs double-counts the UR denominator (121 vs 61).
|
||||
const md = `
|
||||
| UR-005 | Control media playback | High | Done |
|
||||
| UR-006 | Browse the library | High | Done |
|
||||
|
||||
### Traceability Matrix
|
||||
|
||||
| UR-005 | - | DR-001, DR-005, DR-009 |
|
||||
| UR-006 | - | DR-012 |
|
||||
`;
|
||||
const defined = countDefinedRequirements(md);
|
||||
expect(defined.UR).toBe(2);
|
||||
});
|
||||
|
||||
it("collects the defined ID set, not just counts", () => {
|
||||
const md = `
|
||||
| UR-001 | A | High | Done |
|
||||
| DR-050 | B | Player | UR-001 | Done |
|
||||
`;
|
||||
const defined = countDefinedRequirements(md);
|
||||
expect(defined.ids.has("UR-001")).toBe(true);
|
||||
expect(defined.ids.has("DR-050")).toBe(true);
|
||||
expect(defined.ids.has("UR-999")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeCoverage", () => {
|
||||
const defined = {
|
||||
UR: 2,
|
||||
IR: 0,
|
||||
DR: 2,
|
||||
JA: 0,
|
||||
total: 4,
|
||||
ids: new Set(["UR-001", "UR-002", "DR-001", "DR-002"]),
|
||||
};
|
||||
|
||||
it("computes coverage as traced ∩ defined over defined", () => {
|
||||
const traced = ["UR-001", "DR-001"];
|
||||
const cov = computeCoverage(traced, defined);
|
||||
expect(cov.covered).toBe(2);
|
||||
expect(cov.total).toBe(4);
|
||||
expect(cov.percent).toBe(50);
|
||||
});
|
||||
|
||||
it("does not let a traced-but-undefined ID inflate the numerator", () => {
|
||||
// This is how a ratio exceeds 100%: a TRACES comment naming a typo'd or
|
||||
// deleted requirement counted as covered.
|
||||
const traced = ["UR-001", "DR-001", "DR-097"];
|
||||
const cov = computeCoverage(traced, defined);
|
||||
expect(cov.covered).toBe(2);
|
||||
expect(cov.percent).toBe(50);
|
||||
});
|
||||
|
||||
it("reports traced-but-undefined IDs as orphaned so they get fixed", () => {
|
||||
const traced = ["UR-001", "DR-097", "JA-404"];
|
||||
const cov = computeCoverage(traced, defined);
|
||||
expect(cov.orphaned).toEqual(["DR-097", "JA-404"]);
|
||||
});
|
||||
|
||||
it("has no orphans when every traced ID is defined", () => {
|
||||
const cov = computeCoverage(["UR-001", "UR-002"], defined);
|
||||
expect(cov.orphaned).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores UT/IT test IDs entirely — they are a separate taxonomy", () => {
|
||||
// UT/IT are defined in §4 of requirements.md, not among the four
|
||||
// requirement types. Treating them as orphans buries real typos in ~60
|
||||
// lines of noise, and counting them would corrupt the ratio.
|
||||
const cov = computeCoverage(["UR-001", "UT-088", "IT-017"], defined);
|
||||
expect(cov.orphaned).toEqual([]);
|
||||
expect(cov.covered).toBe(1);
|
||||
});
|
||||
|
||||
it("reports 0% rather than dividing by zero for an empty trace set", () => {
|
||||
const cov = computeCoverage([], defined);
|
||||
expect(cov.covered).toBe(0);
|
||||
expect(cov.percent).toBe(0);
|
||||
});
|
||||
|
||||
it("reports 0% rather than NaN when nothing is defined", () => {
|
||||
const empty = { UR: 0, IR: 0, DR: 0, JA: 0, total: 0, ids: new Set<string>() };
|
||||
const cov = computeCoverage([], empty);
|
||||
expect(cov.percent).toBe(0);
|
||||
expect(Number.isNaN(cov.percent)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports exactly 100% when all defined requirements are traced, never above", () => {
|
||||
const traced = ["UR-001", "UR-002", "DR-001", "DR-002"];
|
||||
const cov = computeCoverage(traced, defined);
|
||||
expect(cov.percent).toBe(100);
|
||||
});
|
||||
|
||||
it("ignores duplicate traced IDs", () => {
|
||||
const traced = ["UR-001", "UR-001", "UR-001"];
|
||||
const cov = computeCoverage(traced, defined);
|
||||
expect(cov.covered).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("live requirements.md", () => {
|
||||
it("parses the real file to the counts the CI gate must use", () => {
|
||||
// Guards the specific regression: CI hardcoded UR/39, IR/24, DR/48, JA/3
|
||||
// (total 114) while the real file had grown to 211. Update these numbers
|
||||
// deliberately when requirements are added — that edit is the signal the
|
||||
// denominator is live rather than frozen.
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
// import.meta.dir is Bun-only; derive from import.meta.url under vitest.
|
||||
const here = path.dirname(new URL(import.meta.url).pathname);
|
||||
const md = fs.readFileSync(
|
||||
path.resolve(here, "../docs/requirements.md"),
|
||||
"utf-8"
|
||||
);
|
||||
const defined = countDefinedRequirements(md);
|
||||
|
||||
expect(defined.UR).toBe(71);
|
||||
expect(defined.IR).toBe(32);
|
||||
expect(defined.DR).toBe(150);
|
||||
expect(defined.JA).toBe(35);
|
||||
expect(defined.total).toBe(288);
|
||||
});
|
||||
});
|
||||
+193
-16
@@ -34,11 +34,20 @@ interface TracesData {
|
||||
DR: string[];
|
||||
JA: string[];
|
||||
};
|
||||
/** Requirements *defined* in requirements.md — the coverage denominators. */
|
||||
defined?: { UR: number; IR: number; DR: number; JA: number; total: number };
|
||||
coverage?: CoverageResult;
|
||||
}
|
||||
|
||||
// Repo root, derived from this script's location (scripts/ -> repo root).
|
||||
// Must NOT be hardcoded to a developer's machine, or CI checkouts see no files.
|
||||
const BASE_DIR = path.resolve(import.meta.dir, "..");
|
||||
//
|
||||
// `import.meta.dir` is a Bun extension and is undefined when this module is
|
||||
// imported by vitest (which runs it as an ordinary ESM module), so fall back to
|
||||
// import.meta.url — this file must stay importable for extract-traces.test.ts.
|
||||
const SCRIPT_DIR =
|
||||
import.meta.dir ?? path.dirname(new URL(import.meta.url).pathname);
|
||||
const BASE_DIR = path.resolve(SCRIPT_DIR, "..");
|
||||
|
||||
const TRACES_PATTERN = /TRACES:\s*([^\n]+)/gi;
|
||||
const REQ_ID_PATTERN = /([A-Z]{2})-(\d{3})/g;
|
||||
@@ -50,7 +59,10 @@ function extractRequirementIds(tracesString: string): string[] {
|
||||
|
||||
function getAllSourceFiles(): string[] {
|
||||
const baseDir = BASE_DIR;
|
||||
const patterns = ["src", "src-tauri/src"];
|
||||
// `scripts` is scanned too: build tooling implements requirements (e.g.
|
||||
// DR-093, the coverage engine itself) and would otherwise be invisible to the
|
||||
// very matrix it generates.
|
||||
const patterns = ["src", "src-tauri/src", "scripts"];
|
||||
const files: string[] = [];
|
||||
|
||||
function walkDir(dir: string) {
|
||||
@@ -192,6 +204,109 @@ function extractTraces(): TracesData {
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Coverage: how many *defined* requirements are actually traced.
|
||||
//
|
||||
// The denominators MUST be derived from requirements.md, never hardcoded. The
|
||||
// CI gate previously divided by frozen literals (UR/39, IR/24, DR/48, JA/3,
|
||||
// total 114) while the real file had grown to 211 requirements, so it reported
|
||||
// 158% coverage and the 50% threshold became unreachable — the gate could not
|
||||
// fail. See docs/specs/traceability-gate-repair.md.
|
||||
//
|
||||
// TRACES: | DR-093
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DefinedRequirements {
|
||||
UR: number;
|
||||
IR: number;
|
||||
DR: number;
|
||||
JA: number;
|
||||
total: number;
|
||||
ids: Set<string>;
|
||||
}
|
||||
|
||||
export interface CoverageResult {
|
||||
covered: number;
|
||||
total: number;
|
||||
percent: number;
|
||||
/** Traced in code but not defined in requirements.md (typo, or deleted req). */
|
||||
orphaned: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A requirement is *defined* only where its ID is the leading cell of a markdown
|
||||
* table row: `| DR-001 | … |`.
|
||||
*
|
||||
* This deliberately ignores IDs in the "Traces To" column and in prose — a
|
||||
* naive scan for /DR-\d{3}/ counts those as definitions and inflates the
|
||||
* denominator. IDs are deduplicated because requirements.md lists each UR twice
|
||||
* (once in §1 as a definition, again in §3's traceability matrix), which would
|
||||
* otherwise double the UR count from 61 to 121.
|
||||
*
|
||||
* TRACES: | DR-093
|
||||
*/
|
||||
export function countDefinedRequirements(markdown: string): DefinedRequirements {
|
||||
const ids = new Set<string>();
|
||||
const ROW_ID = /^\|\s*(UR|IR|DR|JA)-(\d{3})\s*\|/;
|
||||
|
||||
for (const line of markdown.split("\n")) {
|
||||
const match = line.match(ROW_ID);
|
||||
if (match) ids.add(`${match[1]}-${match[2]}`);
|
||||
}
|
||||
|
||||
const countOf = (type: string) =>
|
||||
[...ids].filter((id) => id.startsWith(`${type}-`)).length;
|
||||
|
||||
return {
|
||||
UR: countOf("UR"),
|
||||
IR: countOf("IR"),
|
||||
DR: countOf("DR"),
|
||||
JA: countOf("JA"),
|
||||
total: ids.size,
|
||||
ids,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Coverage is the *intersection* of traced and defined IDs over defined IDs.
|
||||
*
|
||||
* Using the raw traced count as the numerator is what lets a ratio exceed 100%:
|
||||
* a TRACES comment naming a requirement that no longer exists would count as
|
||||
* covered. Those IDs are reported as `orphaned` so they get fixed rather than
|
||||
* silently counted or silently dropped.
|
||||
*
|
||||
* TRACES: | DR-093
|
||||
*/
|
||||
export function computeCoverage(
|
||||
tracedIds: string[],
|
||||
defined: DefinedRequirements
|
||||
): CoverageResult {
|
||||
// Only the four *requirement* types participate in coverage. UT/IT are test
|
||||
// identifiers defined in §4 of requirements.md — a different taxonomy, and
|
||||
// flagging them as orphans would bury real typos in ~60 lines of noise.
|
||||
const isRequirement = (id: string) => /^(UR|IR|DR|JA)-\d{3}$/.test(id);
|
||||
|
||||
const traced = new Set(tracedIds.filter(isRequirement));
|
||||
const covered = [...traced].filter((id) => defined.ids.has(id));
|
||||
const orphaned = [...traced].filter((id) => !defined.ids.has(id)).sort();
|
||||
|
||||
return {
|
||||
covered: covered.length,
|
||||
total: defined.total,
|
||||
percent:
|
||||
defined.total === 0
|
||||
? 0
|
||||
: Math.round((covered.length / defined.total) * 100),
|
||||
orphaned,
|
||||
};
|
||||
}
|
||||
|
||||
/** Read requirements.md from the repo and count what it defines. */
|
||||
export function readDefinedRequirements(): DefinedRequirements {
|
||||
const reqPath = path.join(BASE_DIR, "docs", "requirements.md");
|
||||
return countDefinedRequirements(fs.readFileSync(reqPath, "utf-8"));
|
||||
}
|
||||
|
||||
function generateMarkdown(data: TracesData): string {
|
||||
let md = `# Code Traceability Matrix
|
||||
|
||||
@@ -265,21 +380,83 @@ function generateJson(data: TracesData): string {
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
// Main
|
||||
const args = Bun.argv.slice(2);
|
||||
const format = args.includes("--format")
|
||||
? args[args.indexOf("--format") + 1]
|
||||
: "markdown";
|
||||
/**
|
||||
* Human-readable coverage report; exits non-zero below the threshold so this is
|
||||
* runnable as a local gate (`bun run traces:coverage`), not just in CI.
|
||||
*
|
||||
* TRACES: | DR-093
|
||||
*/
|
||||
function reportCoverage(data: TracesData, minThreshold: number): number {
|
||||
const defined = data.defined!;
|
||||
const cov = data.coverage!;
|
||||
|
||||
console.error("🔍 Extracting TRACES from codebase...");
|
||||
const data = extractTraces();
|
||||
const definedIds = readDefinedRequirements().ids;
|
||||
|
||||
if (format === "json") {
|
||||
console.log(generateJson(data));
|
||||
} else {
|
||||
console.log(generateMarkdown(data));
|
||||
console.log("📋 Requirement coverage (traced / defined):");
|
||||
for (const type of ["UR", "IR", "DR", "JA"] as const) {
|
||||
const traced = data.byType[type].filter((id) => definedIds.has(id)).length;
|
||||
console.log(` ${type}: ${traced} / ${defined[type]}`);
|
||||
}
|
||||
console.log("");
|
||||
console.log(`📈 Overall: ${cov.covered} / ${cov.total} (${cov.percent}%)`);
|
||||
|
||||
if (cov.orphaned.length > 0) {
|
||||
console.log("");
|
||||
console.log(
|
||||
`⚠️ Traced but not defined in requirements.md: ${cov.orphaned.join(", ")}`
|
||||
);
|
||||
console.log(" Fix the TRACES comment or add the requirement.");
|
||||
}
|
||||
|
||||
// A ratio above 100% means the computation is broken (the condition that hid
|
||||
// the stale-denominator bug for so long). Fail loudly rather than report it.
|
||||
if (cov.percent > 100) {
|
||||
console.log("");
|
||||
console.log(`❌ Coverage > 100% — the gate is miscomputing.`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (cov.percent < minThreshold) {
|
||||
console.log("");
|
||||
console.log(`❌ Coverage (${cov.percent}%) is below minimum (${minThreshold}%)`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(`✅ Coverage is acceptable (${cov.percent}% >= ${minThreshold}%)`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.error(
|
||||
`\n✅ Complete! Found ${data.totalTraces} TRACES across ${data.totalFiles} files`
|
||||
);
|
||||
// Main — guarded so this module stays importable from extract-traces.test.ts.
|
||||
if (import.meta.main) {
|
||||
const args = process.argv.slice(2);
|
||||
const format = args.includes("--format")
|
||||
? args[args.indexOf("--format") + 1]
|
||||
: "markdown";
|
||||
|
||||
console.error("🔍 Extracting TRACES from codebase...");
|
||||
const data = extractTraces();
|
||||
|
||||
const defined = readDefinedRequirements();
|
||||
const allTraced = Object.keys(data.requirements);
|
||||
data.defined = {
|
||||
UR: defined.UR,
|
||||
IR: defined.IR,
|
||||
DR: defined.DR,
|
||||
JA: defined.JA,
|
||||
total: defined.total,
|
||||
};
|
||||
data.coverage = computeCoverage(allTraced, defined);
|
||||
|
||||
if (format === "json") {
|
||||
console.log(generateJson(data));
|
||||
} else if (format === "coverage") {
|
||||
process.exit(reportCoverage(data, 50));
|
||||
} else {
|
||||
console.log(generateMarkdown(data));
|
||||
}
|
||||
|
||||
console.error(
|
||||
`\n✅ Complete! Found ${data.totalTraces} TRACES across ${data.totalFiles} files`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Find all files implementing a specific requirement
|
||||
#
|
||||
# Usage: ./find-req-implementations.sh UR-004
|
||||
#
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Usage: $0 <REQUIREMENT_ID>"
|
||||
echo "Example: $0 UR-004"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REQ_ID=$1
|
||||
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo " Implementations of $REQ_ID"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
|
||||
# Full implementations
|
||||
echo "Full Implementations:"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
grep -rn "@req: $REQ_ID" src-tauri/ src/ 2>/dev/null | \
|
||||
grep -v "@req-partial" | \
|
||||
grep -v "@req-planned" | \
|
||||
sed 's/src-tauri\/src\///' | \
|
||||
sed 's/src\///' || echo " (none)"
|
||||
|
||||
echo ""
|
||||
|
||||
# Partial implementations
|
||||
echo "Partial Implementations:"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
grep -rn "@req-partial: $REQ_ID" src-tauri/ src/ 2>/dev/null | \
|
||||
sed 's/src-tauri\/src\///' | \
|
||||
sed 's/src\///' || echo " (none)"
|
||||
|
||||
echo ""
|
||||
|
||||
# Planned
|
||||
echo "Planned Implementations:"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
grep -rn "@req-planned: $REQ_ID" src-tauri/ src/ 2>/dev/null | \
|
||||
sed 's/src-tauri\/src\///' | \
|
||||
sed 's/src\///' || echo " (none)"
|
||||
|
||||
echo ""
|
||||
|
||||
# Tests
|
||||
echo "Test Cases:"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
grep -rn "@req-test: $REQ_ID" src-tauri/ 2>/dev/null | \
|
||||
sed 's/src-tauri\/src\///' || echo " (none)"
|
||||
|
||||
echo ""
|
||||
Executable
+111
@@ -0,0 +1,111 @@
|
||||
#!/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.
|
||||
#
|
||||
# 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.
|
||||
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=$(( 1000 + MAJ*10000 + MIN*100 + 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,169 @@
|
||||
/**
|
||||
* 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", () => {
|
||||
// Codes below 1000 are already in the field; a newer release must never
|
||||
// produce a smaller number than an older one.
|
||||
it("clears the 1000 floor shipped by earlier builds", () => {
|
||||
run("0.0.1");
|
||||
expect(versionCode()).toBeGreaterThan(1000);
|
||||
});
|
||||
|
||||
it("uses 1000 + major*10000 + minor*100 + patch", () => {
|
||||
const cases: Array<[string, number]> = [
|
||||
["0.0.14", 1014],
|
||||
["0.0.15", 1015],
|
||||
["0.1.0", 1100],
|
||||
["0.4.8", 1408],
|
||||
["0.5.0", 1500],
|
||||
["1.0.0", 11000],
|
||||
];
|
||||
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(1600);
|
||||
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/"
|
||||
echo " Copied res: values"
|
||||
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
|
||||
# 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
|
||||
@@ -108,4 +118,26 @@ if [ -d "$RES_SRC" ]; then
|
||||
"$RES_DST"/drawable*/ic_launcher_background.xml
|
||||
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"
|
||||
|
||||
+8
-1
@@ -7,7 +7,7 @@ echo "🧪 Running all tests..."
|
||||
echo ""
|
||||
|
||||
echo "📦 Running frontend tests..."
|
||||
bun run test
|
||||
bun run test --run
|
||||
|
||||
echo ""
|
||||
echo "🦀 Running Rust tests..."
|
||||
@@ -15,5 +15,12 @@ cd src-tauri
|
||||
cargo test
|
||||
cd ..
|
||||
|
||||
echo ""
|
||||
echo "🚧 Checking architectural gates..."
|
||||
# Boundary tripwire (DR-094): no Jellyfin taxonomy in the presentation layer.
|
||||
bun run check:boundary
|
||||
# Traceability coverage (DR-093): fails below 50%, or above 100% (miscount).
|
||||
bun run traces:coverage
|
||||
|
||||
echo ""
|
||||
echo "✅ All tests passed!"
|
||||
|
||||
Generated
+39
-1
@@ -150,6 +150,12 @@ version = "1.0.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "ascii"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
@@ -552,6 +558,12 @@ dependencies = [
|
||||
"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]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
@@ -1671,12 +1683,24 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-range"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.8.1"
|
||||
@@ -1994,7 +2018,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jellytau"
|
||||
version = "0.1.0"
|
||||
version = "0.4.8"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
@@ -2025,6 +2049,7 @@ dependencies = [
|
||||
"tauri-plugin-os",
|
||||
"tauri-specta",
|
||||
"tempfile",
|
||||
"tiny_http",
|
||||
"tokio",
|
||||
"tokio-rusqlite",
|
||||
"tokio-util",
|
||||
@@ -4192,6 +4217,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"http-range",
|
||||
"jni",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -4571,6 +4597,18 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tinystr"
|
||||
version = "0.8.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "jellytau"
|
||||
version = "0.1.0"
|
||||
version = "0.4.8"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
@@ -23,7 +23,12 @@ debug = "line-tables-only"
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[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-os = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -54,6 +59,7 @@ env_logger = "0.11"
|
||||
tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
|
||||
specta-typescript = "=0.0.9"
|
||||
specta = { version = "=2.0.0-rc.22", features = ["chrono", "derive"] }
|
||||
tiny_http = { version = "0.12.0", default-features = false }
|
||||
|
||||
# Linux-specific dependencies
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
# breaking the PiP button in release builds only.
|
||||
-keep class com.dtourolle.jellytau.PictureInPictureManager { *; }
|
||||
-keep class com.dtourolle.jellytau.VideoOverlayManager { *; }
|
||||
-keep class com.dtourolle.jellytau.WindowInsetsBridge { *; }
|
||||
-keepclassmembers class * {
|
||||
@android.webkit.JavascriptInterface <methods>;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.jellytau"
|
||||
android:hardwareAccelerated="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|density"
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
package com.dtourolle.jellytau
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
@@ -19,8 +14,6 @@ class MainActivity : TauriActivity() {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var configAttempts = 0
|
||||
private val maxConfigAttempts = 10
|
||||
private var audioFocusRequest: AudioFocusRequest? = null
|
||||
private val audioManager by lazy { getSystemService(Context.AUDIO_SERVICE) as AudioManager }
|
||||
|
||||
/**
|
||||
* Coarse override for whether backgrounding the app should auto-enter PiP.
|
||||
@@ -50,10 +43,41 @@ class MainActivity : TauriActivity() {
|
||||
*/
|
||||
private var mediaWebView: WebView? = null
|
||||
|
||||
/**
|
||||
* The WebView the @JavascriptInterface bridges have been injected into.
|
||||
*
|
||||
* addJavascriptInterface must run once per WebView instance: re-injecting
|
||||
* over an already-loaded page hands JS a stale proxy whose methods are gone.
|
||||
* Compared by identity so a genuinely new WebView still gets its bridges.
|
||||
*/
|
||||
private var bridgesInstalledOn: WebView? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
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
|
||||
handler.postDelayed({
|
||||
configureWebViewForMedia()
|
||||
@@ -159,19 +183,42 @@ class MainActivity : TauriActivity() {
|
||||
android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
|
||||
mediaWebView = webView
|
||||
|
||||
// Add JavaScript interface for audio focus control
|
||||
webView.addJavascriptInterface(object : Any() {
|
||||
@JavascriptInterface
|
||||
fun requestAudioFocus() {
|
||||
handler.post { this@MainActivity.requestAudioFocus() }
|
||||
}
|
||||
// Register the @JavascriptInterface bridges EXACTLY ONCE per WebView.
|
||||
//
|
||||
// configureWebViewForMedia() runs from onCreate's delayed post AND from
|
||||
// every onResume (plus each WebView re-find), so this used to re-inject
|
||||
// all four bridges repeatedly - 5 times in a 45s session. WebView binds
|
||||
// injected objects at page-load time; re-injecting over a live page
|
||||
// leaves JS holding a stale proxy. The object stays truthy while its
|
||||
// methods vanish, which surfaced as a flood of
|
||||
// "WebView: Unknown object" chromium errors and, in JS,
|
||||
// "TypeError: setEnabled is not a function".
|
||||
//
|
||||
// The visible bug: the background-audio toggle turned blue but never
|
||||
// reached native, so backgroundAudioEnabled stayed false, onStop never
|
||||
// dispatched 'jellytau-background', and a locked screen killed audio
|
||||
// instantly (UR-040). Audio focus and PiP broke the same way.
|
||||
//
|
||||
// The settings/WebChromeClient work below is idempotent and must keep
|
||||
// running on resume; only the bridge injection is one-shot.
|
||||
|
||||
@JavascriptInterface
|
||||
fun abandonAudioFocus() {
|
||||
handler.post { this@MainActivity.abandonAudioFocus() }
|
||||
}
|
||||
}, "AndroidAudioFocus")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidAudioFocus' added")
|
||||
// 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) {
|
||||
android.util.Log.d("MainActivity", "JS bridges already installed on this WebView - skipping re-injection")
|
||||
configureWebViewSettings(webView)
|
||||
return
|
||||
}
|
||||
bridgesInstalledOn = webView
|
||||
|
||||
// NOTE: there is deliberately no "AndroidAudioFocus" bridge. Manual focus
|
||||
// requests from the WebView competed with Chromium's own
|
||||
// AudioFocusDelegate and with ExoPlayer, and the resulting
|
||||
// AUDIOFOCUS_LOSS paused playback. See the comment on the video listeners
|
||||
// in configureWebViewSettings().
|
||||
|
||||
// Add JavaScript interface for picture-in-picture control.
|
||||
// enterPip/canEnterPip must run on the main thread; @JavascriptInterface
|
||||
@@ -212,10 +259,6 @@ class MainActivity : TauriActivity() {
|
||||
backgroundAudioEnabled = enabled
|
||||
android.util.Log.d("MainActivity", "backgroundAudioEnabled = $enabled")
|
||||
}
|
||||
|
||||
/** Whether background audio is available on this device (needs PiP-era APIs unnecessary; audio service always present on Android). */
|
||||
@JavascriptInterface
|
||||
fun isSupported(): Boolean = true
|
||||
}, "AndroidBackgroundAudio")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidBackgroundAudio' added")
|
||||
|
||||
@@ -242,12 +285,72 @@ class MainActivity : TauriActivity() {
|
||||
}, "AndroidNetworkType")
|
||||
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")
|
||||
|
||||
// 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
|
||||
// WiFi" resumes the moment an acceptable network appears.
|
||||
NetworkTypeMonitor.startWatching(this) {
|
||||
dispatchWebEvent("jellytau-network-changed")
|
||||
}
|
||||
|
||||
configureWebViewSettings(webView)
|
||||
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("MainActivity", "Failed to configure WebView for media", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WebView settings, chrome client and the video-unmute script.
|
||||
*
|
||||
* Split out from the bridge injection because this half is idempotent and
|
||||
* must re-run on every resume, whereas addJavascriptInterface must not.
|
||||
*/
|
||||
private fun configureWebViewSettings(webView: WebView) {
|
||||
try {
|
||||
// Set WebChromeClient to handle video playback and audio focus
|
||||
webView.webChromeClient = object : WebChromeClient() {
|
||||
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
|
||||
@@ -259,6 +362,21 @@ class MainActivity : TauriActivity() {
|
||||
super.onHideCustomView()
|
||||
android.util.Log.d("MainActivity", "Video exited fullscreen")
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward WebView console output to logcat under the "JellyTauWeb" tag.
|
||||
*
|
||||
* Without this the frontend is invisible to `adb logcat`, which makes
|
||||
* diagnosing anything that spans the JS/native boundary (the
|
||||
* background-audio handoff in particular) guesswork.
|
||||
*/
|
||||
override fun onConsoleMessage(msg: android.webkit.ConsoleMessage): Boolean {
|
||||
android.util.Log.d(
|
||||
"JellyTauWeb",
|
||||
"${msg.message()} (${msg.sourceId()}:${msg.lineNumber()})"
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
android.util.Log.d("MainActivity", "WebChromeClient configured")
|
||||
|
||||
@@ -287,29 +405,18 @@ class MainActivity : TauriActivity() {
|
||||
video.volume = 1.0;
|
||||
console.log('[Android] Video unmuted, volume:', video.volume, 'muted:', video.muted);
|
||||
|
||||
// Add event listeners to manage audio focus
|
||||
video.addEventListener('play', function() {
|
||||
console.log('[Android] Video play event - requesting audio focus');
|
||||
if (typeof AndroidAudioFocus !== 'undefined') {
|
||||
AndroidAudioFocus.requestAudioFocus();
|
||||
}
|
||||
console.log('[Android] Video state - muted:', this.muted, 'volume:', this.volume);
|
||||
});
|
||||
|
||||
video.addEventListener('pause', function() {
|
||||
console.log('[Android] Video pause event - abandoning audio focus');
|
||||
if (typeof AndroidAudioFocus !== 'undefined') {
|
||||
AndroidAudioFocus.abandonAudioFocus();
|
||||
}
|
||||
});
|
||||
|
||||
video.addEventListener('ended', function() {
|
||||
console.log('[Android] Video ended event - abandoning audio focus');
|
||||
if (typeof AndroidAudioFocus !== 'undefined') {
|
||||
AndroidAudioFocus.abandonAudioFocus();
|
||||
}
|
||||
});
|
||||
|
||||
// NOTE: deliberately no audio-focus calls here.
|
||||
//
|
||||
// WebView already manages audio focus for <video> through
|
||||
// Chromium's own AudioFocusDelegate. Requesting AUDIOFOCUS_GAIN
|
||||
// again from MainActivity made two requesters compete inside one
|
||||
// uid: the grant was immediately followed by AUDIOFOCUS_LOSS
|
||||
// (~45ms), whose handler paused playback - so arming background
|
||||
// audio, or simply pressing play, paused the video in a loop.
|
||||
//
|
||||
// ExoPlayer is the third potential owner and stays authoritative
|
||||
// for native playback (JellyTauPlayer manages its own focus).
|
||||
// Leave focus to whichever engine is actually rendering.
|
||||
video.addEventListener('volumechange', function() {
|
||||
console.log('[Android] Video volume changed - volume:', this.volume, 'muted:', this.muted);
|
||||
});
|
||||
@@ -356,48 +463,4 @@ class MainActivity : TauriActivity() {
|
||||
return null
|
||||
}
|
||||
|
||||
private fun requestAudioFocus() {
|
||||
android.util.Log.d("MainActivity", "Requesting audio focus for video playback")
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val audioAttributes = AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
|
||||
.build()
|
||||
|
||||
audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
||||
.setAudioAttributes(audioAttributes)
|
||||
.setAcceptsDelayedFocusGain(true)
|
||||
.setOnAudioFocusChangeListener { focusChange ->
|
||||
android.util.Log.d("MainActivity", "Audio focus changed: $focusChange")
|
||||
}
|
||||
.build()
|
||||
|
||||
val result = audioManager.requestAudioFocus(audioFocusRequest!!)
|
||||
android.util.Log.d("MainActivity", "Audio focus request result: $result")
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
val result = audioManager.requestAudioFocus(
|
||||
{ focusChange ->
|
||||
android.util.Log.d("MainActivity", "Audio focus changed: $focusChange")
|
||||
},
|
||||
AudioManager.STREAM_MUSIC,
|
||||
AudioManager.AUDIOFOCUS_GAIN
|
||||
)
|
||||
android.util.Log.d("MainActivity", "Audio focus request result (legacy): $result")
|
||||
}
|
||||
}
|
||||
|
||||
private fun abandonAudioFocus() {
|
||||
android.util.Log.d("MainActivity", "Abandoning audio focus")
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
audioFocusRequest?.let {
|
||||
audioManager.abandonAudioFocusRequest(it)
|
||||
}
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
audioManager.abandonAudioFocus { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaCodecList
|
||||
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.
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -36,6 +36,53 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
/** Position update interval in milliseconds */
|
||||
private const val POSITION_UPDATE_INTERVAL_MS = 250L
|
||||
|
||||
/** AudioEffect priority. Positive = higher priority than the default. */
|
||||
private const val EFFECT_PRIORITY = 1000
|
||||
|
||||
/**
|
||||
* Canonical 10-band ISO centre frequencies (Hz), mirroring EQ_BANDS in
|
||||
* settings.rs. Kept in sync deliberately: Rust owns the band layout, this
|
||||
* is only the lookup table used to map those gains onto whatever bands
|
||||
* the device's equalizer actually has.
|
||||
*/
|
||||
private val CANONICAL_BAND_CENTRES_HZ =
|
||||
intArrayOf(31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000)
|
||||
|
||||
/**
|
||||
* Map canonical band gains onto a device's band centres by nearest
|
||||
* centre frequency.
|
||||
*
|
||||
* Pure function so it can be unit-tested without a device — device band
|
||||
* counts vary (commonly 5) and getting this wrong silently mis-shapes the
|
||||
* EQ curve rather than failing.
|
||||
*
|
||||
* TRACES: UR-027 | DR-030
|
||||
*/
|
||||
@JvmStatic
|
||||
fun resampleBands(
|
||||
canonicalGains: FloatArray,
|
||||
canonicalCentresHz: IntArray,
|
||||
deviceCentresHz: IntArray
|
||||
): FloatArray {
|
||||
if (canonicalGains.isEmpty() || deviceCentresHz.isEmpty()) {
|
||||
return FloatArray(deviceCentresHz.size)
|
||||
}
|
||||
val usable = minOf(canonicalGains.size, canonicalCentresHz.size)
|
||||
return FloatArray(deviceCentresHz.size) { d ->
|
||||
val target = deviceCentresHz[d]
|
||||
var nearest = 0
|
||||
var bestDelta = Int.MAX_VALUE
|
||||
for (c in 0 until usable) {
|
||||
val delta = kotlin.math.abs(canonicalCentresHz[c] - target)
|
||||
if (delta < bestDelta) {
|
||||
bestDelta = delta
|
||||
nearest = c
|
||||
}
|
||||
}
|
||||
canonicalGains[nearest]
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton instance for JNI access */
|
||||
@Volatile
|
||||
private var instance: JellyTauPlayer? = null
|
||||
@@ -83,7 +130,7 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
}
|
||||
|
||||
// Detect and report hardware codec capabilities to Rust
|
||||
detectAndReportCodecs()
|
||||
detectAndReportCodecs(context.applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,23 +141,31 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
* Called during player initialization.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun detectAndReportCodecs() {
|
||||
fun detectAndReportCodecs(context: Context) {
|
||||
val capabilities = CodecDetector.detectHardwareCodecs()
|
||||
|
||||
// Convert lists to comma-separated strings for JNI transfer
|
||||
val videoCodecsStr = capabilities.videoCodecs.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
|
||||
nativeOnCodecsDetected(videoCodecsStr, audioCodecsStr)
|
||||
nativeOnCodecsDetected(videoCodecsStr, audioCodecsStr, maxAudioChannels)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -135,6 +190,18 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
private var positionUpdateJob: Job? = null
|
||||
|
||||
/** Graphic EQ bound to the current audio session, or null if not attached. */
|
||||
private var equalizer: android.media.audiofx.Equalizer? = null
|
||||
|
||||
/** Loudness/normalization effect bound to the current audio session. */
|
||||
private var loudnessEnhancer: android.media.audiofx.LoudnessEnhancer? = null
|
||||
|
||||
/**
|
||||
* Last settings pushed from Rust, replayed when the audio session is rebuilt.
|
||||
* Held as the raw payload so re-application needs no second parse contract.
|
||||
*/
|
||||
private var lastAudioSettings: org.json.JSONObject? = null
|
||||
|
||||
/** Current media ID being played */
|
||||
private var currentMediaId: String? = null
|
||||
|
||||
@@ -173,6 +240,20 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
}
|
||||
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 {
|
||||
// Configure audio attributes for music playback with audio focus handling
|
||||
val audioAttributes = AudioAttributes.Builder()
|
||||
@@ -302,29 +383,61 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
android.util.Log.d("JellyTauPlayer", " Video group: ${group.length} tracks, selected=${group.isSelected}")
|
||||
}
|
||||
|
||||
// CRITICAL FIX: Auto-select first audio track if none is selected
|
||||
// This fixes the issue where some videos play without audio
|
||||
// TRACES: UR-004 | DR-146
|
||||
// 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) {
|
||||
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
|
||||
if (trackSelector != null) {
|
||||
try {
|
||||
// Select the first audio track group
|
||||
val firstAudioGroup = audioTracks[0]
|
||||
val override = androidx.media3.common.TrackSelectionOverride(
|
||||
firstAudioGroup.mediaTrackGroup,
|
||||
0 // Select the first track in this group
|
||||
)
|
||||
var chosenGroup: androidx.media3.common.Tracks.Group? = null
|
||||
var chosenIndex = -1
|
||||
outer@ for (group in audioTracks) {
|
||||
for (i in 0 until group.length) {
|
||||
if (group.isTrackSupported(i)) {
|
||||
chosenGroup = group
|
||||
chosenIndex = i
|
||||
break@outer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val parameters = trackSelector.parameters
|
||||
.buildUpon()
|
||||
.clearOverridesOfType(C.TRACK_TYPE_AUDIO)
|
||||
.addOverride(override)
|
||||
.build()
|
||||
if (chosenGroup == null) {
|
||||
// Every track is undecodable on this device. The
|
||||
// server should have transcoded; say so loudly
|
||||
// rather than leaving a silent video unexplained.
|
||||
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)
|
||||
android.util.Log.d("JellyTauPlayer", "✓ Auto-selected first audio track")
|
||||
val parameters = trackSelector.parameters
|
||||
.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) {
|
||||
android.util.Log.e("JellyTauPlayer", "Failed to auto-select audio track", e)
|
||||
}
|
||||
@@ -334,6 +447,11 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
|
||||
override fun onAudioSessionIdChanged(audioSessionId: Int) {
|
||||
android.util.Log.d("JellyTauPlayer", "▶▶▶ AUDIO SESSION ID CHANGED: $audioSessionId")
|
||||
// ExoPlayer rebuilt its audio sink (e.g. on a format change), so
|
||||
// effects bound to the old session are dead. Re-attach, or the EQ
|
||||
// silently stops applying mid-queue.
|
||||
releaseAudioEffects()
|
||||
lastAudioSettings?.let { applyAudioEffects(it) }
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -359,6 +477,15 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
*/
|
||||
fun play() {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -416,6 +543,133 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply audio settings pushed from Rust as JSON.
|
||||
*
|
||||
* Rust owns *what* the values are (band layout, preset curves, normalization
|
||||
* presets); this owns *when* the Android AudioEffect objects exist, since
|
||||
* that needs the live audio session id and must survive a sink rebuild.
|
||||
*
|
||||
* Posted to the main handler rather than run inline: AudioEffect construction
|
||||
* from a player callback can re-enter the player and deadlock.
|
||||
*
|
||||
* TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036
|
||||
*/
|
||||
fun setAudioSettings(json: String) {
|
||||
mainHandler.post {
|
||||
try {
|
||||
val settings = org.json.JSONObject(json)
|
||||
lastAudioSettings = settings
|
||||
|
||||
// Gapless: ExoPlayer is gapless by default for compatible
|
||||
// formats, so honouring the setting means disabling it when off.
|
||||
exoPlayer.pauseAtEndOfMediaItems = !settings.optBoolean("gaplessPlayback", true)
|
||||
|
||||
applyAudioEffects(settings)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("JellyTauPlayer", "Failed to apply audio settings", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Attach/update the EQ and loudness effects for the current audio session. */
|
||||
private fun applyAudioEffects(settings: org.json.JSONObject) {
|
||||
val sessionId = exoPlayer.audioSessionId
|
||||
if (sessionId == C.AUDIO_SESSION_ID_UNSET) {
|
||||
// No sink yet; onAudioSessionIdChanged will re-drive this.
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
applyEqualizer(sessionId, settings)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("JellyTauPlayer", "Equalizer unavailable on this device", e)
|
||||
}
|
||||
|
||||
try {
|
||||
applyNormalization(sessionId, settings)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("JellyTauPlayer", "LoudnessEnhancer unavailable on this device", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyEqualizer(sessionId: Int, settings: org.json.JSONObject) {
|
||||
val enabled = settings.optBoolean("equalizerEnabled", false)
|
||||
|
||||
if (!enabled) {
|
||||
equalizer?.enabled = false
|
||||
return
|
||||
}
|
||||
|
||||
val eq = equalizer ?: android.media.audiofx.Equalizer(EFFECT_PRIORITY, sessionId).also {
|
||||
equalizer = it
|
||||
}
|
||||
|
||||
val bandsJson = settings.optJSONArray("equalizerBands")
|
||||
val canonicalGains = FloatArray(bandsJson?.length() ?: 0) { i ->
|
||||
bandsJson!!.optDouble(i, 0.0).toFloat()
|
||||
}
|
||||
if (canonicalGains.isEmpty()) {
|
||||
eq.enabled = false
|
||||
return
|
||||
}
|
||||
|
||||
// The device's band count/centres are device-dependent (commonly 5) and
|
||||
// will not match our canonical 10-band ISO layout, so resample.
|
||||
val deviceBandCount = eq.numberOfBands.toInt()
|
||||
val deviceCentresHz = IntArray(deviceBandCount) { i ->
|
||||
eq.getCenterFreq(i.toShort()) / 1000 // device reports milliHertz
|
||||
}
|
||||
val levelRange = eq.bandLevelRange // millibels, [min, max]
|
||||
|
||||
val resampled = resampleBands(canonicalGains, CANONICAL_BAND_CENTRES_HZ, deviceCentresHz)
|
||||
|
||||
for (i in 0 until deviceBandCount) {
|
||||
val millibels = (resampled[i] * 100f)
|
||||
.coerceIn(levelRange[0].toFloat(), levelRange[1].toFloat())
|
||||
eq.setBandLevel(i.toShort(), millibels.toInt().toShort())
|
||||
}
|
||||
eq.enabled = true
|
||||
}
|
||||
|
||||
private fun applyNormalization(sessionId: Int, settings: org.json.JSONObject) {
|
||||
val enabled = settings.optBoolean("normalizeVolume", false)
|
||||
|
||||
if (!enabled) {
|
||||
loudnessEnhancer?.enabled = false
|
||||
return
|
||||
}
|
||||
|
||||
val enhancer = loudnessEnhancer
|
||||
?: android.media.audiofx.LoudnessEnhancer(sessionId).also { loudnessEnhancer = it }
|
||||
|
||||
// Approximate parity with the Linux dynaudnorm path: LoudnessEnhancer is
|
||||
// a gain stage, not a true EBU R128 normalizer, so these are relative
|
||||
// offsets preserving the Loud > Normal > Quiet ordering.
|
||||
val targetGainMb = when (settings.optString("volumeLevel", "normal")) {
|
||||
"loud" -> 600
|
||||
"quiet" -> -600
|
||||
else -> 0
|
||||
}
|
||||
enhancer.setTargetGain(targetGainMb)
|
||||
enhancer.enabled = true
|
||||
}
|
||||
|
||||
private fun releaseAudioEffects() {
|
||||
try {
|
||||
equalizer?.release()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("JellyTauPlayer", "Equalizer release failed", e)
|
||||
}
|
||||
try {
|
||||
loudnessEnhancer?.release()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("JellyTauPlayer", "LoudnessEnhancer release failed", e)
|
||||
}
|
||||
equalizer = null
|
||||
loudnessEnhancer = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current playback position in seconds.
|
||||
*/
|
||||
@@ -620,14 +874,17 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
android.util.Log.d("JellyTauPlayer", "ExoPlayer audio session ID: ${exoPlayer.audioSessionId}")
|
||||
|
||||
// Setup video surface if needed
|
||||
var focusGranted = true
|
||||
if (currentMediaType == MediaType.VIDEO) {
|
||||
getOrCreateSurfaceView()
|
||||
android.util.Log.d("JellyTauPlayer", "Video surface created for playback")
|
||||
// Automatically attach the surface to the Activity
|
||||
autoAttachSurface()
|
||||
|
||||
// CRITICAL: Request audio focus for video playback
|
||||
requestAudioFocus()
|
||||
// CRITICAL: Request audio focus for video playback. Video manages
|
||||
// focus by hand (handleAudioFocus=false above), so nothing else
|
||||
// will hold playback back if the request is delayed or refused.
|
||||
focusGranted = requestAudioFocus()
|
||||
} else {
|
||||
clearVideoSurface()
|
||||
// Abandon audio focus when switching to audio (audio uses ExoPlayer's built-in handling)
|
||||
@@ -697,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)}")
|
||||
|
||||
exoPlayer.playWhenReady = true
|
||||
android.util.Log.d("JellyTauPlayer", "playWhenReady set to TRUE. Current state: ${exoPlayer.playbackState}")
|
||||
// Only roll if we hold audio focus. A DELAYED grant means the system
|
||||
// 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
|
||||
startPlaybackService()
|
||||
@@ -756,6 +1018,7 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
mainHandler.post {
|
||||
stopPositionUpdates()
|
||||
coroutineScope.cancel()
|
||||
releaseAudioEffects()
|
||||
exoPlayer.release()
|
||||
instance = null
|
||||
}
|
||||
@@ -954,8 +1217,14 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
/**
|
||||
* Request audio focus for video playback.
|
||||
* 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")
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
@@ -972,17 +1241,29 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
when (focusChange) {
|
||||
AudioManager.AUDIOFOCUS_GAIN -> {
|
||||
android.util.Log.d("JellyTauPlayer", "✓ Audio focus GAINED - ensuring full volume")
|
||||
hasAudioFocus = true
|
||||
if (exoPlayer.volume < 1.0f) {
|
||||
exoPlayer.volume = 1.0f
|
||||
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 -> {
|
||||
android.util.Log.d("JellyTauPlayer", "Audio focus LOST - pausing")
|
||||
hasAudioFocus = false
|
||||
pendingPlayOnFocusGain = false
|
||||
pause()
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
||||
android.util.Log.d("JellyTauPlayer", "Audio focus LOST TRANSIENT - pausing")
|
||||
hasAudioFocus = false
|
||||
pendingPlayOnFocusGain = false
|
||||
pause()
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
|
||||
@@ -993,16 +1274,25 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
}
|
||||
.build()
|
||||
|
||||
val result = audioManager.requestAudioFocus(audioFocusRequest!!)
|
||||
when (result) {
|
||||
return when (val result = audioManager.requestAudioFocus(audioFocusRequest!!)) {
|
||||
AudioManager.AUDIOFOCUS_REQUEST_GRANTED -> {
|
||||
android.util.Log.d("JellyTauPlayer", "✓ Audio focus GRANTED")
|
||||
hasAudioFocus = true
|
||||
true
|
||||
}
|
||||
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 -> {
|
||||
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 {
|
||||
@@ -1014,10 +1304,15 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
AudioManager.STREAM_MUSIC,
|
||||
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)")
|
||||
hasAudioFocus = true
|
||||
true
|
||||
} 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)!")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1028,6 +1323,11 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
private fun abandonAudioFocus() {
|
||||
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) {
|
||||
audioFocusRequest?.let {
|
||||
val result = audioManager.abandonAudioFocusRequest(it)
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
<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">
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
<!-- Don't draw behind status bar -->
|
||||
<item name="android:windowLightNavigationBar" tools:targetApi="o_mr1">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||
<!-- Ensure content doesn't extend into system bars -->
|
||||
<item name="android:fitsSystemWindows">true</item>
|
||||
</style>
|
||||
</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
|
||||
//! heal-and-pump pattern in `player_preload_upcoming`.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use log::{info, warn};
|
||||
use tauri::State;
|
||||
use tauri::{Emitter, Manager, State};
|
||||
|
||||
use crate::commands::download::{pump_download_queue, DownloadManagerWrapper};
|
||||
use crate::commands::repository::RepositoryManagerWrapper;
|
||||
@@ -29,18 +31,89 @@ use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||
/// full-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
|
||||
/// 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] = &[
|
||||
"MusicAlbum",
|
||||
"MusicArtist",
|
||||
"Movie",
|
||||
"Series",
|
||||
"Season",
|
||||
"Episode",
|
||||
"Audio",
|
||||
"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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CatalogSyncResult {
|
||||
@@ -48,6 +121,9 @@ pub struct CatalogSyncResult {
|
||||
pub items_cached: usize,
|
||||
/// Libraries that failed to sync (e.g. server hiccup); best-effort.
|
||||
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)]
|
||||
@@ -71,8 +147,6 @@ pub async fn sync_full_catalog(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
handle: String,
|
||||
) -> Result<CatalogSyncResult, String> {
|
||||
use crate::repository::MediaRepository;
|
||||
|
||||
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
|
||||
|
||||
let db_service = {
|
||||
@@ -80,6 +154,27 @@ pub async fn sync_full_catalog(
|
||||
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())?;
|
||||
info!(
|
||||
"[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();
|
||||
|
||||
// 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 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.
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let upsert = Query::with_params(
|
||||
@@ -133,16 +261,169 @@ pub async fn sync_full_catalog(
|
||||
}
|
||||
|
||||
info!(
|
||||
"[Catalog] Full sync complete: {} items cached, {} libraries failed",
|
||||
items_cached, libraries_failed
|
||||
"[Catalog] Full sync complete: {} items cached, {} pruned, {} libraries failed",
|
||||
items_cached, items_pruned, libraries_failed
|
||||
);
|
||||
|
||||
Ok(CatalogSyncResult {
|
||||
items_cached,
|
||||
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
|
||||
/// to trigger a fresh sync.
|
||||
#[tauri::command]
|
||||
@@ -190,6 +471,51 @@ pub struct ResumeQueuedResult {
|
||||
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
|
||||
/// `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.
|
||||
@@ -203,11 +529,31 @@ where
|
||||
F: Fn(String, String, String) -> Fut,
|
||||
Fut: std::future::Future<Output = Option<String>>,
|
||||
{
|
||||
let rows_query = Query::new(
|
||||
"SELECT id, item_id, COALESCE(media_type, 'audio'), COALESCE(quality_preset, 'original')
|
||||
FROM downloads
|
||||
WHERE status = 'pending' AND stream_url IS NULL",
|
||||
);
|
||||
// A row's own media_type wins; otherwise the *item's* type decides. Rows
|
||||
// queued from a media card never carry one (`download_item` does not record
|
||||
// it), and defaulting that NULL to 'audio' resolved movies against
|
||||
// `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
|
||||
.query_many(rows_query, |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
|
||||
@@ -317,6 +663,16 @@ pub async fn resume_queued_downloads(
|
||||
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.
|
||||
let repo_for_resolve = Arc::clone(&repo);
|
||||
let outcome = resolve_pending_download_urls(
|
||||
@@ -378,6 +734,43 @@ mod tests {
|
||||
use rusqlite::Connection;
|
||||
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> {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
@@ -389,7 +782,15 @@ mod tests {
|
||||
stream_url TEXT,
|
||||
target_dir 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 +798,18 @@ mod tests {
|
||||
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(
|
||||
db: &Arc<RusqliteService>,
|
||||
item_id: &str,
|
||||
@@ -433,6 +846,12 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// IT-017: a download queued from a greyed-out offline catalog entry
|
||||
/// (pending, `stream_url IS NULL`) persists, and on reconnect its URL is
|
||||
/// resolved and the row is healed (URL + target dir) so the pump can start
|
||||
/// it — while already-resolved rows are left untouched.
|
||||
///
|
||||
/// TRACES: UR-052, UR-011 | IT-017
|
||||
#[tokio::test]
|
||||
async fn resolves_offline_queued_row_and_leaves_resolved_rows_untouched() {
|
||||
let db = test_db();
|
||||
@@ -483,6 +902,137 @@ mod tests {
|
||||
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]
|
||||
async fn video_rows_use_media_type_in_resolver() {
|
||||
let db = test_db();
|
||||
|
||||
@@ -270,6 +270,19 @@ pub async fn download_item(
|
||||
if !can_download {
|
||||
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
|
||||
match cache_arc
|
||||
.evict_lru_async(&db_service, &user_id, size as u64)
|
||||
|
||||
@@ -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 device;
|
||||
pub mod download;
|
||||
pub mod favorites;
|
||||
pub mod offline;
|
||||
pub mod playback_mode;
|
||||
pub mod playback_reporting;
|
||||
@@ -16,6 +17,7 @@ pub mod repository;
|
||||
pub mod sessions;
|
||||
pub mod storage;
|
||||
pub mod sync;
|
||||
pub mod sync_drain;
|
||||
|
||||
pub use auth::*;
|
||||
pub use catalog::*;
|
||||
@@ -33,3 +35,4 @@ pub use repository::{RepositoryManager, RepositoryManagerWrapper, *};
|
||||
pub use sessions::*;
|
||||
pub use storage::*;
|
||||
pub use sync::*;
|
||||
pub use sync_drain::*;
|
||||
|
||||
@@ -57,18 +57,6 @@ pub struct MediaSessionManagerWrapper(pub Mutex<MediaSessionManager>);
|
||||
/// @req: DR-048 - Video settings (auto-play toggle, countdown duration)
|
||||
pub struct VideoSettingsWrapper(pub Mutex<VideoSettings>);
|
||||
|
||||
/// Base offset (seconds) for the active background-audio handoff.
|
||||
///
|
||||
/// The audio-only stream is requested with `StartTimeTicks` = the handoff
|
||||
/// position, so the server makes that point the stream's zero. ExoPlayer then
|
||||
/// reports position RELATIVE to that zero. To convert back to an absolute
|
||||
/// position on exit (so the video resumes where the audio actually reached), we
|
||||
/// add this stored base to the native player's reported position.
|
||||
///
|
||||
/// TRACES: UR-040 | DR-052
|
||||
#[derive(Default)]
|
||||
pub struct BackgroundAudioOffset(pub Mutex<f64>);
|
||||
|
||||
/// Response for player state queries
|
||||
#[derive(specta::Type, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -205,6 +193,36 @@ pub struct PlayItemRequest {
|
||||
/// zero-duration session renders no scrubber, even with ACTION_SEEK_TO set.
|
||||
#[serde(default)]
|
||||
pub duration_seconds: Option<f64>,
|
||||
/// Item type (e.g. "Episode", "Movie", "Audio"). Carried through the
|
||||
/// background-audio handoff so an episode played as audio-only is still
|
||||
/// recognised as an episode by autoplay (UR-040) and advances to the next one.
|
||||
#[serde(default)]
|
||||
pub item_type: Option<String>,
|
||||
/// Series ID for TV episodes. Needed alongside `item_type` so the backend can
|
||||
/// look up the next episode when a background-audio track ends.
|
||||
#[serde(default)]
|
||||
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?
|
||||
@@ -376,12 +394,81 @@ pub(super) async fn create_media_item(
|
||||
needs_transcoding: req.needs_transcoding,
|
||||
video_width: 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
|
||||
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
|
||||
pub(super) async fn check_for_local_download(
|
||||
db: &DatabaseWrapper,
|
||||
@@ -392,30 +479,33 @@ pub(super) async fn check_for_local_download(
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
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())],
|
||||
);
|
||||
resolve_local_media_path(&db_service, item_id).await
|
||||
}
|
||||
|
||||
let path: Option<String> = db_service
|
||||
.query_optional(query, |row| row.get(0))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
/// 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
|
||||
#[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
|
||||
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)
|
||||
}
|
||||
resolve_local_media_path(&db_service, &item_id).await
|
||||
}
|
||||
|
||||
/// Re-point queued streaming items at completed local downloads.
|
||||
@@ -577,7 +667,7 @@ pub async fn player_play_item(
|
||||
pub async fn player_enter_background_audio(
|
||||
player: State<'_, PlayerStateWrapper>,
|
||||
session: State<'_, MediaSessionManagerWrapper>,
|
||||
bg_offset: State<'_, BackgroundAudioOffset>,
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
item: PlayItemRequest,
|
||||
position_seconds: f64,
|
||||
) -> Result<PlayerStatus, String> {
|
||||
@@ -586,6 +676,19 @@ pub async fn player_enter_background_audio(
|
||||
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
|
||||
// create_media_item() because that hardcodes MediaType::Video; background
|
||||
// audio must be Audio so no video decode is started.
|
||||
@@ -601,22 +704,21 @@ pub async fn player_enter_background_audio(
|
||||
artists: None,
|
||||
primary_image_tag: item.primary_image_tag.clone(),
|
||||
image_id: item.primary_image_tag.clone(),
|
||||
item_type: None,
|
||||
// Carry episode identity so autoplay can advance to the next episode when
|
||||
// this audio-only handoff ends while backgrounded (UR-040).
|
||||
item_type: item.item_type.clone(),
|
||||
playlist_id: None,
|
||||
// Carry the real duration so the lockscreen MediaSession can draw a scrubber.
|
||||
duration: item.duration_seconds,
|
||||
artwork_url: None,
|
||||
media_type: MediaType::Audio,
|
||||
source: MediaSource::Remote {
|
||||
stream_url: item.stream_url,
|
||||
jellyfin_item_id: item.id.clone(),
|
||||
},
|
||||
source,
|
||||
video_codec: None,
|
||||
needs_transcoding: false,
|
||||
video_width: None,
|
||||
video_height: None,
|
||||
subtitles: vec![],
|
||||
series_id: None,
|
||||
series_id: item.series_id.clone(),
|
||||
server_id: item.server_id.clone(),
|
||||
};
|
||||
|
||||
@@ -625,17 +727,18 @@ pub async fn player_enter_background_audio(
|
||||
session_mgr.start_audio_session(media_item.clone());
|
||||
}
|
||||
|
||||
// Remember where the video was: the audio stream's zero == this position
|
||||
// (the URL was built with StartTimeTicks=position_seconds), so on exit we add
|
||||
// this base to the native player's relative position to get the absolute one.
|
||||
*bg_offset.0.lock().map_err(|e| e.to_string())? = position_seconds.max(0.0);
|
||||
|
||||
// Same base offset drives the lockscreen scrubber: ExoPlayer reports position
|
||||
// relative to the stream's StartTimeTicks zero, but the metadata duration is
|
||||
// absolute, so shift the reported position back to absolute for the scrubber.
|
||||
let _ = crate::player::set_lockscreen_position_offset(position_seconds.max(0.0));
|
||||
|
||||
let controller = player.0.lock().await;
|
||||
// Remember where the video was: the audio stream's zero == this position
|
||||
// (the URL was built with StartTimeTicks=position_seconds), so on exit we add
|
||||
// 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
|
||||
// clears it along with the stream it described.
|
||||
controller.enter_background_audio(position_seconds);
|
||||
controller
|
||||
.play_item(media_item)
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -666,21 +769,15 @@ pub async fn player_enter_background_audio(
|
||||
#[specta::specta]
|
||||
pub async fn player_exit_background_audio(
|
||||
player: State<'_, PlayerStateWrapper>,
|
||||
bg_offset: State<'_, BackgroundAudioOffset>,
|
||||
) -> Result<f64, String> {
|
||||
// The base offset (handoff position) + native player's relative position =
|
||||
// the absolute position to resume the video at. Read/reset the base first.
|
||||
let base = {
|
||||
let mut off = bg_offset.0.lock().map_err(|e| e.to_string())?;
|
||||
let b = *off;
|
||||
*off = 0.0;
|
||||
b
|
||||
};
|
||||
|
||||
// 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;
|
||||
// The base offset (handoff position) + native player's relative position =
|
||||
// the absolute position to resume the video at. Zero after a backend-driven
|
||||
// episode advance, whose stream already starts at its own zero.
|
||||
let base = controller.exit_background_audio();
|
||||
// Capture position into a `let` BEFORE stop() — never hold work across a lock
|
||||
// re-entrant call (deadlock discipline, CLAUDE.md).
|
||||
let relative = controller.position();
|
||||
@@ -906,6 +1003,16 @@ pub async fn player_stop(
|
||||
.clone()
|
||||
};
|
||||
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 {
|
||||
// Local playback
|
||||
let controller = player.0.lock().await;
|
||||
@@ -1557,6 +1664,43 @@ pub async fn player_get_queue(
|
||||
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 {
|
||||
// Determine backend at compile time based on platform
|
||||
let (backend, use_html5_element) = if cfg!(target_os = "android") {
|
||||
@@ -2386,6 +2530,263 @@ pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> R
|
||||
|
||||
#[cfg(test)]
|
||||
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
|
||||
/// download exists on disk — this is what makes preloaded tracks (and
|
||||
/// offline playback after a connection drop) actually use the cache.
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
//! Audio and video playback settings commands.
|
||||
//!
|
||||
//! TRACES: UR-022, UR-031, UR-032, UR-033 | DR-025, DR-034, DR-035, DR-036
|
||||
//! TRACES: UR-022, UR-027, UR-031, UR-032, UR-033 | DR-025, DR-030, DR-034, DR-035, DR-036, IR-020
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use super::{PlayerStateWrapper, VideoSettingsWrapper};
|
||||
use crate::player::AutoplaySettings;
|
||||
use crate::settings::{AudioSettings, VideoSettings};
|
||||
use crate::settings::{AudioSettings, EqPreset, VideoSettings};
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
@@ -14,13 +14,32 @@ pub async fn player_set_audio_settings(
|
||||
player: State<'_, PlayerStateWrapper>,
|
||||
settings: AudioSettings,
|
||||
) -> Result<AudioSettings, String> {
|
||||
// Validate/normalise domain values before applying: clamp crossfade to its
|
||||
// range and normalise the equalizer band vector (length + gain clamps).
|
||||
let validated = settings
|
||||
.with_crossfade_clamped()
|
||||
.with_equalizer_normalised();
|
||||
let mut controller = player.0.lock().await;
|
||||
controller
|
||||
.set_audio_settings(&settings)
|
||||
.set_audio_settings(&validated)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(controller.audio_settings())
|
||||
}
|
||||
|
||||
/// The built-in equalizer presets and their per-band gain curves (dB), for the
|
||||
/// settings UI. The curve numbers are domain data defined by the band layout,
|
||||
/// so the frontend reads them here rather than encoding them.
|
||||
///
|
||||
/// TRACES: UR-027 | DR-030
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_get_eq_presets() -> Result<Vec<(EqPreset, Vec<f32>)>, String> {
|
||||
Ok(EqPreset::ALL
|
||||
.iter()
|
||||
.map(|p| (*p, p.gains().to_vec()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_get_audio_settings(
|
||||
|
||||
@@ -141,6 +141,8 @@ pub async fn player_play_next_episode(
|
||||
/// - Frontend when HTML5 video ends (Linux/desktop) - passes itemId + repositoryHandle for the video
|
||||
/// - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
|
||||
/// - Android JNI callback also triggers this logic directly
|
||||
///
|
||||
/// TRACES: UR-023, UR-026, UR-040 | DR-047, DR-052, DR-129
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_on_playback_ended(
|
||||
@@ -242,12 +244,34 @@ pub async fn player_on_playback_ended(
|
||||
});
|
||||
}
|
||||
|
||||
// Start countdown if auto_advance enabled
|
||||
// Advance if auto_advance is enabled. This is the path that actually
|
||||
// runs on Android: the JNI callback's own decision is swallowed by the
|
||||
// NewTrackLoaded end reason set at load, so it returns Stop, emits
|
||||
// PlaybackEnded, and the frontend echoes it back into this command —
|
||||
// which is where the real decision lands.
|
||||
if auto_advance {
|
||||
controller_arc
|
||||
.lock()
|
||||
.await
|
||||
.start_autoplay_countdown(next_episode, countdown_seconds);
|
||||
.auto_advance_to_next_episode(next_episode, countdown_seconds)
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -255,6 +279,53 @@ pub async fn player_on_playback_ended(
|
||||
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 =====
|
||||
//
|
||||
// On platforms where video renders in the webview (Linux WebKitGTK HTML5
|
||||
|
||||
@@ -12,9 +12,11 @@ use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::rank_search_results;
|
||||
use crate::jellyfin::HttpClient;
|
||||
use crate::repository::{
|
||||
types::*, HybridRepository, MediaRepository, OfflineRepository, OnlineRepository,
|
||||
series_progress, types::*, HybridRepository, MediaRepository, OfflineRepository,
|
||||
OnlineRepository,
|
||||
};
|
||||
|
||||
/// Repository handle manager
|
||||
@@ -39,6 +41,19 @@ impl RepositoryManager {
|
||||
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) {
|
||||
let mut repos = self.repositories.lock_safe();
|
||||
repos.remove(handle);
|
||||
@@ -319,6 +334,71 @@ pub async fn repository_get_next_up_episodes(
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Every episode of a series, across all seasons, in series order.
|
||||
///
|
||||
/// Jellyfin hangs episodes off season folders — except for "flat" series whose
|
||||
/// children are episodes directly. Both shapes are provider vocabulary, so the
|
||||
/// fan-out and its fallback live in Rust rather than being reimplemented in the
|
||||
/// frontend (which is what it used to do).
|
||||
///
|
||||
/// TRACES: UR-062 | DR-101
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_series_episodes(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
series_id: String,
|
||||
) -> Result<Vec<MediaItem>, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
series_progress::fetch_series_episodes(repo.as_ref(), &series_id)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// The episode a viewer should land on when they open a series.
|
||||
///
|
||||
/// "Current" is domain policy, not layout: an episode in progress, else the
|
||||
/// server's Next Up for the series, else the first unwatched episode, else the
|
||||
/// first. The third rung is what makes this work offline, where Next Up is
|
||||
/// always empty. Returns `None` only when the series has no episodes at all.
|
||||
///
|
||||
/// TRACES: UR-062 | DR-101
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_series_current_episode(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
series_id: String,
|
||||
) -> Result<Option<MediaItem>, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
series_progress::resolve_current_episode(repo.as_ref(), &series_id)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Erase the viewer's watch history for an item.
|
||||
///
|
||||
/// Clears the played flag and the resume position; on a series or season the
|
||||
/// server applies it to everything inside. A series cleared this way is "never
|
||||
/// watched" again, so `repository_get_series_current_episode` returns its
|
||||
/// premiere. Requires the server — offline this fails rather than diverging
|
||||
/// local state the next sync would overwrite.
|
||||
///
|
||||
/// TRACES: UR-064 | DR-106
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_clear_watch_history(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
item_id: String,
|
||||
) -> Result<(), String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref()
|
||||
.clear_watch_history(&item_id)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Get recently played audio
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
@@ -394,7 +474,12 @@ pub struct SearchUpdateEvent {
|
||||
pub result: SearchResult,
|
||||
}
|
||||
|
||||
/// Search for items
|
||||
/// Search for items.
|
||||
///
|
||||
/// Resolves `SearchOptions::scope` into concrete Jellyfin item types before
|
||||
/// dispatching, so scope taxonomy stays in Rust.
|
||||
///
|
||||
/// TRACES: UR-049, UR-050 | DR-063
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_search(
|
||||
@@ -407,9 +492,19 @@ pub async fn repository_search(
|
||||
) -> Result<SearchResult, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
|
||||
// Expand the opaque scope into item types HERE — once, before the cache and
|
||||
// server paths diverge — so both phases filter identically. Doing it later
|
||||
// (or in only one path) makes offline results disagree with online ones.
|
||||
// The frontend sends `scope` and never names a Jellyfin item type for
|
||||
// search; see docs/specs/scoped-search-boundary.md.
|
||||
let options = options.map(|mut o| {
|
||||
o.resolve_scope();
|
||||
o
|
||||
});
|
||||
|
||||
// Phase 1: instant local results from the cache (downloaded content) so the
|
||||
// UI can render immediately while the server is still being queried.
|
||||
let cache_result = repo
|
||||
let mut cache_result = repo
|
||||
.search_cache_only(&query, options.clone())
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
@@ -420,6 +515,12 @@ pub async fn repository_search(
|
||||
}
|
||||
});
|
||||
|
||||
// Neither backend orders by *where* the query matched, so a mid-word hit
|
||||
// ("Sparks" for "parks") can outrank a prefix hit ("Parks and Recreation").
|
||||
// Both phases are ranked with the same rules so the list does not reshuffle
|
||||
// when the server results land.
|
||||
rank_search_results(&mut cache_result.items, &query);
|
||||
|
||||
// Phase 2: query the live server in the background, merge with the cache,
|
||||
// and push the union to the frontend via a `search-event`. Tagged with
|
||||
// `request_id` so the frontend can discard results from superseded queries.
|
||||
@@ -428,7 +529,11 @@ pub async fn repository_search(
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match repo_bg.search_server_only(&query, options).await {
|
||||
Ok(server_result) => {
|
||||
let merged = HybridRepository::merge_search_results(cache_for_merge, server_result);
|
||||
let mut merged =
|
||||
HybridRepository::merge_search_results(cache_for_merge, server_result);
|
||||
// Rank the union, not each half: a server-only prefix match must
|
||||
// be able to outrank a cached mid-word one.
|
||||
rank_search_results(&mut merged.items, &query);
|
||||
let event = SearchUpdateEvent {
|
||||
request_id,
|
||||
result: merged,
|
||||
@@ -607,9 +712,19 @@ pub async fn repository_report_playback_progress(
|
||||
}
|
||||
|
||||
/// 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]
|
||||
#[specta::specta]
|
||||
pub async fn repository_report_playback_stopped(
|
||||
db: State<'_, crate::commands::storage::DatabaseWrapper>,
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
item_id: String,
|
||||
@@ -618,10 +733,39 @@ pub async fn repository_report_playback_stopped(
|
||||
// Milliseconds across the boundary; the Jellyfin API wants ticks.
|
||||
let position_ticks = position_ms * 10_000;
|
||||
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)
|
||||
.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
|
||||
.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
|
||||
@@ -688,6 +832,125 @@ pub async fn repository_mark_favorite(
|
||||
.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
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
@@ -761,6 +1024,44 @@ mod tests {
|
||||
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]
|
||||
fn test_repository_manager_wrapper_structure() {
|
||||
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())
|
||||
}
|
||||
|
||||
/// 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)
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
|
||||
@@ -47,10 +47,25 @@ pub async fn storage_save_person(
|
||||
};
|
||||
|
||||
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,
|
||||
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![
|
||||
QueryParam::String(person.id),
|
||||
QueryParam::String(person.server_id),
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
//!
|
||||
//! The sync queue stores mutations (favorites, playback progress, etc.)
|
||||
//! 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 std::sync::Arc;
|
||||
@@ -24,6 +26,12 @@ pub struct SyncQueueItem {
|
||||
pub retry_count: i32,
|
||||
pub created_at: 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
|
||||
@@ -74,20 +82,20 @@ pub async fn sync_get_pending(
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
let sql = if let Some(l) = limit {
|
||||
format!(
|
||||
"SELECT id, user_id, operation, item_id, payload, status, retry_count, created_at, error_message
|
||||
FROM sync_queue
|
||||
WHERE user_id = ? AND status IN ('pending', 'failed')
|
||||
ORDER BY created_at ASC
|
||||
LIMIT {}",
|
||||
l
|
||||
)
|
||||
} else {
|
||||
"SELECT id, user_id, operation, item_id, payload, status, retry_count, created_at, error_message
|
||||
FROM sync_queue
|
||||
WHERE user_id = ? AND status IN ('pending', 'failed')
|
||||
ORDER BY created_at ASC".to_string()
|
||||
// The `items` join names the queued item where the catalog has it; a row for
|
||||
// an item that was never cached still lists, with a null name.
|
||||
// `abandoned` rows (DR-131 gave up on them) are excluded here for the same
|
||||
// reason they are excluded from the count — they are no longer waiting.
|
||||
const SELECT: &str = "SELECT q.id, q.user_id, q.operation, q.item_id, q.payload, q.status,
|
||||
COALESCE(q.retry_count, 0), q.created_at, q.error_message, i.name
|
||||
FROM sync_queue q
|
||||
LEFT JOIN items i ON i.id = q.item_id
|
||||
WHERE q.user_id = ? AND q.status IN ('pending', 'failed')
|
||||
ORDER BY q.created_at ASC, q.id ASC";
|
||||
|
||||
let sql = match limit {
|
||||
Some(l) => format!("{} LIMIT {}", SELECT, l),
|
||||
None => SELECT.to_string(),
|
||||
};
|
||||
|
||||
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)?,
|
||||
created_at: row.get(7)?,
|
||||
error_message: row.get(8)?,
|
||||
item_name: row.get(9)?,
|
||||
})
|
||||
})
|
||||
.await
|
||||
@@ -256,6 +265,7 @@ mod tests {
|
||||
retry_count: 0,
|
||||
created_at: Some("2024-02-14T08:00:00Z".to_string()),
|
||||
error_message: None,
|
||||
item_name: None,
|
||||
};
|
||||
|
||||
// Should serialize successfully
|
||||
@@ -280,6 +290,7 @@ mod tests {
|
||||
retry_count: 3,
|
||||
created_at: Some("2024-02-14T07:00:00Z".to_string()),
|
||||
error_message: Some("Connection timeout".to_string()),
|
||||
item_name: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&item).unwrap();
|
||||
@@ -300,6 +311,7 @@ mod tests {
|
||||
retry_count: 0,
|
||||
created_at: None,
|
||||
error_message: None,
|
||||
item_name: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&item).unwrap();
|
||||
@@ -323,6 +335,7 @@ mod tests {
|
||||
retry_count: 0,
|
||||
created_at: None,
|
||||
error_message: None,
|
||||
item_name: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&item).unwrap();
|
||||
@@ -363,6 +376,7 @@ mod tests {
|
||||
retry_count: 0,
|
||||
created_at: None,
|
||||
error_message: None,
|
||||
item_name: None,
|
||||
};
|
||||
|
||||
// Simulate retries
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,8 @@
|
||||
|
||||
pub mod from_jellyfin;
|
||||
pub mod media;
|
||||
pub mod search_rank;
|
||||
|
||||
pub use from_jellyfin::{kind_from_jellyfin, stream_kind_from_jellyfin, ticks_to_ms};
|
||||
pub use media::{MediaKind, StreamKind};
|
||||
pub use search_rank::rank_search_results;
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
//! Relevance ranking for search results.
|
||||
//!
|
||||
//! Both search paths (the SQLite FTS cache and the Jellyfin server) return items
|
||||
//! in an order that ignores *where* in the name the query matched: a server
|
||||
//! substring hit like "Sparks of Love" can outrank "Parks and Recreation" for
|
||||
//! the query "parks". Neither backend is going to change, so the app imposes its
|
||||
//! own ordering on the union.
|
||||
//!
|
||||
//! Ranking is domain logic, not presentation: it encodes what a "better match"
|
||||
//! means and which media kinds outrank which. The frontend only renders the
|
||||
//! order it is given.
|
||||
//!
|
||||
//! Two rules, in priority order:
|
||||
//!
|
||||
//! 1. **Match position** — a prefix match beats a word-start match, which beats
|
||||
//! a mid-word substring match. This is what makes "parks" find
|
||||
//! "Parks and Recreation" before "Sparks of Love".
|
||||
//! 2. **Kind** — containers before their contents at equal match quality, so a
|
||||
//! series outranks its own episodes.
|
||||
//!
|
||||
//! Ties fall back to the input order, so a backend's own relevance signal (FTS
|
||||
//! `rank`) still breaks ties it was never overruled on.
|
||||
|
||||
use crate::domain::MediaKind;
|
||||
use crate::repository::types::MediaItem;
|
||||
|
||||
/// How well a query matched an item's name — better matches sort first.
|
||||
///
|
||||
/// Ordered by discriminant: `Prefix` is the strongest. Derived `Ord` gives the
|
||||
/// comparison for free, so adding a tier in the right position is all it takes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum MatchQuality {
|
||||
/// The name starts with the query — "parks" in "Parks and Recreation".
|
||||
Prefix,
|
||||
/// Some later *word* starts with the query — "recreation" in "Parks and
|
||||
/// Recreation". Still a deliberate hit: users type whole words.
|
||||
WordStart,
|
||||
/// The query appears mid-word — "parks" in "Sparks of Love". Weakest hit
|
||||
/// that still counts as a match.
|
||||
Substring,
|
||||
/// No match on the name at all. The backend returned it for some other
|
||||
/// reason (overview, artist, album), so it is kept but sorted last.
|
||||
None,
|
||||
}
|
||||
|
||||
/// Rank of a media kind when match quality ties — lower sorts first.
|
||||
///
|
||||
/// Containers outrank the items they contain: searching a show's name should
|
||||
/// surface the show, not an arbitrary episode of it. Within a tier the order is
|
||||
/// arbitrary but stable, and equal ranks fall through to input order.
|
||||
fn kind_rank(kind: MediaKind) -> u8 {
|
||||
match kind {
|
||||
// Top-level containers a user is most likely to be looking for.
|
||||
MediaKind::Series | MediaKind::Movie | MediaKind::Album | MediaKind::Artist => 0,
|
||||
// Sub-containers and standalone collections.
|
||||
MediaKind::Season | MediaKind::Playlist | MediaKind::Channel | MediaKind::Folder => 1,
|
||||
// Leaves — an episode/track is a match *inside* something bigger.
|
||||
MediaKind::Episode | MediaKind::Track | MediaKind::LiveChannel | MediaKind::ChannelItem => {
|
||||
2
|
||||
}
|
||||
// Peripheral matches.
|
||||
MediaKind::Person | MediaKind::Other => 3,
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify how `query` matches `name`, case-insensitively.
|
||||
///
|
||||
/// Both sides are trimmed and lowercased; an empty query matches everything
|
||||
/// equally (`Prefix`), which leaves the input order untouched.
|
||||
pub fn match_quality(name: &str, query: &str) -> MatchQuality {
|
||||
let query = query.trim().to_lowercase();
|
||||
if query.is_empty() {
|
||||
return MatchQuality::Prefix;
|
||||
}
|
||||
let name = name.trim().to_lowercase();
|
||||
|
||||
let Some(index) = name.find(&query) else {
|
||||
return MatchQuality::None;
|
||||
};
|
||||
|
||||
if index == 0 {
|
||||
return MatchQuality::Prefix;
|
||||
}
|
||||
|
||||
// A word start is any match preceded by a non-alphanumeric character, so
|
||||
// "the-office" and "The Office" behave the same. Indexing back one char is
|
||||
// safe on the byte index `find` returned only via `char_indices`, since a
|
||||
// multi-byte char would panic on a raw slice.
|
||||
let preceded_by_boundary = name[..index]
|
||||
.chars()
|
||||
.next_back()
|
||||
.is_some_and(|c| !c.is_alphanumeric());
|
||||
|
||||
if preceded_by_boundary {
|
||||
MatchQuality::WordStart
|
||||
} else {
|
||||
MatchQuality::Substring
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort search results by relevance to `query`, in place.
|
||||
///
|
||||
/// Stable, so items the rules rank equally keep the order the backend supplied
|
||||
/// (FTS `rank` for cache hits, Jellyfin's own ordering for server hits).
|
||||
///
|
||||
/// TRACES: UR-060 | DR-090
|
||||
pub fn rank_search_results(items: &mut [MediaItem], query: &str) {
|
||||
// An empty query carries no relevance signal, so there is nothing to rank
|
||||
// by — reordering on kind alone would shuffle the backend's own ordering
|
||||
// for no reason.
|
||||
if query.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
items.sort_by_key(|item| (match_quality(&item.name, query), kind_rank(item.kind)));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn item(name: &str, kind: MediaKind) -> MediaItem {
|
||||
let mut item = MediaItem::default();
|
||||
item.id = format!("id-{}-{:?}", name, kind);
|
||||
item.name = name.to_string();
|
||||
item.kind = kind;
|
||||
item
|
||||
}
|
||||
|
||||
fn names(items: &[MediaItem]) -> Vec<&str> {
|
||||
items.iter().map(|i| i.name.as_str()).collect()
|
||||
}
|
||||
|
||||
/// UT-085: a prefix match outranks a mid-word substring match.
|
||||
#[test]
|
||||
fn prefix_match_beats_midword_substring() {
|
||||
assert_eq!(
|
||||
match_quality("Parks and Recreation", "parks"),
|
||||
MatchQuality::Prefix
|
||||
);
|
||||
assert_eq!(
|
||||
match_quality("Sparks of Love", "parks"),
|
||||
MatchQuality::Substring
|
||||
);
|
||||
assert!(MatchQuality::Prefix < MatchQuality::Substring);
|
||||
}
|
||||
|
||||
/// UT-085: the reported bug — "parks" must find the show, not "Sparks".
|
||||
#[test]
|
||||
fn ranks_prefix_match_before_substring_match() {
|
||||
let mut items = vec![
|
||||
item("Sparks of Love", MediaKind::Series),
|
||||
item("Parks and Recreation", MediaKind::Series),
|
||||
];
|
||||
|
||||
rank_search_results(&mut items, "parks");
|
||||
|
||||
assert_eq!(
|
||||
names(&items),
|
||||
vec!["Parks and Recreation", "Sparks of Love"]
|
||||
);
|
||||
}
|
||||
|
||||
/// A match at a later word start beats a mid-word one but loses to a prefix.
|
||||
#[test]
|
||||
fn word_start_ranks_between_prefix_and_substring() {
|
||||
assert_eq!(
|
||||
match_quality("The Office", "office"),
|
||||
MatchQuality::WordStart
|
||||
);
|
||||
assert_eq!(match_quality("Bofficer", "office"), MatchQuality::Substring);
|
||||
|
||||
let mut items = vec![
|
||||
item("Bofficer", MediaKind::Series),
|
||||
item("The Office", MediaKind::Series),
|
||||
item("Office Space", MediaKind::Movie),
|
||||
];
|
||||
|
||||
rank_search_results(&mut items, "office");
|
||||
|
||||
assert_eq!(
|
||||
names(&items),
|
||||
vec!["Office Space", "The Office", "Bofficer"]
|
||||
);
|
||||
}
|
||||
|
||||
/// UT-086: at equal match quality a series outranks an episode.
|
||||
#[test]
|
||||
fn series_ranks_before_episode_at_equal_match_quality() {
|
||||
let mut items = vec![
|
||||
item("Parks and Recreation S01E01", MediaKind::Episode),
|
||||
item("Parks and Recreation", MediaKind::Series),
|
||||
];
|
||||
|
||||
rank_search_results(&mut items, "parks");
|
||||
|
||||
assert_eq!(
|
||||
names(&items),
|
||||
vec!["Parks and Recreation", "Parks and Recreation S01E01"]
|
||||
);
|
||||
}
|
||||
|
||||
/// Albums outrank their tracks for the same reason series outrank episodes.
|
||||
#[test]
|
||||
fn album_ranks_before_track_at_equal_match_quality() {
|
||||
let mut items = vec![
|
||||
item("Rumours", MediaKind::Track),
|
||||
item("Rumours", MediaKind::Album),
|
||||
];
|
||||
|
||||
rank_search_results(&mut items, "rumours");
|
||||
|
||||
assert_eq!(items[0].kind, MediaKind::Album);
|
||||
}
|
||||
|
||||
/// Match quality dominates kind: a better-matching episode beats a
|
||||
/// worse-matching series, so kind never drags an irrelevant show to the top.
|
||||
#[test]
|
||||
fn match_quality_outranks_kind() {
|
||||
let mut items = vec![
|
||||
item("Sparks of Love", MediaKind::Series),
|
||||
item("Parks Cleanup", MediaKind::Episode),
|
||||
];
|
||||
|
||||
rank_search_results(&mut items, "parks");
|
||||
|
||||
assert_eq!(names(&items), vec!["Parks Cleanup", "Sparks of Love"]);
|
||||
}
|
||||
|
||||
/// Items the backend returned for a non-name reason (overview, artist) are
|
||||
/// kept, but sort below everything that actually matched the name.
|
||||
#[test]
|
||||
fn non_matching_names_sort_last_without_being_dropped() {
|
||||
let mut items = vec![
|
||||
item("Unrelated Documentary", MediaKind::Movie),
|
||||
item("Parks and Recreation", MediaKind::Series),
|
||||
];
|
||||
|
||||
rank_search_results(&mut items, "parks");
|
||||
|
||||
assert_eq!(
|
||||
names(&items),
|
||||
vec!["Parks and Recreation", "Unrelated Documentary"]
|
||||
);
|
||||
}
|
||||
|
||||
/// Ranking is stable: equally-ranked items keep the backend's order, so the
|
||||
/// FTS/server relevance signal still breaks ties.
|
||||
#[test]
|
||||
fn equal_rank_preserves_input_order() {
|
||||
let mut items = vec![
|
||||
item("Parks A", MediaKind::Series),
|
||||
item("Parks B", MediaKind::Series),
|
||||
item("Parks C", MediaKind::Series),
|
||||
];
|
||||
|
||||
rank_search_results(&mut items, "parks");
|
||||
|
||||
assert_eq!(names(&items), vec!["Parks A", "Parks B", "Parks C"]);
|
||||
}
|
||||
|
||||
/// Case and surrounding whitespace never change the tier.
|
||||
#[test]
|
||||
fn matching_is_case_and_whitespace_insensitive() {
|
||||
assert_eq!(
|
||||
match_quality("PARKS AND RECREATION", " parks "),
|
||||
MatchQuality::Prefix
|
||||
);
|
||||
assert_eq!(
|
||||
match_quality("Parks and Recreation", "PARKS"),
|
||||
MatchQuality::Prefix
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty query leaves the order alone rather than reshuffling on kind.
|
||||
#[test]
|
||||
fn empty_query_preserves_input_order() {
|
||||
let mut items = vec![
|
||||
item("Zebra", MediaKind::Episode),
|
||||
item("Apple", MediaKind::Series),
|
||||
];
|
||||
|
||||
rank_search_results(&mut items, "");
|
||||
|
||||
assert_eq!(names(&items), vec!["Zebra", "Apple"]);
|
||||
}
|
||||
|
||||
/// A multi-byte name must not panic when the match is mid-string — the
|
||||
/// boundary check walks chars rather than slicing raw bytes.
|
||||
#[test]
|
||||
fn handles_multibyte_names_without_panicking() {
|
||||
assert_eq!(
|
||||
match_quality("Pokémon Journeys", "journeys"),
|
||||
MatchQuality::WordStart
|
||||
);
|
||||
assert_eq!(
|
||||
match_quality("Café Parks", "parks"),
|
||||
MatchQuality::WordStart
|
||||
);
|
||||
}
|
||||
|
||||
/// Punctuation counts as a word boundary, so "office" hits "The-Office".
|
||||
#[test]
|
||||
fn punctuation_counts_as_a_word_boundary() {
|
||||
assert_eq!(
|
||||
match_quality("The-Office", "office"),
|
||||
MatchQuality::WordStart
|
||||
);
|
||||
assert_eq!(
|
||||
match_quality("Show: Parks", "parks"),
|
||||
MatchQuality::WordStart
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,12 @@ pub struct CacheConfig {
|
||||
pub storage_limit: u64,
|
||||
/// Only cache on WiFi
|
||||
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 {
|
||||
@@ -37,6 +43,10 @@ impl Default for CacheConfig {
|
||||
album_affinity_threshold: 3,
|
||||
storage_limit: 10 * 1024 * 1024 * 1024, // 10GB
|
||||
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
|
||||
}
|
||||
|
||||
/// 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>(
|
||||
&self,
|
||||
db_service: &Arc<S>,
|
||||
@@ -207,10 +302,26 @@ impl SmartCache {
|
||||
let to_free = (current_size + space_needed) - limit;
|
||||
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(
|
||||
"SELECT id, file_size, file_path FROM downloads
|
||||
WHERE user_id = ? AND status = 'completed'
|
||||
AND COALESCE(download_source, 'user') = 'auto'
|
||||
ORDER BY completed_at ASC",
|
||||
vec![QueryParam::String(user_id.to_string())],
|
||||
);
|
||||
@@ -304,6 +415,205 @@ mod tests {
|
||||
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]
|
||||
async fn test_storage_limit_check() {
|
||||
use crate::storage::db_service::RusqliteService;
|
||||
|
||||
+87
-7
@@ -5,6 +5,7 @@ mod credentials;
|
||||
mod domain;
|
||||
mod download;
|
||||
mod jellyfin;
|
||||
mod media_server;
|
||||
mod playback_mode;
|
||||
mod playback_reporting;
|
||||
mod player;
|
||||
@@ -85,6 +86,7 @@ use commands::{
|
||||
lms_unsync_player,
|
||||
mark_download_completed,
|
||||
mark_download_failed,
|
||||
media_local_url,
|
||||
offline_get_items,
|
||||
offline_is_available,
|
||||
offline_search,
|
||||
@@ -121,12 +123,16 @@ use commands::{
|
||||
player_get_audio_settings,
|
||||
player_get_autoplay_settings,
|
||||
player_get_cache_config,
|
||||
player_get_capabilities,
|
||||
player_get_eq_presets,
|
||||
player_get_queue,
|
||||
// Session management commands
|
||||
player_get_session,
|
||||
player_get_sleep_timer,
|
||||
player_get_status,
|
||||
player_get_video_settings,
|
||||
// Preload commands
|
||||
player_local_media_path,
|
||||
player_move_in_queue,
|
||||
player_next,
|
||||
player_on_playback_ended,
|
||||
@@ -137,9 +143,9 @@ use commands::{
|
||||
player_play_next_episode,
|
||||
player_play_queue,
|
||||
player_play_tracks,
|
||||
// Preload commands
|
||||
player_preload_upcoming,
|
||||
player_previous,
|
||||
player_recover_stream,
|
||||
player_remove_from_queue,
|
||||
player_report_media_loaded,
|
||||
player_report_position,
|
||||
@@ -177,6 +183,7 @@ use commands::{
|
||||
remote_session_set_volume,
|
||||
remote_session_toggle_mute,
|
||||
// Repository commands
|
||||
repository_clear_watch_history,
|
||||
repository_create,
|
||||
repository_destroy,
|
||||
repository_get_audio_only_stream_url_for_video,
|
||||
@@ -185,6 +192,7 @@ use commands::{
|
||||
repository_get_download_disk_usage,
|
||||
repository_get_downloaded_items,
|
||||
repository_get_downloaded_libraries,
|
||||
repository_get_favorites,
|
||||
repository_get_genres,
|
||||
repository_get_image_url,
|
||||
repository_get_item,
|
||||
@@ -200,6 +208,8 @@ use commands::{
|
||||
repository_get_rediscover_albums,
|
||||
repository_get_resume_items,
|
||||
repository_get_resume_movies,
|
||||
repository_get_series_current_episode,
|
||||
repository_get_series_episodes,
|
||||
repository_get_similar_items,
|
||||
repository_get_subtitle_url,
|
||||
repository_get_video_download_url,
|
||||
@@ -266,6 +276,7 @@ use commands::{
|
||||
sync_mark_completed,
|
||||
sync_mark_failed,
|
||||
sync_mark_processing,
|
||||
sync_process_pending,
|
||||
// Sync queue commands
|
||||
sync_queue_mutation,
|
||||
thumbnail_clear_cache,
|
||||
@@ -515,6 +526,12 @@ fn emit_backend_init_failed(app_handle: &tauri::AppHandle, backend: &'static str
|
||||
}
|
||||
|
||||
/// Create the appropriate player backend for the current platform.
|
||||
// playback_reporter/position_throttler are consumed only by the native audio
|
||||
// backends (mpv/exo); on platforms using the webview audio backend they're unused.
|
||||
#[cfg_attr(
|
||||
not(any(target_os = "linux", target_os = "android")),
|
||||
allow(unused_variables)
|
||||
)]
|
||||
fn create_player_backend(
|
||||
app_handle: tauri::AppHandle,
|
||||
playback_reporter: Arc<tokio::sync::Mutex<Option<playback_reporting::PlaybackReporter>>>,
|
||||
@@ -615,11 +632,19 @@ fn create_player_backend(
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for other platforms
|
||||
// Platforms with no native audio backend (e.g. Windows): render audio-only
|
||||
// playback through a webview <audio> element (all video already renders in
|
||||
// the webview). Falls back to NullBackend only if the backend can't init.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
{
|
||||
warn!("WARNING: No audio backend available for this platform");
|
||||
Box::new(NullBackend::new())
|
||||
info!("No native audio backend for this platform - using webview <audio> backend");
|
||||
match player::WebviewAudioBackend::new(_event_emitter) {
|
||||
Ok(backend) => Box::new(backend),
|
||||
Err(e) => {
|
||||
emit_backend_init_failed(&app_handle, "webview-audio", e.to_string());
|
||||
Box::new(NullBackend::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -658,6 +683,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
player_cycle_repeat,
|
||||
player_get_status,
|
||||
player_get_queue,
|
||||
player_get_capabilities,
|
||||
player_add_to_queue,
|
||||
player_add_track_by_id,
|
||||
player_add_tracks_by_ids,
|
||||
@@ -666,6 +692,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
player_skip_to,
|
||||
player_set_audio_settings,
|
||||
player_get_audio_settings,
|
||||
player_get_eq_presets,
|
||||
player_set_video_settings,
|
||||
player_get_video_settings,
|
||||
// Sleep timer and autoplay commands
|
||||
@@ -677,10 +704,12 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
player_cancel_autoplay_countdown,
|
||||
player_play_next_episode,
|
||||
player_on_playback_ended,
|
||||
player_recover_stream,
|
||||
player_report_state,
|
||||
player_report_position,
|
||||
player_report_media_loaded,
|
||||
// Preload commands
|
||||
player_local_media_path,
|
||||
player_preload_upcoming,
|
||||
player_set_cache_config,
|
||||
player_get_cache_config,
|
||||
@@ -782,6 +811,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
get_download_storage_stats,
|
||||
mark_download_completed,
|
||||
mark_download_failed,
|
||||
media_local_url,
|
||||
start_download,
|
||||
enqueue_download,
|
||||
enqueue_video_downloads,
|
||||
@@ -822,6 +852,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
sync_mark_completed,
|
||||
sync_mark_failed,
|
||||
sync_get_pending_count,
|
||||
sync_process_pending,
|
||||
sync_cleanup_completed,
|
||||
sync_clear_user,
|
||||
// Thumbnail cache and image commands
|
||||
@@ -853,6 +884,9 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
repository_get_latest_items,
|
||||
repository_get_resume_items,
|
||||
repository_get_next_up_episodes,
|
||||
repository_get_series_episodes,
|
||||
repository_get_series_current_episode,
|
||||
repository_clear_watch_history,
|
||||
repository_get_recently_played_audio,
|
||||
repository_get_resume_movies,
|
||||
repository_get_rediscover_albums,
|
||||
@@ -871,6 +905,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
repository_get_image_url,
|
||||
repository_mark_favorite,
|
||||
repository_unmark_favorite,
|
||||
repository_get_favorites,
|
||||
repository_get_person,
|
||||
repository_get_items_by_person,
|
||||
repository_get_similar_items,
|
||||
@@ -973,6 +1008,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)]
|
||||
pub fn run() {
|
||||
// Initialize logger
|
||||
@@ -1180,9 +1225,6 @@ pub fn run() {
|
||||
let video_settings = VideoSettingsWrapper(Mutex::new(VideoSettings::default()));
|
||||
app.manage(video_settings);
|
||||
|
||||
// Background-audio handoff base offset (UR-040).
|
||||
app.manage(commands::player::BackgroundAudioOffset::default());
|
||||
|
||||
// Initialize thumbnail cache
|
||||
info!("[INIT] Initializing thumbnail cache...");
|
||||
let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
|
||||
@@ -1203,6 +1245,22 @@ pub fn run() {
|
||||
let smart_cache_wrapper = SmartCacheWrapper(Mutex::new(smart_cache));
|
||||
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
|
||||
info!("[INIT] Initializing download manager...");
|
||||
let download_dir = app_data_dir.join("downloads");
|
||||
@@ -1269,6 +1327,28 @@ pub fn run() {
|
||||
let playback_reporter_wrapper = PlaybackReporterWrapper(playback_reporter.clone());
|
||||
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");
|
||||
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.
|
||||
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
|
||||
/// 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
|
||||
pub struct PlaybackModeManager {
|
||||
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
|
||||
/// mirror store stays in sync with this authoritative one. `None` in tests.
|
||||
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 {
|
||||
@@ -65,6 +115,24 @@ impl PlaybackModeManager {
|
||||
current_mode: Arc::new(RwLock::new(PlaybackMode::Idle)),
|
||||
is_transferring: Arc::new(AtomicBool::new(false)),
|
||||
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
|
||||
/// write lock is released before emitting to avoid holding it across the
|
||||
/// 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) {
|
||||
log::info!("[PlaybackMode] Setting mode to: {:?}", mode);
|
||||
let changed = {
|
||||
let (changed, was_remote) = {
|
||||
let mut current = self.current_mode.write_safe();
|
||||
let changed = *current != mode;
|
||||
let was_remote = matches!(*current, PlaybackMode::Remote { .. });
|
||||
*current = mode.clone();
|
||||
changed
|
||||
(changed, was_remote)
|
||||
};
|
||||
|
||||
if !changed {
|
||||
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 {
|
||||
PlaybackMode::Local => ("local".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
|
||||
/// remote-entry path (notably the empty-queue early return in
|
||||
/// `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) {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
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.
|
||||
}
|
||||
}
|
||||
self.remote_volume.enable(DEFAULT_REMOTE_VOLUME);
|
||||
}
|
||||
|
||||
/// Check if currently transferring
|
||||
@@ -766,18 +849,10 @@ impl PlaybackModeManager {
|
||||
// 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");
|
||||
|
||||
// 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);
|
||||
|
||||
// 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");
|
||||
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
|
||||
/// (and the event channel) shouldn't be spammed on no-op transitions.
|
||||
#[test]
|
||||
|
||||
@@ -18,6 +18,7 @@ use super::events::{PlayerStatusEvent, SharedEventEmitter};
|
||||
use super::media::{MediaItem, MediaType};
|
||||
use super::state::PlayerState;
|
||||
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
|
||||
use crate::settings::{audio_settings_jni_payload, AudioSettings};
|
||||
use crate::utils::conversions::seconds_to_ticks;
|
||||
|
||||
/// Global reference to the JavaVM for JNI callbacks
|
||||
@@ -57,11 +58,20 @@ static POSITION_THROTTLER: OnceLock<Arc<EventThrottler>> = OnceLock::new();
|
||||
struct DetectedCodecs {
|
||||
video_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 {
|
||||
/// 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 {
|
||||
video_codecs: video_codecs
|
||||
.split(',')
|
||||
@@ -73,6 +83,7 @@ impl DetectedCodecs {
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.collect(),
|
||||
max_audio_channels,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,11 +98,19 @@ impl DetectedCodecs {
|
||||
}
|
||||
}
|
||||
|
||||
/// Public function to get detected codecs (for use in repository layer)
|
||||
pub fn get_detected_codecs() -> Option<(String, String)> {
|
||||
DETECTED_CODECS
|
||||
.get()
|
||||
.map(|codecs| (codecs.video_codecs_string(), codecs.audio_codecs_string()))
|
||||
/// Public function to get detected codecs (for use in repository layer).
|
||||
///
|
||||
/// Returns `(video, audio, max_audio_channels)` — the third element is how many
|
||||
/// channels the current audio output can actually voice, which bounds what the
|
||||
/// 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.
|
||||
@@ -148,6 +167,10 @@ struct ExoPlayerState {
|
||||
volume: f32,
|
||||
is_loaded: bool,
|
||||
current_media: Option<MediaItem>,
|
||||
/// Last applied audio settings. Unlike the fields above (which JNI callbacks
|
||||
/// push *in*), this is commanded *out* — audio settings are never reported
|
||||
/// by the player, so this is the authoritative copy for `audio_settings()`.
|
||||
audio_settings: AudioSettings,
|
||||
}
|
||||
|
||||
impl ExoPlayerState {
|
||||
@@ -159,6 +182,7 @@ impl ExoPlayerState {
|
||||
volume: 1.0,
|
||||
is_loaded: false,
|
||||
current_media: None,
|
||||
audio_settings: AudioSettings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -529,6 +553,56 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
self.shared_state.lock_safe().volume
|
||||
}
|
||||
|
||||
/// Apply audio settings to ExoPlayer (equalizer, normalization, gapless).
|
||||
///
|
||||
/// Sent as JSON rather than a wide JNI signature so new fields do not change
|
||||
/// the method signature — the same approach `load()` uses for subtitles. The
|
||||
/// Kotlin side owns the *mechanics* (attaching AudioEffects to the audio
|
||||
/// session); the canonical band layout and preset curves stay in Rust.
|
||||
///
|
||||
/// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036
|
||||
fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
|
||||
let json = audio_settings_jni_payload(settings).map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to serialize audio settings: {}", e))
|
||||
})?;
|
||||
|
||||
let vm = JAVA_VM
|
||||
.get()
|
||||
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
|
||||
|
||||
let mut env = vm
|
||||
.attach_current_thread()
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to attach thread: {}", e)))?;
|
||||
|
||||
let json_jstring = env.new_string(&json).map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to create settings string: {}", e))
|
||||
})?;
|
||||
|
||||
env.call_method(
|
||||
&self.player_ref,
|
||||
"setAudioSettings",
|
||||
"(Ljava/lang/String;)V",
|
||||
&[JValue::Object(&json_jstring)],
|
||||
)
|
||||
.map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to call setAudioSettings: {}", e))
|
||||
})?;
|
||||
|
||||
// Store the sanitised form so audio_settings() reflects what was applied,
|
||||
// not what was requested.
|
||||
self.shared_state.lock_safe().audio_settings = settings
|
||||
.clone()
|
||||
.with_crossfade_clamped()
|
||||
.with_equalizer_normalised();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036
|
||||
fn audio_settings(&self) -> AudioSettings {
|
||||
self.shared_state.lock_safe().audio_settings.clone()
|
||||
}
|
||||
|
||||
fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError> {
|
||||
let vm = JAVA_VM
|
||||
.get()
|
||||
@@ -858,12 +932,39 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
});
|
||||
}
|
||||
|
||||
// Start countdown if auto_advance enabled
|
||||
if auto_advance {
|
||||
// Shared with the frontend-invoked command path
|
||||
// (player_on_playback_ended) so the two dispatchers cannot
|
||||
// disagree about how a background audio-only episode
|
||||
// advances — they did, and the command's copy was missing
|
||||
// the case entirely. That copy is the one that actually
|
||||
// decides here: the end reason set at load makes this
|
||||
// callback's own decision Stop, and the frontend echoes the
|
||||
// resulting PlaybackEnded back into the command.
|
||||
controller
|
||||
.lock()
|
||||
.await
|
||||
.start_autoplay_countdown(next_episode, countdown_seconds);
|
||||
.auto_advance_to_next_episode(next_episode, countdown_seconds)
|
||||
.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) => {
|
||||
@@ -910,11 +1011,61 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
.get_string(&message)
|
||||
.map(|s| s.into())
|
||||
.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() {
|
||||
emitter.emit(PlayerStatusEvent::Error {
|
||||
message: message_str,
|
||||
recoverable: recoverable != 0,
|
||||
recoverable,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -992,6 +1143,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_00024Co
|
||||
_class: JClass,
|
||||
video_codecs: JString,
|
||||
audio_codecs: JString,
|
||||
max_audio_channels: jint,
|
||||
) {
|
||||
let video_str: String = env
|
||||
.get_string(&video_codecs)
|
||||
@@ -1003,7 +1155,10 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_00024Co
|
||||
.map(|s| s.into())
|
||||
.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!(
|
||||
"[CodecDetection] Detected {} video codecs: {}",
|
||||
@@ -1015,6 +1170,10 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_00024Co
|
||||
codecs.audio_codecs.len(),
|
||||
codecs.audio_codecs_string()
|
||||
);
|
||||
log::info!(
|
||||
"[CodecDetection] Audio route max channels: {:?}",
|
||||
codecs.max_audio_channels
|
||||
);
|
||||
|
||||
// Store in global state
|
||||
if DETECTED_CODECS.set(codecs).is_err() {
|
||||
|
||||
@@ -11,6 +11,10 @@ pub enum AutoplayDecision {
|
||||
Stop,
|
||||
/// Advance to next track in queue (for audio/movies)
|
||||
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
|
||||
ShowNextEpisodePopup {
|
||||
current_episode: MediaItem,
|
||||
|
||||
@@ -332,6 +332,7 @@ mod tests {
|
||||
gapless_playback: false,
|
||||
normalize_volume: true,
|
||||
volume_level: VolumeLevel::Loud,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
backend.set_audio_settings(&settings).unwrap();
|
||||
|
||||
@@ -156,6 +156,25 @@ pub enum PlayerStatusEvent {
|
||||
/// Target position in seconds (only meaningful for "seek").
|
||||
position: Option<f64>,
|
||||
},
|
||||
/// Ask the frontend webview `<audio>` element to load and play a stream.
|
||||
///
|
||||
/// Emitted by `WebviewAudioBackend` on platforms with no native audio
|
||||
/// backend (e.g. Windows): audio-only playback is rendered by an `<audio>`
|
||||
/// element in the webview, mirroring how all video already renders through
|
||||
/// the webview `<video>`. The element then reports its state/position back
|
||||
/// through the `player_report_*` commands, so the Rust controller stays the
|
||||
/// single source of truth. Subsequent play/pause/seek/stop reach the element
|
||||
/// via `ControlCommand`.
|
||||
WebviewAudioLoad {
|
||||
/// Stream URL for the `<audio>` element to play.
|
||||
url: String,
|
||||
/// Jellyfin item id, used as the media_id when reporting state back.
|
||||
media_id: Option<String>,
|
||||
/// Resume position in seconds (0 = start from the beginning).
|
||||
position: f64,
|
||||
/// Whether to begin playing immediately after loading.
|
||||
autoplay: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Trait for emitting player events to the frontend.
|
||||
|
||||
@@ -23,6 +23,23 @@ pub enum QueueContext {
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
pub struct SubtitleTrack {
|
||||
/// Stream index in the media source
|
||||
@@ -33,7 +50,8 @@ pub struct SubtitleTrack {
|
||||
pub language: Option<String>,
|
||||
/// Display title
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
+1424
-13
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,9 @@ use super::backend::{PlayerBackend, PlayerError};
|
||||
use super::events::{PlayerEventEmitter, PlayerStatusEvent};
|
||||
use super::media::{MediaItem, MediaSource};
|
||||
use super::state::PlayerState;
|
||||
use super::stream_end::ObservedTime;
|
||||
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
|
||||
use crate::settings::AudioSettings;
|
||||
use crate::settings::{AudioSettings, VolumeLevel, EQ_BANDS};
|
||||
use crate::utils::conversions::{seconds_to_ticks, volume_to_percent};
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use libmpv::Mpv;
|
||||
@@ -26,6 +27,13 @@ pub struct MpvBackend {
|
||||
playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
|
||||
position_throttler: Arc<EventThrottler>,
|
||||
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 {
|
||||
@@ -139,6 +147,31 @@ impl MpvBackend {
|
||||
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 {
|
||||
current_media: None,
|
||||
volume: 1.0,
|
||||
@@ -152,6 +185,7 @@ impl MpvBackend {
|
||||
playback_reporter,
|
||||
position_throttler,
|
||||
last_seek_time: Arc::new(AtomicU64::new(0)),
|
||||
observed: Arc::new(Mutex::new(ObservedTime::default())),
|
||||
};
|
||||
|
||||
// Start event loop in background thread
|
||||
@@ -250,8 +284,22 @@ impl MpvBackend {
|
||||
debug!("[MpvBackend] Player quitting, NOT emitting PlaybackEnded");
|
||||
// Don't emit - player is shutting down
|
||||
} else if reason == MPV_END_FILE_REASON_ERROR {
|
||||
warn!("[MpvBackend] Track ended with error, NOT emitting PlaybackEnded");
|
||||
// Don't emit - we should handle errors separately
|
||||
// NOT PlaybackEnded — the track did not finish, so
|
||||
// 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 {
|
||||
debug!("[MpvBackend] Unknown end file reason {}, NOT emitting PlaybackEnded", reason);
|
||||
}
|
||||
@@ -283,6 +331,7 @@ impl MpvBackend {
|
||||
let reporter_for_position = reporter.clone();
|
||||
let throttler_for_position = throttler.clone();
|
||||
let last_seek_time_for_position = self.last_seek_time.clone();
|
||||
let observed_for_position = self.observed.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
@@ -294,6 +343,13 @@ impl MpvBackend {
|
||||
mpv_for_position.get_property::<f64>("time-pos"),
|
||||
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
|
||||
// to avoid "jumping to zero" visual glitches while MPV is seeking
|
||||
let now = SystemTime::now()
|
||||
@@ -404,6 +460,9 @@ impl PlayerBackend for MpvBackend {
|
||||
let mut state = self.state.lock_safe();
|
||||
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
|
||||
self.mpv
|
||||
@@ -469,6 +528,10 @@ impl PlayerBackend for MpvBackend {
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -491,15 +554,26 @@ impl PlayerBackend for MpvBackend {
|
||||
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 {
|
||||
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> {
|
||||
self.mpv
|
||||
.get_property::<f64>("duration")
|
||||
.ok()
|
||||
.filter(|d| *d > 0.0)
|
||||
let live = self.mpv.get_property::<f64>("duration").ok();
|
||||
self.observed.lock_safe().duration_or_last(live)
|
||||
}
|
||||
|
||||
fn state(&self) -> PlayerState {
|
||||
@@ -552,8 +626,19 @@ impl PlayerBackend for MpvBackend {
|
||||
})?;
|
||||
}
|
||||
|
||||
// Audio filter chain: build a single lavfi graph combining the EQ
|
||||
// peaking bands and (optionally) a dynamic loudness normalizer, and
|
||||
// set the `af` property. An empty string clears all filters. Both
|
||||
// features share one `af` graph because MPV exposes a single filter
|
||||
// property. See docs/specs/audio-equalizer.md and IR-020.
|
||||
let af = build_af_filter(settings);
|
||||
self.mpv
|
||||
.set_property("af", af.as_str())
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("Failed to set audio filters: {:?}", e),
|
||||
})?;
|
||||
|
||||
// TODO: Implement crossfade via MPV audio filters if needed
|
||||
// TODO: Implement volume normalization if needed
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -563,9 +648,200 @@ impl PlayerBackend for MpvBackend {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the full MPV `af` (audio filter) value from the audio settings.
|
||||
///
|
||||
/// Combines the equalizer peaking bands and the loudness-normalization filter
|
||||
/// into a single `lavfi` graph, because MPV exposes one `af` property. The
|
||||
/// normalizer runs *after* the EQ so it levels the post-EQ signal. Returns an
|
||||
/// empty string when neither feature contributes a filter, which clears `af`.
|
||||
///
|
||||
/// TRACES: UR-027, UR-033 | IR-020, DR-036
|
||||
fn build_af_filter(settings: &AudioSettings) -> String {
|
||||
let mut entries = eq_filter_entries(settings.equalizer_enabled, &settings.equalizer_bands);
|
||||
if let Some(norm) = normalize_filter_entry(settings.normalize_volume, settings.volume_level) {
|
||||
entries.push(norm);
|
||||
}
|
||||
|
||||
if entries.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
format!("lavfi=[{}]", entries.join(","))
|
||||
}
|
||||
|
||||
/// Peaking-EQ filter entries (unwrapped), one ffmpeg `equalizer` (two-pole
|
||||
/// peaking) per band with a non-zero gain, e.g.
|
||||
/// `equalizer=f=31:width_type=o:width=1:g=5`. Returns an empty vec when the EQ
|
||||
/// is disabled or every gain is ~0. Gains are assumed already normalised by
|
||||
/// [`AudioSettings::with_equalizer_normalised`]; bands beyond [`EQ_BANDS`] are
|
||||
/// ignored.
|
||||
///
|
||||
/// TRACES: UR-027 | IR-020
|
||||
fn eq_filter_entries(enabled: bool, bands: &[f32]) -> Vec<String> {
|
||||
if !enabled {
|
||||
return Vec::new();
|
||||
}
|
||||
bands
|
||||
.iter()
|
||||
.zip(EQ_BANDS.iter())
|
||||
.filter(|(gain, _)| gain.abs() >= 0.05) // skip ~0 dB bands
|
||||
.map(|(gain, freq)| {
|
||||
// width_type=o → octave bandwidth; width=1 → one octave per band.
|
||||
format!("equalizer=f={}:width_type=o:width=1:g={}", freq, gain)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Reference peak (`dynaudnorm` `p`, linear amplitude) for the default
|
||||
/// [`VolumeLevel::Normal`] (−14 LUFS) target, leaving −1.2 dB of headroom.
|
||||
const NORMALIZE_REF_PEAK: f32 = 0.87;
|
||||
/// Reference loudness the peak table is anchored at (Normal preset, −14 LUFS).
|
||||
const NORMALIZE_REF_LUFS: f32 = -14.0;
|
||||
|
||||
/// The loudness-normalization filter entry (unwrapped), or `None` when
|
||||
/// normalization is disabled. Uses ffmpeg's `dynaudnorm`, a gentle real-time
|
||||
/// dynamic normalizer that avoids the gain "pumping" `loudnorm`'s single-pass
|
||||
/// mode can produce on very dynamic material.
|
||||
///
|
||||
/// `dynaudnorm` targets a peak amplitude (`p`, linear 0–1), not a LUFS value,
|
||||
/// so the Loud/Normal/Quiet presets become *approximate*: each preset's LUFS
|
||||
/// offset from the Normal reference is applied as a dB offset to the reference
|
||||
/// peak, preserving the Loud > Normal > Quiet ordering. `g=15` (gaussian window
|
||||
/// size) further smooths gain changes; the peak is clamped to a safe (0, 0.99]
|
||||
/// so loud presets never request full-scale.
|
||||
///
|
||||
/// TRACES: UR-033 | DR-036
|
||||
fn normalize_filter_entry(enabled: bool, level: VolumeLevel) -> Option<String> {
|
||||
if !enabled {
|
||||
return None;
|
||||
}
|
||||
// LUFS above the reference → louder → higher peak; each +1 LUFS ≈ +1 dB.
|
||||
let db_offset = level.target_lufs() - NORMALIZE_REF_LUFS;
|
||||
let peak = (NORMALIZE_REF_PEAK * 10f32.powf(db_offset / 20.0)).clamp(0.10, 0.99);
|
||||
// 3 decimals is plenty for a peak target and keeps the filter string stable.
|
||||
Some(format!("dynaudnorm=p={:.3}:g=15", peak))
|
||||
}
|
||||
|
||||
impl Drop for MpvBackend {
|
||||
fn drop(&mut self) {
|
||||
info!("[MpvBackend] Shutting down");
|
||||
// MPV will be automatically cleaned up
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod af_filter_tests {
|
||||
use super::{build_af_filter, eq_filter_entries, normalize_filter_entry};
|
||||
use crate::settings::{AudioSettings, VolumeLevel};
|
||||
|
||||
fn settings() -> AudioSettings {
|
||||
AudioSettings {
|
||||
equalizer_enabled: false,
|
||||
equalizer_bands: vec![0.0; 10],
|
||||
normalize_volume: false,
|
||||
..AudioSettings::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Disabled EQ, or an all-zero curve, produces no EQ entries.
|
||||
///
|
||||
/// TRACES: UR-027 | IR-020 | UT-083
|
||||
#[test]
|
||||
fn test_eq_entries_empty_when_disabled_or_flat() {
|
||||
assert!(eq_filter_entries(false, &[5.0, -3.0, 2.0]).is_empty());
|
||||
assert!(eq_filter_entries(true, &[0.0; 10]).is_empty());
|
||||
// Sub-threshold gains count as flat.
|
||||
assert!(eq_filter_entries(true, &[0.01, -0.02]).is_empty());
|
||||
}
|
||||
|
||||
/// Enabled EQ builds one peaking `equalizer` per non-zero band at the right
|
||||
/// centre frequency and gain, chained inside a single `lavfi` filter.
|
||||
///
|
||||
/// TRACES: UR-027 | IR-020 | UT-084
|
||||
#[test]
|
||||
fn test_eq_filter_builds_lavfi_chain() {
|
||||
// First band (31 Hz) +5 dB, third band (125 Hz) -2 dB, rest flat.
|
||||
let mut s = settings();
|
||||
s.equalizer_enabled = true;
|
||||
s.equalizer_bands = vec![5.0, 0.0, -2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
|
||||
let af = build_af_filter(&s);
|
||||
assert!(af.starts_with("lavfi=["), "wrapped in lavfi: {af}");
|
||||
assert!(af.ends_with("]"));
|
||||
assert!(af.contains("equalizer=f=31:width_type=o:width=1:g=5"));
|
||||
assert!(af.contains("equalizer=f=125:width_type=o:width=1:g=-2"));
|
||||
// Only two bands are non-zero → exactly two peaking filters.
|
||||
assert_eq!(af.matches("equalizer=").count(), 2);
|
||||
}
|
||||
|
||||
/// Disabled normalization yields no filter entry; the combined `af` for a
|
||||
/// fully default (all-off) settings is empty, which clears `af`.
|
||||
///
|
||||
/// TRACES: UR-033 | DR-036 | UT-085
|
||||
#[test]
|
||||
fn test_normalize_disabled_produces_no_filter() {
|
||||
assert!(normalize_filter_entry(false, VolumeLevel::Normal).is_none());
|
||||
assert_eq!(build_af_filter(&settings()), "");
|
||||
}
|
||||
|
||||
/// Enabled normalization emits a `dynaudnorm` filter with a peak target, and
|
||||
/// the peak preserves the Loud > Normal > Quiet ordering.
|
||||
///
|
||||
/// TRACES: UR-033 | DR-036 | UT-086
|
||||
#[test]
|
||||
fn test_normalize_peak_preserves_preset_ordering() {
|
||||
fn peak_of(entry: &str) -> f32 {
|
||||
// "dynaudnorm=p=0.870:g=15" → 0.870
|
||||
entry
|
||||
.split("p=")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split(':').next())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.expect("parseable peak")
|
||||
}
|
||||
|
||||
let loud = normalize_filter_entry(true, VolumeLevel::Loud).unwrap();
|
||||
let normal = normalize_filter_entry(true, VolumeLevel::Normal).unwrap();
|
||||
let quiet = normalize_filter_entry(true, VolumeLevel::Quiet).unwrap();
|
||||
for entry in [&loud, &normal, &quiet] {
|
||||
assert!(
|
||||
entry.starts_with("dynaudnorm="),
|
||||
"dynaudnorm filter: {entry}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
peak_of(&loud) > peak_of(&normal) && peak_of(&normal) > peak_of(&quiet),
|
||||
"Loud {} > Normal {} > Quiet {}",
|
||||
peak_of(&loud),
|
||||
peak_of(&normal),
|
||||
peak_of(&quiet),
|
||||
);
|
||||
// Every preset stays within the safe (0, 0.99] clamp.
|
||||
for p in [peak_of(&loud), peak_of(&normal), peak_of(&quiet)] {
|
||||
assert!(p > 0.0 && p <= 0.99, "peak in range: {p}");
|
||||
}
|
||||
|
||||
let mut s = settings();
|
||||
s.normalize_volume = true;
|
||||
s.volume_level = VolumeLevel::Quiet;
|
||||
let af = build_af_filter(&s);
|
||||
assert!(af.starts_with("lavfi=["));
|
||||
assert!(af.contains("dynaudnorm=p="));
|
||||
}
|
||||
|
||||
/// EQ and normalization coexist in one `lavfi` graph, with the normalizer
|
||||
/// placed after the EQ bands so it levels the post-EQ signal.
|
||||
///
|
||||
/// TRACES: UR-027, UR-033 | IR-020, DR-036 | UT-087
|
||||
#[test]
|
||||
fn test_eq_and_normalize_combine_in_order() {
|
||||
let mut s = settings();
|
||||
s.equalizer_enabled = true;
|
||||
s.equalizer_bands = vec![5.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
|
||||
s.normalize_volume = true;
|
||||
s.volume_level = VolumeLevel::Normal;
|
||||
let af = build_af_filter(&s);
|
||||
|
||||
let eq_pos = af.find("equalizer=").expect("has EQ");
|
||||
let norm_pos = af.find("dynaudnorm=").expect("has normalizer");
|
||||
assert!(eq_pos < norm_pos, "normalizer runs after EQ: {af}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,295 @@
|
||||
//! Webview audio backend — audio-only playback for platforms without a native
|
||||
//! audio backend (currently Windows).
|
||||
//!
|
||||
//! ## Why this exists
|
||||
//! All *video* already renders through the webview HTML5 `<video>` element on
|
||||
//! every platform (see `VideoPlayer.svelte`); libmpv/ExoPlayer only ever drive
|
||||
//! *audio-only* (music) playback. On Windows there is no native audio backend,
|
||||
//! so `create_player_backend()` used to fall back to `NullBackend` and music was
|
||||
//! silent.
|
||||
//!
|
||||
//! This backend fills that gap without any C dependency (so it still
|
||||
//! cross-compiles from Linux): instead of decoding audio itself, it hands the
|
||||
//! stream URL to a frontend `<audio>` element via a `WebviewAudioLoad` event and
|
||||
//! then drives play/pause/seek/stop through `ControlCommand` events — exactly the
|
||||
//! round-trip the HTML5 video path already uses. The `<audio>` element reports
|
||||
//! its real state/position back through the `player_report_*` commands, so the
|
||||
//! Rust `PlayerController` remains the single source of truth (the controller's
|
||||
//! `report_html5_*` methods fold those reports into the normal event pipeline).
|
||||
//!
|
||||
//! Because the reported state flows through the event pipeline (not through this
|
||||
//! backend's `position()`/`state()` pollers — the timer loop does not poll the
|
||||
//! backend for HTML5-rendered media), this backend only needs to keep a
|
||||
//! best-effort local mirror for direct `player_get_state` queries.
|
||||
//!
|
||||
//! TRACES: UR-003, UR-004, UR-005 | DR-004
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use log::{debug, info};
|
||||
|
||||
use super::backend::{PlayerBackend, PlayerError};
|
||||
use super::events::{PlayerEventEmitter, PlayerStatusEvent};
|
||||
use super::media::{MediaItem, MediaSource};
|
||||
use super::state::PlayerState;
|
||||
use crate::settings::AudioSettings;
|
||||
use crate::utils::lock::MutexSafe;
|
||||
|
||||
/// Extract a webview-playable URL from a media item's source.
|
||||
///
|
||||
/// Remote/DirectUrl are HTTP(S) URLs the `<audio>` element can play directly.
|
||||
/// Local files would need the Tauri asset protocol (`convertFileSrc`) on the
|
||||
/// frontend; for now we pass the path through and let the frontend resolve it.
|
||||
fn stream_url(media: &MediaItem) -> String {
|
||||
match &media.source {
|
||||
MediaSource::Remote { stream_url, .. } => stream_url.clone(),
|
||||
MediaSource::DirectUrl { url } => url.clone(),
|
||||
MediaSource::Local { file_path, .. } => file_path.to_string_lossy().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
struct InternalState {
|
||||
current_media: Option<MediaItem>,
|
||||
volume: f32,
|
||||
position: f64,
|
||||
duration: Option<f64>,
|
||||
state: PlayerState,
|
||||
audio_settings: AudioSettings,
|
||||
}
|
||||
|
||||
pub struct WebviewAudioBackend {
|
||||
emitter: Arc<dyn PlayerEventEmitter>,
|
||||
state: Arc<std::sync::Mutex<InternalState>>,
|
||||
}
|
||||
|
||||
impl WebviewAudioBackend {
|
||||
pub fn new(emitter: Arc<dyn PlayerEventEmitter>) -> Result<Self, PlayerError> {
|
||||
info!("[WebviewAudioBackend] Initializing (audio renders in webview <audio>)");
|
||||
Ok(Self {
|
||||
emitter,
|
||||
state: Arc::new(std::sync::Mutex::new(InternalState {
|
||||
current_media: None,
|
||||
volume: 1.0,
|
||||
position: 0.0,
|
||||
duration: None,
|
||||
state: PlayerState::Idle,
|
||||
audio_settings: AudioSettings::default(),
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
/// Emit a backend-originated control intent to the active frontend adapter
|
||||
/// (the webview `<audio>` element, via `playerEvents.ts` -> active adapter).
|
||||
fn emit_control(&self, action: &str, position: Option<f64>) {
|
||||
self.emitter.emit(PlayerStatusEvent::ControlCommand {
|
||||
action: action.to_string(),
|
||||
position,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl PlayerBackend for WebviewAudioBackend {
|
||||
fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError> {
|
||||
let url = stream_url(media);
|
||||
info!("[WebviewAudioBackend] load: {} - {}", media.title, url);
|
||||
|
||||
{
|
||||
let mut st = self.state.lock_safe();
|
||||
st.current_media = Some(media.clone());
|
||||
st.position = 0.0;
|
||||
st.duration = media.duration;
|
||||
st.state = PlayerState::Loading {
|
||||
media: media.clone(),
|
||||
};
|
||||
}
|
||||
|
||||
// Hand the URL to the frontend <audio> element. autoplay=true so a plain
|
||||
// load-then-play (the common queue-advance path) starts immediately; an
|
||||
// explicit pause afterwards is still honored via ControlCommand.
|
||||
self.emitter.emit(PlayerStatusEvent::WebviewAudioLoad {
|
||||
url,
|
||||
media_id: media.jellyfin_id().map(|s| s.to_string()),
|
||||
position: 0.0,
|
||||
autoplay: true,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn play(&mut self) -> Result<(), PlayerError> {
|
||||
debug!("[WebviewAudioBackend] play");
|
||||
self.emit_control("play", None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pause(&mut self) -> Result<(), PlayerError> {
|
||||
debug!("[WebviewAudioBackend] pause");
|
||||
self.emit_control("pause", None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stop(&mut self) -> Result<(), PlayerError> {
|
||||
debug!("[WebviewAudioBackend] stop");
|
||||
{
|
||||
let mut st = self.state.lock_safe();
|
||||
st.current_media = None;
|
||||
st.position = 0.0;
|
||||
st.duration = None;
|
||||
st.state = PlayerState::Idle;
|
||||
}
|
||||
self.emit_control("stop", None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn seek(&mut self, position: f64) -> Result<(), PlayerError> {
|
||||
debug!("[WebviewAudioBackend] seek: {}", position);
|
||||
self.state.lock_safe().position = position;
|
||||
self.emit_control("seek", Some(position));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
|
||||
let clamped = volume.clamp(0.0, 1.0);
|
||||
self.state.lock_safe().volume = clamped;
|
||||
// Volume is applied on the element by the frontend, which observes the
|
||||
// volume via the player store; no dedicated ControlCommand action yet.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn position(&self) -> f64 {
|
||||
self.state.lock_safe().position
|
||||
}
|
||||
|
||||
fn duration(&self) -> Option<f64> {
|
||||
self.state.lock_safe().duration
|
||||
}
|
||||
|
||||
fn state(&self) -> PlayerState {
|
||||
self.state.lock_safe().state.clone()
|
||||
}
|
||||
|
||||
fn volume(&self) -> f32 {
|
||||
self.state.lock_safe().volume
|
||||
}
|
||||
|
||||
fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
|
||||
self.state.lock_safe().audio_settings = settings.clone().with_crossfade_clamped();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn audio_settings(&self) -> AudioSettings {
|
||||
self.state.lock_safe().audio_settings.clone()
|
||||
}
|
||||
}
|
||||
|
||||
// TRACES: UR-003, UR-004, UR-005 | DR-004
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::player::events::PlayerStatusEvent;
|
||||
use crate::player::media::{MediaSource, MediaType};
|
||||
use std::sync::Mutex as StdMutex;
|
||||
|
||||
/// Test emitter that records everything emitted.
|
||||
struct RecordingEmitter {
|
||||
events: Arc<StdMutex<Vec<PlayerStatusEvent>>>,
|
||||
}
|
||||
|
||||
impl PlayerEventEmitter for RecordingEmitter {
|
||||
fn emit(&self, event: PlayerStatusEvent) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_media() -> MediaItem {
|
||||
MediaItem {
|
||||
id: "track1".to_string(),
|
||||
title: "Song".to_string(),
|
||||
name: Some("Song".to_string()),
|
||||
artist: Some("Artist".to_string()),
|
||||
album: Some("Album".to_string()),
|
||||
album_name: Some("Album".to_string()),
|
||||
album_id: None,
|
||||
artist_items: None,
|
||||
artists: Some(vec!["Artist".to_string()]),
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: Some("Audio".to_string()),
|
||||
playlist_id: None,
|
||||
duration: Some(200.0),
|
||||
artwork_url: None,
|
||||
media_type: MediaType::Audio,
|
||||
source: MediaSource::DirectUrl {
|
||||
url: "http://example.com/song.mp3".to_string(),
|
||||
},
|
||||
video_codec: None,
|
||||
needs_transcoding: false,
|
||||
video_width: None,
|
||||
video_height: None,
|
||||
subtitles: vec![],
|
||||
series_id: None,
|
||||
server_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn backend() -> (WebviewAudioBackend, Arc<StdMutex<Vec<PlayerStatusEvent>>>) {
|
||||
let events = Arc::new(StdMutex::new(Vec::new()));
|
||||
let emitter = Arc::new(RecordingEmitter {
|
||||
events: events.clone(),
|
||||
});
|
||||
(WebviewAudioBackend::new(emitter).unwrap(), events)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_emits_webview_audio_load_with_url() {
|
||||
let (mut b, events) = backend();
|
||||
b.load(&test_media()).unwrap();
|
||||
|
||||
let ev = events.lock().unwrap();
|
||||
let load = ev
|
||||
.iter()
|
||||
.find(|e| matches!(e, PlayerStatusEvent::WebviewAudioLoad { .. }))
|
||||
.expect("WebviewAudioLoad emitted");
|
||||
if let PlayerStatusEvent::WebviewAudioLoad { url, autoplay, .. } = load {
|
||||
assert_eq!(url, "http://example.com/song.mp3");
|
||||
assert!(*autoplay);
|
||||
}
|
||||
assert!(matches!(b.state(), PlayerState::Loading { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_and_seek_emit_control_commands() {
|
||||
let (mut b, events) = backend();
|
||||
b.load(&test_media()).unwrap();
|
||||
b.pause().unwrap();
|
||||
b.seek(42.0).unwrap();
|
||||
|
||||
let ev = events.lock().unwrap();
|
||||
assert!(ev.iter().any(|e| matches!(
|
||||
e,
|
||||
PlayerStatusEvent::ControlCommand { action, .. } if action == "pause"
|
||||
)));
|
||||
assert!(ev.iter().any(|e| matches!(
|
||||
e,
|
||||
PlayerStatusEvent::ControlCommand { action, position: Some(p) }
|
||||
if action == "seek" && (*p - 42.0).abs() < f64::EPSILON
|
||||
)));
|
||||
assert_eq!(b.position(), 42.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volume_is_clamped_and_stored() {
|
||||
let (mut b, _events) = backend();
|
||||
b.set_volume(1.5).unwrap();
|
||||
assert_eq!(b.volume(), 1.0);
|
||||
b.set_volume(-0.2).unwrap();
|
||||
assert_eq!(b.volume(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_resets_to_idle() {
|
||||
let (mut b, _events) = backend();
|
||||
b.load(&test_media()).unwrap();
|
||||
b.stop().unwrap();
|
||||
assert!(matches!(b.state(), PlayerState::Idle));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
//! 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 video does not play through ExoPlayer. Both Android and Linux render it
|
||||
/// in a webview `<video>` element, 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.
|
||||
///
|
||||
/// 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: the
|
||||
/// default, or the first when none is marked. 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 {
|
||||
let served = streams
|
||||
.iter()
|
||||
.find(|(_, is_default)| *is_default)
|
||||
.or_else(|| streams.first());
|
||||
|
||||
match served {
|
||||
Some((Some(codec), _)) => !webview_can_decode_audio(codec),
|
||||
// No audio at all, or a codec the server did not name: leave it alone.
|
||||
Some((None, _)) | None => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[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)]));
|
||||
}
|
||||
|
||||
#[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.
|
||||
/// Delegates to online repository for connection reuse and proper auth.
|
||||
pub async fn download_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
|
||||
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
|
||||
/// (the plugin lives on the Jellyfin server); empty when JRay isn't present.
|
||||
pub async fn get_jray_actors(
|
||||
@@ -113,6 +135,41 @@ impl HybridRepository {
|
||||
.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
|
||||
/// offline cache synchronously (unlike `get_items`, which saves in a
|
||||
/// 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)
|
||||
///
|
||||
/// @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> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
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 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 };
|
||||
|
||||
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(
|
||||
@@ -641,6 +779,24 @@ impl MediaRepository for HybridRepository {
|
||||
self.online.get_audio_stream_url(item_id).await
|
||||
}
|
||||
|
||||
async fn get_audio_only_stream_url_for_video(
|
||||
&self,
|
||||
item_id: &str,
|
||||
media_source_id: Option<&str>,
|
||||
start_time_seconds: Option<f64>,
|
||||
audio_stream_index: Option<i32>,
|
||||
) -> Result<String, RepoError> {
|
||||
// Audio-only transcode of a video requires the server - delegate to online.
|
||||
self.online
|
||||
.build_audio_only_stream_url_for_video(
|
||||
item_id,
|
||||
media_source_id,
|
||||
start_time_seconds,
|
||||
audio_stream_index,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
|
||||
// Live TV requires server communication - delegate to online repository
|
||||
self.online.get_live_tv_channels().await
|
||||
@@ -732,6 +888,16 @@ impl MediaRepository for HybridRepository {
|
||||
self.online.unmark_favorite(item_id).await
|
||||
}
|
||||
|
||||
async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
// Write operations go directly to server
|
||||
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> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
@@ -767,6 +933,41 @@ impl MediaRepository for HybridRepository {
|
||||
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(
|
||||
&self,
|
||||
item_id: &str,
|
||||
@@ -1028,6 +1229,16 @@ mod tests {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_audio_only_stream_url_for_video(
|
||||
&self,
|
||||
_item_id: &str,
|
||||
_media_source_id: Option<&str>,
|
||||
_start_time_seconds: Option<f64>,
|
||||
_audio_stream_index: Option<i32>,
|
||||
) -> Result<String, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -1100,6 +1311,22 @@ mod tests {
|
||||
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> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -1276,6 +1503,16 @@ mod tests {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_audio_only_stream_url_for_video(
|
||||
&self,
|
||||
_item_id: &str,
|
||||
_media_source_id: Option<&str>,
|
||||
_start_time_seconds: Option<f64>,
|
||||
_audio_stream_index: Option<i32>,
|
||||
) -> Result<String, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -1348,6 +1585,22 @@ mod tests {
|
||||
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> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
pub mod device_profile;
|
||||
pub mod hybrid;
|
||||
pub mod offline;
|
||||
pub mod online;
|
||||
pub mod series_progress;
|
||||
pub mod types;
|
||||
|
||||
pub use hybrid::HybridRepository;
|
||||
@@ -117,6 +119,22 @@ pub trait MediaRepository: Send + Sync {
|
||||
/// @req: JA-007 - Get playback info and stream URL
|
||||
async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError>;
|
||||
|
||||
/// Get an audio-only stream URL for a *video* item (background-audio handoff).
|
||||
///
|
||||
/// Used when autoplay advances to the next episode while the app is playing a
|
||||
/// video in audio-only mode in the background: the backend needs the next
|
||||
/// episode's audio-only URL without any frontend round-trip. Online-only;
|
||||
/// offline/cache repositories return an error.
|
||||
///
|
||||
/// TRACES: UR-040 | JA-032
|
||||
async fn get_audio_only_stream_url_for_video(
|
||||
&self,
|
||||
item_id: &str,
|
||||
media_source_id: Option<&str>,
|
||||
start_time_seconds: Option<f64>,
|
||||
audio_stream_index: Option<i32>,
|
||||
) -> Result<String, RepoError>;
|
||||
|
||||
/// Get Live TV channels (broadcast / IPTV) for browsing.
|
||||
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError>;
|
||||
|
||||
@@ -195,6 +213,36 @@ pub trait MediaRepository: Send + Sync {
|
||||
/// Unmark item as favorite
|
||||
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
|
||||
/// its resume position. On a container (series, season) this applies to
|
||||
/// everything inside it, so a series is returned to "never watched" and
|
||||
/// reopens on its premiere.
|
||||
///
|
||||
/// TRACES: UR-064 | DR-106
|
||||
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
|
||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError>;
|
||||
|
||||
|
||||
+1554
-56
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,497 @@
|
||||
//! Where a viewer is in a TV series.
|
||||
//!
|
||||
//! This is domain policy, not presentation: it encodes what Jellyfin's user-data
|
||||
//! means ("in progress", "played") and what Jellyfin's season numbering means
|
||||
//! (season 0 is specials). The frontend asks for *the* current episode and
|
||||
//! renders it; it does not get to decide what "current" means.
|
||||
//!
|
||||
//! Split into a pure half (`pick_current_episode`, `sort_series_order`) and an
|
||||
//! I/O half (`fetch_series_episodes`, `resolve_current_episode`) so the policy
|
||||
//! can be unit-tested without standing up a repository.
|
||||
//!
|
||||
//! TRACES: UR-062 | DR-101
|
||||
|
||||
use super::{GetItemsOptions, MediaItem, MediaRepository, RepoError};
|
||||
|
||||
/// Jellyfin files specials under season 0.
|
||||
const SPECIALS_SEASON: i32 = 0;
|
||||
|
||||
/// Below this fraction watched, a position is a false start rather than
|
||||
/// progress — the same threshold the resume dialog uses.
|
||||
const MIN_PROGRESS_FRACTION: f64 = 0.01;
|
||||
|
||||
/// Above this fraction watched, an episode is effectively finished; resuming it
|
||||
/// would drop the viewer into the closing credits.
|
||||
const MAX_PROGRESS_FRACTION: f64 = 0.95;
|
||||
|
||||
/// Sort key for a season number. Specials sort *after* every numbered season:
|
||||
/// a viewer works through S1, S2, … and only then the extras, so season 0 must
|
||||
/// not lead just because `0 < 1`.
|
||||
fn season_rank(season: Option<i32>) -> i64 {
|
||||
match season {
|
||||
Some(SPECIALS_SEASON) => i64::MAX,
|
||||
Some(n) => n as i64,
|
||||
None => i64::MAX - 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Order episodes as the series is watched: season ascending, then episode,
|
||||
/// specials last.
|
||||
pub fn sort_series_order(episodes: &mut [MediaItem]) {
|
||||
episodes.sort_by(|a, b| {
|
||||
season_rank(a.parent_index_number)
|
||||
.cmp(&season_rank(b.parent_index_number))
|
||||
.then(
|
||||
a.index_number
|
||||
.unwrap_or(0)
|
||||
.cmp(&b.index_number.unwrap_or(0)),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
/// Is this episode genuinely part-watched (not a false start, not finished)?
|
||||
fn is_in_progress(item: &MediaItem) -> bool {
|
||||
let Some(user_data) = item.user_data.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
if user_data.is_played.unwrap_or(false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let position_ms = user_data
|
||||
.playback_position_ms
|
||||
.or_else(|| user_data.playback_position_ticks.map(|t| t / 10_000))
|
||||
.unwrap_or(0);
|
||||
if position_ms <= 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Without a duration we cannot tell "2 minutes in" from "2 minutes left",
|
||||
// so any recorded position counts as progress.
|
||||
let Some(duration_ms) = item.duration_ms.filter(|d| *d > 0) else {
|
||||
return true;
|
||||
};
|
||||
|
||||
let fraction = position_ms as f64 / duration_ms as f64;
|
||||
(MIN_PROGRESS_FRACTION..MAX_PROGRESS_FRACTION).contains(&fraction)
|
||||
}
|
||||
|
||||
fn is_played(item: &MediaItem) -> bool {
|
||||
item.user_data
|
||||
.as_ref()
|
||||
.and_then(|u| u.is_played)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn belongs_to_series(item: &MediaItem, series_id: &str) -> bool {
|
||||
item.series_id.as_deref() == Some(series_id)
|
||||
}
|
||||
|
||||
/// The episode a viewer should land on when they open `series_id`.
|
||||
///
|
||||
/// Order of preference, and why:
|
||||
///
|
||||
/// 1. **An episode in progress.** That is literally where playback stopped;
|
||||
/// Next Up would skip past it. On a tie the earliest in series order wins, so
|
||||
/// a viewer who dipped into a later episode still returns to the one they are
|
||||
/// working through.
|
||||
/// 2. **The server's Next Up** for this series — it accounts for watch history
|
||||
/// we do not cache locally.
|
||||
/// 3. **The episode after the furthest-watched one**, falling back to the first
|
||||
/// unwatched episode when nothing has been watched or the series is finished.
|
||||
/// 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
|
||||
/// rather than on nothing.
|
||||
///
|
||||
/// `next_up` / `resume` entries are honoured even when absent from `episodes`
|
||||
/// (the season fan-out can miss an id the server returns), but only when they
|
||||
/// belong to this series.
|
||||
pub fn pick_current_episode(
|
||||
series_id: &str,
|
||||
episodes: &[MediaItem],
|
||||
next_up: &[MediaItem],
|
||||
resume: &[MediaItem],
|
||||
) -> Option<MediaItem> {
|
||||
// 1. In progress — prefer a match inside the ordered episode list so the
|
||||
// "earliest in series order" tie-break is meaningful; fall back to the
|
||||
// resume feed for an episode the fan-out missed.
|
||||
if let Some(found) = episodes.iter().find(|e| is_in_progress(e)) {
|
||||
return Some(found.clone());
|
||||
}
|
||||
if let Some(found) = resume
|
||||
.iter()
|
||||
.find(|e| belongs_to_series(e, series_id) && is_in_progress(e))
|
||||
{
|
||||
return Some(found.clone());
|
||||
}
|
||||
|
||||
// 2. Next Up for this series.
|
||||
if let Some(found) = next_up
|
||||
.iter()
|
||||
.find(|e| e.series_id.is_none() || belongs_to_series(e, series_id))
|
||||
{
|
||||
// Prefer the copy from `episodes` when we have one: it carries the
|
||||
// user-data and images the list already fetched.
|
||||
let matched = episodes.iter().find(|e| e.id == found.id);
|
||||
return Some(matched.unwrap_or(found).clone());
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
return Some(found.clone());
|
||||
}
|
||||
|
||||
// 4. First episode — a fully-watched series reopens at the start.
|
||||
episodes.first().cloned()
|
||||
}
|
||||
|
||||
/// Every episode of a series, in series order.
|
||||
///
|
||||
/// Jellyfin hangs episodes off season folders, except for "flat" series whose
|
||||
/// children are episodes directly. Both shapes are provider vocabulary, so the
|
||||
/// fan-out and the fallback live here rather than in the frontend.
|
||||
pub async fn fetch_series_episodes(
|
||||
repo: &dyn MediaRepository,
|
||||
series_id: &str,
|
||||
) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let children = repo.get_items(series_id, list_options()).await?;
|
||||
|
||||
let mut episodes: Vec<MediaItem> = Vec::new();
|
||||
for season in children.items.iter().filter(|i| is_season(i)) {
|
||||
// One failing season must not blank the whole show.
|
||||
match repo.get_items(&season.id, list_options()).await {
|
||||
Ok(result) => episodes.extend(result.items.into_iter().filter(is_episode)),
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[series] season {} of {} failed to load: {:?}",
|
||||
season.id,
|
||||
series_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flat series: the children *are* the episodes.
|
||||
if episodes.is_empty() {
|
||||
episodes.extend(children.items.into_iter().filter(is_episode));
|
||||
}
|
||||
|
||||
sort_series_order(&mut episodes);
|
||||
Ok(episodes)
|
||||
}
|
||||
|
||||
/// Resolve the current episode, fetching everything the policy needs.
|
||||
///
|
||||
/// Next Up and resume are best-effort: offline they fail or come back empty, and
|
||||
/// `pick_current_episode` has fallbacks for exactly that.
|
||||
pub async fn resolve_current_episode(
|
||||
repo: &dyn MediaRepository,
|
||||
series_id: &str,
|
||||
) -> Result<Option<MediaItem>, RepoError> {
|
||||
let episodes = fetch_series_episodes(repo, series_id).await?;
|
||||
|
||||
let next_up = repo
|
||||
.get_next_up_episodes(Some(series_id), Some(1))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let resume = repo
|
||||
.get_resume_items(Some(series_id), Some(10))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(pick_current_episode(
|
||||
series_id, &episodes, &next_up, &resume,
|
||||
))
|
||||
}
|
||||
|
||||
fn list_options() -> Option<GetItemsOptions> {
|
||||
Some(GetItemsOptions {
|
||||
limit: Some(500),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn is_season(item: &MediaItem) -> bool {
|
||||
item.item_type == "Season" || matches!(item.kind, crate::domain::MediaKind::Season)
|
||||
}
|
||||
|
||||
fn is_episode(item: &MediaItem) -> bool {
|
||||
item.item_type == "Episode" || matches!(item.kind, crate::domain::MediaKind::Episode)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::repository::UserData;
|
||||
|
||||
const SERIES: &str = "series-1";
|
||||
|
||||
fn episode(id: &str, season: i32, number: i32) -> MediaItem {
|
||||
MediaItem {
|
||||
id: id.to_string(),
|
||||
name: format!("S{season}E{number}"),
|
||||
item_type: "Episode".to_string(),
|
||||
series_id: Some(SERIES.to_string()),
|
||||
parent_index_number: Some(season),
|
||||
index_number: Some(number),
|
||||
duration_ms: Some(1_000_000),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn watched(mut item: MediaItem) -> MediaItem {
|
||||
item.user_data = Some(UserData {
|
||||
is_played: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
item
|
||||
}
|
||||
|
||||
fn in_progress(mut item: MediaItem, fraction: f64) -> MediaItem {
|
||||
let duration = item.duration_ms.unwrap_or(1_000_000) as f64;
|
||||
item.user_data = Some(UserData {
|
||||
is_played: Some(false),
|
||||
playback_position_ms: Some((duration * fraction) as i64),
|
||||
..Default::default()
|
||||
});
|
||||
item
|
||||
}
|
||||
|
||||
fn season(n: i32, count: i32) -> Vec<MediaItem> {
|
||||
(1..=count)
|
||||
.map(|i| episode(&format!("s{n}e{i}"), n, i))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorts_by_season_then_episode() {
|
||||
let mut eps = vec![
|
||||
episode("b", 2, 1),
|
||||
episode("d", 1, 10),
|
||||
episode("a", 1, 2),
|
||||
episode("c", 2, 2),
|
||||
];
|
||||
sort_series_order(&mut eps);
|
||||
let ids: Vec<&str> = eps.iter().map(|e| e.id.as_str()).collect();
|
||||
assert_eq!(ids, ["a", "d", "b", "c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorts_specials_after_numbered_seasons() {
|
||||
let mut eps = vec![episode("special", 0, 1), episode("premiere", 1, 1)];
|
||||
sort_series_order(&mut eps);
|
||||
let ids: Vec<&str> = eps.iter().map(|e| e.id.as_str()).collect();
|
||||
assert_eq!(ids, ["premiere", "special"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_the_in_progress_episode_over_next_up() {
|
||||
let mut eps = season(1, 5);
|
||||
eps[0] = watched(eps[0].clone());
|
||||
eps[1] = in_progress(eps[1].clone(), 0.4);
|
||||
// The server would send us past it; the half-watched episode wins.
|
||||
let next_up = vec![episode("s1e3", 1, 3)];
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_the_earliest_in_progress_episode() {
|
||||
let mut eps = season(1, 5);
|
||||
eps[1] = in_progress(eps[1].clone(), 0.3);
|
||||
eps[3] = in_progress(eps[3].clone(), 0.5);
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_a_false_start_and_a_finished_episode() {
|
||||
let mut eps = season(1, 5);
|
||||
eps[0] = watched(eps[0].clone());
|
||||
eps[1] = in_progress(eps[1].clone(), 0.001); // barely started
|
||||
eps[2] = in_progress(eps[2].clone(), 0.99); // effectively over
|
||||
|
||||
// Neither counts as progress, so Next Up decides.
|
||||
let next_up = vec![episode("s1e4", 1, 4)];
|
||||
let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_next_up_when_nothing_is_in_progress() {
|
||||
let eps = season(1, 5);
|
||||
let next_up = vec![episode("s1e3", 1, 3)];
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_up_from_another_series_is_ignored() {
|
||||
let eps = season(1, 3);
|
||||
let mut foreign = episode("other-show-ep", 1, 1);
|
||||
foreign.series_id = Some("series-2".to_string());
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &[foreign], &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e1");
|
||||
}
|
||||
|
||||
/// The offline path: `OfflineRepository::get_next_up_episodes` returns an
|
||||
/// empty vec, so the first unwatched episode has to carry the feature.
|
||||
#[test]
|
||||
fn falls_back_to_first_unwatched_when_next_up_is_empty() {
|
||||
let mut eps = [season(1, 3), season(2, 3)].concat();
|
||||
for ep in eps.iter_mut().take(4) {
|
||||
*ep = watched(ep.clone());
|
||||
}
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
||||
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]
|
||||
fn crosses_a_season_boundary_when_a_season_is_finished() {
|
||||
let mut eps = [season(1, 3), season(2, 3)].concat();
|
||||
for ep in eps.iter_mut().take(3) {
|
||||
*ep = watched(ep.clone());
|
||||
}
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
||||
assert_eq!(current.id, "s2e1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_never_watched_series_opens_on_its_premiere() {
|
||||
let eps = [season(2, 3), season(1, 3)].concat();
|
||||
let mut ordered = eps.clone();
|
||||
sort_series_order(&mut ordered);
|
||||
|
||||
let current = pick_current_episode(SERIES, &ordered, &[], &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fully_watched_series_reopens_at_the_start() {
|
||||
let eps: Vec<MediaItem> = season(1, 3).into_iter().map(watched).collect();
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn honours_a_resume_entry_missing_from_the_episode_list() {
|
||||
// Season fan-out returned nothing usable, but the resume feed knows.
|
||||
let resume = vec![in_progress(episode("s3e7", 3, 7), 0.5)];
|
||||
|
||||
let current = pick_current_episode(SERIES, &[], &[], &resume).unwrap();
|
||||
assert_eq!(current.id, "s3e7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_entries_from_other_series_are_ignored() {
|
||||
let mut foreign = in_progress(episode("other", 1, 1), 0.5);
|
||||
foreign.series_id = Some("series-2".to_string());
|
||||
|
||||
assert!(pick_current_episode(SERIES, &[], &[], &[foreign]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_series_with_no_episodes_has_no_current_episode() {
|
||||
assert!(pick_current_episode(SERIES, &[], &[], &[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_episode_without_a_duration_still_counts_as_in_progress() {
|
||||
let mut ep = episode("s1e2", 1, 2);
|
||||
ep.duration_ms = None;
|
||||
ep.user_data = Some(UserData {
|
||||
is_played: Some(false),
|
||||
playback_position_ms: Some(120_000),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let current = pick_current_episode(SERIES, &[episode("s1e1", 1, 1), ep], &[], &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_tick_positions_still_register_as_progress() {
|
||||
let mut ep = episode("s1e2", 1, 2);
|
||||
ep.user_data = Some(UserData {
|
||||
is_played: Some(false),
|
||||
// 400_000 ms expressed in Jellyfin ticks, no ms field.
|
||||
playback_position_ticks: Some(400_000 * 10_000),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let current = pick_current_episode(SERIES, &[episode("s1e1", 1, 1), ep], &[], &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e2");
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ pub struct Library {
|
||||
}
|
||||
|
||||
/// User-specific data for an item (playback state, favorites, etc.)
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UserData {
|
||||
/// Legacy Jellyfin resume position in ticks. Being replaced by
|
||||
@@ -292,6 +292,59 @@ pub struct GetItemsOptions {
|
||||
pub fields: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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*.
|
||||
///
|
||||
/// The expansion table below is Jellyfin domain vocabulary: it changes when
|
||||
/// Jellyfin adds or renames an item type, never when the UI is redesigned. It
|
||||
/// previously lived in the frontend (`searchScope.ts`), which is the boundary
|
||||
/// leak documented in docs/specs/scoped-search-boundary.md. The frontend now
|
||||
/// sends the enum and never names an item type in connection with search.
|
||||
///
|
||||
/// TRACES: UR-049 | DR-063
|
||||
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SearchScope {
|
||||
All,
|
||||
Music,
|
||||
Movies,
|
||||
Tv,
|
||||
}
|
||||
|
||||
impl SearchScope {
|
||||
/// The Jellyfin item types this scope requests, or `None` for `All`.
|
||||
///
|
||||
/// `All` returns `None` rather than the union of every listed type on
|
||||
/// purpose: an explicit `includeItemTypes` list filters out anything not
|
||||
/// named in it, so a union would silently drop People, folders and any type
|
||||
/// nobody enumerated. Callers must omit the filter entirely on `None`.
|
||||
///
|
||||
/// TRACES: UR-049 | DR-063
|
||||
pub fn item_types(self) -> Option<Vec<String>> {
|
||||
match self {
|
||||
SearchScope::All => None,
|
||||
SearchScope::Music => Some(
|
||||
["MusicAlbum", "MusicArtist", "Audio", "Playlist"]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect(),
|
||||
),
|
||||
SearchScope::Movies => Some(vec!["Movie".to_string()]),
|
||||
SearchScope::Tv => Some(
|
||||
["Series", "Episode"]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Options for search queries
|
||||
@@ -304,6 +357,28 @@ pub struct SearchOptions {
|
||||
pub include_item_types: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub search_term: Option<String>,
|
||||
/// Opaque scope selected by the UI. When set it **wins** over
|
||||
/// `include_item_types`, which remains for the non-search `get_items`
|
||||
/// callers that legitimately request a single concrete type.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scope: Option<SearchScope>,
|
||||
}
|
||||
|
||||
impl SearchOptions {
|
||||
/// Expand `scope` into `include_item_types` in place.
|
||||
///
|
||||
/// Call this once, in the search command, *before* dispatching to the
|
||||
/// cache and server paths — both already honour `include_item_types`, and
|
||||
/// resolving in one place keeps online and offline results identical.
|
||||
///
|
||||
/// TRACES: UR-049 | DR-063
|
||||
pub fn resolve_scope(&mut self) {
|
||||
if let Some(scope) = self.scope {
|
||||
// `All` yields None, which clears the filter — the correct
|
||||
// behaviour, not an omission.
|
||||
self.include_item_types = scope.item_types();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Playback information
|
||||
@@ -455,6 +530,131 @@ impl MeaningfulContent for PlaylistCreatedResult {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod search_scope_tests {
|
||||
use super::*;
|
||||
|
||||
/// Music expands to the four Jellyfin types that make up the category.
|
||||
///
|
||||
/// This table is the domain vocabulary that used to live in the frontend
|
||||
/// (`searchScope.ts`'s `SCOPE_ITEM_TYPES`) — the boundary leak that
|
||||
/// docs/specs/scoped-search-boundary.md was written about.
|
||||
///
|
||||
/// @req-test: UT-089 - SearchScope expands to Jellyfin item types
|
||||
#[test]
|
||||
fn music_scope_expands_to_music_item_types() {
|
||||
assert_eq!(
|
||||
SearchScope::Music.item_types(),
|
||||
Some(vec![
|
||||
"MusicAlbum".to_string(),
|
||||
"MusicArtist".to_string(),
|
||||
"Audio".to_string(),
|
||||
"Playlist".to_string(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/// @req-test: UT-089 - SearchScope expands to Jellyfin item types
|
||||
#[test]
|
||||
fn movies_scope_expands_to_movie_only() {
|
||||
assert_eq!(
|
||||
SearchScope::Movies.item_types(),
|
||||
Some(vec!["Movie".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
/// @req-test: UT-089 - SearchScope expands to Jellyfin item types
|
||||
#[test]
|
||||
fn tv_scope_expands_to_series_and_episode() {
|
||||
assert_eq!(
|
||||
SearchScope::Tv.item_types(),
|
||||
Some(vec!["Series".to_string(), "Episode".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
/// `All` must send NO filter — not the union of the other scopes.
|
||||
///
|
||||
/// Sending a union would silently drop every type nobody enumerated
|
||||
/// (Person, folders, …), which an explicit `includeItemTypes` list filters
|
||||
/// out. This is why `item_types()` returns Option rather than Vec.
|
||||
///
|
||||
/// @req-test: UT-090 - All scope sends no item-type filter
|
||||
#[test]
|
||||
fn all_scope_sends_no_filter() {
|
||||
assert_eq!(SearchScope::All.item_types(), None);
|
||||
}
|
||||
|
||||
/// Scope wins over an explicitly supplied include_item_types.
|
||||
///
|
||||
/// @req-test: UT-091 - Scope takes precedence over include_item_types
|
||||
#[test]
|
||||
fn resolve_scope_overrides_include_item_types() {
|
||||
let mut options = SearchOptions {
|
||||
include_item_types: Some(vec!["Movie".to_string()]),
|
||||
scope: Some(SearchScope::Music),
|
||||
..Default::default()
|
||||
};
|
||||
options.resolve_scope();
|
||||
|
||||
assert_eq!(
|
||||
options.include_item_types,
|
||||
Some(vec![
|
||||
"MusicAlbum".to_string(),
|
||||
"MusicArtist".to_string(),
|
||||
"Audio".to_string(),
|
||||
"Playlist".to_string(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/// `All` clears any include_item_types so no filter reaches the query.
|
||||
///
|
||||
/// @req-test: UT-090 - All scope sends no item-type filter
|
||||
#[test]
|
||||
fn resolve_all_scope_clears_include_item_types() {
|
||||
let mut options = SearchOptions {
|
||||
include_item_types: Some(vec!["Movie".to_string()]),
|
||||
scope: Some(SearchScope::All),
|
||||
..Default::default()
|
||||
};
|
||||
options.resolve_scope();
|
||||
|
||||
assert_eq!(options.include_item_types, None);
|
||||
}
|
||||
|
||||
/// With no scope set, include_item_types passes through untouched — the
|
||||
/// non-search `getItems` callers rely on this.
|
||||
///
|
||||
/// @req-test: UT-091 - Scope takes precedence over include_item_types
|
||||
#[test]
|
||||
fn resolve_without_scope_preserves_include_item_types() {
|
||||
let mut options = SearchOptions {
|
||||
include_item_types: Some(vec!["MusicAlbum".to_string()]),
|
||||
scope: None,
|
||||
..Default::default()
|
||||
};
|
||||
options.resolve_scope();
|
||||
|
||||
assert_eq!(
|
||||
options.include_item_types,
|
||||
Some(vec!["MusicAlbum".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
/// The frontend sends the enum as camelCase over IPC.
|
||||
///
|
||||
/// @req-test: UT-089 - SearchScope expands to Jellyfin item types
|
||||
#[test]
|
||||
fn scope_deserializes_from_camel_case() {
|
||||
let options: SearchOptions =
|
||||
serde_json::from_str(r#"{"scope": "music", "limit": 10}"#).unwrap();
|
||||
assert!(matches!(options.scope, Some(SearchScope::Music)));
|
||||
|
||||
let all: SearchOptions = serde_json::from_str(r#"{"scope": "all"}"#).unwrap();
|
||||
assert!(matches!(all.scope, Some(SearchScope::All)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+250
-1
@@ -1,4 +1,4 @@
|
||||
//! TRACES: UR-023, UR-031, UR-032, UR-033 | DR-034, DR-035, DR-036, DR-048
|
||||
//! TRACES: UR-023, UR-027, UR-031, UR-032, UR-033 | DR-030, DR-034, DR-035, DR-036, DR-048, IR-020
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -26,6 +26,67 @@ impl VolumeLevel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Centre frequencies (Hz) of the fixed 10-band ISO equalizer. The band count
|
||||
/// and layout are a property of the audio engine, not the UI — presets and the
|
||||
/// MPV filter are defined against these bands. See docs/specs/audio-equalizer.md.
|
||||
///
|
||||
/// TRACES: UR-027 | DR-030, IR-020
|
||||
pub const EQ_BANDS: [f32; 10] = [
|
||||
31.0, 62.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0,
|
||||
];
|
||||
/// Minimum per-band gain in dB.
|
||||
pub const EQ_GAIN_MIN: f32 = -12.0;
|
||||
/// Maximum per-band gain in dB.
|
||||
pub const EQ_GAIN_MAX: f32 = 12.0;
|
||||
|
||||
/// 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
|
||||
/// in Rust so the frontend never encodes the taxonomy.
|
||||
///
|
||||
/// TRACES: UR-027 | DR-030
|
||||
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum EqPreset {
|
||||
Flat,
|
||||
Rock,
|
||||
Pop,
|
||||
Jazz,
|
||||
Classical,
|
||||
BassBoost,
|
||||
TrebleBoost,
|
||||
Vocal,
|
||||
}
|
||||
|
||||
impl EqPreset {
|
||||
/// All presets, for enumerating the curve table across the IPC boundary.
|
||||
pub const ALL: [EqPreset; 8] = [
|
||||
EqPreset::Flat,
|
||||
EqPreset::Rock,
|
||||
EqPreset::Pop,
|
||||
EqPreset::Jazz,
|
||||
EqPreset::Classical,
|
||||
EqPreset::BassBoost,
|
||||
EqPreset::TrebleBoost,
|
||||
EqPreset::Vocal,
|
||||
];
|
||||
|
||||
/// The 10-band gain curve (dB) for this preset, one entry per [`EQ_BANDS`].
|
||||
/// Curves are conservative (within ±8 dB) so presets stack safely with the
|
||||
/// player volume. Bands: 31 62 125 250 500 1k 2k 4k 8k 16k.
|
||||
pub fn gains(&self) -> [f32; 10] {
|
||||
match self {
|
||||
EqPreset::Flat => [0.0; 10],
|
||||
EqPreset::Rock => [5.0, 4.0, 3.0, 1.0, -1.0, -1.0, 1.0, 3.0, 4.0, 5.0],
|
||||
EqPreset::Pop => [-1.0, 0.0, 2.0, 4.0, 5.0, 4.0, 2.0, 0.0, -1.0, -1.0],
|
||||
EqPreset::Jazz => [3.0, 2.0, 1.0, 2.0, -1.0, -1.0, 0.0, 1.0, 2.0, 3.0],
|
||||
EqPreset::Classical => [4.0, 3.0, 2.0, 1.0, -1.0, -1.0, 0.0, 2.0, 3.0, 4.0],
|
||||
EqPreset::BassBoost => [7.0, 6.0, 5.0, 3.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0],
|
||||
EqPreset::TrebleBoost => [0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 3.0, 5.0, 6.0, 7.0],
|
||||
EqPreset::Vocal => [-2.0, -1.0, 0.0, 2.0, 4.0, 5.0, 4.0, 2.0, 0.0, -1.0],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Audio playback settings
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -38,6 +99,18 @@ pub struct AudioSettings {
|
||||
pub normalize_volume: bool,
|
||||
/// Target volume level for normalization
|
||||
pub volume_level: VolumeLevel,
|
||||
/// Enable the graphic equalizer. When false, no EQ filter is applied.
|
||||
#[serde(default)]
|
||||
pub equalizer_enabled: bool,
|
||||
/// Per-band gains in dB, one per [`EQ_BANDS`]. Normalised to 10 entries and
|
||||
/// clamped to [`EQ_GAIN_MIN`, `EQ_GAIN_MAX`] via [`Self::with_equalizer_normalised`].
|
||||
#[serde(default = "default_eq_bands")]
|
||||
pub equalizer_bands: Vec<f32>,
|
||||
}
|
||||
|
||||
/// Flat 10-band curve — the default equalizer state.
|
||||
fn default_eq_bands() -> Vec<f32> {
|
||||
vec![0.0; EQ_BANDS.len()]
|
||||
}
|
||||
|
||||
impl Default for AudioSettings {
|
||||
@@ -47,6 +120,8 @@ impl Default for AudioSettings {
|
||||
gapless_playback: true,
|
||||
normalize_volume: false,
|
||||
volume_level: VolumeLevel::Normal,
|
||||
equalizer_enabled: false,
|
||||
equalizer_bands: default_eq_bands(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,6 +132,20 @@ impl AudioSettings {
|
||||
self.crossfade_duration = self.crossfade_duration.clamp(0.0, 12.0);
|
||||
self
|
||||
}
|
||||
|
||||
/// Normalise the equalizer band vector to exactly [`EQ_BANDS`]`.len()`
|
||||
/// entries (pad with 0 dB / truncate) and clamp each gain to the valid
|
||||
/// range. Guards against malformed persisted or IPC input.
|
||||
///
|
||||
/// TRACES: UR-027 | DR-030
|
||||
pub fn with_equalizer_normalised(mut self) -> Self {
|
||||
let n = EQ_BANDS.len();
|
||||
self.equalizer_bands.resize(n, 0.0);
|
||||
for g in &mut self.equalizer_bands {
|
||||
*g = g.clamp(EQ_GAIN_MIN, EQ_GAIN_MAX);
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Video playback settings
|
||||
@@ -90,10 +179,80 @@ impl VideoSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialise `AudioSettings` into the JSON payload handed to the Android player
|
||||
/// over JNI.
|
||||
///
|
||||
/// Sanitises first (crossfade clamped, band vector normalised) so a malformed
|
||||
/// vector can never reach the Kotlin parser. JSON is used rather than a wide JNI
|
||||
/// signature so that adding a field does not change the method signature — the
|
||||
/// same approach `load()` already uses for subtitles.
|
||||
///
|
||||
/// The emitted keys are camelCase (serde) and `volumeLevel` is lowercase; the
|
||||
/// Kotlin side matches on those literals. Both are pinned by tests.
|
||||
///
|
||||
/// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036
|
||||
pub fn audio_settings_jni_payload(settings: &AudioSettings) -> Result<String, serde_json::Error> {
|
||||
let sanitised = settings
|
||||
.clone()
|
||||
.with_crossfade_clamped()
|
||||
.with_equalizer_normalised();
|
||||
serde_json::to_string(&sanitised)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The JNI payload must sanitise before serialising: an over-long crossfade
|
||||
/// is clamped and a wrong-length band vector is normalised to EQ_BANDS.len().
|
||||
/// Sending raw values would let a malformed vector reach the Kotlin parser.
|
||||
///
|
||||
/// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036 | UT-AUDIO-JNI-1
|
||||
#[test]
|
||||
fn test_audio_settings_jni_payload_is_sanitised() {
|
||||
let settings = AudioSettings {
|
||||
crossfade_duration: 30.0,
|
||||
equalizer_bands: vec![20.0, -30.0],
|
||||
..AudioSettings::default()
|
||||
};
|
||||
|
||||
let json = audio_settings_jni_payload(&settings).expect("serialises");
|
||||
let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
|
||||
|
||||
assert_eq!(v["crossfadeDuration"], 12.0, "crossfade clamped to 12s");
|
||||
|
||||
let bands = v["equalizerBands"].as_array().expect("bands array");
|
||||
assert_eq!(bands.len(), EQ_BANDS.len(), "band vector normalised to 10");
|
||||
assert_eq!(bands[0], EQ_GAIN_MAX as f64, "gain clamped to +12dB");
|
||||
assert_eq!(bands[1], EQ_GAIN_MIN as f64, "gain clamped to -12dB");
|
||||
}
|
||||
|
||||
/// The Kotlin side parses these exact keys. camelCase is what serde emits
|
||||
/// for AudioSettings; a rename here silently breaks the Android parser,
|
||||
/// which is why the contract is pinned by a test rather than by convention.
|
||||
///
|
||||
/// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036 | UT-AUDIO-JNI-2
|
||||
#[test]
|
||||
fn test_audio_settings_jni_payload_key_contract() {
|
||||
let json = audio_settings_jni_payload(&AudioSettings::default()).expect("serialises");
|
||||
let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
|
||||
|
||||
for key in [
|
||||
"crossfadeDuration",
|
||||
"gaplessPlayback",
|
||||
"normalizeVolume",
|
||||
"volumeLevel",
|
||||
"equalizerEnabled",
|
||||
"equalizerBands",
|
||||
] {
|
||||
assert!(v.get(key).is_some(), "JNI payload must carry `{key}`");
|
||||
}
|
||||
|
||||
// VolumeLevel is #[serde(rename_all = "lowercase")]; Kotlin matches on
|
||||
// these literals.
|
||||
assert_eq!(v["volumeLevel"], "normal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_settings() {
|
||||
let settings = AudioSettings::default();
|
||||
@@ -101,6 +260,95 @@ mod tests {
|
||||
assert!(settings.gapless_playback);
|
||||
assert!(!settings.normalize_volume);
|
||||
assert_eq!(settings.volume_level, VolumeLevel::Normal);
|
||||
// Equalizer defaults: disabled and flat.
|
||||
assert!(!settings.equalizer_enabled);
|
||||
assert_eq!(settings.equalizer_bands, vec![0.0; EQ_BANDS.len()]);
|
||||
}
|
||||
|
||||
/// EQ presets each return one gain per band; Flat is all zeros.
|
||||
///
|
||||
/// TRACES: UR-027 | DR-030 | UT-079
|
||||
#[test]
|
||||
fn test_eq_preset_curves() {
|
||||
for preset in EqPreset::ALL {
|
||||
assert_eq!(
|
||||
preset.gains().len(),
|
||||
EQ_BANDS.len(),
|
||||
"preset {:?} must have one gain per band",
|
||||
preset
|
||||
);
|
||||
// Every preset stays within the advertised gain range.
|
||||
for g in preset.gains() {
|
||||
assert!(
|
||||
(EQ_GAIN_MIN..=EQ_GAIN_MAX).contains(&g),
|
||||
"preset {:?} gain {} out of range",
|
||||
preset,
|
||||
g
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(EqPreset::Flat.gains(), [0.0; 10]);
|
||||
// Bass boost lifts the low bands and leaves the top flat.
|
||||
let bass = EqPreset::BassBoost.gains();
|
||||
assert!(bass[0] > 0.0 && bass[9] == 0.0);
|
||||
}
|
||||
|
||||
/// `with_equalizer_normalised` clamps out-of-range gains and forces the
|
||||
/// band vector to exactly EQ_BANDS.len() (pad short, truncate long).
|
||||
///
|
||||
/// TRACES: UR-027 | DR-030 | UT-080
|
||||
#[test]
|
||||
fn test_eq_normalisation() {
|
||||
// Out-of-range gains are clamped.
|
||||
let s = AudioSettings {
|
||||
equalizer_bands: vec![100.0, -100.0, 3.0],
|
||||
..Default::default()
|
||||
}
|
||||
.with_equalizer_normalised();
|
||||
assert_eq!(s.equalizer_bands.len(), EQ_BANDS.len());
|
||||
assert_eq!(s.equalizer_bands[0], EQ_GAIN_MAX);
|
||||
assert_eq!(s.equalizer_bands[1], EQ_GAIN_MIN);
|
||||
assert_eq!(s.equalizer_bands[2], 3.0);
|
||||
// Short vector padded with 0 dB.
|
||||
assert_eq!(s.equalizer_bands[9], 0.0);
|
||||
|
||||
// Over-long vector truncated.
|
||||
let long = AudioSettings {
|
||||
equalizer_bands: vec![1.0; 20],
|
||||
..Default::default()
|
||||
}
|
||||
.with_equalizer_normalised();
|
||||
assert_eq!(long.equalizer_bands.len(), EQ_BANDS.len());
|
||||
}
|
||||
|
||||
/// Old persisted JSON without the EQ fields loads as disabled + flat.
|
||||
///
|
||||
/// TRACES: UR-027 | DR-030 | UT-081
|
||||
#[test]
|
||||
fn test_audio_settings_eq_backward_compat() {
|
||||
let json = r#"{"crossfadeDuration":0.0,"gaplessPlayback":true,"normalizeVolume":false,"volumeLevel":"normal"}"#;
|
||||
let parsed: AudioSettings = serde_json::from_str(json).unwrap();
|
||||
assert!(!parsed.equalizer_enabled);
|
||||
assert_eq!(parsed.equalizer_bands, vec![0.0; EQ_BANDS.len()]);
|
||||
}
|
||||
|
||||
/// EQ fields serialize as camelCase and round-trip.
|
||||
///
|
||||
/// TRACES: UR-027 | DR-030 | UT-082
|
||||
#[test]
|
||||
fn test_audio_settings_eq_serialization() {
|
||||
let settings = AudioSettings {
|
||||
equalizer_enabled: true,
|
||||
equalizer_bands: EqPreset::Rock.gains().to_vec(),
|
||||
..Default::default()
|
||||
};
|
||||
let json = serde_json::to_string(&settings).unwrap();
|
||||
assert!(json.contains("\"equalizerEnabled\":true"));
|
||||
assert!(json.contains("\"equalizerBands\":"));
|
||||
|
||||
let parsed: AudioSettings = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed.equalizer_enabled);
|
||||
assert_eq!(parsed.equalizer_bands, EqPreset::Rock.gains().to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -134,6 +382,7 @@ mod tests {
|
||||
gapless_playback: true,
|
||||
normalize_volume: true,
|
||||
volume_level: VolumeLevel::Loud,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&settings).unwrap();
|
||||
|
||||
@@ -24,6 +24,10 @@ pub const MIGRATIONS: &[(&str, &str)] = &[
|
||||
("017_downloads_resume_url", MIGRATION_017),
|
||||
("018_items_is_folder", MIGRATION_018),
|
||||
("019_genres_cache", MIGRATION_019),
|
||||
("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
|
||||
@@ -281,6 +285,7 @@ CREATE INDEX IF NOT EXISTS idx_items_parent ON items(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_type ON items(item_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_album ON items(album_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_series ON items(series_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_season ON items(season_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_data_user ON user_data(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_data_item ON user_data(item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads(status);
|
||||
@@ -714,3 +719,103 @@ CREATE TABLE IF NOT EXISTS genres (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_genres_scope ON genres(server_id, library_id);
|
||||
"#;
|
||||
|
||||
/// Migration to index `items.season_id`.
|
||||
///
|
||||
/// Episodes link to their season via `season_id` (parent_id is NULL in the
|
||||
/// cache). The container-rollup queries used by the Downloaded browse and the
|
||||
/// disk-usage aggregation join `children.season_id = c.id`, which without this
|
||||
/// index degrades to an unindexable scan — a large synced catalog then makes
|
||||
/// the Downloaded page hang ("Loading your downloads…"). `parent_id`,
|
||||
/// `album_id`, and `series_id` were already indexed; this closes the gap.
|
||||
const MIGRATION_020: &str = r#"
|
||||
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",
|
||||
"productName": "jellytau",
|
||||
"version": "0.0.16",
|
||||
"version": "0.4.8",
|
||||
"identifier": "com.dtourolle.jellytau",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
@@ -18,12 +18,16 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
"csp": null,
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": ["$APPDATA/**"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["deb", "rpm"],
|
||||
"targets": ["deb", "rpm", "nsis"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
||||
+55
-5
@@ -14,18 +14,68 @@
|
||||
--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 */
|
||||
html, body {
|
||||
@apply h-full;
|
||||
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 {
|
||||
@apply text-white antialiased;
|
||||
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);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user