Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61df2730bc | ||
|
|
c18d79c656 | ||
|
|
69c2498cf7 | ||
|
|
73dd0ef68b | ||
|
|
caebf2d139 | ||
|
|
d5d0e35bca | ||
|
|
a1cb142df4 | ||
|
|
2c52077b1d | ||
|
|
6dfc6b259a | ||
|
|
42e7d86ec4 | ||
|
|
4e451bb534 | ||
|
|
889289286b | ||
|
|
8500da1a42 | ||
|
|
88e15e3e12 | ||
|
|
c9f33ae6a4 | ||
|
|
a93cee9241 | ||
|
|
4996727ca9 | ||
|
|
e3cdb12967 | ||
|
|
2d21f092d5 | ||
|
|
ebf9a99b80 | ||
|
|
38dd1129e5 | ||
|
|
4c9361d020 | ||
|
|
b9dab56379 | ||
|
|
73641e192c | ||
|
|
be907b4945 | ||
|
|
3b9a8ad695 | ||
|
|
ab95f5013d | ||
|
|
5e8efa252e | ||
|
|
1285908733 | ||
|
|
8e98e1c37a | ||
|
|
440d7a01a9 | ||
|
|
dccb5f53dd | ||
|
|
c142568230 | ||
|
|
95129d04a3 | ||
|
|
f0f98feae8 | ||
|
|
d9e1e256e9 | ||
|
|
42868fc2e6 | ||
|
|
c0c6c5023e | ||
|
|
521acc75fd | ||
|
|
886cbcb29a | ||
|
|
2cc39cd7fd | ||
|
|
e457a9884c | ||
|
|
de1c13e72f | ||
|
|
5096c01960 | ||
|
|
2d67b0e4f5 | ||
|
|
13264e225b | ||
|
|
041969f446 | ||
|
|
1a9805f0f3 | ||
|
|
82b6982d68 | ||
|
|
3363ff7f08 | ||
|
|
9858b7cb92 | ||
|
|
f46d7bf676 | ||
|
|
74bffea650 | ||
|
|
7e1f0e0547 | ||
|
|
99ceeadb83 | ||
|
|
e015c4c9b1 | ||
|
|
7387f35c7e | ||
|
|
0861523015 | ||
|
|
ac4fccd499 | ||
|
|
a5535f2941 | ||
|
|
d49d027020 | ||
|
|
9c352fdb77 | ||
|
|
dda2ff86a3 | ||
|
|
8ad3dc5c4f | ||
|
|
9f5f57cba4 | ||
|
|
50934e2ac6 | ||
|
|
8fbc080733 | ||
|
|
ba5fd55204 | ||
|
|
fec4b7ae8c | ||
|
|
2ca2174cea | ||
|
|
0ca2857c3a | ||
|
|
1f32e4040b | ||
|
|
e4632bb2b2 | ||
|
|
2d50744320 | ||
|
|
adc460f35d | ||
|
|
9d7cb085e9 | ||
|
|
85bd227714 | ||
|
|
3619f71aba | ||
|
|
8e081845d0 | ||
|
|
5fa74d9e34 | ||
|
|
c480276a97 | ||
|
|
ca490c34ec | ||
|
|
e144e62b31 | ||
|
|
07d10dfed7 | ||
|
|
acddcdd6fa | ||
|
|
6a712c46cb | ||
|
|
211792947d | ||
|
|
2c3955914e | ||
|
|
1b70926c36 | ||
|
|
7b531a40be | ||
|
|
19bc265a8d | ||
|
|
cc7f1cece0 | ||
|
|
a53042fe80 | ||
|
|
db520c6551 | ||
|
|
1ef6180776 | ||
|
|
878ac5fa59 | ||
|
|
6aaa80ff92 | ||
|
|
f7bcfe521d | ||
|
|
30dc3ba7f6 | ||
|
|
32f8de5c91 | ||
|
|
62873cab3d | ||
|
|
c55ff45692 | ||
|
|
58f2506966 | ||
|
|
a818fee297 | ||
|
|
a26a853f01 | ||
|
|
1e599627b5 | ||
|
|
fa7cb6e908 |
@@ -61,6 +61,32 @@ jobs:
|
||||
bunx svelte-kit sync
|
||||
bun run test
|
||||
|
||||
# CLAUDE.md has required `cargo fmt` + `cargo clippy` before every commit
|
||||
# for as long as the rule has existed, but nothing in CI checked either,
|
||||
# so the requirement rested entirely on memory. Both components are baked
|
||||
# into the builder image (Dockerfile.builder: `rustup component add
|
||||
# rustfmt clippy`) — nothing is installed at job time.
|
||||
- name: Check Rust formatting
|
||||
run: |
|
||||
cd src-tauri
|
||||
cargo fmt --all -- --check
|
||||
|
||||
# ⚠️ Advisory for now — clippy warnings do NOT fail this job yet.
|
||||
#
|
||||
# The tree carries ~51 pre-existing warnings; adding `-D warnings` today
|
||||
# would paint CI red on unrelated work. A compile *error* still fails the
|
||||
# step, so this is not a no-op: it stops new breakage and surfaces the
|
||||
# backlog in every run.
|
||||
#
|
||||
# TODO: once the existing warnings are cleared, tighten this to
|
||||
# cargo clippy --all-targets -- -D warnings
|
||||
# Flip that flag — do not delete the step. Track progress with
|
||||
# `cd src-tauri && cargo clippy --all-targets 2>&1 | grep -c '^warning'`.
|
||||
- name: Run clippy (advisory)
|
||||
run: |
|
||||
cd src-tauri
|
||||
cargo clippy --all-targets
|
||||
|
||||
- name: Run Rust tests
|
||||
run: |
|
||||
cd src-tauri
|
||||
|
||||
@@ -54,6 +54,22 @@ jobs:
|
||||
bun run test --run
|
||||
continue-on-error: false
|
||||
|
||||
# Same gate as build-and-test.yml. A release must not ship from a tree
|
||||
# that would fail the per-commit checks. rustfmt/clippy come from the
|
||||
# builder image; nothing is installed here.
|
||||
- name: Check Rust formatting
|
||||
run: |
|
||||
cd src-tauri
|
||||
cargo fmt --all -- --check
|
||||
continue-on-error: false
|
||||
|
||||
# Advisory until the ~51 pre-existing warnings are cleared; see the longer
|
||||
# note in build-and-test.yml. Tighten both to `-- -D warnings` together.
|
||||
- name: Run clippy (advisory)
|
||||
run: |
|
||||
cd src-tauri
|
||||
cargo clippy --all-targets
|
||||
|
||||
- name: Run Rust tests
|
||||
run: bun run test:rust
|
||||
continue-on-error: false
|
||||
@@ -96,6 +112,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:
|
||||
@@ -156,15 +178,13 @@ jobs:
|
||||
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: |
|
||||
# On a tag build the tag is the single source of truth for the version.
|
||||
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: Build Windows (NSIS installer + exe)
|
||||
run: OUTPUT_DIR="$PWD/dist/windows" WIN_BUNDLES=nsis ./scripts/build-windows-cross.sh
|
||||
@@ -217,48 +237,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
|
||||
|
||||
@@ -81,8 +81,20 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check minimum threshold
|
||||
MIN_THRESHOLD=50
|
||||
# Minimum coverage. RATCHET POLICY: this number only ever goes UP.
|
||||
#
|
||||
# It sits a few points under the coverage actually achieved, so a real
|
||||
# regression trips it. It was 50 while true coverage was 86%, which
|
||||
# meant nearly half the matrix could rot before CI said a word — a
|
||||
# gate that cannot fail is not a gate.
|
||||
#
|
||||
# When coverage rises durably, raise this to just under the new figure
|
||||
# (`bun run traces:coverage` prints it). Never lower it to make a red
|
||||
# build pass — add the missing TRACES comments instead.
|
||||
#
|
||||
# Keep in sync with MIN_COVERAGE_PERCENT in scripts/extract-traces.ts;
|
||||
# scripts/extract-traces.test.ts fails if the two drift apart.
|
||||
MIN_THRESHOLD=82
|
||||
if [ "$COVERAGE" -lt "$MIN_THRESHOLD" ]; then
|
||||
echo "❌ ERROR: Coverage ($COVERAGE%) is below minimum threshold ($MIN_THRESHOLD%)"
|
||||
exit 1
|
||||
@@ -90,6 +102,15 @@ jobs:
|
||||
|
||||
echo "✅ Coverage is acceptable ($COVERAGE% >= $MIN_THRESHOLD%)"
|
||||
|
||||
# Every ID named by a TRACES comment must be defined as a table row in
|
||||
# docs/requirements.md. The extractor used to accept any well-formed ID
|
||||
# silently, so a typo or a rename that missed a call site passed CI
|
||||
# unnoticed (DR-189 and UT-188 lived in three source files, defined
|
||||
# nowhere, for months). This covers UT/IT too, which the coverage
|
||||
# orphan list above deliberately ignores.
|
||||
- name: Validate requirement IDs
|
||||
run: bun run traces:validate
|
||||
|
||||
- name: Check modified files
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
|
||||
+1127
-7
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,16 @@ bun run android:dev # build + deploy
|
||||
bun run android:logs # logcat
|
||||
```
|
||||
|
||||
The **debug** build type carries `applicationIdSuffix ".debug"`, so
|
||||
`com.dtourolle.jellytau.debug` ("JellyTau Debug") installs *alongside* a release
|
||||
build with its own data dir — never uninstall the release app to test a debug
|
||||
one. `./scripts/build-and-deploy.sh release --device --debug` puts an
|
||||
R8-minified *release* build in that same slot, signed with the local debug
|
||||
keystore, for validating minification without the real key. Only the
|
||||
applicationId is suffixed; Kotlin classes stay in the `namespace` package
|
||||
`com.dtourolle.jellytau`, so JNI lookups and R8 keep rules are unaffected. See
|
||||
[README_ANDROID_BUILD.md](src-tauri/android/README_ANDROID_BUILD.md).
|
||||
|
||||
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`.
|
||||
@@ -91,14 +101,22 @@ Tooling:
|
||||
bun run traces # extract traces (default format)
|
||||
bun run traces:json # JSON — e.g. | jq '.byType' or '.requirements."UR-005"'
|
||||
bun run traces:markdown # regenerate docs/traceability.md
|
||||
bun run traces:coverage # coverage gate — exits non-zero below the threshold
|
||||
bun run traces:validate # dangling-ID gate — every traced ID must be defined
|
||||
git diff --name-only | xargs grep -L "TRACES:" # find untraced changed files
|
||||
```
|
||||
|
||||
Every ID a `TRACES:` comment names must exist as a table row in
|
||||
`docs/requirements.md` — `traces:validate` fails otherwise, so a typo or a
|
||||
rename that missed a call site can no longer pass silently.
|
||||
|
||||
**CI is Gitea Actions** (`.gitea/workflows/`, remote `gitea.tourolle.paris`), not
|
||||
GitHub. `traceability-check.yml` fails the build if coverage drops below
|
||||
**50%** (`MIN_THRESHOLD`); `build-and-test.yml` runs frontend + Rust tests and an
|
||||
Android `cargo check`. See [docs/traceability-ci.md](docs/traceability-ci.md) and
|
||||
[docs/traces-quick-ref.md](docs/traces-quick-ref.md).
|
||||
**82%** (`MIN_THRESHOLD`, a *ratchet* — raise it as coverage climbs, never lower
|
||||
it to make a build pass) or if any traced ID is undefined; `build-and-test.yml`
|
||||
runs frontend + Rust tests, `cargo fmt --check`, an advisory `cargo clippy`, and
|
||||
an Android `cargo check`. See [docs/traceability-ci.md](docs/traceability-ci.md)
|
||||
and [docs/traces-quick-ref.md](docs/traces-quick-ref.md).
|
||||
|
||||
### Traces drive release notes
|
||||
|
||||
|
||||
@@ -87,6 +87,22 @@ 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
|
||||
|
||||
@@ -50,6 +50,68 @@ pub struct EncryptedFileStorage; // AES-256-GCM fallback
|
||||
| Certificate Validation | System CA store (configurable for self-signed) |
|
||||
| Token Transmission | Bearer token in `Authorization` header only |
|
||||
| Token Refresh | Handled by Jellyfin server (long-lived tokens) |
|
||||
| Android cleartext | `res/xml/network_security_config.xml` blocks cleartext everywhere except `127.0.0.1` (the loopback media server, DR-137/DR-138). The manifest's `usesCleartextTraffic` is ignored once the config is present, so the config is the single authority |
|
||||
| Android WebView | `mixedContentMode = COMPATIBILITY` with `allowFileAccess`/`allowContentAccess` both `false` (DR-199). These are the second half of the cleartext policy: `ALWAYS_ALLOW` re-opened by hand what the network security config closes. Change the two together |
|
||||
|
||||
## Webview Content Security Policy
|
||||
|
||||
`app.security.csp` in `tauri.conf.json` (TRACES: UR-012, UR-071 | DR-198). It was
|
||||
`null` — CSP disabled — which meant any script that reached the web layer
|
||||
inherited the full IPC surface. Tauri computes the header from this value when it
|
||||
serves the embedded HTML, injecting a nonce for SvelteKit's inline bootstrap
|
||||
script, so `script-src` needs no `'unsafe-inline'`.
|
||||
|
||||
```
|
||||
default-src 'self';
|
||||
script-src 'self';
|
||||
style-src 'self' 'unsafe-inline';
|
||||
font-src 'self' data:;
|
||||
img-src 'self' data: blob: asset: http://asset.localhost http: https:;
|
||||
media-src 'self' blob: asset: http://asset.localhost http://127.0.0.1:* http: https:;
|
||||
connect-src 'self' ipc: http://ipc.localhost http: https:;
|
||||
worker-src 'self' blob:;
|
||||
object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
|
||||
```
|
||||
|
||||
| Directive | Why |
|
||||
|-----------|-----|
|
||||
| `default-src 'self'` | Everything not named below is same-origin only. |
|
||||
| `script-src 'self'` | The genuinely restrictive half. Bundled JS only; Tauri's build-time nonce covers the one inline `<script>` in `index.html`. Adding `'unsafe-inline'` here would silently do nothing anyway — a nonce in a directive voids it. |
|
||||
| `style-src 'self' 'unsafe-inline'` | Svelte compiles `style="…"` attributes into markup, including `app.html`'s `display: contents` wrapper, and CSP treats a style *attribute* as inline. Safe only while no `<style>` **element** survives into `index.html`: Tauri would nonce it, and the nonce would then void `'unsafe-inline'`. The production build extracts all CSS to files, so it currently has none. |
|
||||
| `img-src` | Thumbnails come from two places: the asset protocol (`asset://localhost/…` on Linux/macOS, `http://asset.localhost/…` on Windows/Android — the same protocol, named differently by `convertFileSrc`) and, on a cache miss, straight from the Jellyfin server. `data:`/`blob:` cover inline and generated images. |
|
||||
| `media-src` | `<video>`/`<audio>` sources: HLS transcodes and progressive streams from the server, the token-guarded loopback media server on `http://127.0.0.1:<random port>` (DR-137), and `blob:` for the MSE object URL hls.js attaches. |
|
||||
| `connect-src` | `ipc:` / `http://ipc.localhost` is Tauri's `invoke` transport (custom scheme on Linux/macOS, `http` host on Windows/Android) — without it every command is blocked. `http:`/`https:` is hls.js fetching manifests and segments; ordinary API traffic goes through Rust and is not subject to CSP. |
|
||||
| `worker-src 'self' blob:` | hls.js runs its demuxer in a worker built from a blob (`enableWorker: true`). Without `blob:` it falls back to main-thread demuxing — playback survives but costs more CPU. |
|
||||
| `object-src`, `frame-src` = `'none'` | No plugins, no iframes; both are classic injection sinks. |
|
||||
| `base-uri 'self'`, `form-action 'self'`, `frame-ancestors 'none'` | Block `<base>` hijacking, form exfiltration and framing. `frame-ancestors` is only honoured when the policy is delivered as a header, which is platform-dependent; it is harmless where it is not. |
|
||||
|
||||
**`img-src`/`media-src`/`connect-src` are deliberately permissive.** The Jellyfin
|
||||
origin is typed in by the user at run time and is routinely plain `http` on a
|
||||
LAN, so it cannot be enumerated at build time. `http: https:` is a wide grant for
|
||||
*data* — but it still bars `file:`, `filesystem:` and scripting schemes, and it
|
||||
does not touch `script-src`, which is where an injected origin would actually
|
||||
hurt. A run-time policy naming the server exactly was considered and rejected:
|
||||
Tauri derives the header from immutable config at the moment it serves the HTML,
|
||||
so it would mean rebuilding the config and reloading the webview whenever the
|
||||
user adds or switches a server, to constrain a destination the user chooses
|
||||
anyway.
|
||||
|
||||
`devCsp` mirrors the policy with `'unsafe-inline' 'unsafe-eval'` on `script-src`
|
||||
and `ws:`/`wss:` on `connect-src`, because the Vite dev server injects styles and
|
||||
code and drives HMR over a websocket. It applies only to `tauri dev`.
|
||||
|
||||
### Asset protocol scope
|
||||
|
||||
`app.security.assetProtocol.scope` is `$APPDATA/thumbnails/**` — not the storage
|
||||
root. `imageCache.ts` is the only `convertFileSrc` caller left in the frontend:
|
||||
downloaded media moved to the loopback media server in DR-137, and downloaded
|
||||
audio is opened by MPV/ExoPlayer directly from its path. The old `$APPDATA/**`
|
||||
grant let the webview read the SQLite database and the encrypted-token fallback
|
||||
file alongside the thumbnails it actually needs.
|
||||
|
||||
If a new feature hands the webview a local file, widen this scope to that
|
||||
subdirectory specifically; a path outside it resolves to nothing and the webview
|
||||
reports `NETWORK_NO_SOURCE` (which is exactly how DR-134's failure presented).
|
||||
|
||||
## Local Data Protection
|
||||
|
||||
@@ -67,3 +129,4 @@ pub struct EncryptedFileStorage; // AES-256-GCM fallback
|
||||
3. **Logout Cleanup**: Token deletion from secure storage on logout
|
||||
4. **No Token Logging**: Tokens are never written to logs or debug output
|
||||
5. **IPC Security**: Tauri's IPC uses structured commands, not arbitrary code execution
|
||||
6. **Webview Containment**: A restrictive `script-src` keeps injected script off the IPC surface; the asset protocol is scoped to the thumbnail cache only (see above)
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
# JellyTau Codebase Audit
|
||||
|
||||
**Date:** 2026-08-16 · **Version:** v0.6.0 · **Commit:** `be907b49` (master)
|
||||
|
||||
A review of the Rust/Svelte/Android codebase against its own requirements matrix
|
||||
and against current Android and Tauri v2 platform practice. Every finding was
|
||||
verified by running the project's own tooling or reading the code it points at —
|
||||
nothing here is inferred from documentation alone.
|
||||
|
||||
**Scale:** 55,835 LOC Rust · 50,490 LOC TS/Svelte · 530 requirements · 824 traces
|
||||
|
||||
| Severity | Count |
|
||||
|----------|-------|
|
||||
| High | 5 |
|
||||
| Medium | 9 |
|
||||
| Low | 6 |
|
||||
| Tests passing | 1,719 |
|
||||
| Untraced requirements | 86 |
|
||||
| Traceability coverage | 86% (285/330) |
|
||||
|
||||
> **Revisions, 2026-08-16.** Three rankings changed after device testing and
|
||||
> platform research, all documented in place:
|
||||
> - **B1 High → Low.** The predicted impact was refuted on a physical Android 16
|
||||
> device. The residual risk turned out to be a different, narrower one.
|
||||
> - **B7 Low → Medium, re-framed.** The original reading of predictive back was
|
||||
> backwards: at targetSdk 36 it is already enabled, not merely un-opted-into.
|
||||
> - **B8 added (Medium).** Android 16 Local Network Protections versus a
|
||||
> LAN-hosted Jellyfin server.
|
||||
> - **D3 Medium → Low.** The "820 unwraps" figure was a measurement error; the
|
||||
> real number is 19, and none are in command handlers.
|
||||
> - **B1's stated mechanism was wrong** even though its conclusion held. FGS
|
||||
> notifications are *not* exempt from `POST_NOTIFICATIONS`; media-session
|
||||
> notifications are. See B1 — the distinction changes what the fix should be.
|
||||
>
|
||||
> Original ranking was 6 High / 8 Medium / 5 Low.
|
||||
|
||||
**Verified by running:** `bun run check` · `bun run test` · `cargo test` ·
|
||||
`cargo clippy --all-targets` · `bun run check:boundary` · `bun run traces:json`
|
||||
|
||||
**Device-verified (2026-08-16):** B1 and B2 were checked against a physical HONOR
|
||||
ROD2-W09 running Android 16 (SDK 36) with the shipped app installed. B2 was
|
||||
confirmed; B1 was refuted and downgraded.
|
||||
|
||||
**Not covered:** the e2e suite (`test:e2e` is not wired into CI and was not run),
|
||||
Windows and Arch packaging paths, and the docs-site build. B3, C1 and C2 still
|
||||
need a device/desktop playback pass.
|
||||
|
||||
---
|
||||
|
||||
## A. Requirements versus code
|
||||
|
||||
The traceability matrix is the project's own claim about what is built. Of 530
|
||||
defined requirement IDs, 86 carry no `TRACES:` tag anywhere in the tree. Most of
|
||||
those gaps are documentation debt rather than missing features — which is
|
||||
precisely the problem, because it makes the matrix unreliable as evidence.
|
||||
|
||||
### A1 · High · Twelve requirements are marked "Done" but have zero traces
|
||||
|
||||
`UR-006` (lockscreen/BLE control), `UR-037` (video library presentation),
|
||||
`IR-006` (Android MediaSession), `IR-008` (audio focus), `IR-022` (person/cast
|
||||
API), `IR-024` (home-screen API) and six Jellyfin API requirements (`JA-006`,
|
||||
`JA-009`, `JA-013`, `JA-014`, `JA-015`, `JA-018`) all claim completion with
|
||||
nothing pointing at an implementation.
|
||||
|
||||
These features demonstrably work — lockscreen control, Next Up, favourites are
|
||||
all shipped. The code is there; the tags are not. That means the matrix currently
|
||||
over-reports on exactly the requirements a reviewer would most want to verify,
|
||||
and a regression in any of them would leave no trace to follow.
|
||||
|
||||
**Fix:** Tag the existing implementations. Highest value per keystroke in the
|
||||
whole audit: six of the twelve are single Jellyfin API call sites.
|
||||
|
||||
### A2 · Medium · Requirement statuses contradict each other across layers
|
||||
|
||||
`UR-020` (subtitle selection) and `UR-021` (audio track selection) are marked
|
||||
*Done*, while the integration requirements they decompose into — `IR-018` and
|
||||
`IR-019`, both libmpv-specific — are still *Planned*. Similarly `IR-005` (MPRIS)
|
||||
sits at *Planned* under a *Done* `UR-006`.
|
||||
|
||||
The likely truth is that these user requirements were satisfied through a
|
||||
different path than the one originally specified (HTML5 `<video>` and ExoPlayer
|
||||
rather than libmpv), and the IRs were never re-scoped. Left as-is, the matrix
|
||||
reads as though shipped features depend on unbuilt integrations.
|
||||
|
||||
**Fix:** Re-scope or retire the stale IRs so each Done UR rests on Done IRs.
|
||||
|
||||
### A3 · Medium · The traceability gate is set far below actual coverage
|
||||
|
||||
`traceability-check.yml` fails only below 50%. Real coverage is well above that,
|
||||
so the gate cannot catch a coverage regression until roughly half the matrix has
|
||||
rotted. A gate that can only fire after a catastrophe is not protecting anything.
|
||||
|
||||
**Measured coverage: 86% (285/330)** — UR 71/75, IR 19/32, DR 166/187, JA 29/36.
|
||||
IR is by far the weakest dimension, which corroborates A1.
|
||||
|
||||
**Fix applied:** `MIN_THRESHOLD` ratcheted 50 → 82, with the ratchet policy
|
||||
written into the workflow (only goes up; never lowered to make a red build pass).
|
||||
The same figure is mirrored as `MIN_COVERAGE_PERCENT` in
|
||||
`scripts/extract-traces.ts` so local `traces:coverage` gates on the same bar, and
|
||||
a test parses the workflow YAML and fails if the two drift apart.
|
||||
|
||||
### A4 · Low · Two traced IDs do not exist in the requirements document
|
||||
|
||||
`DR-189` and `UT-188` are referenced by `TRACES:` comments but are defined
|
||||
nowhere in `docs/requirements.md`. The extraction tool accepts them silently, so
|
||||
typos and renames pass unnoticed.
|
||||
|
||||
**Fix:** Add a dangling-ID check to the extractor and fail CI on it — cheap, and
|
||||
it keeps the matrix honest in both directions.
|
||||
|
||||
### A5 · Not a gap · The remaining untraced requirements are legitimately unbuilt
|
||||
|
||||
`UR-016`, `UR-022` and `UR-070` are Planned or Proposed, and `UR-031`
|
||||
(crossfade) is explicitly blocked by `DR-034`. Their absence from the trace graph
|
||||
is correct and needs no action — noted so it does not get swept into the fix list.
|
||||
|
||||
---
|
||||
|
||||
## B. Android platform practice
|
||||
|
||||
The app targets SDK 36 with a minSdk of 24. Several manifest and WebView settings
|
||||
still reflect an earlier target level.
|
||||
|
||||
### B1 · Low · `POST_NOTIFICATIONS` is declared but never requested at runtime
|
||||
|
||||
*Downgraded from High. The original ranking was refuted by device testing — the
|
||||
evidence is below, and it is the reason this finding is now near-trivial.*
|
||||
|
||||
The permission appears in the manifest, but there is no `requestPermissions` call
|
||||
anywhere in the Kotlin, Rust or TypeScript sources, and
|
||||
`JellyTauPlaybackService.startForeground()` runs with no `checkSelfPermission`
|
||||
guard. On Android 13+ notification permission defaults to denied.
|
||||
|
||||
This was ranked High on the theory that it would suppress the media notification
|
||||
and with it the lockscreen transport controls (`UR-006`). Testing on an HONOR
|
||||
ROD2-W09 running **Android 16 (SDK 36)**, with the shipped app installed and
|
||||
playing, shows otherwise. The permission is genuinely denied:
|
||||
|
||||
```
|
||||
POST_NOTIFICATIONS: granted=false, flags=[USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED]
|
||||
appops POST_NOTIFICATION: ignore
|
||||
```
|
||||
|
||||
and the notification is nonetheless live and complete:
|
||||
|
||||
```
|
||||
ServiceRecord{... com.dtourolle.jellytau/.player.JellyTauPlaybackService}
|
||||
isForeground=true foregroundId=1 types=0x00000002
|
||||
foregroundNoti=Notification(flags=NO_CLEAR|FOREGROUND_SERVICE
|
||||
category=transport actions=3 vis=PUBLIC)
|
||||
```
|
||||
|
||||
**`UR-006` is not at risk.** But the *reason* is not the one this audit first
|
||||
gave, and the correction is load-bearing rather than pedantic.
|
||||
|
||||
The first explanation here was "foreground-service notifications are exempt." That
|
||||
is wrong. Android's own wording is that the permission covers "non-exempt
|
||||
(**including Foreground Services (FGS)**) notifications", and that users who deny
|
||||
it see FGS notices "in the Task Manager but [not] in the notification drawer" — an
|
||||
FGS notification is explicitly *not* exempt. What is exempt is **media-session**
|
||||
notifications. The platform predicate is `Notification.isMediaNotification()`,
|
||||
requiring `MediaStyle`/`DecoratedMediaCustomViewStyle` **and** a non-null
|
||||
`EXTRA_MEDIA_SESSION`; it is byte-identical across API 33–36, and
|
||||
`NotificationManagerService` has no FGS clause in either enforcement site.
|
||||
|
||||
Why the difference matters: under the FGS theory, anything the service posts is
|
||||
safe, and the code needs no care. Under the correct one, the exemption is earned
|
||||
per-notification by the token — so losing the token loses not just the shade entry
|
||||
but the lockscreen controls entirely, since SystemUI's media carousel
|
||||
(`MediaDataProcessor.onNotificationAdded`) gates on the *same* predicate. A
|
||||
token-less notification never even reaches the notification listener.
|
||||
|
||||
**The real risk here is not the permission — it is how narrowly the exemption is
|
||||
earned.** AOSP's `Notification.isMediaNotification()` grants it only when the
|
||||
style is `MediaStyle`/`DecoratedMediaCustomViewStyle` **and**
|
||||
`Notification.EXTRA_MEDIA_SESSION` holds a non-null *platform* session token. If
|
||||
either is missing while the permission is denied, the notification is **silently
|
||||
suppressed** — no exception, no log.
|
||||
|
||||
JellyTau earns it at two sites, both of which hang it on a null-safe call:
|
||||
|
||||
```kotlin
|
||||
androidx.media.app.NotificationCompat.MediaStyle()
|
||||
.setMediaSession(mediaSessionCompat?.sessionToken) // :273 and :466
|
||||
```
|
||||
|
||||
Ordering currently saves it — `mediaSessionCompat` is assigned in `onCreate`
|
||||
(:195) and `createBasicNotification()` is only reached from `onStartCommand`
|
||||
(:251) — and the device test confirms it works. But it is one reordering away
|
||||
from breaking invisibly, and only for users who denied the permission, which is
|
||||
a population most developers never test as.
|
||||
|
||||
**Fix:** Keep the permission declared — download-service FGS notifications are
|
||||
*not* covered by the media exemption, and this app has a downloads feature that
|
||||
may want them. Comment both `setMediaSession` sites to record what earns the
|
||||
exemption, and log loudly if the token is ever null at build time, converting a
|
||||
silent failure into a diagnosable one.
|
||||
|
||||
**Location:** `src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlaybackService.kt:251`, `:273`, `:466`
|
||||
|
||||
### B2 · High · Cloud backup is on by default, and it will break credential restore
|
||||
|
||||
The manifest sets neither `android:allowBackup="false"` nor a
|
||||
`dataExtractionRules`/`fullBackupContent` file, so Android's default applies: the
|
||||
app's data directory is backed up to the user's Google account. That ships the
|
||||
SQLite catalogue — library metadata and watch history — off the device.
|
||||
|
||||
The credential path makes it worse rather than better. `SecureStorage.kt`
|
||||
encrypts with AES/GCM under an Android Keystore key, and Keystore keys are never
|
||||
backed up. A user restoring onto a new phone therefore gets the ciphertext
|
||||
without the key: undecryptable credentials and a silent authentication failure,
|
||||
with no code path that recognises the situation.
|
||||
|
||||
**Fix applied.** `allowBackup="false"`. Extraction rules that merely excluded the
|
||||
DB and credential prefs would have left nothing worth backing up: the SQLite
|
||||
catalogue is a rebuildable mirror of the server and watch state lives server-side,
|
||||
so there is no user-authored data to preserve.
|
||||
|
||||
**A gap this audit missed:** on API 31+, `allowBackup="false"` disables *cloud*
|
||||
backup but **not device-to-device transfer**, which reproduces the identical
|
||||
failure — the prefs travel, the Keystore key does not. A
|
||||
`data_extraction_rules.xml` excluding all five domains from both `<cloud-backup>`
|
||||
and `<device-transfer>` was added to close it.
|
||||
|
||||
**A real bug found while fixing this:** the Rust encrypted-file fallback in
|
||||
`credentials.rs` propagated a decrypt failure as `CredentialError::Encryption`,
|
||||
which `storage_get_access_token` turned into a hard `Err` — so an undecryptable
|
||||
blob was an error state, not a logout. It now logs and returns an empty map, so
|
||||
the caller sees `NotFound` → `Ok(None)` → login screen, and the next sign-in
|
||||
self-heals the file. `SecureStorage.getCredential` on the Kotlin side already
|
||||
returned null, but could not distinguish "nothing stored" from "unreadable" and
|
||||
left the dead blob in prefs forever; it now separates the cases and discards it.
|
||||
Three tests written and watched fail first, per the red→green rule.
|
||||
|
||||
### B3 · High · `MIXED_CONTENT_ALWAYS_ALLOW` undoes the network security config
|
||||
|
||||
`network_security_config.xml` is careful and well-argued: cleartext blocked
|
||||
everywhere, exempted only for `127.0.0.1` so the local media server can serve
|
||||
downloads. Its own comment warns "this must not become a blanket cleartext
|
||||
opt-in."
|
||||
|
||||
But `MainActivity.kt` sets `mixedContentMode = MIXED_CONTENT_ALWAYS_ALLOW`, which
|
||||
permits the WebView to load http subresources into an https page from any origin.
|
||||
Alongside it, `allowFileAccess = true` and `allowContentAccess = true` are both
|
||||
broader than anything the app needs, since Tauri serves the UI from its own scheme
|
||||
and media comes from the token-guarded loopback server. These read as leftovers
|
||||
from before the media server existed.
|
||||
|
||||
**Fix:** Drop to `MIXED_CONTENT_COMPATIBILITY_MODE` and set both file and content
|
||||
access to false, then verify offline video still plays.
|
||||
|
||||
**Location:** `src-tauri/android/src/main/java/com/dtourolle/jellytau/MainActivity.kt:504-507`
|
||||
|
||||
### B4 · Medium · Android TV is half-declared
|
||||
|
||||
The manifest advertises `LEANBACK_LAUNCHER` and a non-required leanback feature,
|
||||
but omits `<uses-feature android:name="android.hardware.touchscreen"
|
||||
android:required="false"/>` and an `android:banner`. That combination fails Play's
|
||||
TV validation, and on a real TV the app would launch into a UI with no D-pad focus
|
||||
model behind it.
|
||||
|
||||
**Fix:** Either commit to TV — add the feature declaration, a banner, and a focus
|
||||
pass — or remove the leanback category until you do.
|
||||
|
||||
### B5 · Medium · `jvmTarget` is pinned to 1.8 under compileSdk 36
|
||||
|
||||
The Kotlin target has not moved with the SDK. AGP 8 warns on it, and it locks the
|
||||
Kotlin sources out of APIs and desugaring behaviour that everything else in the
|
||||
toolchain assumes.
|
||||
|
||||
**Fix:** Move `jvmTarget` and the Java source/target compatibility to 17.
|
||||
|
||||
### B6 · Low · Media3 is several minor versions behind
|
||||
|
||||
`androidx.media3` is pinned at 1.5.0 across exoplayer, hls, session and common.
|
||||
Given how much of this app's hard-won behaviour lives in ExoPlayer edge cases —
|
||||
truncated progressive streams, background audio handoff, HLS resume — staying
|
||||
current on its bug-fix releases has unusually high value here.
|
||||
|
||||
**Fix:** Schedule a Media3 bump with a device pass over the playback regression list.
|
||||
|
||||
### B7 · Medium · Predictive back is already on, not merely un-opted-into
|
||||
|
||||
*Upgraded from Low, and re-framed — the original framing was backwards.*
|
||||
|
||||
The audit first read the absent `enableOnBackInvokedCallback` as the app
|
||||
*forgoing* the Android 13+ back-gesture preview. That is not what the flag means
|
||||
at this target level. Predictive back is enabled by default for apps targeting
|
||||
recent SDKs, and Android 16's own behaviour-change list carries "Migration or
|
||||
opt-out required for predictive back" — with the opt-out being removed. Targeting
|
||||
36, JellyTau is already getting predictive back; it simply hasn't been checked
|
||||
against it.
|
||||
|
||||
That matters more than a missing opt-in would, because the app does not use
|
||||
ordinary Android back. It runs a WebView with its own history model —
|
||||
`src/lib/utils/navigation.ts` tracks a depth counter, applies a popstate delta,
|
||||
and falls back to a path when `history.back()` would trap the user, with
|
||||
`scrollRestore.ts` keying off the same popstate events. That is exactly the kind
|
||||
of custom back handling predictive back is most likely to disagree with.
|
||||
|
||||
**Fix:** This is a device test, not a code change — exercise the back gesture
|
||||
(including the drag-and-release preview and the cancel) from a library page, a
|
||||
detail page, the player, and the settings screen, and watch for the depth counter
|
||||
desynchronising. Only change code if it misbehaves.
|
||||
|
||||
Separately and unrelatedly: `JellyTauPlaybackService` is `exported="true"` with a
|
||||
`MediaSessionService` intent filter — conventional for Media3, but it means any
|
||||
app on the device can attempt to bind and drive playback. Confirm the session's
|
||||
`onConnect` callback rejects unknown packages.
|
||||
|
||||
### B8 · Medium (forward-looking) · Android 16 Local Network Protections vs a LAN Jellyfin server
|
||||
|
||||
*New finding, surfaced while researching B1.*
|
||||
|
||||
Android 16's behaviour-change list includes **Local Network Permission**. JellyTau's
|
||||
entire purpose is reaching a Jellyfin server that, for most users, sits on the
|
||||
local network — so a permission gate on local-network access is a direct threat to
|
||||
the app's core function, not a peripheral concern.
|
||||
|
||||
Stated carefully, because the timing matters: in Android 16 this is **opt-in for
|
||||
testing**, not enforced by default, with enforcement signalled for a future
|
||||
release. Nothing is broken today, and the device test will not surface it. But
|
||||
this is the rare platform change that could stop the app working at all, and it
|
||||
is much cheaper to handle before it is mandatory.
|
||||
|
||||
**Fix:** Investigate what the permission will require, then test the app against
|
||||
it with the opt-in flag enabled on the Android 16 device already to hand. Track it
|
||||
as a release-blocking item for whichever Android version enforces it.
|
||||
|
||||
---
|
||||
|
||||
## C. Tauri v2 configuration
|
||||
|
||||
The capability model here is genuinely well done — see section E. The gaps are in
|
||||
the two settings that govern what a compromised web layer could reach.
|
||||
|
||||
### C1 · High · `"csp": null` contradicts the project's own security convention
|
||||
|
||||
`CLAUDE.md` lists "keep the CSP restrictive in `tauri.conf.json`" as a standing
|
||||
rule; the config disables CSP entirely. With it off, any script that reaches the
|
||||
web layer inherits the full IPC surface.
|
||||
|
||||
The realistic exposure today is low, and worth stating plainly rather than
|
||||
inflating: the frontend has a single `{@html}` — an app-owned icon in
|
||||
`GenericGenreBrowser.svelte`, not server data — and no `innerHTML`, `eval` or
|
||||
`new Function` outside tests. So this is a missing defence rather than an open
|
||||
hole. But it is the defence that stops the next careless interpolation of a
|
||||
Jellyfin-supplied string from becoming a full compromise.
|
||||
|
||||
**Fix:** Set a CSP permitting `'self'`, `asset.localhost`, `http://127.0.0.1:*`
|
||||
for media, and the configured Jellyfin origin for images. Expect one or two
|
||||
iterations against HLS playback.
|
||||
|
||||
### C2 · Medium · The asset protocol scope is wider than what it serves
|
||||
|
||||
`assetProtocol.scope` is `$APPDATA/**`, which covers the whole app data directory
|
||||
— the SQLite database and the credential store included — while the protocol only
|
||||
needs to reach cached thumbnails and downloaded media.
|
||||
|
||||
Since `DR-137` introduced the token-guarded loopback media server, the asset
|
||||
protocol's remaining job may be thumbnails alone, which would make the narrowing
|
||||
nearly free.
|
||||
|
||||
**Fix applied:** scoped to `$APPDATA/thumbnails/**`. Confirmed on device that
|
||||
`jellytau.db` (8 MB catalogue) and `shared_prefs` sit in the `$APPDATA` root and
|
||||
are now outside the grant.
|
||||
|
||||
**But device testing found the finding was aimed at the wrong thing.** The asset
|
||||
protocol is not narrowly used — it is **entirely unused at runtime**:
|
||||
|
||||
- `getCachedImageUrl` in `imageCache.ts` has **no production callers**. Its only
|
||||
references are its own test file. `convertFileSrc`'s sole production mention
|
||||
sits inside that uncalled function, so it never executes.
|
||||
- The real path is `MediaCard` → `CachedImage` → `commands.imageGetUrl()`, which
|
||||
returns **base64 from Rust**. Every image in the app is a `data:` URI delivered
|
||||
over IPC.
|
||||
- Confirmed on device: zero `asset.localhost` requests across a full session of
|
||||
browsing home, the library list and a poster grid; the thumbnail cache stayed
|
||||
at 12 files and never grew, because nothing calls `thumbnailSave` either.
|
||||
|
||||
Two consequences worth acting on, neither yet done:
|
||||
|
||||
1. **The `protocol-asset` Cargo feature and the whole `assetProtocol` config
|
||||
block can likely be removed**, which retires the attack surface rather than
|
||||
shrinking it. `imageCache.ts` is dead code and can go with it.
|
||||
2. **`img-src` in the new CSP can be much tighter.** It currently grants
|
||||
`http: https:` on the reasoning that thumbnails are fetched direct-from-server
|
||||
on a cache miss — but they are not; they arrive as data URIs. With no
|
||||
webview-side server image loads anywhere in `src/`, `img-src 'self' data:
|
||||
blob:` should suffice. That is a real tightening the CSP work left on the
|
||||
table because it reasoned from the dead code path.
|
||||
|
||||
Both need their own device pass, since a wrong `img-src` blanks every image.
|
||||
|
||||
### C3 · Low · Shipped desktop bundles have no update path
|
||||
|
||||
The bundle targets deb, rpm and nsis, but `tauri-plugin-updater` is not among the
|
||||
dependencies. Every desktop user upgrades by manually fetching a new package,
|
||||
which in practice means a long tail of installs pinned to whatever version they
|
||||
first downloaded.
|
||||
|
||||
**Fix:** Add the updater plugin with a signed release manifest, or document the
|
||||
manual upgrade path in the README so the omission is at least deliberate.
|
||||
|
||||
---
|
||||
|
||||
## D. CI and code health
|
||||
|
||||
Local discipline in this project is strong and well documented. CI enforces only
|
||||
part of it, which means the discipline holds exactly as long as every contributor
|
||||
remembers it.
|
||||
|
||||
### D1 · High · CI runs neither `cargo clippy` nor `cargo fmt --check`
|
||||
|
||||
`CLAUDE.md` requires both before committing. Neither appears anywhere in
|
||||
`.gitea/workflows/`. The build-and-test job runs the boundary check, the frontend
|
||||
tests, the Rust tests and an Android `cargo check` — a good set, with the two lint
|
||||
gates missing.
|
||||
|
||||
Clippy currently reports 51 warnings across the lib and its tests, including
|
||||
unused imports and a redundant import that a gate would have stopped at the door.
|
||||
|
||||
**Fix:** Add both to the test job. Start with `-D warnings` on new code only if
|
||||
clearing the existing 51 is too large a first step.
|
||||
|
||||
### D2 · Medium · A flaky test will intermittently redden CI
|
||||
|
||||
`offlineCatalog.test.ts` — "pushes include=true while the server is reachable"
|
||||
(`UT-068`) — timed out at the 5 s limit during a full-suite run, then passed twice
|
||||
in isolation taking 1.13 s and 0.61 s.
|
||||
|
||||
**Root cause (corrected):** this audit originally attributed it to a real
|
||||
wall-clock timer. It isn't. The cost is the **first dynamic
|
||||
`import("./offlineCatalog")`**, which pays to transform the service and its whole
|
||||
dependency graph (~1072 ms cold) inside a test body, charged against vitest's 5 s
|
||||
default. Later re-imports after `vi.resetModules()` cost ~30 ms. Under full-suite
|
||||
contention the cold transform alone crosses the limit.
|
||||
|
||||
**Fix applied:** warm the import once at collection time with a top-level
|
||||
`await import(...)`, so no test is timing the compiler. Slowest test 1072 ms →
|
||||
129 ms; file total 1170 ms → 238 ms. Timeout deliberately left at the default.
|
||||
A latent cross-test leak was also fixed alongside it — the store shim's
|
||||
subscribers were never cleared, so every module instance discarded by
|
||||
`resetModules()` kept pushing its own visibility value.
|
||||
|
||||
**Location:** `src/lib/services/offlineCatalog.test.ts:58`
|
||||
|
||||
### D3 · Low · ~~820~~ **19** production `unwrap()`/`expect()` calls
|
||||
|
||||
*Downgraded from Medium. This audit substantially overstated the problem, and the
|
||||
correction is worth recording because the measurement error is instructive.*
|
||||
|
||||
The original 820 figure came from grepping for `unwrap()`/`expect()` and filtering
|
||||
lines containing "test". That does not exclude test *modules* — it only excludes
|
||||
lines with "test" in them. Scripting the actual `#[cfg(test)]` boundaries gives
|
||||
**19 real production sites**, not 820. `player/mod.rs`'s 154 hits, for instance,
|
||||
are *all* past its `#[cfg(test)]` at line 2183, as are the bulk of
|
||||
`repository/offline.rs`, `storage/mod.rs` and `commands/download/mod.rs`.
|
||||
|
||||
**More importantly: zero bare unwraps exist in any `#[tauri::command]` handler.**
|
||||
The specific risk this finding was built around — a panic inside a command killing
|
||||
the task and stranding shared player state — is already absent.
|
||||
|
||||
The same correction applies to the lock half: all 33 raw `.lock().unwrap()` hits
|
||||
were in test modules (three weren't even code, but prose in `utils/lock.rs`'s doc
|
||||
comment). Production was already fully on `lock_safe()`/`read_safe()`/
|
||||
`write_safe()`. Converting them was consistency work, not a bug fix.
|
||||
|
||||
**What is genuinely worth doing** is a three-site cluster, all the same pattern —
|
||||
`Runtime::new().unwrap()` in threads owning playback-critical state:
|
||||
|
||||
| | Site | Consequence of a panic |
|
||||
|---|------|------------------------|
|
||||
| 1 | `session_poller/mod.rs:102` | Poller thread dies silently; it drives remote-mode state *and* offline→online recovery, so the app strands offline with nothing surfaced |
|
||||
| 2 | `player/mpv_backend.rs:424` | Position reporting stops mid-playback; the scrubber freezes while audio keeps going |
|
||||
| 3 | `player/android/mod.rs:761` | Same pattern across a JNI boundary; progress reporting dies and no resume points are written |
|
||||
|
||||
**Fix:** One shared helper returning `Option<Runtime>` and logging on failure
|
||||
retires all three. The remaining 16 are startup `expect()`s and two provably
|
||||
infallible calls.
|
||||
|
||||
### D4 · Low · Five files carry a disproportionate share of the complexity
|
||||
|
||||
`player/mod.rs` (4,726 lines), `repository/offline.rs` (4,696),
|
||||
`repository/online.rs` (3,702), `commands/player/mod.rs` (3,299) and
|
||||
`commands/download/mod.rs` (3,226), plus `VideoPlayer.svelte` (2,778) on the
|
||||
frontend.
|
||||
|
||||
These are the same files the changelog keeps returning to for deadlocks and
|
||||
playback regressions. Not a defect in itself, and not worth a speculative
|
||||
refactor — but the next time one of them needs substantial work, splitting it is
|
||||
likely cheaper than continuing to grow it.
|
||||
|
||||
---
|
||||
|
||||
## E. Verified sound
|
||||
|
||||
Things this audit specifically went looking for and found in good order —
|
||||
including one that looked alarming from the warning output and turned out to be
|
||||
fine.
|
||||
|
||||
| Area | Finding |
|
||||
|------|---------|
|
||||
| **The 9 "MutexGuard across await" warnings are test-only** | All nine sit in `#[tokio::test]` functions holding a serialization lock, not in the production async paths that `CLAUDE.md`'s deadlock gotcha warns about. |
|
||||
| **The local media server is exemplary** | Loopback-only bind, a 32-hex-char per-session token, lexical `..` folding rather than `canonicalize`, and a test asserting reads stay inside the data directory. |
|
||||
| **Tauri capabilities are minimal** | Three permissions total — `core:default`, `opener:default`, `core:path:default`. No blanket grants, no `withGlobalTauri`. |
|
||||
| **SQL is parameterised** | Two `format!`-built statements in the whole Rust tree, neither interpolating caller-controlled input into a query. |
|
||||
| **R8 keep rules are correct and explained** | JNI-loaded player and security classes, the JavascriptInterface bridges and Media3 are all kept, each with a comment naming the crash it prevents. |
|
||||
| **Type and boundary gates are green** | `svelte-check`: 0 errors, 0 warnings. `check:boundary` passes with three reviewed allowlist entries. 698 Rust tests and 1,021 frontend tests pass. |
|
||||
|
||||
---
|
||||
|
||||
## F. Suggested order
|
||||
|
||||
Sequenced so the cheap gates land before the work they would have caught. B2
|
||||
leads because it is the finding a user is most likely to actually feel.
|
||||
|
||||
*(B1 originally led this list. It was demoted to row 10 after device testing —
|
||||
see B1. This is a good advertisement for testing a finding before scheduling
|
||||
work against it.)*
|
||||
|
||||
| # | Finding | What it buys | Effort |
|
||||
|---|---------|--------------|--------|
|
||||
| 1 | B2 | Catalogue and credentials stop leaving the device; restore stops failing silently | S — **confirmed on device**: `ALLOW_BACKUP` set, Google transport active |
|
||||
| 3 | D1 | Lint discipline becomes enforced rather than remembered | S |
|
||||
| 4 | B3 | The network security config actually holds | S — needs an offline-playback check |
|
||||
| 5 | A1 | The matrix stops over-reporting on twelve shipped requirements | M — mostly mechanical |
|
||||
| 6 | D2 | CI stops flaking | S |
|
||||
| 7 | C1 · C2 | The web layer stops being one interpolation away from full IPC | M — iterate against HLS |
|
||||
| 8 | A2 · A3 · A4 | The matrix becomes self-consistent and defended by a real gate | M |
|
||||
| 9 | B4 · B5 · B6 · B7 | Platform hygiene brought level with the SDK target | M |
|
||||
| 10 | B1 · D3 · C3 · D4 | Long-tail robustness; opportunistic rather than scheduled | L |
|
||||
@@ -0,0 +1,151 @@
|
||||
# Defect windows — which bugs were present when
|
||||
|
||||
For each fixed defect, the releases it was actually present in. Companion to
|
||||
[CHANGELOG.md](../CHANGELOG.md), which says what changed; this says how long each
|
||||
fault had been shipping before it did.
|
||||
|
||||
**"Present since"** is the first *release* containing the defective code, not the
|
||||
first release where a user could hit it — those differ, sometimes by months, and
|
||||
the gap is called out where it matters. **"How dated"** records the evidence, so a
|
||||
row can be re-checked or disputed:
|
||||
|
||||
| Method | Meaning |
|
||||
|--------|---------|
|
||||
| `pickaxe` | `git log -S<token>` on the defective token — the commit that introduced the exact string, then the earliest tag containing it. Strongest evidence. |
|
||||
| `feature` | The defect is inseparable from a feature that landed whole (bad rung in a new algorithm, missing caller in new plumbing), dated to that feature's release. |
|
||||
| `absence` | The fix *adds* something that was never there. Dated to when the surrounding code was built, since there is no introducing commit to find. Weakest — treat as "no later than". |
|
||||
|
||||
## Present since the first release
|
||||
|
||||
Nine defects date to the initial proof of concept (v0.0.1, 2026-06-23) and shipped
|
||||
for between two weeks and seven weeks short of two months before anyone hit them.
|
||||
That is the dominant pattern here: not regressions, but original assumptions that
|
||||
went unexercised until a later feature leaned on them.
|
||||
|
||||
| Defect | Present since | Fixed in | Shipped broken for | How dated |
|
||||
|---|---|---|---|---|
|
||||
| `AudioStreamIndex=0` pinned the video stream as the audio track (DR-140) | v0.0.1 | **v0.4.6** | ~7 weeks | pickaxe |
|
||||
| Download URL spelled `videoBitrate`, which Jellyfin does not bind (DR-123) | v0.0.1 | **v0.5.1** | ~7 weeks | pickaxe |
|
||||
| `pause_download` / `resume_download` were no-ops (DR-168) | v0.0.1 | **v0.5.3** | ~7.5 weeks | pickaxe |
|
||||
| `.part` sidecar named by `with_extension`, so no cleanup path matched it (DR-169) | v0.0.1 | **v0.5.3** | ~7.5 weeks | pickaxe |
|
||||
| `Range` sent on every retry regardless of the response (DR-170) | v0.0.1 | **v0.5.3** | ~7.5 weeks | pickaxe |
|
||||
| `/Items/Latest` requested with the default `GroupItems=false` | v0.0.1 | **v0.5.1** | ~7 weeks | pickaxe |
|
||||
| `SubtitleStreamIndex` omitted from PlaybackInfo, letting the server burn in (DR-176) | v0.0.1 | **v0.5.5** | ~8 weeks | pickaxe |
|
||||
| No `PlaySessionId`, and one hardcoded `DeviceId`, on every stream URL (DR-177) | v0.0.1 | **v0.5.5** | ~8 weeks | pickaxe |
|
||||
| `download_item` never recorded `media_type`; NULL read as `'audio'` (DR-135) | v0.0.1 | **v0.4.6** | ~7 weeks | pickaxe |
|
||||
| `download_album` read its track list from the local cache (DR-173) | v0.0.1 | **v0.5.5** | ~8 weeks | pickaxe |
|
||||
| Device profile carried no `MaxAudioChannels` (DR-141) | v0.0.1 | **v0.4.6** | ~7 weeks | absence |
|
||||
| Streaming ceiling fixed at 20 Mbps with no way to lower it (UR-074) | v0.0.1 | **v0.5.3** (as a feature) | ~7.5 weeks | pickaxe |
|
||||
|
||||
### Why they took so long to surface
|
||||
|
||||
Four of these were **latent until a later feature exercised them**, which is why
|
||||
the fix lands so far from the cause:
|
||||
|
||||
- The `videoBitrate` casing was harmless while every download was `original`. It
|
||||
became visible only once a quality picker existed to select against — and then
|
||||
produced no error, just a full-size file, because Jellyfin discards an unbound
|
||||
query key silently.
|
||||
- The unconditional `Range` header was inert for the same reason: `original` is
|
||||
the one rung served with a `Content-Length` and real byte-range support. It
|
||||
started corrupting files in **v0.5.1**, the moment the casing fix made
|
||||
transcoded downloads actually transcode. So the *code* dates to v0.0.1 and the
|
||||
*corruption* to v0.5.1 — a one-release window for the visible symptom.
|
||||
- The missing `PlaySessionId` only bites when a stream is re-opened for the same
|
||||
item. Nothing re-opened one until quality switching, transcoded seek and
|
||||
audio-track switching existed.
|
||||
- The omitted `SubtitleStreamIndex` only bites on sources whose own default
|
||||
subtitle track is image-based, since that is what forces the server from
|
||||
sidecar to burn-in.
|
||||
|
||||
Two were **masked by soft failure**: the asset protocol being disabled (DR-134)
|
||||
was hidden by the thumbnail cache falling back to the server copy whenever the
|
||||
server was reachable, and `AudioStreamIndex=0` was hidden by servers that
|
||||
silently correct an out-of-range index — which is exactly why it was reported as
|
||||
"*some* videos have no audio" rather than as a bug in the client.
|
||||
|
||||
## Introduced by a feature, fixed later
|
||||
|
||||
| Defect | Present since | Fixed in | How dated |
|
||||
|---|---|---|---|
|
||||
| Native-path resume position never applied (both layers assumed the other seeked) | v0.0.9/v0.0.10 | **v0.5.1** | feature (`PlayerAdapter` contract) |
|
||||
| `get_downloaded_items` matched "this library exists" rather than constraining the item to it (DR-167) | v0.0.17 | **v0.5.3** | feature (browsable downloaded library) |
|
||||
| `SCOPE_ITEM_TYPES` — the frontend/backend boundary leak (DR-063) | v0.0.17 | **v0.2.1** | pickaxe |
|
||||
| `check:boundary` anchored to the query site, blind to a named const (DR-094) | v0.0.17 | **v0.2.1** | feature (tripwire landed with the leak it missed) |
|
||||
| Coverage gate divided by hardcoded denominators, reporting 158% (DR-093) | v0.0.1 | **v0.2.1** | pickaxe |
|
||||
| Tap deferral raced the WebView's synthesized click (DR-092 → DR-098) | v0.1.5 | **v0.2.7** | feature (the deferral itself) |
|
||||
| Transport for webview media decided from `el.paused` in the DOM (DR-097) | v0.0.9/v0.0.10 | **v0.2.7** | feature (`Html5PlayerAdapter`) |
|
||||
| `pick_current_episode` rung 3 returned the first *gap*, not the furthest watched | v0.3.0 | **v0.5.1** | feature |
|
||||
| `mirror_user_data` mirrored `is_favorite` alone and returned early (DR-155) | v0.4.0 | **v0.5.1** | pickaxe |
|
||||
| Stop-report path never fed the sync queue that existed for it (DR-154) | v0.4.6 | **v0.5.1** | feature (queue + drain landed with no producer) |
|
||||
| Background-audio base applied in two display-only places (DR-159) | v0.2.9 | **v0.5.3** | pickaxe |
|
||||
| Positions reported as 0 before the first tick, and always 0 for webview media (DR-178/179/180) | v0.5.3 | **v0.5.5** | feature (DR-159's tick boundary) |
|
||||
| Length-less handoff transcode left to the player's own load-error retry, which can only restart it (DR-203) | v0.0.16 | **v0.8.2** | feature (the handoff's progressive-mp3 choice) |
|
||||
|
||||
Three of these are worth separating out, because the defect is not a mistake in
|
||||
the code so much as **plumbing that was built and never connected**:
|
||||
|
||||
- `repository_get_next_up_episodes` accepted a `series_id` from the day it was
|
||||
written, and no caller passed one until v0.3.0.
|
||||
- The sync queue and its drain were built, tested and running in v0.4.6 with
|
||||
neither of its two would-be producers ever called.
|
||||
- Both halves of the watched-state backend existed with no caller before v0.5.3.
|
||||
|
||||
An automated check cannot see any of these — the code is present, tested and
|
||||
reachable in principle. Only tracing a requirement to a *call site* catches it.
|
||||
|
||||
## Short windows (one release or less)
|
||||
|
||||
| Defect | Present since | Fixed in | Note |
|
||||
|---|---|---|---|
|
||||
| `experimentalNativeVideo` defaulted on, shipping audio with a blank screen (DR-161 → DR-172) | v0.5.3 | **v0.5.4** | One release. The decode path was fine; the compositing step never ran. |
|
||||
| Webview-shaped audio profile insufficient — server ignores a profile's audio codec (DR-149) | v0.4.7 | **v0.4.8** | The v0.4.7 fix for DR-148 was necessary and not sufficient. |
|
||||
| Android `versionCode` floor went stale (`minor*100` yielding less than the 5002 already in the field) | v0.5.0 | **v0.5.3** | Caught before a broken APK shipped; no released build was un-installable. |
|
||||
| Subtitle sidecar work reverted by a commit assembled from a stale tree | v0.5.5 | **v0.5.5** | Never released broken — both commits are in v0.5.5. |
|
||||
|
||||
## Fixed twice / never actually broken
|
||||
|
||||
- **Autoplay time reset (v0.0.2).** Two commit objects carry this identical
|
||||
change: `dcf08f30` (merged via Gitea PR #3, tagged v0.0.2) and `fa7cb6e9` (the
|
||||
local original). Both have the same parent `674c8e5c` and the same diff. A merge
|
||||
chain pulled `fa7cb6e9` and its follow-up `1e599627` into master's history
|
||||
during v0.5.5, so `git log v0.5.4..v0.5.5` lists an autoplay fix that changed no
|
||||
file in that release — `nextEpisodeService.ts` is byte-identical across the tag
|
||||
boundary. The fix shipped in **v0.0.2** and has not regressed.
|
||||
|
||||
This is the one case where reading the changelog off `git log` subjects would
|
||||
have produced a false entry, and it is a good argument for the project's
|
||||
practice of deriving release notes from TRACES rather than commit subjects.
|
||||
|
||||
## Recurring shapes
|
||||
|
||||
Four causes account for most of the table:
|
||||
|
||||
1. **An omitted parameter is not a neutral default.** `SubtitleStreamIndex`,
|
||||
`AudioStreamIndex`, `GroupItems` and `MaxAudioChannels` all had a server-side
|
||||
default that was actively wrong, and in three of the four the server's choice
|
||||
was more expensive than the one intended — burn-in forcing a full re-encode
|
||||
being the extreme case.
|
||||
2. **Silent binding failures.** `videoBitRate` produced no error, no warning and a
|
||||
plausible-looking file. So did an unbound `Range`, and so did the coverage gate
|
||||
dividing by a stale denominator.
|
||||
3. **Two layers each assuming the other acts.** Native resume (adapter recorded
|
||||
the position, backend never seeked), end-of-playback dispatch (two paths, one
|
||||
unreachable), and the surface/attach split in v0.5.0's native video.
|
||||
4. **A guard keyed on state that moves.** The tap deferral keyed suppression on a
|
||||
timer handle the callback had already cleared; the HTML5 toggle keyed
|
||||
play-vs-pause on `el.paused`, which flips while buffering.
|
||||
|
||||
## Reproducing this
|
||||
|
||||
The pickaxe rows can be re-derived directly:
|
||||
|
||||
```bash
|
||||
git log --oneline --reverse -S'<defective token>' -- src-tauri/src # introducing commit
|
||||
git tag --contains <sha> | sort -V | head -1 # first release with it
|
||||
```
|
||||
|
||||
Blaming the lines a fix removed (`git blame` at the fix's parent) is faster to run
|
||||
across many commits but was **not** used for the rows above: it reliably lands on
|
||||
whichever commit last touched the adjacent lines, which is usually not the commit
|
||||
that introduced the defect. It was used only to shortlist candidates.
|
||||
+311
-24
@@ -16,7 +16,7 @@ For a narrative overview of the system design, see
|
||||
| UR-003 | Play videos | High | Done |
|
||||
| UR-004 | Play audio uninterrupted | High | Done |
|
||||
| UR-005 | Control media playback (pause, play, skip, scrub) | High | Done |
|
||||
| UR-006 | Control media when device is on lock screen or via BLE headsets | Medium | Done |
|
||||
| UR-006 | Control media when device is on lock screen or via BLE headsets | Medium | Done (Android); **not implemented on Linux** — see IR-005 |
|
||||
| UR-007 | Navigate media in library | High | Done |
|
||||
| UR-008 | Search media across libraries | High | Done |
|
||||
| UR-009 | Connect to Jellyfin to access media | High | Done |
|
||||
@@ -72,6 +72,20 @@ For a narrative overview of the system design, see
|
||||
| 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 |
|
||||
| UR-073 | Watched state is something the viewer can **set**, not only something playback records. Any episode, season, series or movie can be marked watched — or unwatched again — from where it is shown, without sitting through it or erasing its history wholesale. Marking a season or series covers the episodes inside it, and works with the server unreachable | Medium | Done |
|
||||
| UR-072 | Each page opens where a page should open. Moving to a new screen starts at the top of it, and going Back returns the viewer to the place they left — their position in a long library grid or home screen, not the top of it. A page never inherits the scroll position of the page before it | Medium | Done |
|
||||
| UR-075 | Artwork is shown at the shape it was made in. Where a screen presents a set of things side by side — the libraries on the library page and on home — they are laid out as a mosaic: rows of a common height in which each tile is as wide as its own picture, rather than a grid that crops every cover to one box. Favourites are reachable per category from that same mosaic, beside the library they belong to, not only as one undifferentiated list | Medium | Done |
|
||||
| UR-074 | Video streaming can be held to a **bandwidth budget the viewer sets**, rather than spent at whatever rate the server would otherwise send. A ceiling chosen once — from the source's own bitrate down to a rung that still plays on a poor connection — governs every video the app opens, live TV included, and survives a restart, so a metered connection is not quietly drained by the next thing played. A single video can be moved to a different ceiling from the player, resuming where it was, without disturbing that default | Medium | Done |
|
||||
|
||||
---
|
||||
|
||||
@@ -87,7 +101,7 @@ External system integrations and platform-specific implementations.
|
||||
| IR-002 | Build scripts for Android and Linux | Build | UR-001 | Done |
|
||||
| IR-003 | Integration of libmpv for Linux playback | Playback | UR-003, UR-004 | Done |
|
||||
| IR-004 | Integration of ExoPlayer for Android playback | Playback | UR-003, UR-004 | In Progress (basic playback works, audio settings missing) |
|
||||
| IR-005 | MPRIS D-Bus integration for Linux lockscreen/media controls | Platform | UR-006 | Planned |
|
||||
| IR-005 | MPRIS D-Bus integration for Linux lockscreen/media controls | Platform | UR-006 | Planned — genuinely absent: no `mpris`/`souvlaki`/`zbus`/`dbus` code or dependency in the project (`zbus` appears in `Cargo.lock` only transitively, via `tauri-plugin-opener`), and no `navigator.mediaSession` use in the frontend. `player::update_lockscreen_metadata` is a no-op off Android. UR-006 is therefore Android-only |
|
||||
| IR-006 | Android MediaSession integration for lockscreen controls | Platform | UR-006 | Done |
|
||||
| IR-007 | Bluetooth AVRCP integration via system media session | Platform | UR-006 | Planned |
|
||||
| IR-008 | Android audio focus handling (pause on call) | Platform | UR-004, UR-006 | Done |
|
||||
@@ -101,8 +115,8 @@ External system integrations and platform-specific implementations.
|
||||
| IR-015 | Jellyfin API client for playback progress reporting | API | UR-019, UR-025 | Done |
|
||||
| IR-016 | Jellyfin API client for subtitle/audio track info | API | UR-020, UR-021 | Done |
|
||||
| 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-018 | Subtitle rendering and selection in the **video** playback backends: ExoPlayer sideloads each track as a `MediaItem.SubtitleConfiguration` and selects by text-track-group position (Android), and the WebKitGTK HTML5 `<video>` element renders `<track kind="subtitles">` children carrying `data-stream-index` (Linux). **Originally scoped to libmpv, which never implemented it**: `MpvBackend` is the audio-only backend here and does not override `PlayerBackend::set_subtitle_track`, so the default `not_implemented()` still stands there. UR-020 is satisfied by the two paths above rather than by MPV | Playback | UR-020 | Done |
|
||||
| IR-019 | Audio track selection in the **video** playback backends: ExoPlayer switches track by index natively (Android), while the HTML5 `<video>` path cannot switch a track in the element and instead re-opens the stream at the chosen `AudioStreamIndex` and resumes at the same position (Linux) — the two outcomes `AudioTrackSwitchResponse` distinguishes. **Originally scoped to libmpv, which never implemented it**: `MpvBackend` does not override `PlayerBackend::set_audio_track`, so the default `not_implemented()` still stands there. UR-021 is satisfied by the two paths above rather than by MPV | Playback | UR-021 | Done |
|
||||
| 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 |
|
||||
@@ -112,6 +126,29 @@ 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 |
|
||||
|
||||
> **Where a UR is met by a different mechanism than its IR anticipated.** Several
|
||||
> integration requirements were written when libmpv was expected to be the single
|
||||
> playback backend. It is not: `MpvBackend` is the **audio-only** backend, Linux
|
||||
> plays video through a WebKitGTK HTML5 `<video>` element (HLS/h264), and Android
|
||||
> plays through ExoPlayer. So:
|
||||
>
|
||||
> * **UR-020 / UR-021** (subtitle and audio track selection) are Done, but not by
|
||||
> MPV — `MpvBackend` overrides neither `PlayerBackend::set_subtitle_track` nor
|
||||
> `set_audio_track`, leaving the trait's `not_implemented()` default. IR-018 and
|
||||
> IR-019 have been **re-scoped to the backends that actually deliver them**
|
||||
> (ExoPlayer sideloaded `SubtitleConfiguration`s and native track switching;
|
||||
> HTML5 `<track>` children and stream re-open at the chosen `AudioStreamIndex`)
|
||||
> and marked Done on that basis. IT-008 / IT-009 were re-worded to match.
|
||||
> * **UR-006** (lockscreen / BLE headset control) is Done **on Android only**, via
|
||||
> `MediaSessionCompat` (IR-006) and ExoPlayer/`AudioManager` focus (IR-008).
|
||||
> IR-005 (MPRIS) remains Planned because it genuinely does not exist — there is
|
||||
> no MPRIS/D-Bus code or dependency in the project, and
|
||||
> `player::update_lockscreen_metadata` is a no-op off Android. UR-006's status
|
||||
> was corrected rather than IR-005's.
|
||||
|
||||
### 2.2 Jellyfin API Requirements
|
||||
|
||||
@@ -151,6 +188,10 @@ 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 |
|
||||
| JA-036 | Query next-up episodes excluding in-progress ones (`/Shows/NextUp` with `EnableResumable=false`) | Shows | UR-059 | Done |
|
||||
|
||||
### 2.3 Development Requirements
|
||||
|
||||
@@ -253,7 +294,105 @@ Internal architecture, components, and application logic.
|
||||
| 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 was `$APPDATA/**` — the storage root under which the database, `downloads/` and the thumbnail cache all live — rather than an unrestricted grant; DR-198 narrows it further to `$APPDATA/thumbnails/**`, since DR-137 moved downloaded media off this protocol and thumbnails are all it still serves | Security | UR-071 | Done |
|
||||
| DR-140 | An audio track is pinned only when the user picked one. Jellyfin's `MediaStream.Index` is global across every stream in a media source, so index 0 is the *video* stream on virtually all files — yet `AudioStreamIndex=0` was sent as "the first audio track" on the HLS transcode URL, the background audio-only handoff URL, the direct-play fallback URL, and the `PlaybackInfo` negotiation body. A server that honours the request literally then transcodes the video stream into the audio slot and the result plays as a picture with no sound; only servers that silently correct the index hid the bug, which is why it presented as "some videos have no audio". The parameter is now omitted whenever no track has been chosen, so the server resolves the source's `DefaultAudioStreamIndex`; an explicit selection from `player_switch_audio_track` is still carried through unchanged. On the `static=true` direct-play URL it is dropped outright — the original file is served untouched, so the parameter could only mislead | Playback | UR-004, UR-040 | Done |
|
||||
| DR-147 | One search input per screen, and the URL is the search's single source of truth. The header bar rendered only under `/library/**` and merely *navigated* to `/search` (DR-063), so a desktop search handed the user to a screen whose input was a different element — the header box cleared itself and vanished, and the page's own box took over mid-word. That page then re-derived its input from `?q=` against `library.searchQuery` on every store write, so the next keystroke re-ran the effect and snapped the text back to the query the header had sent (and a scope chip back to the URL's scope); entering from the bottom-nav Search tab skipped it only because the effect early-returned on an empty query. The bar now renders on `/search` too (`showHeaderSearch`) and is the sole md+ input — the page's own input is `md:hidden` — and on that route it republishes the query into the URL with `replaceState`, so a whole session of typing costs one history entry. The page *consumes* that URL once per distinct value (`seedFromSearchUrl` against a non-reactive `applied` marker) instead of continuously reconciling it, and the scope chips publish through the same URL so the bar and the chips cannot disagree. Landing on `/search` with a seeded query focuses the bar and puts the caret at the end, because the box the user was typing in belonged to the unmounted route | UI | UR-049, UR-054 | Done |
|
||||
| DR-142 | An episode has exactly **one** surface, and it is complete. Two divergent renderings existed: `EpisodeFocusView` (reached from Continue Watching, the series episode list, the TV landing page and Downloads — i.e. every real entry point) offered only Play and Favourite, while the bare `/library/<episodeId>` page nobody routed to carried the download button, the series/season breadcrumbs and the cast section. Opening an episode the normal way therefore silently lost the ability to download it. The Focus View is now the single surface and carries the full §5B.2 composition — hero action row `Play / Download / Favourite`, series name and `SxEy` badge as links back to the series and to that season's anchor, then genres → cast → similar shows *below* the episode strip, never above it (DR-062). `/library/<episodeId>` redirects into it (`episodeRedirectTarget`, the same rule seasons follow under DR-103), and an episode with no `seriesId` renders the same component series-less rather than falling back to a second, lesser page. The focused episode is fetched in full rather than reused from the season fan-out, because that is a *list* query and carries neither cast nor genres — the sections would have rendered empty. The strip hides itself when the episode has no siblings, a card that only shows the episode you are already on being noise | UI | UR-048, UR-058 | Done |
|
||||
| DR-141 | The device profile states how many channels the audio route can actually voice. `MediaCodecList` answers "can this device *decode* 5.1", which is not the question that decides whether the user hears anything — a phone decodes an AC-3 5.1 track happily and still has two channels to play it out of. With no `MaxAudioChannels` in the profile, Jellyfin was free to direct-play the multichannel track, and the result is device dependent: a failed `AudioSink` configuration (silence) or dialogue folded into surround channels that go nowhere. media3's `AudioCapabilities.maxChannelCount` for the current route is reported over JNI alongside the codec lists, and bounds both the direct-play profile and the transcoding profiles, so the server downmixes rather than shipping channels the sink cannot take. Codecs are never removed from the profile — a device with genuine surround output keeps direct-playing it. A missing or zero reading means "route not yet established", not "no audio", and falls back to stereo, the one capability every sink has | Playback | UR-004 | Done |
|
||||
| DR-145 | Video playback starts only once the app actually holds audio focus. Video manages focus by hand (`handleAudioFocus=false`, because ExoPlayer's automatic handling is reserved for the audio path), and the request's three outcomes were all treated as success: `AUDIOFOCUS_REQUEST_DELAYED` — which `setAcceptsDelayedFocusGain(true)` explicitly invites, and which means the system is *withholding our audio* until it calls back — and an outright `REQUEST_FAILED` were logged and then followed by `playWhenReady = true`. The picture rolled with no sound, indistinguishable to the user from a broken stream. Playback is now held when focus is not granted and started from the `AUDIOFOCUS_GAIN` callback; an explicit `play()` re-requests focus rather than resuming into a stream the system is still muting, guarded by a held-focus flag so repeated plays do not leak focus requests. A `LOSS` clears the pending flag, so an unrelated later `GAIN` cannot start playback the user never asked for | Playback | UR-004 | Done |
|
||||
| DR-146 | The no-audio-track fallback picks a track the renderer can actually play. When ExoPlayer selected no audio track, the recovery forced group 0 / track 0 unconditionally — but the most likely reason nothing was selected is that this very track cannot be decoded on this device, so the override reinstated the silence it was meant to fix. It now scans the groups for the first `isTrackSupported` track and overrides to that, and clears `setTrackTypeDisabled(TRACK_TYPE_AUDIO)` because audio may equally have been off at the type level, which an override alone does not undo. When no group holds a supported track the condition is logged as an error — the server was expected to transcode — rather than leaving a silent video with no explanation in the log | Playback | UR-004 | Done |
|
||||
| DR-148 | The video direct-play profile advertises only what the **webview** can decode. The audio codec list comes from `MediaCodecList`, which describes ExoPlayer — but video does not play through ExoPlayer on either platform: Android force-renders every video in the webview `<video>` element (the interim override in `VideoPlayer.svelte`, because the native SurfaceView sits behind an opaque webview) and Linux always has. Chromium and WebKit decode a far narrower set than the platform does, and the gap is widest on devices whose vendor licenses Dolby: a phone shipping `/vendor/etc/media_codecs_dolby_audio.xml` reports `ac3,eac3`, so Jellyfin direct-played an E-AC-3 track with `static=true` and the webview built a video decoder and no audio decoder at all — full picture, no sound. The defect is triggered by *capability*, not the lack of it, which is why it reproduced on one Motorola while a Fairphone and an Honor tablet played the same file on the same build: a device without the Dolby decoder never claims the codec, so the server transcodes to AAC and it plays. `video_audio_codecs` narrows the platform list to the webview-decodable set (`aac,mp3,opus,vorbis,flac`) for the video direct-play profile *only* — the audio-only profile keeps the full list, since that playback really is the native player's and narrowing it would transcode music that plays perfectly well. A list with nothing decodable still claims `aac` rather than going out empty, because a profile that claims nothing invites the server to give up instead of transcoding. The video codec list is deliberately untouched: HEVC direct-plays through the webview correctly, so the constraint is specific to audio | Playback | UR-004 | Done |
|
||||
| DR-149 | The client decides whether its own renderer can decode the audio, rather than trusting the server's negotiation. Advertising a webview-shaped profile (DR-148) turned out to be necessary but not sufficient: Jellyfin 10.11.5 enforces a `DirectPlayProfile`'s `Container` and `VideoCodec` — excluding either returns `SupportsDirectPlay: false` with `TranscodeReasons=ContainerNotSupported` / `VideoCodecNotSupported` — but **ignores its `AudioCodec`**, offering an E-AC-3 track for direct play against a profile listing only `aac,flac,mp3,opus,vorbis`. Neither a `VideoAudio` `CodecProfile` forbidding the codec nor a `MaxAudioChannels: 2` against a 6-channel track changes the answer, so no profile the client can send fixes it and the picture plays silent. The negotiated source's audio is therefore checked locally against what the webview decodes, and an undecodable track forces the existing h264/aac HLS transcode URL regardless of the server saying direct play is fine — `direct_play` and `needs_transcoding` are corrected to match, so the frontend and the reporting path agree with the URL actually used. The track judged is the one the server would serve: the default, or the first when nothing is marked default, since a supported track further down the list is not the one that plays. A source with no audio streams, or a stream whose codec the server did not name, is left alone — forcing a transcode on a guess spends server CPU on files that already play | Playback | UR-004 | Done |
|
||||
| DR-150 | Android video renders on the native ExoPlayer surface behind a transparent WebView, behind the `experimentalNativeVideo` opt-in. Rust already reported `use_html5_element: false` on Android, but two frontend overrides discarded it — `createAdapter()` hardcoded `"html5"`, and `VideoPlayer.svelte` forced `useHtml5Element = true` and stopped the native backend `player_play_item` had just started. The flag is a **suppressor, never a promoter**: off forces HTML5 even where Rust says native, so an in-progress spike cannot ship as the default, but it can never select native where Rust reported HTML5 (Linux cannot composite behind WebKitGTK, so promoting there is a black screen). Compositing requires clearing two independent opaque layers, and clearing only one leaves audio over a black picture — the WebView widget background and window drawable from Kotlin (`AndroidVideoSurface.setTransparent`), and the page's `html`/`body` and app-shell background from CSS (`data-native-video`). Transparency is declared in `tauri.android.conf.json` rather than the base config, because a transparent window on Linux has nothing behind it, and is toggled per playback session rather than set once, because a permanently transparent window shows the launcher through the rest of the app | Playback | UR-003, UR-004 | Done (behind `experimentalNativeVideo`, default off) |
|
||||
| DR-151 | The player's video SurfaceView actually reaches the view hierarchy. `JellyTauPlayer.setActivity()` had zero callers, so `currentActivity` was always null and `autoAttachSurface()` returned at "Cannot attach surface - no Activity reference". The surface was created and handed to ExoPlayer but never added to the content view, so native video decoded to a surface that was never on screen — independent of any webview transparency. `MainActivity.onCreate` now supplies the reference, which also revives PiP on the video path: `canEnterPip()` gates on `isVideoSurfaceAttached()`, which had been permanently false | Playback | UR-003, UR-041 | Done |
|
||||
| DR-152 | Platform playback facilities are reported by Rust, not sniffed from the user agent. `webviewAudio.ts` re-derived "does this platform have a native audio backend" by matching `navigator.userAgent` against `android`/`linux` — a second copy of the `cfg!` gate the backends are compiled under, free to drift from it. `player_get_capabilities` now returns `usesWebviewAudio` and `supportsNativeVideo` from the same cfg gates, and the frontend consumes them; the settings toggle for native video is hidden entirely where the platform cannot support it | Player | UR-003, UR-005 | Done |
|
||||
| DR-153 | The git tag is the single source of truth for a release version. The version lived in four files (`package.json`, `tauri.conf.json`, `Cargo.toml`, `Cargo.lock`) that had to be hand-edited in lockstep, and CI's release job rewrote exactly one of them — so a tagged build produced an installer named for the tag wrapped around package metadata naming the previous release, while the Linux job had no version step at all and shipped whatever was committed. `scripts/set-version.sh` writes all four from one argument and is the only thing that does; every release job calls it with the tag. The Android `versionCode` is derived in the same place as `1000 + major*10000 + minor*100 + patch`, which is monotonic in semver order and clears the 1000 floor already installed in the field — a lower code than the installed one makes Android refuse the update. A prerelease suffix is stripped before that arithmetic, which would otherwise abort the script, and a non-tag ref (CI passes `${GITHUB_REF#refs/tags/}` unconditionally) falls back to `git describe` rather than failing a branch build | Build | - | Done |
|
||||
| DR-154 | A watch position that cannot reach the server is queued, not dropped. `sync_queue` and its drain (DR-131) were built, tested and running, but the stop-report path never fed them: `HybridRepository::report_playback_stopped` is a bare pass-through to the online repository ("Playback reporting goes directly to server"), and on failure the error surfaced to a frontend `catch` whose own comment read "Server error - could queue, but for now just log". Both producers that *would* have queued it — `PlaybackReporter::queue_for_sync` in Rust and `syncService.queuePlaybackProgress` on the frontend — have no callers on the playback path, so closing a video while the server was unreachable lost the resume point outright even though `user_data.pending_sync` was dutifully set to 1 and nothing ever drains that flag for positions (unlike favourites, DR-120). The command layer now enqueues a `report_playback_stopped` row whenever the push fails, which the existing drain already knows how to parse and replay. The pending row for an item is **superseded in place** rather than appended to: progress is reported every 10s, so a server that stays down would otherwise add a row per tick, all of them obsoleted by the newest — the unbounded queue DR-131 exists to prevent. Only `pending`/`failed` rows are superseded, because an `abandoned` row has been given up on and reviving it would restore that same growing counter. Queueing is best-effort and never fails the command: the local position is already saved, so a failed *queue* write must not be reported as a lost position | Backend | UR-025, UR-002 | Done |
|
||||
| DR-155 | A watch position set on another device reaches this one. The resume check reads the local `user_data` row and nothing else, but `mirror_user_data` — the only path by which server `UserData` lands in that table — mirrored `is_favorite` alone, and returned early whenever that field was absent, which is exactly the shape of an ordinary watched episode. So `playback_position_ticks` was write-only from this device's perspective: watch 40 minutes in a browser, open JellyTau, and it resumed from whatever *this* device last saw or offered no resume at all — the same user-visible symptom as DR-150's Android bug, from an unrelated cause, which is why resume read as broadly flaky. The mirror now carries the position alongside the favourite flag under the same `pending_sync = 0` conflict rule, so a local position still waiting to be pushed is never pulled *backwards* by a server that has not yet heard where we got to; `COALESCE(excluded.x, user_data.x)` means a field the server omitted keeps its stored value rather than being nulled, and a row with neither field is still skipped rather than fabricated as zeroes. Mirroring alone was not sufficient: `get_item` — the call the player route makes — returned the cached copy on a hit and never consulted the server, so for an already-cached item the mirror never ran. It now refreshes in the background on a cache hit (`race_with_refresh`, the reusable form of what `get_items` already did inline), which is why browsing a season picked up other devices' state while opening the episode directly did not. The refreshed value lands for the next read, the cache-first race still answering immediately | Backend | UR-025, UR-002 | Done |
|
||||
| DR-156 | A page no longer inherits the previous page's scroll position. The shell keeps its scrollers alive across navigation by design — the root layout, the home page and the library layout each own a `flex-1 overflow-y-auto` box that outlives the route inside it, which is what lets `BottomUi` be a flex sibling rather than a measured overlay — but the element therefore never remounts and its `scrollTop` survives the route change. SvelteKit's own scroll restoration could not help: it saves and restores `window` scroll, and in this app the window never scrolls at all, so there was no scroll handling of any kind. The symptom was that opening an item from half-way down a library grid dropped the viewer half-way down the detail page, and returning to the grid landed at the top of it — exactly backwards. `ScrollMemory` (pure, one instance per container, keyed on path + query so a genre-filtered grid keeps its own place) records the offset a route is left at in `beforeNavigate` and decides in `afterNavigate`: `link`/`goto`/`form` reset to the top, `popstate` restores that route's saved offset, and the initial `enter` is left alone. Deciding does not consume the offset, so a route returned to more than once restores each time. Applied via the `scrollContainer` action on all three scrollers | UI | UR-072 | Done |
|
||||
| DR-160 | Picture-in-picture works on the path that actually plays video. PiP shrinks the whole *Activity*, so `canEnterPip` demanded a native ExoPlayer `SurfaceView` be attached and rendering — `isPlayingVideo() && getSurfaceView() != null && isVideoSurfaceAttached()`. But the native path sits behind `experimentalNativeVideo`, which defaulted to **off**, so in the shipping configuration video played in the WebView's `<video>` element and all three conditions were false. `enterPip` bailed with "Not entering PiP: no local video playing" every single time: the button was offered (gated only on OS capability) and could not work, however it was pressed. The manager now accepts either surface. The frontend reports the element through `AndroidPictureInPicture.setHtml5VideoState(active, width, height, playing)` — intrinsic size because the PiP window's aspect ratio came from the letterboxed surface's measured bounds, which do not exist here, and play state because `ExoPlayer.isPlaying` is false on this path and the PiP play/pause action would be frozen on "Play" mid-playback. Two behaviours invert when the WebView *is* the video: it must stay visible in PiP rather than be hidden (`hideWebView` is now gated on the native path — hiding it would leave an empty black window), and the play/pause `RemoteAction` has to reach the element, so the receiver dispatches `jellytau-pip-play`/`jellytau-pip-pause` DOM events instead of driving ExoPlayer. `jellytau-pip-entered`/`-exited` let the player strip its own chrome, since controls, title and gradients would otherwise be rendered into a window a couple of inches wide. The `<video>` is deregistered on teardown so PiP is never offered over a video that has gone | UI | UR-041 | Done (pending device verification) |
|
||||
| DR-167 | Each downloaded library shows only its own media. Cached items carry no link back to their library — `library_id` and `parent_id` are NULL on every row ([[offline-libraries-never-cached]]) — so `get_downloaded_items` matched the library branch with `EXISTS (SELECT 1 FROM libraries l WHERE l.id = ?)`, which asserts only that the requested library *exists* and never constrains the item to it. Opening any downloaded library therefore listed every downloaded top-level item on the server: films under Music, albums under TV. The sibling query that decides which libraries *appear* already carried the right rule — a `collection_type` ↔ `item_type` mapping — so the two disagreed about the same question. That mapping is now the named constant `LIBRARY_HOLDS_ITEM`, used by both, and a library of unknown collection type still keeps everything rather than being emptied by a rule that cannot classify it. The taxonomy stays in Rust, never the frontend | Downloads | UR-055 | Done |
|
||||
| DR-168 | Pause and resume actually stop and restart the bytes. `pause_download` wrote `status = 'paused'` and did nothing else, and no cancellation existed anywhere in the download stack — no token, no flag, no abort — so the streaming task ran on, kept writing, and overwrote the row with `completed`/`failed` when it finished: the row flicked to "paused" and undid itself. `resume_download` had the mirror defect, flipping the row to `pending` without calling `pump_download_queue`; the pump runs when something calls it rather than polling, so a resumed download sat untouched until an unrelated event happened to pump the queue. A per-download stop flag (`download::stop`) is the missing half — a module-level registry because the two sides never meet, the command holding Tauri state and the worker running detached in `async_runtime::spawn`. The worker reads it between chunks and on retry (so a pause is not swallowed by a 45-second backoff), flushes, and returns `Stopped`, which is deliberately **not** retryable and **not** recorded as a failure: the `.part` file is left intact because that is exactly what the resume's Range request continues from. Registering returns a *fresh* flag, or a resumed download would inherit the pause that stopped it and halt instantly. Cancel and `clear_stale_downloads` signal it too, so neither deletes a file still being written | Downloads | UR-055 | Done |
|
||||
| DR-169 | Partial files are actually reaped. The worker named its sidecar with `Path::with_extension("part")`, which *replaces* the extension — `movie.mp4` became `movie.part` — while every cleanup path deleted `"{file_path}.part"`, i.e. `movie.mp4.part`. The two never matched, so the partial file of every cancelled or failed download stayed on disk indefinitely, invisible to the disk-usage totals because no `downloads` row pointed at it. `partial_path` appends instead, is the single definition both the writer and the cleaners use, and incidentally removes a collision the old form had, where `movie.mp4` and `movie.mkv` mapped to one `movie.part` | Downloads | UR-055 | Done |
|
||||
| DR-173 | Downloading an album queues the **whole** album, and every track it queued is findable offline afterwards. Two independent gaps left an album with a handful of its tracks on the device while the button reported the album as downloaded. First, `download_album` took its track list from `items WHERE album_id = ?` — the local catalog cache. Jellyfin does not return `AlbumId` on every listing endpoint, so tracks cached by one of those endpoints sit in `items` with a NULL `album_id` and are invisible to that query; on the reporter's database three whole albums (18, 12 and 9 tracks) had it NULL on *every* track, so "download album" would have queued nothing for them, and a partially-linked album queued only the linked subset. Second, the frontend then resolved one stream URL per track from its own list and paired it with the returned row ids **by position** — a pairing with no basis, since the ids came back in the backend's `index_number` order over a different set of rows, so a row could be handed another track's URL and any track past the end of the shorter list was never started at all; on Android that loop also stopped wherever the webview was suspended. The same `album_id` is what `OfflineRepository::get_items` joins a track to its album on, so a track that did download stayed invisible under its album offline — the two halves of the same missing link. The operation now belongs to Rust end to end: `HybridRepository::get_album_tracks` asks the **server** what the album contains (cache-first `get_items` is right for browsing and wrong for deciding what to download) and errors offline so the caller falls back to the ungated local catalog, keeping the queue-while-offline flow; `queue_album_tracks` writes the album link onto every track it queues — queuing a track *is* the statement that it belongs to the album, rather than something to hope a listing endpoint recorded — and the stream URLs are resolved here through the existing reconnect resolver, now scoped to the rows just queued so one album cannot start every unrelated pending row. Nothing crosses the IPC boundary but the album id. Re-queuing a broken album heals it: the missing tracks are added and the tracks already on disk get their link. `download_series`/`download_season` still derive their episode lists from the cache the same way and want the same treatment | Downloads | UR-018, UR-055 | Done |
|
||||
| DR-170 | Downloads at a chosen bitrate are no longer corrupted by their own retries. Only the `original` preset asks for `Static=true`; every other rung requests a **transcode**, which Jellyfin serves chunked, with no `Content-Length`, and cannot byte-seek — so it ignores `Range` and answers `200` with the whole stream from the beginning rather than `206` with the requested tail. The worker sent the Range header whenever a `.part` existed and appended the body unconditionally, so each retry and each resume concatenated a fresh copy of the entire transcode onto the bytes already on disk: the file grew past its real size and would not play, which is why "downloads for different bitrates" stayed broken after the `videoBitRate` casing fix (DR-adc460f3) corrected the *request*. `resume_offset` makes the response decide — append only on a `206`, otherwise truncate and take the stream from the top — and the total size is computed from that offset rather than from a partial length the server never agreed to | Downloads | UR-071 | Done |
|
||||
| DR-172 | Native Android video is opt-in again, because as a default it shipped as **audio with no picture**. DR-161 flipped `experimentalNativeVideo` on so picture-in-picture could shrink a real video surface; on a device that produced sound and a blank screen. The decode path was never the problem — logcat showed ExoPlayer running (`Position update` ticks) and feeding a live `SurfaceView` with an active BufferQueue. The compositing was: the SurfaceView sits *behind* the WebView, and the step that clears the opaque layers above it never took effect, with `WebView transparent = false` logged and `= true` never appearing. So the video rendered correctly the whole time, behind an opaque page. This is exactly the defect the flag existed to contain — `VideoPlayer.scrubRegression.test.ts` had recorded that "the native SurfaceView has never been visible through the webview" — and enabling it by default shipped a verified decode path on top of an unverified display path. Reverting costs nothing that matters: PiP does not depend on it (DR-160 drives PiP from the WebView `<video>`), and working video outranks PiP showing a native surface. The flag stays available in Settings, now described as incomplete rather than as a performance win, and the scrub-regression mocks that were made explicit under DR-161 are kept explicit so those tests state which path they guard rather than inheriting a default that has now moved twice. Fixing the compositing is the prerequisite for trying this default again | UI | UR-003, UR-004, UR-041 | Done |
|
||||
| DR-171 | A downloaded video keeps audio the device can actually decode. `original` quality asked for `Static=true`, which hands back the source file byte-for-byte — E-AC-3/AC-3/DTS/TrueHD track included — and video is rendered on both platforms by the webview `<video>` element, which decodes none of them. Streaming already knew this: DR-149 judges the track the server would serve against `WEBVIEW_AUDIO_CODECS` and forces a transcode over Jellyfin's own direct-play offer, because 10.11.5 honours a `DirectPlayProfile`'s container and video codec but ignores its audio codec. The download path never consulted that policy, so the *same film* had sound when streamed and played as picture in silence once downloaded — and offline a download is the only source a video has, so there was no working path left to fall back to. The rule is now one rule: `served_audio_codec` picks the track the server will serve (the default, or the first when none is marked) and both callers judge it, the streaming verdict staying a bool and the download path needing the codec itself so it can say what to re-encode. Only the audio is re-encoded — `allowVideoStreamCopy=true` keeps an h264 source's picture byte-for-byte and no bitrate or resolution cap is added, so `original` still means original quality; a source the webview could not have rendered anyway (HEVC) becomes h264 as a side effect, which is the only form of it that would have played. The decision is per item rather than blanket because the transcode costs the byte-range resumability `Static=true` gives the download worker (see DR-170 for what a chunked, length-less response does to a resume), so a file whose audio already plays keeps the direct copy. An unknown codec — item not fetchable, or the server named none — changes nothing: the policy only ever *adds* a transcode, so it cannot make a working download worse. The codec set judged against is the **webview's**, not the platform's, even though DR-161 made ExoPlayer the Android default: `experimentalNativeVideo` is a user setting, a downloaded file outlives whatever it was set to when the file arrived, and the narrow list is the only one that holds on both sides of it — at the cost of a Dolby-licensed device re-encoding a track its ExoPlayer could have played. `resolve_video_download_url` is the single entrance for all three resolution sites (the frontend's per-item command, the bulk series/season enqueue, and the offline-queued resume), since the pure builder cannot look a codec up and a caller that forgets to is exactly how the silent downloads shipped. **Files already downloaded stay silent** — the bytes on disk are the wrong bytes and only a re-download replaces them | Downloads | UR-071, UR-004 | Done |
|
||||
| DR-162 | Video streams are opened against a **bandwidth ceiling the user chose**, instead of a fixed allowance nobody could change. Every video URL carried `MaxStreamingBitrate=20000000`/`VideoBitrate=18000000`, `PlaybackInfo` negotiated at 20 Mbps, and the device profile advertised `999999999` — so on a metered or slow connection the only lever was not watching. `StreamingQuality` is a ladder of ceilings (Original, 20/10/8/4/2/1 Mbps, 720 kbps) in which a step is not a label but a bundle of transcode parameters: the total ceiling, the audio share of it, and the resolution that budget can carry. It lives in Rust because those numbers are Jellyfin encoding vocabulary — the frontend names a variant and reads labels back over `player_get_streaming_qualities`, the same arrangement as the EQ preset curves. The video bitrate is the total *minus* the audio share, so the two together honour the cap rather than overshooting it by the size of the audio track, and `MaxHeight` falls with the ladder so a small budget is not spent on pixels it cannot afford. The cap has to reach the **negotiation**, not only the transcode URL: `max_static_bitrate` in the device profile is what makes the server refuse to direct-play a source fatter than the ceiling, and without it a 30 Mbps remux is handed over untouched and every URL parameter downstream is moot — which is why it is applied at all four places that decide bandwidth (the HLS builder, `PlaybackInfo`, `open_live_stream`, and the background-audio handoff, which takes the lower of the cap and its own 384 kbps). The ceiling is process-wide rather than a field on `OnlineRepository`, mirroring `INCLUDE_CATALOG_BROWSE`: it is a preference about *this device's connection*, it must survive a repository rebuilt on re-login, and every builder plus the negotiation have to agree on it or the cap leaks. Settings owns the durable default and is the only writer to `app_settings` — persisted unlike the rest of `VideoSettings`, because a limit set for a metered connection that silently reverts to uncapped on the next launch spends the user's data with no changed setting to show for it — and it is restored at startup from the async runtime, defaulting to uncapped if the read fails so a database problem degrades to the old behaviour rather than to an arbitrary limit. The in-player menu is the per-video override: a cap is a property of the stream the server is producing, so it cannot be applied to one already in flight — `player_set_stream_quality` re-opens the stream at the new quality and resumes at the current position, reloading a native backend itself and handing HTML5 a URL for the same `reloadSource` primitive the audio-track switch uses, so no strategy branch lives in the UI. It deliberately does not persist. This gives UR-070 its resume-at-the-same-point mechanism; the server-offered per-item rendition list that requirement also asks for remains proposed | Playback | UR-074, UR-070 | Done |
|
||||
| DR-174 | Tiles of mixed shapes are laid out **justified** rather than gridded. A CSS grid gives every cell one box, so on a page holding square music covers, 16:9 library backdrops and 2:3 posters at once, everything that is not the chosen shape is cropped to it — the home shortcut strip was explicitly forcing `aspect="video"` on music libraries for exactly this reason, which lined the row up by cutting the covers down. `layoutMosaic` packs tiles into rows of a **shared height** and gives each its own width from its own aspect ratio: it adds tiles to a row until the height needed to fill the container has fallen to the target, closes the row there (so rows land at or below the target, never above), and justifies the row to the container width by absorbing the rounding remainder into its widest tile, where a pixel is least visible. The last row is deliberately *not* justified — with one tile left over, filling the width would inflate it to a banner — so it sits at the target height, left-aligned. Ratios are clamped to a band, which costs a crop on genuine outliers and stops one panorama owning a row or one very tall image shrinking to a sliver. It is a pure module with no DOM: the component supplies only the two things the DOM knows — the measured container width, and the artwork's *decoded* aspect ratio, reported by `CachedImage` so the layout uses the shape an image actually has rather than the one its item type implies. Those measurements are committed in one debounced batch rather than per image, because artwork arrives over several hundred milliseconds and re-packing on each arrival would shuffle the grid under the pointer repeatedly. Labels are drawn *over* the bottom of each tile rather than beneath it: a caption below sits outside the computed box, and one that wraps to two lines would break the row alignment the layout exists to provide | UI | UR-075 | Done |
|
||||
| DR-175 | A library knows which favourites category it belongs to, and the frontend does not work it out. The mosaic offers a favourites tile per category beside its library, which needs a collection-type → category answer; deriving it in Svelte would have re-created the exact leak `SearchScope::item_types` was extracted to close (docs/specs/scoped-search-boundary.md) — one table of Jellyfin vocabulary, differing only in which vocabulary. `SearchScope::for_collection_type` maps `movies`/`tvshows`/`music` and returns `None` for everything else, so a Live TV or books library gets no tile at all rather than one opening an unfiltered list; `All` is never derived from a library, being the cross-library entry offered beside them rather than a property of one. `Library::new` stamps the result onto every library at construction — a constructor rather than a struct literal precisely so a derived field cannot be forgotten at one of the four sites — and it rides to the frontend as an optional `favoritesScope`, absent rather than null when there is none. The UI's remaining share is presentation only: what to call the tile, where to put it, and showing a category's tile **once** however many libraries share it, since two movie libraries have one favourites list between them | UI | UR-075, UR-067 | Done |
|
||||
| DR-176 | The server is never asked to burn a subtitle into the picture. `PlaybackInfo` omitted `SubtitleStreamIndex`, which does not mean "none" — the server then honours the source's default/forced flag and picks a track itself. On a source whose default subtitle is image-based (PGS/DVD/DVB) that track cannot go out as a sidecar, so the server falls back to `SubtitleMethod=Encode` and composites it into the video. The cost lands on the *video*, not the subtitle: burn-in rules out remuxing, so an HEVC stream the device could have taken untouched is re-encoded frame by frame. Observed on an HEVC + E-AC-3 + PGSSUB episode, where only the audio actually needed transcoding: the server could not sustain the re-encode in real time, the buffer never grew past a single segment, and playback stalled every few seconds — taking seeking with it, since each seek restarted the encoder and cost seconds before the first frame. The fix is to request `SubtitleStreamIndex=-1` explicitly and to advertise every *text* format we can render (`srt`/`subrip`/`ass`/`ssa`/`vtt`) as `External`, so a subtitle can only ever arrive as a sidecar. Nothing is lost, because the app already fetches subtitle tracks itself and draws them over the video (UR-020) — the server's composited copy was always redundant. Image-based tracks are consequently not offered, which is honest rather than a regression: the renderer cannot composite a bitmap, and the previous behaviour paid for them by making the whole stream unwatchable. Both halves of that hold at the layer that can enforce them. The sentinel travels on the stream URL as well as in the negotiation, because the negotiation is not what opens most streams — a quality switch, a transcoded seek and an audio-track switch each rebuild the URL on their own, and an omitted index there lets the server pick the default track back up out of whatever session state it still holds. And "not offered" is enforced where the offer is made: each subtitle stream crosses the boundary carrying the backend's verdict on whether it can arrive as a sidecar, so the picker lists only tracks the app can draw instead of showing an entry that ticks and displays nothing. Only an explicit "no" hides a track, so a stream carrying no verdict behaves as before | Playback | UR-020, UR-004 | Done |
|
||||
| DR-177 | Each video transcode this device opens is its own server-side job, and the one it replaces is stopped. Jellyfin keys a transcode job by device **and** play session, and every stream URL the app built carried the same hardcoded `DeviceId` with no `PlaySessionId` at all — so the second stream for an item was indistinguishable from the first. Re-opening a stream is not rare: a mid-playback quality switch (UR-074), a transcoded seek and an audio-track switch all do it, each leaving the previous ffmpeg running. Observed on-device when switching bitrate mid-film: the server served the new playlist, then rejected the new job's segments with `400 hls1/main/0.ts` while the two jobs contended for one transcode path, and playback stalled — reproducible against the server, where a second stream for a live job's item alternates between serving bytes and 400ing per attempt, which is what made it read as flaky rather than broken. `begin_video_play_session` mints a session id per open and reports the one it supersedes; the URL builder stops that job (`DELETE /Videos/ActiveEncodings`, un-retried and best-effort — a slow stop must not delay playback, and the new stream no longer collides either way) before returning. Placing it in the URL builder rather than in each caller means every re-open path is covered by construction. Two client faults made the same incident worse and are fixed with it: the fatal-HLS-error handler added the transcode seek offset to a position that already included it, so past roughly the halfway mark of a film any transient network error cleared the "near end" threshold and was reported as end-of-stream — turning a recoverable stall into a skip to the next item, exactly when a quality switch had just made the offset large; and the HTML5 reload primitive resolved on its own `canplay` timeout, so a reload the server never served reported success, leaving the picker showing a quality that was not playing and the caller with nothing to revert | Playback | UR-074, UR-004 | Done |
|
||||
| DR-178 | Every position that leaves the app is read from the controller, not from a backend that may not be playing anything. `PlayerController::position()` forwards to the native backend, which is authoritative for exactly one of the three ways this app renders media. On the **webview** path — the shipping default for video on both platforms — nothing is loaded into that backend at all: the `<video>` element is the player, its ticks were re-emitted to the frontend and then dropped, and the backend answered 0 forever. During a **background-audio handoff** the base that converts the stream's relative timeline to the episode's is applied once at the native tick boundary (DR-159), so before ExoPlayer's first tick nothing has applied it and the reading is 0 there too. Both holes surfaced as the same user-visible bug through different doors: returning to the foreground while the audio-only transcode was still opening handed the frontend `0.0`, and the video reloaded at `StartTimeTicks=0` — the episode restarting from the beginning — while the `Stopped` report that followed wrote that zero to Jellyfin as the resume point. `absolute_position()` answers for all three paths: the maximum of the backend's reading, the last position webview-rendered media reported, and the handoff base. The maximum is exact rather than a heuristic, because at most one term is ever meaningful at a time and the base is a floor the stream cannot physically be behind. `duration()` gains the same fallback for the same reason. The element's reading is cleared wherever it stops being the player — teardown, a handoff taking over, a different item loading — so it can never be attributed to what plays next | Player | UR-005, UR-025, UR-040 | Done (pending device verification) |
|
||||
| DR-179 | Jellyfin is told what was played: progress while it plays, and a stop when it ends. A device trace of 35 minutes' playback requested `/Sessions/Playing/Progress` **zero** times and sent 14 `Stopped` reports, every one of them at position 0. Three faults, one subject. *Progress never left the device*: the frontend service writes it to the local DB by design, and nothing on the Rust side reported it for webview-rendered media — so the server learned a position only when the player was closed, and a crash or a swipe-away cost the session. It is now reported from the controller's own position ticks, through the 30s throttler it already owned and shares with the native audio path, which covers all three rendering paths in one place instead of adding a second frequent IPC caller. *Zero-position stops were sent*: Jellyfin stores the reported position as the resume point, so a zero does not merely fail to inform, it instructs the server to forget — and no zero was ever real, each one coming from asking a player that was not rendering the media (see DR-178). They are withheld; one landed 40s after the frontend had correctly reported 15:22 for the same episode, overwriting it. *A finished episode reported nothing at all*: Jellyfin decides "watched" from the stop report and its percentage, and in background audio-only mode nobody sends one — the webview is suspended and its element was torn down at the handoff, while the backend advances to the next episode without a word about the one that ended, so an episode listened to end-to-end on the lockscreen never counted as watched. `on_playback_ended` now reports it stopped at its **runtime** (not the last tick, which can be seconds short or, on a handoff whose ticks stopped early, nowhere near the end) before any advance, since after one the queue's current item is the next episode. Scoped to the audio-only handoff, the case the frontend provably cannot cover, so foreground playback keeps its single existing report; music ending natively remains unreported and wants its own change. The reporting seam is a `PlaybackReportSink` the controller sends to, which also collapses three copies of the spawn-a-task-and-hope block into one and is what let all of this be written as failing tests rather than found on a device a second time | Player | UR-025, UR-005, UR-040 | Done (pending device verification) |
|
||||
| DR-180 | A background-audio handoff of a **downloaded** episode starts where the video left off. The handoff prefers a local file over the audio-only stream (DR-128), but the two begin in different places and were treated alike: a stream is built with `StartTimeTicks`, so the server makes the handoff point that stream's zero and the base is the handoff position with no seek — while a file has no such parameter and begins at the episode's own zero, so basing it at the handoff position claimed minutes of audio that were about to play from the beginning. Backgrounding a downloaded episode therefore restarted it while the lockscreen scrubber, dutifully adding the base, showed the position it should have been at. `background_audio_plan` splits the two: a file gets no base and a real seek, a stream keeps the base and no seek (seeking one would skip *past* the content by the handoff position again). The same distinction settles an inbound seek — `seek_absolute` re-opens a *streamed* handoff at the requested position because a chunked length-less transcode cannot honour a seek, which is not true of local media, and `resume_stream_at` refuses a non-remote source outright, so routing a lockscreen scrub of a downloaded episode through it failed the seek rather than performing it | Player | UR-040, UR-071 | Done (pending device verification) |
|
||||
| DR-181 | A resumed transcode plays. Every video stream URL carried the resume position as `StartTimeTicks`, which is correct for a progressive response and fatal for an HLS one: Jellyfin builds each segment URI by echoing the **master playlist's** query string into it, and its segment handler opens by rejecting any request carrying `StartTimeTicks > 0` (`ArgumentException` → `400`). One position on the playlist therefore 400s every `hls1/main/N.ts` behind it, so hls.js exhausted its retries and gave up — presenting as an episode that will not resume while the same episode from the beginning is fine, the `> 0` being exactly why the beginning survived. The parameter is also unnecessary there: a playlist spans the whole item and asking for segment N *is* the seek, which the server transcodes from. So it is removed from the URL builder entirely rather than conditionalised — the builder has one caller shape and no way to know whether the response will be segmented — and the position becomes what it always was for HLS, a seek issued once the player has loaded: the seek path reloads at zero and seeks the element, and the resume path lets the player seek itself. The progressive `/Audio/universal` builder used by the background-audio handoff is a different endpoint with no segments and keeps its `StartTimeTicks`, which is why an audio-only handoff resumes correctly and a video one did not | Playback | UR-004, UR-074 | Done |
|
||||
| DR-182 | Native video shows a picture. The poster/title card is an opaque `bg-black` overlay drawn over the whole video area while `isMediaReady` is false, and **every** signal that clears it is emitted by the HTML5 `<video>` element — `canplay`, `loadedmetadata`, hls.js `FRAG_BUFFERED`, the `playing` event, and two `readyState` timeouts. The native path renders no such element (`{#if !!useHtml5Element}`), so on Android nothing could ever clear it: ExoPlayer decoded to a live SurfaceView behind a black div for the entire session. That is DR-172's "audio with no picture" report, and it is indistinguishable on screen from the compositing failure DR-172 attributed it to — which is why the flag was reverted rather than fixed. Both the overlay and the native branch date from the original POC commit, so the native path has never been able to reveal itself; the 2026-08-11 device verification predates neither and does not contradict this, since a spike run that never reached a steady state would not have shown it. The backend's own events are the equivalent signals and `nativeSignalRevealsVideo` is the rule for reading them: `state === "playing"` mirrors the element's `playing` event, and a position tick carrying a real position or duration mirrors the `readyState` backstops, covering a first state event that is dropped or arrives before the listener is attached. `buffering`/`paused`/`stopped`/`error` deliberately do not qualify — revealing on `error` would replace the title card with a transparent hole showing the launcher through the app. The rule is a pure module rather than a branch inside the component because the decision that was missing is exactly the part worth guarding, and the component needs a DOM and a mounted player to exercise | UI | UR-003, UR-004, UR-041 | Done |
|
||||
| DR-183 | The JavaScript bridges are installed before the page that uses them loads. WebView binds an injected object into JS at **page-load time**: an `addJavascriptInterface` call landing after the page has loaded does not appear to that page. They were installed from `configureWebViewForMedia`, which finds the WebView by walking the view tree 500 ms after `onCreate` — a race against Tauri's own page load, and one that is *permanent* when lost, because the identity guard added for DR-097's stale-proxy bug then declines to re-inject on every later resume pass. The whole set (`AndroidVideoSurface`, `AndroidPictureInPicture`, `AndroidBackgroundAudio`, `AndroidNetworkType`, `AndroidImmersive`, `AndroidInsets`) would simply be absent from `window`, and silently: every call site optional-chains the bridge, so a missing one is a no-op rather than an error. This is a candidate explanation for DR-172's other piece of evidence — `WebView transparent = false` logged, `= true` never appearing, i.e. the enable call never reaching Kotlin at all. `WryActivity.setWebView()` calls the `onWebViewCreate` hook immediately before wry issues the first `loadUrl` (confirmed in wry 0.55's `main_pipe.rs`, where the `setWebView` JNI call precedes `load_url`), so a bridge installed there is bound by the time any page runs. The hook can fire during `super.onCreate()`, before the rest of our own `onCreate`, so only work needing nothing but the WebView moves into it — insets stay in `configureWebViewForMedia`, which runs later and on every resume. The tree-walk path is kept as a fallback, and `enableNativeVideoCompositing` now logs an explicit error when the bridge is missing, so the ambiguity that left DR-172 unresolved cannot recur silently | Android | UR-003, UR-004, UR-040, UR-041 | Done |
|
||||
| DR-184 | The video SurfaceView leaves the view hierarchy when the video does. `VideoOverlayManager.detachVideoSurface` had **no callers anywhere in the tree** — the mirror of the DR-151 defect, where `setActivity` had none — so `attachVideoSurface` was one-way: `JellyTauPlayer.clearVideoSurface()` dropped its `surfaceView` reference and cleared ExoPlayer's without removing the view, leaving it parented to the content view for the life of the process, with the next native video adding another SurfaceView beneath it. The stack was invisible while the WebView was opaque, which is why it went unnoticed. Two consequences outlive the leak: `isVideoSurfaceAttached()` gates `PictureInPictureManager.canEnterPip` through `isNativeVideoPath()`, so it reported an attached surface forever after the first native video (saved from offering PiP over nothing only by the `isPlayingVideo()` check beside it), and every abandoned surface held its `OnLayoutChangeListener` on the content view. Detach is called from `clearVideoSurface`, which covers stop, the switch to audio, and the background-audio handoff, and always runs on the main thread because every caller is already inside a `mainHandler.post`. It removes the view from its *own* parent rather than looking the content view up from an Activity reference, so an Activity recreated underneath it cannot strand the view | Android | UR-003, UR-041 | Done |
|
||||
| DR-185 | The app shell stops painting over the video surface. `app.css` clears the page's opaque layers for native video through three selectors, and one of them — `html[data-native-video="active"] [data-app-shell]` — was written against an attribute **no component has ever set, in any commit**. The shell is `+layout.svelte`'s root `div`, which paints `--color-background` across the entire viewport; VideoPlayer is `fixed inset-0 z-50` and correctly makes *itself* transparent on the native path, but it stacks *above* the shell, so the WebView still composited the shell's opaque background over the whole screen and the SurfaceView behind it could never be seen. This is the missing half of the compositing DR-172 went looking for: the spec's own layer table lists this layer as "cleared by `data-native-video` → app.css", which was written but never wired, and `html`/`body` being genuinely transparent made the CSS look correct in isolation. The failure is invisible three ways over — the CSS is valid, the selector is plausible, and a rule matching nothing looks exactly like a rule matching something already transparent — while the symptom (black screen, audio fine) is identical to a real compositing failure, which is how it survived DR-150 through DR-172. Fixed by setting the attribute the rule was written for, and guarded by asserting the *relationship* rather than the rule: every attribute the compositing block targets must be set somewhere in the app, so a selector aimed at nothing fails the suite instead of failing silently on a device | UI | UR-003, UR-004, UR-041 | Done |
|
||||
| DR-186 | The play overlay comes down when the backend plays. `isPlaying` was assigned once from the `player_play_item` response and thereafter only by the `player://state-changed` listener — a channel the backend never emits, the same dead wire that DR-182's first fix was mistakenly hung on. On the native path the flag therefore froze at whatever the initial response said: with ExoPlayer playing, the UI still believed it was paused, so the `bg-black/30` play-button overlay stayed raised across the whole video area and the transport button kept showing ▶. The video was simultaneously dimmed and covered while it played, which reads as "the overlay never goes away" and is easily mistaken for a second compositing fault. The mirror reads the same `player` store `playerEvents.ts` feeds, which is what the architecture already says is authoritative — the player reports state, the UI consumes it — and is gated to the native path so HTML5 keeps its element-event wiring, which is authoritative there | UI | UR-003, UR-005 | Done |
|
||||
| DR-187 | The system bars go away with the player, not only with the fullscreen button. `enterImmersive()` had exactly one caller, `toggleFullscreen()`, so opening the player left the status and navigation bars painted over it until the user pressed a button most never press. On the native path this is worse than cosmetic: the SurfaceView fills the content view, so the bars sit directly on top of the video. The player is a full-screen surface by construction — `fixed inset-0 z-50` over a `MATCH_PARENT` surface — so entry is the right moment. Called synchronously in `onMount` before any `await`, per the native-mode pitfall, and paired with the `exitImmersive()` already unconditional in `onDestroy`, so a player torn down while immersive cannot leave the rest of the app without bars | UI | UR-066, UR-003 | Done |
|
||||
| DR-188 | Native Android video is **ready to be the default except for the background-audio handoff**, and the flip therefore waits. The picture defects behind DR-172 are all found, fixed and device-verified — DR-185 (the app shell painted over the surface through a CSS rule targeting an attribute nothing set), DR-182 (nothing could lift the poster card on a path with no `<video>` element), DR-183 (the JS bridges raced the page load, so `setTransparent(true)` could never arrive), DR-184 (the SurfaceView was never detached), plus DR-186 and DR-187, the two UI defects only this path could reveal. On a device logcat now carries `WebView transparent = true` and `Marking media ready` with video on screen, which is the pair DR-172 went looking for and could not find, and skip, seek and rotation were exercised by hand. Turning the default on then surfaced a *different* unverified sub-path: the background-audio handoff could only *return* through the HTML5 element, so coming back from the lockscreen left playback dead, and the flip waited for that rather than shipping a verified sub-path over an unverified one as DR-161 had. **The default is now on.** The two defects holding it back are fixed and device-verified — DR-196 (the handoff return restarts the renderer that is actually on screen) and DR-194 (the letterbox bars are painted rather than retaining stale framebuffer content) — with the evidence this default has been held to since DR-161: an audio handoff at 69:54 returning to video playing at 70:18, and clean bars across playback, the control bar and a rotation round-trip. An explicit stored choice still wins in both directions, so an opt-out survives the flip (the stored value is null-checked rather than compared to "true", which would have silently re-enabled it for everyone who turned it off) | Android | UR-003, UR-004 | Done |
|
||||
| DR-189 | The control bar comes down on a touchscreen. Its hide timer was armed from exactly one place — the player container's `onmousemove` — and a touchscreen never fires `mousemove`, so on Android the bar was never scheduled to hide and sat over the video for the whole film. It went unnoticed for as long as the native video surface was itself invisible (DR-172/DR-185): with nothing behind it to obscure, a permanent control bar reads as the UI rather than as a defect. Two changes, because there were two faults. `revealControls()` replaces `handleMouseMove` and is called on entry and on every touch interaction as well as on mouse movement, so touch arms the countdown. And the countdown became an `$effect` over the state rather than a one-shot timer armed by the input event: the first attempt armed a timer on entry, three seconds later playback had not started, `shouldHideControls` correctly declined, and nothing ever re-armed it — the timer has to follow the conditions that *permit* hiding, which arrive on their own schedule. The decision itself is `shouldHideControls` in `controlsVisibility.ts`, pure and separated from the clock and the DOM, because what was wrong here was the conditions and not the `setTimeout`: the bar stays up while paused (a user who paused by tapping the surface has no other way back), mid-seek (the position readout is the point of the bar then), and while any track/subtitle/quality menu is open (the menus are anchored to the bar, so hiding it would take the open menu with it) | UI | UR-003, UR-066 | Done |
|
||||
| DR-191 | Forcing the WebView overlay to redraw from the Activity, because with the ExoPlayer **SurfaceView** beneath it the overlay's ordinary damage stopped reaching the screen: the page kept mutating — the clock text every second, the control bar's opacity going to 0 — while the display held whatever frame it last presented, over video that animated perfectly. Not a state defect; the live DOM showed the slider advancing 476 → 479 across three seconds behind a screen showing neither. Only **structural** changes got through, which is why the play overlay always appeared to work (an `{#if}` block, added and removed) while the progress bar never did, and why rotation lost the transport UI. A CSS animation cannot help, since opacity animates on the compositor without repainting the layer. **Superseded by DR-192**: this drove `postInvalidateOnAnimation` in a loop, which treats the symptom — the cause is the SurfaceView's separate layer, and removing that removes the need. Kept as the record of how the mechanism was identified | Android | UR-003, UR-004 | Superseded by DR-192 |
|
||||
| DR-195 | Play/pause works on the native path, because the frontend stops claiming a webview element is playing when there is none. `html5_playing` is Rust's record of "a webview `<video>` is active and in this state", and `toggle_playback`, `play` and `pause` all route transport to that element whenever it is set. The player route mirrored element state into it **unconditionally** — from `handleReportStart` and, fatally, from `handleReportProgress`, which VideoPlayer calls on a 10-second interval — so on the native path the frontend re-declared every ten seconds that an element was playing when none existed, and every transport intent was emitted into the void. The pause button was dead from the on-screen tap, from the control bar, and from a direct `player_toggle` invocation, while seek and skip kept working because `player_seek_video` decides elsewhere; that asymmetry is the signature. It also explains the flashing, since the control bar and the JRay overlay both key off `isPlaying`, which was being contradicted on every interval tick. DR-193 clearing the flag at load was necessary but insufficient on its own — the interval put it straight back. The mirror now lives in `mirrorElementStateToRust` in VideoPlayer, gated on `useHtml5Element`, which is the only place that knows whether an element renders at all; the route cannot tell the two paths apart, which is precisely how it came to lie. Confirmed on device by ADB: surface tap and control bar each pause (position frozen across repeated samples, transport label flipped) and resume | Playback | UR-005, UR-003 | Done |
|
||||
| DR-196 | Returning from background audio brings the picture back on the **native** path, because the return now restarts the renderer that is actually on screen. The two paths resume by different means: the webview `<video>` reloads off its stream URL, watched by an `$effect` that reinitialises HLS and lets `canplay` drive the seek — while ExoPlayer owns no element and nothing watches the URL on its behalf, so its playback is only ever started by an explicit `player_play_item` + adapter load, issued once from `onMount`. `exitBackgroundAudioHandoff` did only the URL assignment, for both paths, so on the native path it restarted nothing: `player_exit_background_audio` had already stopped the handoff's audio player, leaving the backend holding no item at all. The symptom is a black screen with a play overlay pinned at 0:00, a seek bar at zero, and a play button that does nothing — the process alive and the frontend still logging, since nothing crashed; the transition was simply dropped. The branch is decided by `planHandoffReturn` (pure, in `backgroundAudioHandoff.ts`), which also folds in `shouldResumeOnForeground` so a lockscreen pause during the handoff still wins over the snapshot taken on the way out. Subtitle configurations are reused from the ones resolved at mount, since ExoPlayer sideloads them as `MediaItem.SubtitleConfiguration`s and cannot accept one after `prepare()`. Verified on device: handoff to audio at 69:54, return restored video playing at 70:18 | Playback | UR-040, UR-003 | Done |
|
||||
| DR-197 | Continue Watching and Next Up stop showing the same episode. Jellyfin's `/Shows/NextUp` defaults `EnableResumable=true`, which returns a partially-watched episode as its own series' next up — precisely the episode `/Items/Resume` already returns — so the Home "Next Episode" row and the TV landing's Next Up row duplicated Continue Watching card for card. `build_next_up_endpoint` sends `EnableResumable=false`, and because servers predating that parameter ignore it, `filterInProgressNextUpItems` also drops any next-up entry whose id appears in the resume list. It is the mirror of DR-089 and lives beside it: same presentation-layer de-duplication over two lists the frontend already holds, no Jellyfin taxonomy involved. The resume filter still reads its frontier from the *unfiltered* Next Up list, so removing in-progress entries cannot resurrect a stale resume card. The division is then exact: Continue Watching offers episodes the viewer has started and not finished, Next Up offers the episode after the ones they finished | Repository | UR-059 | Done |
|
||||
| DR-200 | The lockscreen notification is exempt from `POST_NOTIFICATIONS`, because of the **session token**, not because it belongs to a foreground service — and the difference is what the code now records. `POST_NOTIFICATIONS` was declared in the manifest and requested nowhere, so on Android 13+ it sat permanently denied; an audit read that as a threat to UR-006, since the media notification is what carries the lockscreen transport controls. It is not. Android's own wording is that the permission covers "non-exempt (including Foreground Services (FGS)) notifications", with denied users seeing FGS notices "in the Task Manager but [not] in the notification drawer" — so an FGS notification is explicitly *not* exempt — while separately "Notifications related to media sessions are exempt from this behavior change". The platform predicate is `Notification.isMediaNotification()`, which requires `MediaStyle` **and** a non-null `EXTRA_MEDIA_SESSION`, and it is byte-identical across API 33–36. `NotificationManagerService` uses it to decide whether to drop the post, and SystemUI's media carousel (`MediaDataProcessor.onNotificationAdded`) is gated on the *same* predicate — so a token-less notification is not merely absent from the shade, it never reaches the notification listener and the lockscreen/Quick-Settings controls do not exist at all. Confirmed on device (HONOR ROD2-W09, Android 16 / SDK 36): appops `POST_NOTIFICATION: ignore`, `granted=false`, and the service simultaneously `isForeground=true` with `foregroundNoti=Notification(category=transport actions=3 vis=PUBLIC)`. So **no runtime permission request is added** — a prompt the app does not need is a prompt that can be permanently denied for nothing — and no `checkSelfPermission` gate is placed on `startForeground`, which would trade a cosmetic problem for the "did not then call Service.startForeground()" kill. What is added is the guard that matches the real precondition: `mediaSessionCompat?.sessionToken` is a null-safe call, and the exemption hangs entirely on it, so both builders now bind the token once and log an error if it is ever null while the permission is denied — converting a failure that is invisible unless the tester happened to deny the permission (most grant it reflexively) into a logcat line. The manifest declaration is *kept*, unrequested, and documented: media3 does not need it (media3-session declares no permissions and the `MediaSessionService` guide asks only for the two `FOREGROUND_SERVICE` ones), but the exemption covers media and self-managed-call notifications only, so a download-completion notice (UR-011) would be an ordinary notification and silently dropped — keeping the declaration is what makes adding one a one-file change | Android | UR-006 | Done |
|
||||
| DR-201 | A lockscreen skip means different things depending on what is playing, and the backend decides which. `onSkipToNext`/`onSkipToPrevious` forwarded a bare `"next"`/`"previous"` to Rust, which always advanced the queue — correct for music, wrong for a video whose audio is running through a background-audio handoff (UR-040), where the buttons should scrub. Pressing skip to re-hear a line jumped to the next *episode* instead. `resolve_skip_action` in `player/seek.rs` maps the command to either `Advance` or `SeekTo`, and `is_background_audio_active()` is the whole test: the handoff exists only for video, and an episode played through it reports `MediaType::Audio`, so media type cannot distinguish the case. Forward jumps 30s, back 10s — asymmetric because the back button replays dialogue just missed rather than travels — and both clamp to `[0, duration]`, since a negative offset is rejected by backends and a seek past the end reads as EOF and would advance, the very outcome being prevented. Routed through the same spawn-then-`seek_absolute` path as the scrubber, because a handoff seek re-opens the stream and must not run under the blocking lock (DR-159). The Kotlin keeps sending the same opaque command; only the `PlaybackStateCompat` gains `ACTION_FAST_FORWARD`/`ACTION_REWIND` so the system draws seek affordances rather than skip arrows that lie about what they do | Playback | UR-040, UR-006 | Done |
|
||||
| DR-202 | Video keeps the display awake. Android counts its display timeout from the last *user input*, and watching something is exactly the case where there is none, so the screen dimmed and slept mid-film unless the user kept tapping it. Nothing held it: `FLAG_KEEP_SCREEN_ON` appeared nowhere in the app, and neither renderer supplies a hold for free — ExoPlayer's `setWakeMode` is a CPU/wifi wake lock that says nothing about the display, and it draws into the `TextureView` this app owns (DR-192) rather than media3's `PlayerView`, which is the widget that would otherwise set `keepScreenOn` itself; the WebView `<video>` path is no better, because the display wake lock Chrome takes for video lives in the browser layer and not in an embedded WebView. `ScreenWakeManager` toggles `FLAG_KEEP_SCREEN_ON` on the Activity window — window-scoped, so it stops applying the moment the app is not visible and cannot outlive a crash the way an explicitly acquired `PowerManager.WakeLock` can, and it needs no permission (the manifest's `WAKE_LOCK` is the media service's). The two rendering paths are independent holders OR-ed in the pure `ScreenWakeState`: the native path follows `onIsPlayingChanged` plus surface teardown, so the hold tracks what ExoPlayer *reports* rather than what the UI intends, and the webview path reuses the `setHtml5VideoState` report the frontend already sends for PiP (DR-160) rather than adding a bridge. Audio is deliberately not a holder — playing music with the screen off is the point of that path — so the hold is gated on the media type being video, and it is dropped on pause, on stop, on surface teardown, and on a new WebView, since a page that goes away never sends its own final `active = false`. Also the repo's first Kotlin JVM unit tests: `ScreenWakeState` is framework-free so the decision is testable off-device with `./gradlew :app:testUniversalDebugUnitTest`. Verified on device (FP5, native path): `IS PLAYING CHANGED: true` → `keepScreenOn = true` 17 ms later and `fl=KEEP_SCREEN_ON` on the window in `dumpsys`, a pause releasing it and the resume re-taking it. The webview path is unverified | Android | UR-003, UR-004 | Done |
|
||||
| DR-203 | The background-audio handoff stops silently rewinding to the point it started. A player retry is only a *retry* if it can resume where the load failed, and ExoPlayer decides that in `ProgressiveMediaPeriod.configureRetry`: it keeps the load position when the content length is known or the extractor produced a seek map with a duration, and otherwise assumes the source is live — the data at the URL is taken to have changed, so every sample queue is reset and the URL is re-requested from offset 0. The handoff transcode (`/Audio/{id}/universal?Container=mp3&TranscodingProtocol=http`, DR-129) satisfies neither condition: chunked, so no `Content-Length`, and a live mp3 encode carries no `Xing` header, so the duration is unset — on device every position tick reads `<position> / 0.0`. Its URL carries `StartTimeTicks` = the handoff point, so "from offset 0" is the handoff point, and after any transient load error playback resumed there and ran on normally. Nothing was reported: a successful retry raises no error and no `STATE_ENDED`, so neither arm of DR-129 was ever consulted, no `onPositionDiscontinuity` handler existed, and the app's only trace of it was a position that went backwards — which is why it read as random, since it needs a network blip to land while a load is in flight rather than while the ~50s buffer covers it, and why it survived the two earlier fixes for the same *symptom* (DR-129's phantom end, DR-159's relative-timeline leak). The decision is Rust's: `player_retry_restarts_stream` marks a `Remote` audio-only video item, and `loadWithMetadata` carries the answer to Kotlin, where the pure `StreamRetryDecision` holds it for a `DefaultLoadErrorHandlingPolicy` subclass that returns `C.TIME_UNSET` — which makes `onLoadError` answer `DONT_RETRY_FATAL` *before* reaching `configureRetry`. The rewind therefore becomes a recoverable error, and `recoverable_error_resume` already knows what to do with one: re-open at the position playback actually reached, `StartTimeTicks` rewritten, with backoff and the shared attempt budget. Every other source keeps the player's retry, because a static file and an HLS playlist both declare their timeline and are resumed in place. A `onPositionDiscontinuity` handler is added for the log line alone, so a recurrence is visible rather than invisible — loud for `DISCONTINUITY_REASON_INTERNAL`, which is the rewind's own signature, and quiet for the backwards jump a resume's re-prepare legitimately makes. Reproduced and verified on device (FP5), same procedure both times: background-audio handoff, 60s to fill the buffer, a 45s radio outage, then watch. **Before** — the outage passed unnoticed and 3.5 minutes later, with nothing logged in between, `BUFFERING` → `READY` → position `1165.4s` → `840.3s`, exactly the handoff base, no error and no `STATE_ENDED`; the same log line reports `Media ready! Duration: -9.223372036854776E15`, which is `C.TIME_UNSET` and the precondition itself. **After** — `Load error on a stream that cannot be resumed in place — declining the player's retry` at the outage, playback continuing undisturbed off the buffer for 69s (a fatal load error is only raised when the renderer next needs data), then `ERROR_CODE_IO_NETWORK_CONNECTION_FAILED` → `re-opening at 785.6s in 2s` → `READY`, playing on from 785.6s with no rewind in the following 7 minutes | Playback | UR-040, UR-004 | Done |
|
||||
| DR-199 | The webview stops undoing the network security config. `MainActivity.configureWebViewSettings` set `mixedContentMode = MIXED_CONTENT_ALWAYS_ALLOW` together with `allowFileAccess = true` and `allowContentAccess = true`, which is a blanket cleartext opt-in reached by hand — exactly the thing `network_security_config.xml` exists to prevent and its own comment warns against (DR-138). Nothing needed any of the three. `file://` is never loaded: cached thumbnails go through `convertFileSrc`, which on Android resolves to `http://asset.localhost/…` and is answered by wry's request interceptor rather than the filesystem, and downloaded media goes over the loopback HTTP server (DR-137), which exists precisely because the asset/file route cannot stream a large file. `content://` is never loaded either — the manifest's `FileProvider` is for outbound share intents, not webview navigation. And mixed content never arises: Tauri serves the UI from `http://tauri.localhost` (`use_https_scheme` defaults false and is not set in `tauri.conf.json`), while both `127.0.0.1` and `asset.localhost` are loopback/`.localhost` origins that Chromium treats as potentially trustworthy, so they are not mixed content to begin with. A plain-HTTP *remote* Jellyfin server would be, but the network security config already rejects it before any mixed-content check runs — so `ALWAYS_ALLOW` bought nothing and only widened the hole. `COMPATIBILITY_MODE` rather than `NEVER_ALLOW` is a deliberate hedge and not the default — the platform default at targetSdk 21+ *is* `NEVER_ALLOW` — because none of this can be verified anywhere but a device, and compatibility mode keeps passive content (images) working if the analysis missed a path. `allowFileAccess = false` restores the targetSdk-30+ default; `allowContentAccess = false` is a genuine tightening (its default is true) and is the first thing to look at if something that used to render stops. The two files now cross-reference each other so the pair cannot drift apart again | Security | UR-071 | Done (pending device verification) |
|
||||
| DR-194 | Stale pixels in the letterbox bars — the rotation "flash of the previous frame", a ghost control bar stranded in the top bar, each new clock digit drawn over the last (`35:42` with the `1` still showing through the `2`), and menus (sleep timer, quality) leaving their imprint behind. One cause for all of it: **nothing painted the bars.** The window surface is opaque (the theme is not translucent), and for an opaque surface HWUI deliberately does not clear the damaged region before replaying a frame — it assumes the view hierarchy covers every pixel. That hierarchy is window background → video `TextureView` → transparent WebView, and `fitSurfaceToScreen` sizes the TextureView to the *letterboxed* video rect, so the bars were the window background's alone to paint. `setTransparent(true)` cleared that background to `TRANSPARENT`, leaving the bars painted by nobody and whatever was last in the framebuffer surviving in them. Fixed by keeping the window background opaque black while compositing; the WebView's own background is what lets the video through, and the TextureView is drawn on top of the window background, so an opaque one cannot hide it. Three earlier fixes aimed at the window's rotation animation and at TextureView frame-retention (two `postOnAnimation` hops, an `onSurfaceTextureUpdated` reveal, then `ROTATION_ANIMATION_JUMPCUT` + `FLAG_FULLSCREEN`) all missed, because the pixels were never the animation's; the alpha-hiding among them made it worse by blanking the one view that reliably paints its own rect. Those are removed, `FLAG_FULLSCREEN` included — it fought edge-to-edge insets for no gain. Verified on device: ghosting reproduced with native video on, then absent after the fix, across playback, the control bar and a rotation round-trip | Android | UR-003, UR-066 | Done |
|
||||
| DR-193 | Play/pause reaches the player that is actually rendering. `toggle_playback`, `play` and `pause` all route to the webview element when `is_html5_active()`, which is `html5_playing.is_some()` — a flag written **only** by the element's own state reports and cleared only when it reports "stopped"/"idle" (or on a background-audio handoff). An element that went away without that final report, or webview-rendered music earlier in the same process, therefore left the flag set, and on Android's native video path every transport intent was emitted as a `ControlCommand` at an element that no longer existed: the pause button did nothing, from the on-screen tap and from the control bar alike, while seek and skip kept working because `player_seek_video` decides elsewhere. Whether it happened at all depended on what had played before, which is exactly what made it read as flaky rather than broken. `load_and_play` — the native load path, and the one the HTML5 video path deliberately avoids via `set_current_item` — now clears the flag, because loading into the native backend *is* the statement that native renders this item. Nothing is lost on the webview path: an element re-establishes its own authority the moment it reports again, so this is the existing "element is gone" semantics applied where it can be known directly rather than inferred from a report that may never arrive | Playback | UR-005, UR-003 | Done |
|
||||
| DR-192 | Native video presents through a **TextureView**, not a SurfaceView. A SurfaceView renders on its own layer *outside* the app window and punches a transparent region through it; everything drawn above that hole — for us the entire Svelte UI in a transparent WebView — depends on that composition path, and Android's own graphics documentation states that "overlays do not currently work correctly with SurfaceView or TextureView". The consequences were four symptoms of one cause (DR-191): a frozen progress bar, controls that would not fade, rotation losing the transport UI, and overlays that lingered after the DOM removed them. A TextureView is an ordinary view whose frames are drawn as a texture in the window's normal rendering pass, so there is no second layer and no transparent region, and the WebView above composites like it would over any other view — which is why media3 offers `surface_type="texture_view"` and why it is the standard remedy for ExoPlayer overlay problems. The trade is accepted rather than hidden: TextureView costs more power and memory than SurfaceView and adds a frame of latency, but hardware decode through MediaCodec is untouched, so the reason native video exists survives it. `setVideoTextureView` installs ExoPlayer's own `SurfaceTextureListener`, so the old `SurfaceHolder.Callback` wiring is deleted rather than ported — adding a listener of ours would displace it and the video would never appear. PiP needs no change, since a TextureView is a View and the aspect-ratio probe reads its measured bounds | Android | UR-003, UR-004, UR-041 | Done |
|
||||
| DR-190 | The background-audio handoff can return to the native path. Everything that restores playback on the way back is written around the WebView `<video>`: `applyPendingForegroundSeek` returns early on `!videoElement`, the HLS re-init `$effect` returns early on `!useHtml5Element`, and `pendingForegroundSeek`/`pendingForegroundPlay` — which own the post-handoff position and play/pause — are consumed only by `handleCanPlay` and `markMediaReady`, an element event and a path that reaches the same guard. On the native path there is no element, so `exitBackgroundAudioHandoff` completes, clears `handoffState`, blanks and reassigns `currentStreamUrl` to force an effect that will not run, and nothing ever restarts ExoPlayer: the user returns from the lockscreen to a dead player. This never showed while the path was opt-in and its picture was invisible anyway. The return needs the native equivalent of the element reload — re-issue the item to the backend, seek to the position `player_exit_background_audio` reports, then honour `wasPlaying` — routed through the adapter rather than the element, so both paths restore through one contract | Playback | UR-040, UR-003 | Proposed |
|
||||
| DR-161 | Native video is the default, so picture-in-picture has a real surface. DR-160 makes PiP work on the HTML5 path, but that path can only ever shrink the *UI* into the PiP window; showing the video itself needs the SurfaceView behind the WebView, which is what `experimentalNativeVideo` gates. The flag now defaults to on when the user has never chosen, with an explicit stored choice still winning in both directions so anyone who turned it off keeps it off. This is a deliberate acceptance of risk: the flag existed because the native path was an unfinished spike, and `VideoPlayer.scrubRegression.test.ts` documents its history — a native init that flipped to HTML5 mid-lifecycle and left seeks going down one path while ExoPlayer played on another. Those tests pin the **flag-off** interim override (native response overridden to HTML5, backend stopped once), which the default no longer selects, so they now mock the flag off rather than inherit it: they still guard that path, but they no longer describe what ships. The native scrub/seek path is consequently not covered by the suite and needs device verification | UI | UR-041, UR-003 | Needs device verification |
|
||||
| DR-159 | The background-audio handoff stops leaking its relative timeline. The handoff plays the episode as a *relative* stream — the audio-only URL is built with `StartTimeTicks` = the position the screen was locked at, so ExoPlayer's zero is the handoff point — and `background_audio_base` holds the offset that turns one back into a real position. The base was a **display-only** correction, applied in exactly two places (the lockscreen scrubber and the internal truncation maths) while every other consumer worked in the relative timeline treating the number as absolute. Each crossing threw away exactly `base` seconds, which is why the jump-back distance varied with where the screen was locked and read as random. Three crossings were live: progress reporting to Jellyfin sent the relative position every 30s, so the server was told `real − base` — and since DR-155 now mirrors the server's position back and refreshes on a cache hit, that regressed value returned as the resume point (lock at 40 min, listen to 90, reopen at 50); lockscreen seeks went out absolute and came back relative, against a chunked length-less transcode that cannot honour a seek at all, so a clamped seek landed at stream zero; and media3's own `seekToDefaultPosition`/`seekBack`/`seekForward` bypassed the `ForwardingPlayer` wrapper entirely, reaching the real ExoPlayer — `Util.handlePlayButtonAction` seeking an ended player to the relative zero being the same mechanism as DR-129's truncation bug through a different door. The fix converts **once, at the boundary**: `JellyTauPlayer`'s position tick adds the base (and shifts the duration with it, since the stream's own length is only what remains) before either `nativeOnPositionUpdate` or the lockscreen sees it, so position updates, progress reports, the frontend and the truncation check all speak the episode's timeline and none needs to know a handoff happened. The base is consequently *removed* from `claim_stream_resume`, `truncated_stream_resume_position` and `player_exit_background_audio`, where adding it now double-counts, and the lockscreen's `positionOffsetMs` addition goes with it (the field remains, read-only, as the tick's input). Inbound seeks go the other way: `seek_absolute` is the new boundary for every outside seek, re-opening the stream at the requested position via `resume_stream_at` when a handoff is active — which is what `onSeekTo` had claimed for months in a comment describing code that did not exist — and an ordinary seek otherwise. `seekToDefaultPosition` is swallowed rather than forwarded, since Rust already owns what "play after the stream ended" means and the `play()` that follows reaches it. Exit reads the position *before* clearing either base, or a tick landing in between hands back a relative one | Player | UR-040, UR-005, UR-025 | Done (pending device verification) |
|
||||
| DR-158 | A watched toggle, on the episode row, the season header, the series and movie hero, and the Episode Focus View. Both halves of the backend already existed and neither had a caller: `mark_played` (`POST /PlayedItems`) was reachable only from the sync drain replaying rows the *reporter* had queued, and `clear_watch_history` (`DELETE /PlayedItems`) only from the destructive "erase this series' history" button — so the sole way to mark something watched was to play it. Jellyfin applies both recursively over a season or series, so the container case needs no client-side fan-out *online*. Offline it does: `storage_set_watched` writes the item **and its descendants** (drawn from `items` by `parent_id`/`album_id`/`season_id`/`series_id`, so an uncached id selects nothing and the statement no-ops instead of raising a foreign-key error), because otherwise marking a season watched with no server would tick the season and leave every episode inside it unwatched. It is deliberately separate from `storage_mark_played`, which stays the single-item "this finished playing" path that increments `play_count`. Un-marking clears the resume position as well as the flag, matching the server. `QueuedOp::MarkUnplayed` gives the queue the missing direction — pushing as `clear_watch_history` — so the toggle works offline both ways rather than only one; without it un-marking would have been the half that needed a connection. The button is an everyday toggle, so unlike `ClearHistoryButton` it does not confirm, and it holds an optimistic state because the caller's `watched` prop only catches up after a reload (a season means a round trip, during which the button would otherwise appear to ignore the tap) | UI | UR-073 | Done |
|
||||
| DR-157 | Full-screen video on Android actually goes full screen. `toggleFullscreen` called `document.documentElement.requestFullscreen()` and nothing else, which inside an Android WebView does not touch the Activity window — it expands the element within a viewport that already spans the whole screen, because `enableEdgeToEdge()` is called in `onCreate` and SDK 36 ignores the opt-out. So the control did nothing visible while the status bar and navigation/gesture bar stayed painted over the video, and (unlike DR-112's chrome-clearance work, which is about *reserving* space for the bars) here the bars should not be there at all. `ImmersiveModeBridge` hides them via `WindowInsetsControllerCompat` with `BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE`, so an edge swipe brings them back transiently over the video instead of resizing the window mid-playback, and the system's own gestures stay reachable. Exposed as the `AndroidImmersive` bridge and posted to the main thread, since `@JavascriptInterface` methods arrive on a WebView binder thread. `requestFullscreen()` is kept for the platforms where it does work, but its rejection is caught rather than allowed to abort the immersive call. Restoring is wired to three paths, not one: leaving fullscreen, Escape (which previously called `document.exitFullscreen()` directly, bypassing the flag and the bars), and `onDestroy` — the bars belong to the Activity, so a player torn down while immersive would strand every screen behind it without them. The `--jt-inset-*` properties need no special handling: hiding the bars fires the decor view's inset listener with zeroes and `WindowInsetsBridge` republishes them | UI | UR-066 | Done |
|
||||
| DR-143 | Flipping the offline downloaded-only gate actually re-queries the listing. The gate (DR-078) is a process-wide flag in Rust consulted only *while a query runs*, but no library surface re-queried when its inputs changed: `useServerReachabilityReload` fires only on the offline → **online** transition, and `GenericMediaListPage`, `GenericGenreBrowser` and the favourites page never even called its `checkServerReachability`. So going offline left the full server catalog on screen under a now-closed gate, and toggling "Show all server media" only greyed cards — `MediaCard.isServerOnly` is a pure frontend derivation that updates instantly — without adding or removing a single row. The filter therefore read as "shows everything until I filter, then greys some of it" while the backend gate was correct and simply never exercised. `catalogFilterVersion` is the refetch signal: `pushCatalogVisibility` now awaits `set_show_server_catalog` and bumps the version only **after** the backend accepts the new flag, since a reload racing the push would re-query under the old gate and undo itself. A failed push clears `lastIncludeCatalog` instead of latching it, so the next identical transition is retried rather than skipped as a no-op and left permanently disagreeing with the backend. `useOfflineFilterReload` subscribes pages to that signal, skipping the value they already loaded under; it is wired into both generic list components and the movies/music/tv/favourites landing pages and the `/library/[id]` detail page | UI | UR-052 | Done |
|
||||
| DR-135 | A download's media type comes from the item, not a default. `download_item` — the path a media card uses to queue an item while offline — never records `media_type`, and the reconnect resolver read that NULL as `'audio'`, so a **movie** queued from a card had its URL resolved by `get_audio_stream_url`. The file that landed on disk was an audio-only transcode, which is why a "downloaded" film could never play offline no matter how the path or protocol was fixed. The resolver now falls back to the item's own `item_type` (`VIDEO_ITEM_TYPES` in Rust, so the frontend never learns which types are video) and only defaults to audio when the item is not cached locally. An explicit `media_type` on the row still wins | Downloads | UR-071, UR-052 | Done |
|
||||
| DR-136 | Rows already downloaded under the audio default are repaired, not just prevented. They are identifiable after the fact — no `media_type`, but a video item — so on reconnect they are reset to `pending` with their audio URL cleared and re-resolved by DR-135's corrected logic, overwriting the audio file in place. Without this the fix is invisible to anyone who had already queued a film: the row still reads "downloaded" and still fails to play. Rows carrying an explicit `media_type` and genuine audio downloads are left untouched | Downloads | UR-071 | Done |
|
||||
| DR-137 | Local media is served to the player over a loopback HTTP server, not the asset protocol. Tauri's `asset` protocol answers a request carrying no `Range` header by reading the whole file into memory, and only advertises `Accept-Ranges: bytes` from *inside* its range branch — so the first request never learns ranges exist and a multi-gigabyte body is attempted instead. Chromium abandoned it with `PIPELINE_ERROR_READ` after ~31s, which reached the user as "downloaded video does not play offline". Real HTTP on `127.0.0.1` is chosen over a custom URI scheme deliberately: range support becomes a property of the transport rather than depending on whether a platform's webview forwards `Range` to a custom scheme. No response ever exceeds a 4 MiB chunk and bodies stream from the file handle, so memory is bounded regardless of file size. Because **loopback is shared between apps on Android**, the server binds `127.0.0.1` only and every URL carries a random per-session token; paths are additionally confined to the app data directory, so a leaked URL cannot read outside it. This is stage 1 of making the server the single media origin — remote passthrough and download-while-watching are deliberately out of scope here | Playback | UR-071 | Done |
|
||||
| DR-138 | Loopback is exempted from Android's cleartext ban, and nothing else is. Release builds set `usesCleartextTraffic="false"`, so the webview's request to the local media server (DR-137) was rejected by network security policy before any I/O — `<video>` failed in the same millisecond as `loadstart`, with `NETWORK_NO_SOURCE` and no server-side log at all, which is why it looked identical to a missing file. A `network-security-config` resource permits cleartext for `127.0.0.1` only and keeps `base-config cleartextTrafficPermitted="false"`, so a remote server must still be HTTPS; this is deliberately not a blanket opt-in. The manifest attribute is ignored once the config is present, so the config is the single authority. `sync-android-sources.sh` also had to learn to copy `res/xml`, which it skipped — the manifest references the resource, so a missed copy fails the resource link rather than degrading quietly | Security | UR-071 | Done |
|
||||
| DR-093 | Traceability coverage gate derives its requirement denominators from `requirements.md` at run time rather than hardcoded literals: `countDefinedRequirements` counts an ID only where it leads a markdown table row (ignoring the "Traces To" column and prose) and deduplicates IDs listed both in the definition tables and in the §3 traceability matrix; `computeCoverage` reports the *intersection* of traced and defined IDs so an ID traced in code but absent from `requirements.md` is surfaced as `orphaned` instead of inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. CI and `bun run traces:coverage` share this computation and fail on both a sub-threshold and an impossible >100% result | Tooling | - | Done |
|
||||
| DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
|
||||
|
||||
---
|
||||
|
||||
@@ -265,29 +404,29 @@ 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-005 | - | DR-001, DR-005, DR-009 |
|
||||
| UR-006 | IR-005, IR-006, IR-007, IR-008 | - |
|
||||
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010, DR-182, DR-183, DR-184, DR-185, DR-186, DR-187, DR-188, DR-190, DR-191, DR-192, DR-193, DR-194, DR-195, DR-196 |
|
||||
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188, DR-203 |
|
||||
| UR-005 | - | DR-001, DR-005, DR-009, DR-178, DR-179, DR-186, DR-193, DR-195 |
|
||||
| UR-006 | IR-005, IR-006, IR-007, IR-008 | DR-200, DR-201 |
|
||||
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
|
||||
| UR-008 | IR-010 | DR-007, DR-011 |
|
||||
| UR-009 | IR-009, IR-010, IR-011 | - |
|
||||
| UR-010 | IR-012, IR-021 | DR-037, DR-059 |
|
||||
| UR-011 | IR-013 | DR-003, DR-015, DR-018 |
|
||||
| UR-012 | IR-009, IR-014 | - |
|
||||
| UR-012 | IR-009, IR-014 | DR-198 |
|
||||
| UR-013 | IR-013 | DR-017 |
|
||||
| UR-014 | IR-010 | DR-014, DR-019 |
|
||||
| UR-015 | - | DR-005, DR-020 |
|
||||
| UR-016 | - | - |
|
||||
| UR-017 | - | DR-014, DR-021 |
|
||||
| UR-018 | IR-013 | DR-015, DR-018 |
|
||||
| UR-018 | IR-013 | DR-015, DR-018, DR-173 |
|
||||
| UR-019 | IR-015 | DR-022 |
|
||||
| UR-020 | IR-016, IR-018 | DR-023 |
|
||||
| UR-021 | IR-016, IR-019 | DR-024 |
|
||||
| UR-020 | IR-016, IR-018 | DR-023, DR-176 | <!-- IR-018 delivered by ExoPlayer + HTML5 `<track>`, not libmpv -->
|
||||
| UR-021 | IR-016, IR-019 | DR-024 | <!-- IR-019 delivered by ExoPlayer + HLS stream re-open, not libmpv -->
|
||||
| UR-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, DR-178, DR-179 |
|
||||
| UR-026 | - | DR-029, DR-048, DR-050 |
|
||||
| UR-027 | IR-020 | DR-030 |
|
||||
| UR-028 | - | DR-031 |
|
||||
@@ -302,27 +441,41 @@ 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-041 | IR-026 | DR-053 |
|
||||
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190, DR-196, DR-201, DR-203 |
|
||||
| UR-041 | IR-026 | DR-053, DR-160, DR-161, DR-172, DR-182, DR-183, DR-184, DR-185, DR-188 |
|
||||
| UR-042 | IR-009, IR-014 | DR-054 |
|
||||
| UR-043 | IR-027 | DR-055 |
|
||||
| UR-044 | - | DR-056 |
|
||||
| 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-055 | - | DR-081, DR-082, DR-083, DR-084 |
|
||||
| UR-054 | - | DR-075, DR-076, DR-077, DR-147 |
|
||||
| UR-055 | - | DR-081, DR-082, DR-083, DR-084, DR-167, DR-168, DR-169, DR-173 |
|
||||
| UR-056 | - | DR-085 |
|
||||
| UR-057 | - | DR-086 |
|
||||
| UR-058 | - | DR-087 |
|
||||
| UR-060 | - | DR-090, DR-091 |
|
||||
| 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, DR-157, DR-187, DR-194 |
|
||||
| UR-067 | - | DR-115, DR-116, DR-117, DR-118 |
|
||||
| UR-068 | - | DR-119 |
|
||||
| UR-069 | - | DR-113, DR-114, DR-120 |
|
||||
| UR-070 | - | DR-121, DR-122 |
|
||||
| UR-071 | IR-032 | DR-123, DR-124, DR-125, DR-126, DR-127, DR-128, DR-133, DR-134, DR-135, DR-136, DR-137, DR-138, DR-170, DR-171, DR-180, DR-198, DR-199 |
|
||||
| UR-072 | - | DR-156 |
|
||||
| UR-073 | - | DR-158 |
|
||||
| UR-074 | - | DR-162, DR-177, DR-181 |
|
||||
| UR-075 | - | DR-174, DR-175 |
|
||||
|
||||
---
|
||||
|
||||
@@ -420,6 +573,112 @@ Internal architecture, components, and application logic.
|
||||
| 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-162 | Each downloaded library lists only its own media: the music library shows the album and neither the film nor the series, the movie library only the film, the TV library only the series | DR-163 | Done |
|
||||
| UT-163 | `partial_path` appends rather than replacing the extension, so it matches what the cleanup paths delete, keeps two sources for one title apart, and still produces a sidecar for an extension-less target | DR-165 | Done |
|
||||
| UT-170 | `queue_album_tracks` queues a row for every track of the album — including tracks the cache holds without an `album_id` and tracks it has never seen at all — links each one to its album so offline browsing can find it, returns the row ids in track order, and is idempotent: re-queuing fills the gaps without duplicating rows or resetting a completed track. `cached_album_tracks` (the offline fallback) finds tracks by either album link and does not sweep in another album's | DR-173 | Done |
|
||||
| UT-171 | `resolve_pending_download_urls` restricted to a set of row ids resolves only those rows and leaves other pending rows untouched, and an empty id set resolves nothing rather than sweeping everything | DR-173 | Done |
|
||||
| UT-172 | `album_file_names` gives every track of an album its own file: a title repeated within the album (deluxe edition, two discs) is disambiguated by track number and item id instead of the second download overwriting the first, an unambiguous title keeps its own name, and path separators in a title are sanitised so a track cannot escape the album directory | DR-173 | Done |
|
||||
| UT-164 | `resume_offset` appends only when the server answered `206`; a `200` after a Range request restarts the file, because that body is the whole stream | DR-166 | Done |
|
||||
| UT-165 | A registered download starts unflagged, `signal` sets the flag its worker reads, signalling an unregistered id reports not-in-flight, `clear` forgets it, and re-registering drops a previous stop so a resumed download does not halt instantly | DR-164 | Done |
|
||||
| UT-166 | `original` quality re-encodes audio the webview cannot decode (E-AC-3/AC-3/DTS/TrueHD) to AAC without capping bitrate or resolution, keeps the `Static=true` direct copy for audio that plays here (AAC/MP3/Opus/Vorbis/FLAC) and for an unknown codec, leaves the explicit quality presets untouched, and picks the served track by the same default-or-first rule the streaming verdict uses | DR-171 | Done |
|
||||
| UT-155 | A seek during a background-audio handoff re-opens the stream at the requested absolute position (`StartTimeTicks`) and rebases the handoff to it, while a seek outside a handoff stays an ordinary seek and invents no base | DR-159 | Done |
|
||||
| UT-154 | `mark_unplayed` parses to `QueuedOp::MarkUnplayed` and is rejected without an item id, and a queued un-mark drains to the server as `clear_watch_history` | DR-158 | Done |
|
||||
| UT-156 | A capped step reaches the transcode URL as all four of its parts (total ceiling, the video/audio split summing to the cap, and a `MaxHeight`), the uncapped default keeps the historical 20/18 Mbps allowance and constrains no resolution, and the background-audio handoff takes the lower of the cap and its own 384 kbps | DR-162 | Done |
|
||||
| UT-157 | The quality ladder is internally consistent — video + audio equals the cap at every step, audio never consumes the budget, only `Original` is uncapped — descends in bitrate, resolution and audio share together, and round-trips through the serde token it is persisted as | DR-162 | Done |
|
||||
| UT-158 | Justified rows fill the container width exactly and never overflow it, every tile in a row shares one height, and each tile's width follows its own aspect ratio — a 16:9 tile coming out more than twice the width of a 2:3 tile at the same height | DR-174 | Done |
|
||||
| UT-159 | The awkward cases of the packing: a short last row is left at the target height rather than stretched across the container, a last row that would overflow is brought down, an extreme ratio is clamped instead of taking a row to itself, a missing or nonsensical ratio falls back to square instead of collapsing the tile, an unmeasured container renders nothing rather than 1px tiles, and every tile is placed exactly once in order | DR-174 | Done |
|
||||
| UT-160 | The default row height suits its container: it grows with the width, stays inside its bounds, and at phone width still fits two 16:9 tiles side by side | DR-174 | Done |
|
||||
| UT-161 | A collection type maps to its favourites scope (`movies`/`tvshows`/`music`), every other kind — Live TV, channels, box sets, books, unknown — maps to none rather than to `All`, and a constructed library carries the scope across the wire as `favoritesScope`, omitted entirely when it has none | DR-175 | Done |
|
||||
| UT-167 | The mosaic's composition: the cross-library favourites entry leads, each library is followed by its own category tile pointing at that category's tab, a category shared by two libraries still yields one tile, a library kind favourites do not carve up yields none, a scope the page offers no tab for is ignored, and every tile is uniquely keyed | DR-174, DR-175 | Done |
|
||||
| UT-168 | Subtitles are negotiated as sidecars, never burned in: the requested `SubtitleStreamIndex` is the explicit "none" sentinel (`-1`) rather than omitted, every text format we can render (`srt`/`subrip`/`ass`/`ssa`/`vtt`) is advertised as `External`, and the burn-in verdict is by format — text never forces it, image formats (PGSSUB, dvdsub) always do, case-insensitively. The same sentinel rides the stream URL itself, so a stream re-opened without a fresh negotiation cannot inherit a subtitle. And the verdict reaches the picker: a subtitle stream carries `supportsExternalDelivery` — set only for subtitles, `false` for a bitmap format and for one the server left unnamed — which drops the tracks the app could never draw from the menu, the `<track>` children and the native play request alike, without even fetching their URLs, while a stream carrying no verdict at all is still offered | DR-176 | Done |
|
||||
| UT-173 | Every video stream URL carries a `PlaySessionId`, each open mints a fresh one, and the open reports the session it superseded so that job can be stopped | DR-177 | Done |
|
||||
| UT-174 | A fatal HLS network error is read against the *absolute* position: mid-film — including after a quality switch, where the seek offset carries the whole resume position — it is retried rather than reported as the end of the stream, the last tenth of a known runtime is treated as the end, an unknown runtime retries, and retries stop once the budget is spent | DR-177 | Done |
|
||||
| UT-175 | A stream reload that never becomes playable is reported as a failure instead of resolving as success, so the caller can revert its selection rather than leave the UI claiming a stream that is not playing | DR-177 | Done |
|
||||
| UT-176 | A handoff's position is floored at its base: with no tick yet landed the exit position is the point the screen was locked at rather than 0, and once ticks are flowing (the base already applied natively) it is not added twice | DR-178 | Done |
|
||||
| UT-177 | Webview-rendered media's reported position and duration are the controller's, and are dropped the moment that element stops being the player — on teardown, and when a handoff takes over | DR-178 | Done |
|
||||
| UT-178 | A stop report at position 0 is withheld rather than sent (it would clear the resume point), while a real position is still reported from either rendering path — the element's on the webview path, the backend's on the native one | DR-179 | Done |
|
||||
| UT-179 | An audio-only episode that ends naturally is reported stopped at its runtime, so Jellyfin marks it played; a truncated stream, which is about to be re-opened, reports nothing | DR-179 | Done |
|
||||
| UT-180 | Position ticks report progress to the server, throttled to one report per item per window rather than one per tick | DR-179 | Done |
|
||||
| UT-181 | The handoff plan matches its source: a downloaded file takes no base and a seek, a stream takes the base and no seek, and a handoff at 0:00 takes neither; a downloaded handoff's absolute seek stays an ordinary seek instead of a stream rebuild | DR-180 | Done |
|
||||
| UT-153 | Scroll handling per navigation kind: a forward move always lands at the top even when the previous page was scrolled and even when the target was visited before, Back restores that route's own saved offset (and the top when it has none), offsets are kept per route rather than shared, a repeated Back still restores, and the initial load leaves the container alone | DR-156 | Done |
|
||||
| UT-142 | The audio codecs offered for video direct play: a Dolby device's real `MediaCodecList` output drops `ac3`/`eac3`, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims `aac`, and stray spacing or casing does not decide whether the user gets sound | DR-148 | Done |
|
||||
| UT-143 | Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to "Off", and a server-default track is never auto-selected | UR-020, DR-023 | Done |
|
||||
| UT-144 | VideoPlayer actually renders `<track kind="subtitles">` children carrying `data-stream-index`, with no `default` attribute and no async `getSubtitleUrl()` bound to `src` | UR-020, DR-023 | Done |
|
||||
| UT-145 | The frontend's subtitle payload survives the IPC hop: a camelCase `PlayItemRequest` carrying `subtitles` deserializes, `create_media_item` lands them on `MediaItem.subtitles` in the order sent, and a request without the field still defaults to empty | UR-020, IR-016 | Done |
|
||||
| UT-146 | The subtitle JSON serialized across the JNI boundary uses the keys `JellyTauPlayer.load()` reads — `url`, `language`, `label` and `mime_type`, never `mimeType` | UR-020, IR-016, JA-008 | Done |
|
||||
| UT-147 | The native subtitle payload and the track-selection index come from the same resolved list: the wire shape keeps `mime_type` and stream order, `playerPlayItem` actually sends it, and the index is a position in the sent list (so a track whose URL failed to resolve cannot shift the others) rather than the menu's row number | UR-020, IR-016 | Done |
|
||||
| UT-182 | An HLS video URL never carries `StartTimeTicks` — with a position supplied or not — while the master playlist, codec, media source and chosen audio track still ride on it | DR-181 | Done |
|
||||
| UT-183 | A reloaded stream is resumed by seeking the element to the absolute position with the transcode offset cleared to zero — never by carrying the position as an offset base, which since DR-181 would display the position while playing the item from its start — and a reload to 0:00 waits for no seek | DR-181 | Done |
|
||||
| UT-184 | The native reveal rule fires on `state === "playing"` and on a position tick carrying a position or a duration, and on nothing else — not `buffering`, `paused`, `stopped`, `ended` or `error`, not an empty tick, and not a negative position | DR-182 | Done |
|
||||
| UT-188 | The control-bar auto-hide rule permits hiding only during uninterrupted playback: it declines while paused, while a seek is in flight, and while a track/subtitle/quality menu is open — asserted against the pure `shouldHideControls` rule rather than a clock or a DOM | DR-189 | Done |
|
||||
| UT-189 | On the native path the player never calls `player_report_state` — driven through the real 10-second progress interval under fake timers, which is the call site that mattered; asserting on a freshly mounted player passes with the guard deleted and guards nothing | DR-195 | Done |
|
||||
| UT-187 | On the native path the play overlay follows the backend: it clears when the backend resumes after a pause and is raised again when the backend pauses, and the system bars are hidden on player entry rather than only by the fullscreen button | DR-186, DR-187 | Done |
|
||||
| UT-186 | Every attribute the native-video compositing block in app.css targets is set somewhere in the app — `[data-app-shell]` in particular — so a selector aimed at nothing fails the suite instead of failing silently on a device | DR-185 | Done |
|
||||
| UT-185 | Mounted on the native path (backend reports native, opt-in flag on, no `<video>` element rendered and the backend not stopped), VideoPlayer keeps the poster card up until the backend reports something, drops it on a playing state or a position tick with a duration, and keeps it up through `error` and `stopped` | DR-182 | Done |
|
||||
| UT-190 | `build_next_up_endpoint` sends `EnableResumable=false` with the user and limit, and no `SeriesId` filter when none was requested | DR-197, JA-036 | Done |
|
||||
| UT-191 | A per-series next-up query keeps `SeriesId` and the resumable exclusion, and defaults the limit | DR-197 | Done |
|
||||
| UT-192 | `filterInProgressNextUpItems` drops an episode present in the resume list, keeps the genuinely unstarted next episode, leaves the rest of the row intact, and is a no-op when nothing is in progress | DR-197 | Done |
|
||||
| UT-193 | The shipped Tauri security config stays restrictive: `csp` is set, `script-src` carries no `'unsafe-inline'`/`'unsafe-eval'`/wildcard, `object-src`/`frame-src` are `'none'`, the directives playback needs (asset scheme, loopback, `blob:`, `ipc:`) are present, and the asset-protocol scope covers only the thumbnail cache — never the storage root that holds the database | DR-198 | Done |
|
||||
| UT-194 | Normal audio (no background-audio handoff) keeps queue advance on both skip buttons | DR-201 | Done |
|
||||
| UT-195 | In background-audio mode a skip scrubs +30s/-10s instead of advancing the queue — the reported defect | DR-201 | Done |
|
||||
| UT-196 | Skipping back near the start clamps to zero rather than seeking negative | DR-201 | Done |
|
||||
| UT-197 | Skipping forward near the end clamps to the duration rather than running past it into an EOF-driven advance | DR-201 | Done |
|
||||
| UT-198 | An unknown duration still scrubs and still refuses to go negative | DR-201 | Done |
|
||||
| UT-199 | The screen-wake decision: video playing holds the display, pausing releases it, audio playing never holds it, a webview element going inactive releases even without a pause report, either renderer alone is enough to hold, and teardown drops both | DR-202 | Done |
|
||||
| UT-200 | The stream a player could only restart is refused its retry: the handoff transcode answers yes to `player_retry_restarts_stream` while music, video and a downloaded episode answer no, and the Kotlin decision starts permissive, flips on a non-resumable load, and is restored by the next ordinary one | DR-203 | Done |
|
||||
|
||||
### Integration Tests
|
||||
|
||||
@@ -432,8 +691,8 @@ Internal architecture, components, and application logic.
|
||||
| IT-005 | MPRIS lockscreen controls on Linux | IR-005, UR-006 | Pending |
|
||||
| IT-006 | Offline mode with local database | IR-013, UR-002 | Pending |
|
||||
| IT-007 | Media download and local playback | DR-015, UR-011 | Pending |
|
||||
| IT-008 | Subtitle track selection via libmpv | IR-018, UR-020 | Pending |
|
||||
| IT-009 | Audio track selection via libmpv | IR-019, UR-021 | Pending |
|
||||
| IT-008 | Subtitle track selection on the video backends (ExoPlayer sideloaded tracks; HTML5 `<track>` children) — *not* via libmpv, which does not implement it | IR-018, UR-020 | Pending |
|
||||
| IT-009 | Audio track selection on the video backends (ExoPlayer track switch; HTML5 stream re-open at the chosen `AudioStreamIndex`) — *not* via libmpv, which does not implement it | IR-019, UR-021 | Pending |
|
||||
| IT-010 | Playback progress sync to Jellyfin | IR-015, UR-025 | Pending |
|
||||
| IT-011 | Resume playback from server position | IR-015, UR-019 | Pending |
|
||||
| IT-012 | Equalizer bands via libmpv | IR-020, UR-027 | Pending |
|
||||
@@ -445,6 +704,34 @@ Internal architecture, components, and application logic.
|
||||
|
||||
## 5. Technical Debt
|
||||
|
||||
### Open items from the codebase audit (2026-08-16)
|
||||
|
||||
Findings from [codebase-audit.md](codebase-audit.md) that were **not** addressed
|
||||
in v0.8.0, plus items the device-verification pass turned up. Ordered by what
|
||||
would hurt most if left. The audit doc carries the full reasoning and evidence
|
||||
for each.
|
||||
|
||||
> **Closed 2026-08-17:** the R8-minified release APK was validated on device.
|
||||
> That was the last item gating confidence in the v0.8.0 release itself; R8
|
||||
> stripping JNI-loaded classes has broken release builds here before, and
|
||||
> v0.8.0 added a new Kotlin path (`onFastForward`/`onRewind`) that the
|
||||
> unminified debug pass did not cover.
|
||||
|
||||
| # | Item | Why it matters | Size |
|
||||
|---|------|----------------|------|
|
||||
| 1 | **Android 16 Local Network Protections** (audit B8) | The rare platform change that could stop the app working at all: JellyTau's core function is reaching a Jellyfin server that, for most users, is on the LAN. Opt-in for testing in Android 16, enforcement signalled for a later release — so nothing is broken today and no device test will surface it. Far cheaper to handle before it is mandatory. An Android 16 device is already to hand to test the opt-in flag against | M |
|
||||
| 2 | **The traceability matrix cannot see Kotlin** | `scripts/extract-traces.ts` walks only `src`, `src-tauri/src` and `scripts`, so every `TRACES:` comment in `src-tauri/android/**` is invisible — pre-existing ones included. A whole platform is unmeasured, which is plausibly why the Android IRs sat untagged for so long, and it means the 90% coverage figure is computed over a codebase that excludes the Android tree | S |
|
||||
| 3 | **Delete the asset protocol outright** | It is not narrowly used, it is **unused**. `getCachedImageUrl` has no production callers (only its own test file), so `convertFileSrc` never executes; images arrive as base64 `data:` URIs from `image_get_url`. Confirmed on device: zero `asset.localhost` requests across a full browsing session. Dropping `protocol-asset` and the `assetProtocol` block retires the surface instead of shrinking it, and `imageCache.ts` goes with it | S |
|
||||
| 4 | **Tighten `img-src`** | The v0.8.0 CSP grants `img-src … http: https:` on the premise that thumbnails are fetched direct-from-server by the webview. They are not (see #3). With no webview-side server image loads anywhere in `src/`, `'self' data: blob:` should suffice. Needs its own device pass — a wrong `img-src` blanks every image, silently | S |
|
||||
| 5 | **Three `Runtime::new().unwrap()` in playback-critical threads** (audit D3) | `session_poller/mod.rs:102`, `player/mpv_backend.rs:424`, `player/android/mod.rs:761`. A panic strands the app offline with nothing surfaced, freezes the scrubber mid-playback, or kills progress reporting across a JNI boundary. One shared helper returning `Option<Runtime>` and logging on failure retires all three. (The wider "820 unwraps" figure was a measurement error — the real count is 19, and none are in command handlers) | S |
|
||||
| 6 | **Confirm the playback service rejects unknown callers** (audit B7, second half) | `JellyTauPlaybackService` is `exported="true"` with a `MediaSessionService` intent filter — conventional for Media3, but it means any app on the device can attempt to bind and drive playback. The session's `onConnect` should reject unknown packages. (The predictive-back half of B7 was verified working on device and needs nothing) | S |
|
||||
| 7 | **Media3 is several minor versions behind** (audit B6) | Pinned at 1.5.0 across exoplayer/hls/session/common. Much of this app's hard-won behaviour lives in ExoPlayer edge cases — truncated progressive streams, background-audio handoff, HLS resume — so its bug-fix releases have unusually high value here. Schedule with a device pass over the playback regression list | M |
|
||||
| 8 | **Shipped desktop bundles have no update path** (audit C3) | deb/rpm/nsis are built but `tauri-plugin-updater` is absent, so every desktop user upgrades by manually fetching a package — in practice a long tail of installs pinned to whatever they first downloaded. Add the updater with a signed manifest, or document the manual path so the omission is deliberate | M |
|
||||
| 9 | **`DR-042` overstates what ships** | It promises "poster cards, year, **and rating badges**", but `MediaCard.svelte` renders only `productionYear`; `CommunityRating`/`OfficialRating` appear solely as sort keys, never as a badge. Either build the badge or correct the requirement text — a requirement that describes unbuilt behaviour is worse than an untraced one | S |
|
||||
| 10 | **Stray duplicate `JellyTauPlayer.kt`** | A copy exists at `src-tauri/android/app/src/main/java/.../player/JellyTauPlayer.kt`, outside the canonical `src-tauri/android/src` tree that `sync-android-sources.sh` reads. Two files with one name in a tree with a strict canonical-source rule is a trap for the next edit | S |
|
||||
| 11 | **Five files carry a disproportionate share of complexity** (audit D4) | `player/mod.rs` (4.7k lines), `repository/offline.rs` (4.7k), `repository/online.rs` (3.7k), `commands/player/mod.rs` (3.3k), `commands/download/mod.rs` (3.2k), plus `VideoPlayer.svelte` (2.8k). The same files the changelog keeps returning to for deadlocks and playback regressions. Not worth a speculative refactor — but the next time one needs substantial work, splitting it is likely cheaper than growing it | L |
|
||||
|
||||
|
||||
### Linux Keyring Integration Workaround
|
||||
|
||||
**Issue**: The `keyring-rs` crate (v3.x) has issues with retrieving credentials from the Linux Secret Service API, despite successfully saving them.
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
# Spec: Android native video — transparent-webview spike
|
||||
|
||||
**Status:** Proposed (spike — timeboxed, may conclude "not viable")
|
||||
**Requirements:** IR-004, UR-003, UR-004 → DR-001, DR-023, DR-024
|
||||
**Status:** Spike succeeded (2026-08-11); shipped behind `experimentalNativeVideo`,
|
||||
default off. Flipping that default shipped **audio with no picture** and was
|
||||
reverted (DR-172). Three defects behind that have since been fixed — DR-182
|
||||
(nothing on the native path could lift the poster overlay), DR-183 (the JS
|
||||
bridges raced the page load), DR-184 (the SurfaceView was never detached).
|
||||
Branch `fix/android-native-video-visible`. **The default stays off until the
|
||||
device criteria below are green.**
|
||||
|
||||
**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)
|
||||
|
||||
@@ -109,6 +125,46 @@ in here rather than leaving a second, subtler copy of the bug behind. If
|
||||
`get_player_status` does not currently expose enough to cover the audio case, add
|
||||
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
|
||||
@@ -124,6 +180,32 @@ the video is effectively fullscreen on Android, which it is in the player route.
|
||||
rotation or the mini-player transition without visible artefacts, the spike fails
|
||||
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
|
||||
@@ -145,12 +227,16 @@ and we keep HTML5. Do not ship a janky native path for a codec win.
|
||||
The spike is **complete** when one of these is true:
|
||||
|
||||
**Success path**
|
||||
- [ ] Transparent WebView confirmed working on a physical device.
|
||||
- [ ] `experimentalNativeVideo` off → behaviour byte-identical to today.
|
||||
- [ ] `webviewAudio.ts` no longer inspects `navigator.userAgent`; the platform's audio backend is read from Rust.
|
||||
- [ ] `experimentalNativeVideo` on → video plays via ExoPlayer/MediaCodec, correctly positioned, with working seek, audio-track switch, and subtitle selection through the existing `PlayerAdapter` contract.
|
||||
- [ ] No artefacts on rotation, background/foreground, or mini-player transition.
|
||||
- [ ] `adb shell dumpsys media.metrics` (or logcat) confirms a hardware decoder is in use.
|
||||
- [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.
|
||||
- [x] The poster/title card comes down on the native path. It never could: every `markMediaReady()` call site is a `<video>` element event and the native branch renders no element, so an opaque `bg-black` overlay covered the ExoPlayer surface for the whole session. See DR-182; guarded by `mediaReady.test.ts` (UT-184) and `VideoPlayer.nativeReveal.test.ts` (UT-185), the latter written failing first.
|
||||
- [x] The `AndroidVideoSurface` bridge is installed before the page that calls it loads, via `WryActivity.onWebViewCreate` instead of a 500 ms tree walk, and a missing bridge now logs an error instead of no-oping. See DR-183.
|
||||
- [x] The SurfaceView is detached when video stops, instead of accumulating one leaked view per native video. See DR-184.
|
||||
- [ ] 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**
|
||||
@@ -159,8 +245,32 @@ The spike is **complete** when one of these is true:
|
||||
- [ ] `nativeAdapter.ts:11-14` no longer cites tauri#10152.
|
||||
|
||||
Either way:
|
||||
- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass.
|
||||
- [ ] `cargo fmt` / `cargo clippy` clean; `bun run test:rust` passes.
|
||||
- [x] `bun run check` (0 errors), `bun run test` (997 passed), `bun run check:boundary` pass.
|
||||
- [x] `cargo fmt` / `cargo clippy` clean (no new warnings); `cargo test` passes (603 lib + 7 doc).
|
||||
|
||||
### Why the 2026-08-11 verification and DR-172 do not contradict each other
|
||||
|
||||
The spike was reported working on device; the same path then shipped as audio
|
||||
with no picture. Both are consistent with DR-182: the poster overlay is drawn
|
||||
only while `isMediaReady` is false, and the native path has no way to set it, so
|
||||
what the surface shows depends entirely on **whether that overlay is on screen**
|
||||
— not on whether compositing works. Any run that reached the player through a
|
||||
path leaving `isMediaReady` already true (a handoff return, a re-render, a
|
||||
session that had previously played on the HTML5 path) shows video; a cold start
|
||||
into the native path never does. That is also why DR-172 read the symptom as a
|
||||
compositing failure: on screen the two are identical, and the one piece of
|
||||
evidence separating them — `WebView transparent = true` never being logged —
|
||||
points at DR-183 rather than at the compositing itself.
|
||||
|
||||
**This reasoning is not yet device-confirmed.** It explains the reports and is
|
||||
backed by the code, but the criteria above are what settle it.
|
||||
|
||||
> 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
|
||||
|
||||
@@ -179,7 +289,7 @@ native adapter), write the failing test first.
|
||||
|
||||
## TRACES
|
||||
|
||||
- `createAdapter` → `// TRACES: UR-003, UR-004 | DR-023, DR-024`
|
||||
- `createAdapter` → `// TRACES: UR-003, UR-004 | DR-004, DR-150 | UT-149`
|
||||
- Adapter-selection tests → `UT-xxx`
|
||||
- No new requirement IDs; this spike either satisfies existing IR-004 expectations or documents why it cannot.
|
||||
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
# Spec: Locally-indexed search
|
||||
|
||||
**Status:** Implemented
|
||||
**Requirements:** UR-065 → DR-108, DR-109, DR-110, DR-111; IR-030
|
||||
**UX spec:** [ux-flows.md §6.1](../ux-flows.md) (search surface is unchanged)
|
||||
**Revises:** [scoped-search.md](scoped-search.md) and
|
||||
[scoped-search-boundary.md](scoped-search-boundary.md) — scope semantics are
|
||||
untouched; this changes only *which corpus* the cache leg searches.
|
||||
|
||||
## Summary
|
||||
|
||||
Search stops depending on a per-keystroke round trip to Jellyfin. The local
|
||||
SQLite catalog — which is already synced and already FTS5-indexed — becomes the
|
||||
corpus the instant leg of search reads, so results appear as fast as SQLite can
|
||||
answer, online or offline. A background indexer keeps that catalog fresh on a
|
||||
schedule instead of only at app start, prunes content deleted on the server, and
|
||||
covers the item types search groups results by. The server query stays, demoted
|
||||
to a background reconciliation that merges in late results for anything indexed
|
||||
since the last pass.
|
||||
|
||||
## Motivation
|
||||
|
||||
The pieces are already built and simply not wired together:
|
||||
|
||||
- [`sync_full_catalog`](../../src-tauri/src/commands/catalog.rs) already walks
|
||||
every library `Recursive=true` and persists items with `synced_at`.
|
||||
- `items_fts` (schema.rs migration 001) already indexes `name`, `overview`,
|
||||
`album_name`, `album_artist`, `artists`, `series_name` with keep-in-sync
|
||||
triggers.
|
||||
- `repository_search` is already two-phase — synchronous cache result, then a
|
||||
spawned server query merged in via the `search-event`.
|
||||
|
||||
What breaks the chain is that the cache leg is hard-restricted to *downloaded*
|
||||
items. `OfflineRepository::search` wraps its FTS query in a `downloaded_items`
|
||||
CTE requiring `d.status = 'completed'`:
|
||||
|
||||
```sql
|
||||
FROM items i
|
||||
JOIN items_fts fts ON fts.rowid = i.rowid
|
||||
INNER JOIN downloaded_items di ON i.id = di.id
|
||||
WHERE i.server_id = ? AND items_fts MATCH ?
|
||||
```
|
||||
|
||||
So for a user with no downloads, phase 1 returns nothing on every query, and
|
||||
every debounced keystroke falls through to a full `Recursive=true` server
|
||||
request with `Limit=10000`. The populated local index is never read.
|
||||
|
||||
`get_items` does not have this problem — it gates a third `synced_at IS NOT NULL`
|
||||
branch on `include_catalog_browse()` (offline.rs, the "Show all server media"
|
||||
toggle). The asymmetry is the bug: **offline you can already browse the whole
|
||||
catalog but cannot search it.**
|
||||
|
||||
Three further defects found while confirming the above:
|
||||
|
||||
1. **The FTS index grows without bound.** `save_to_cache` uses
|
||||
`INSERT OR REPLACE INTO items`, but `recursive_triggers` is never enabled
|
||||
(`storage/mod.rs` sets only `foreign_keys` and `journal_mode`). SQLite fires
|
||||
`AFTER DELETE` triggers on a REPLACE *only* with recursive triggers on — so
|
||||
`items_ad` never runs, the old FTS row is orphaned, and because `items.id` is
|
||||
a `TEXT PRIMARY KEY` the replacement row takes a **new rowid** and inserts a
|
||||
second FTS entry. Every sync appends a duplicate index. Results stay correct
|
||||
(the `INNER JOIN … ON fts.rowid = i.rowid` hides orphans, and no rowid is ever
|
||||
reused because nothing is deleted) but `MATCH` degrades permanently.
|
||||
2. **Server-side deletions never propagate.** There is no `DELETE FROM items`
|
||||
anywhere in the codebase. The local catalog is append-only, so media removed
|
||||
from the server would stay searchable forever — tolerable when the cache was
|
||||
only a browse accelerator, not acceptable when it is the search corpus.
|
||||
3. **The index omits types search groups by.** `CATALOG_ITEM_TYPES` is
|
||||
`MusicAlbum, Movie, Series, Season, Episode, Audio, BoxSet` — no
|
||||
`MusicArtist`, no `Playlist`, and People live in a separate `people` table
|
||||
with no FTS at all. UR-060 mandates Artists and People result groups, so today
|
||||
those can *only* come from the server.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Which corpus search reads (downloads-only vs full synced catalog) | **Rust** | Sync/availability policy over domain data. Changes if Jellyfin's API or the offline rules change, not if the UI is redesigned. Reuses the existing `include_catalog_browse()` flag so search and browse cannot diverge again. |
|
||||
| Index freshness policy — TTL, when a re-index is due, skip-while-offline | **Rust** | Explicitly named as domain policy in [SPEC-REVIEW-CHECKLIST.md](SPEC-REVIEW-CHECKLIST.md) ("reachability/sync policy"). It is currently frontend-driven in `offlineCatalog.ts`; this spec moves it. |
|
||||
| Which Jellyfin item types get indexed (`CATALOG_ITEM_TYPES`) | **Rust** | Textbook domain taxonomy — a category→item-type set. Must never appear in `src/`. |
|
||||
| Reconciling a crawl against local rows (what to prune) | **Rust** | Operates on domain data and depends on crawl completeness semantics. |
|
||||
| FTS query construction, ranking, scope→type expansion | **Rust** | Already there (`search_rank.rs`, `SearchScope::item_types()`); unchanged by this spec. |
|
||||
| Rendering a "catalog last indexed N ago" hint and any re-index button | **Frontend** | Pure presentation of a backend-supplied timestamp. |
|
||||
| Debounce interval, result group order, scope chips | **Frontend** | Input handling and view preference; changes only if the UI is redesigned. |
|
||||
|
||||
Borderline call, recorded: the **TTL value itself** (how many hours before a
|
||||
re-index is due) could be argued as a user preference and therefore frontend. It
|
||||
is placed in Rust because the frontend must not be able to decide *whether the
|
||||
cache is authoritative* — that is the same class of decision as
|
||||
`include_catalog_browse`, which already lives in Rust. If the TTL later becomes
|
||||
user-configurable it stays a Rust-owned setting the frontend edits through a
|
||||
command, not a frontend constant. Borderline defaults to Rust.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Search the full synced catalog (DR-108)
|
||||
|
||||
`OfflineRepository::search` mirrors `get_items` exactly: rename the CTE to
|
||||
`available_items` and add the same third branch, gated on the same flag.
|
||||
|
||||
```rust
|
||||
let catalog_branch = if include_catalog_browse() {
|
||||
"UNION
|
||||
|
||||
-- Synced catalog: fast online search, or the offline 'Show all
|
||||
-- server media' view. Mirrors get_items; see set_include_catalog_browse.
|
||||
SELECT DISTINCT i.id
|
||||
FROM items i
|
||||
WHERE i.synced_at IS NOT NULL"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
```
|
||||
|
||||
No new IPC surface and no frontend change: `set_include_catalog_browse` is
|
||||
already called with `true` when online or when the offline toggle is on, and
|
||||
`false` only when offline with the toggle off. Search inherits the correct
|
||||
behaviour in all three states, and the "search is restricted to downloads" case
|
||||
survives for users who deliberately asked for downloads-only.
|
||||
|
||||
Also fix, in the same function, the `type_filter` built by **string
|
||||
interpolation** of `include_item_types` rather than bound parameters. It is
|
||||
currently safe only because callers pass `SearchScope`-derived values, but
|
||||
`SearchOptions.include_item_types` is settable directly from the frontend (as
|
||||
`GenericMediaListPage` does). Bind the values.
|
||||
|
||||
Phase 2 (the server query) is unchanged and still merges via `search-event`, so
|
||||
content added to the server since the last index still surfaces — just late
|
||||
rather than first.
|
||||
|
||||
### 2. Scheduled background indexer (DR-109, IR-030)
|
||||
|
||||
A Rust-owned task replaces the frontend's startup-only trigger.
|
||||
|
||||
```rust
|
||||
/// How long a full-catalog index stays fresh before a re-index is due.
|
||||
const CATALOG_INDEX_TTL: Duration = Duration::from_secs(6 * 60 * 60);
|
||||
```
|
||||
|
||||
Behaviour:
|
||||
|
||||
- On app setup, spawn a tokio task that ticks every 30 min.
|
||||
- Each tick: if a repository is active **and** the server is reachable **and**
|
||||
`now - last_catalog_sync > CATALOG_INDEX_TTL`, run a full index pass.
|
||||
- On the existing `ConnectivityMonitor` reconnect signal, evaluate the same
|
||||
staleness condition immediately rather than waiting for the next tick.
|
||||
- Never run two passes concurrently (the existing `syncInProgress` guard moves
|
||||
into Rust as an `AtomicBool`).
|
||||
|
||||
`last_catalog_sync` is already written to `app_settings` by `sync_full_catalog`
|
||||
and is currently read only for a UI hint; this makes it load-bearing.
|
||||
|
||||
`RepositoryManager` (`commands/repository.rs`) is a `HashMap<String, …>` with no
|
||||
notion of an active handle, so the task has nothing to run against. Add:
|
||||
|
||||
```rust
|
||||
pub struct RepositoryManager {
|
||||
repositories: Arc<Mutex<HashMap<String, Arc<HybridRepository>>>>,
|
||||
active: Arc<Mutex<Option<String>>>, // set in create(), cleared in destroy()
|
||||
}
|
||||
```
|
||||
|
||||
Progress is reported with a **kebab-case** event (per the project convention):
|
||||
|
||||
```rust
|
||||
// event name: "catalog-index-event"
|
||||
#[derive(specta::Type, Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CatalogIndexEvent {
|
||||
pub state: CatalogIndexState, // #[serde(tag = "type")] Idle | Running | Complete | Failed
|
||||
pub libraries_done: usize,
|
||||
pub libraries_total: usize,
|
||||
pub items_indexed: usize,
|
||||
}
|
||||
```
|
||||
|
||||
`sync_full_catalog` stays a command so the UI can still force a pass; it and the
|
||||
scheduler share one internal `run_index_pass()`.
|
||||
|
||||
### 3. Index hygiene — no orphans, and deletions propagate (DR-110)
|
||||
|
||||
**Orphan growth.** Replace `INSERT OR REPLACE INTO items (…)` in `save_to_cache`
|
||||
with a true upsert:
|
||||
|
||||
```sql
|
||||
INSERT INTO items (id, server_id, …) VALUES (…)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name, overview = excluded.overview, …,
|
||||
synced_at = excluded.synced_at
|
||||
```
|
||||
|
||||
This preserves the rowid (which `items_fts` keys on via `content_rowid`) and
|
||||
fires `items_au` instead of silently orphaning a row. Preferred over
|
||||
`PRAGMA recursive_triggers = ON` because it also stops the rowid churn, and the
|
||||
three FTS triggers are the only triggers in the schema so nothing else depends
|
||||
on REPLACE semantics.
|
||||
|
||||
A new migration `021_rebuild_items_fts` clears the orphans already accumulated on
|
||||
existing installs:
|
||||
|
||||
```sql
|
||||
INSERT INTO items_fts(items_fts) VALUES('rebuild');
|
||||
```
|
||||
|
||||
**Deletions.** After a library crawls *successfully and completely*, reconcile:
|
||||
delete local rows for that library whose `id` was not seen in the crawl. Two
|
||||
constraints the implementation must respect:
|
||||
|
||||
- Skip any item with a completed download — the user has the file; removing the
|
||||
row would orphan it. Prune only synced-but-not-downloaded rows.
|
||||
- Only sweep libraries whose crawl succeeded. `sync_full_catalog` is
|
||||
deliberately best-effort per library, and `items.parent_id` is
|
||||
`ON DELETE CASCADE` — sweeping on a partial crawl would cascade a whole series
|
||||
away because one request timed out.
|
||||
|
||||
### 4. Index the types search groups by (DR-111)
|
||||
|
||||
Add `MusicArtist` and `Playlist` to `CATALOG_ITEM_TYPES`.
|
||||
|
||||
People need a different mechanism: they live in `people` (`id`, `server_id`,
|
||||
`name`, `overview`, `primary_image_tag`, `synced_at`), populated incidentally by
|
||||
item-detail fetches, with no FTS table. Migration `022_people_fts` adds one
|
||||
mirroring the `items_fts` pattern:
|
||||
|
||||
```sql
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS people_fts USING fts5(
|
||||
name, overview, content='people', content_rowid='rowid'
|
||||
);
|
||||
-- plus people_ai / people_ad / people_au triggers
|
||||
```
|
||||
|
||||
`OfflineRepository::search` UNIONs `people_fts` matches into its result set as
|
||||
`Person`-typed items when the resolved scope permits them (i.e. when
|
||||
`include_item_types` is `None` — `SearchScope::All`). `search_rank.rs` already
|
||||
handles `MediaKind::Person`, so ranking needs no change.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Incremental indexing** (e.g. Jellyfin's `MinDateLastSaved`). A full crawl is
|
||||
what makes the deletion sweep in §3 sound — it yields the authoritative id set
|
||||
per library. An incremental pass cannot detect deletions, so it would need a
|
||||
separate reconciliation strategy. Worth revisiting if full crawls prove too
|
||||
slow on large libraries; measure first.
|
||||
- **Changing search UX** — scope chips, group order, the debounce, and the
|
||||
`/search` route are untouched.
|
||||
- **Removing the server leg.** Phase 2 stays.
|
||||
- The two dead search implementations (`storage_search_items` in
|
||||
`commands/storage/mod.rs`, `offline_search` in `commands/offline.rs`) — both
|
||||
registered in `lib.rs` and exported to `bindings.ts`, neither called from the
|
||||
frontend. Deleting them is correct but is cleanup, not this feature; file
|
||||
separately so this spec's diff stays reviewable.
|
||||
- `GenericMediaListPage` passing raw `includeItemTypes` and re-implementing the
|
||||
store's request-id/event protocol. A real boundary smell, tracked separately.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] With a synced catalog and **zero downloads**, typing a query returns
|
||||
results from the local index before any server request completes.
|
||||
- [ ] Offline with "Show all server media" **on**, search returns the full
|
||||
catalog (non-downloaded entries greyed out, matching browse).
|
||||
- [ ] Offline with the toggle **off**, search returns downloaded media only —
|
||||
the behaviour that exists today.
|
||||
- [ ] Re-running a full index pass N times does not grow `items_fts` row count
|
||||
beyond the `items` row count.
|
||||
- [ ] An item deleted server-side disappears from local search after one index
|
||||
pass; a **downloaded** item deleted server-side does not.
|
||||
- [ ] A library that fails mid-crawl prunes nothing.
|
||||
- [ ] Searching an artist or actor name returns results with the server
|
||||
unreachable.
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
||||
- [ ] `bun run check:boundary` passes.
|
||||
- [ ] New requirement-implementing code carries `// TRACES:` comments.
|
||||
- [ ] `bindings.ts` regenerated (new `CatalogIndexEvent` type).
|
||||
|
||||
## Testing
|
||||
|
||||
Per CLAUDE.md, each defect gets a **failing test first**.
|
||||
|
||||
Rust (`cargo test`), against an in-memory DB seeded with synced-but-not-
|
||||
downloaded items:
|
||||
|
||||
- `search` returns synced items when `include_catalog_browse()` is true, and
|
||||
only downloaded items when false. *Fails today* — the current CTE returns
|
||||
empty in the first case.
|
||||
- Upserting the same item twice leaves exactly one `items_fts` row. *Fails
|
||||
today.*
|
||||
- The sweep removes a vanished synced item, retains a vanished downloaded item,
|
||||
and no-ops for a library whose crawl errored.
|
||||
- `type_filter` binds parameters — a type string containing a quote does not
|
||||
alter the query.
|
||||
- Staleness: a `last_catalog_sync` inside the TTL does not trigger a pass; one
|
||||
outside it does; offline never does.
|
||||
- `people_fts` matches surface as `Person` items under `SearchScope::All` and
|
||||
are excluded under `Music`/`Movies`/`Tv`.
|
||||
|
||||
Frontend (`vitest`): the catalog-index event maps to the staleness hint; no
|
||||
change to the search store's request-id/stale-response handling, which stays
|
||||
covered by its existing tests.
|
||||
|
||||
## TRACES
|
||||
|
||||
| Piece | Tag |
|
||||
|---|---|
|
||||
| `OfflineRepository::search` availability CTE | `// TRACES: UR-065 \| DR-108` |
|
||||
| Background indexer task + scheduling | `// TRACES: UR-065 \| DR-109, IR-030` |
|
||||
| `save_to_cache` upsert + FTS rebuild migration | `// TRACES: UR-065 \| DR-110` |
|
||||
| Deletion reconciliation | `// TRACES: UR-065 \| DR-110` |
|
||||
| `CATALOG_ITEM_TYPES` widening + `people_fts` | `// TRACES: UR-065, UR-060 \| DR-111` |
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- **A parallel Claude session may be active in this repo.** Run `git diff`
|
||||
before "repairing" changes you did not make (CLAUDE.md gotchas).
|
||||
- The frontend's `offlineCatalog.ts` startup trigger should be **removed**, not
|
||||
left alongside the Rust scheduler — two independent triggers with one
|
||||
`syncInProgress` guard each is how double-crawls happen.
|
||||
- `downloads` has a relaxed FK to `items` (migration 005). Verify the deletion
|
||||
sweep's interaction with it before enabling the sweep, and check whether
|
||||
`parent_id`'s `ON DELETE CASCADE` reaches further than intended.
|
||||
- The existing 100 ms `cache_with_timeout` in `hybrid.rs` returns *empty* on
|
||||
timeout rather than erroring. Once the cache leg is the primary path, that
|
||||
budget may need raising — an FTS query over a large catalog on cold page cache
|
||||
can exceed it, and the failure mode is a silently empty result.
|
||||
- Keep `SearchScope` semantics as-is: `All => None` (no filter), deliberately
|
||||
not a union, so People and folders are not filtered out (DR-063).
|
||||
- Noted but deliberately not fixed here: `pushCatalogVisibility` in
|
||||
`offlineCatalog.ts` derives the flag as `connected || showCatalog` — the
|
||||
frontend computing an availability *policy*, even though the flag itself is
|
||||
Rust-stored. DR-108 depends on that derivation being correct and it is, so
|
||||
this spec leaves it alone. Once DR-109 has moved sync policy into Rust, the
|
||||
derivation belongs there too, with the frontend pushing only the raw user
|
||||
toggle. Folding it into this change would enlarge the diff for no behavioural
|
||||
gain — but do not add *new* policy on the frontend side of that line.
|
||||
@@ -0,0 +1,403 @@
|
||||
# Spec: Favourites — marking, browsing, and sync
|
||||
|
||||
**Status:** Implemented
|
||||
**Requirements:** UR-067, UR-068, UR-069 → DR-113 … DR-120; JA-033, JA-034
|
||||
(allocated in [requirements.md](../requirements.md); tests UT-099 … UT-107).
|
||||
Note: UR-066/DR-112/IR-031 were claimed by the concurrent safe-area work while
|
||||
this spec was being written, so the ids here start one higher than first drafted.
|
||||
Existing: UR-017 → DR-021, JA-017, JA-018 (the toggle itself, already built).
|
||||
**UX spec:** [ux-flows.md](../ux-flows.md) §3.2 (full-player favourite), §5.2
|
||||
(album detail favourite), §5B.3 (movie detail hero: *Play / Download / Favorite*)
|
||||
— all three already specify favourite affordances that **do not exist in the
|
||||
build**. This spec closes those, and adds a new §5C for the Favourites browse
|
||||
surface.
|
||||
**Supersedes / revises:** nothing.
|
||||
|
||||
## Summary
|
||||
|
||||
JellyTau can favourite an item but can never show you what you favourited. The
|
||||
heart is mounted in exactly one place (the mini player), no query anywhere asks
|
||||
Jellyfin or the local database for favourites, and favourites marked on any other
|
||||
client are invisible here. This spec adds the read side (a Favourites page, home
|
||||
carousels, an in-library filter), puts the heart on detail pages and media cards,
|
||||
teaches the backend to ingest server-side favourite state, and drains favourite
|
||||
toggles made while offline.
|
||||
|
||||
## Background: what exists today
|
||||
|
||||
Verified in code, 2026-08-04. The **write** path is real and mostly correct; the
|
||||
**read** path does not exist at all.
|
||||
|
||||
1. **Toggling works, from one place only.**
|
||||
[FavoriteButton.svelte](../../src/lib/components/FavoriteButton.svelte) is
|
||||
mounted solely in
|
||||
[MiniPlayer.svelte:381](../../src/lib/components/player/MiniPlayer.svelte#L381).
|
||||
Nothing else in `src/` renders it — so the only favouritable item in the app
|
||||
is the one currently playing.
|
||||
|
||||
2. **The toggle's plumbing is sound.**
|
||||
[favorites.ts](../../src/lib/services/favorites.ts) writes local first
|
||||
(`storage_toggle_favorite`,
|
||||
[storage/mod.rs:908](../../src-tauri/src/commands/storage/mod.rs#L908) — sets
|
||||
`user_data.is_favorite` + `pending_sync = 1`), then POST/DELETEs
|
||||
`/Users/{uid}/FavoriteItems/{id}`
|
||||
([online.rs:1652](../../src-tauri/src/repository/online.rs#L1652)) only when
|
||||
connected. Leave this design intact.
|
||||
|
||||
3. **`MediaItem.user_data` is always `None` from the server.**
|
||||
`JellyfinItem` has no `UserData` field, and `to_media_item` hardcodes
|
||||
[`user_data: None`](../../src-tauri/src/repository/online.rs#L656) with the
|
||||
comment *"User data not included in basic item responses"*. The only
|
||||
populated `user_data` in the app comes from
|
||||
[series_progress.rs](../../src-tauri/src/repository/series_progress.rs#L244)
|
||||
and the local read in
|
||||
[offline.rs:115](../../src-tauri/src/repository/offline.rs#L115). **Nothing
|
||||
ingests server favourite state**, which is why the mini player has to fetch
|
||||
`storageGetPlaybackProgress` per track to colour one heart
|
||||
([MiniPlayer.svelte:75-93](../../src/lib/components/player/MiniPlayer.svelte#L75-L93)).
|
||||
|
||||
4. **No favourites query exists.**
|
||||
[`GetItemsOptions`](../../src-tauri/src/repository/types.rs#L278) has no
|
||||
favourites field; `Filters=IsFavorite` appears nowhere; no SQL selects
|
||||
`is_favorite = 1`; there is no `/library/favorites` route and no favourites
|
||||
carousel in [home.ts](../../src/lib/stores/home.ts).
|
||||
|
||||
5. **Offline favourites are silently lossy.** Offline `mark_favorite` /
|
||||
`unmark_favorite` are no-ops
|
||||
([offline.rs:1620](../../src-tauri/src/repository/offline.rs#L1620)), so an
|
||||
offline toggle survives only as a local row with `pending_sync = 1` — and
|
||||
**nothing ever drains that flag**. `syncService.queueFavorite`
|
||||
([syncService.ts:91](../../src/lib/services/syncService.ts#L91)) exists with
|
||||
no callers.
|
||||
|
||||
## Motivation
|
||||
|
||||
Favouriting is a promise: the app takes the input and shows a "Added to
|
||||
favorites" toast, then discards it as far as the user can tell. Three of the UX
|
||||
flows already specify favourite buttons that were never built, and the one that
|
||||
was built (mini player) writes to a store nothing reads. Either the feature gets
|
||||
its read side or the heart should be removed — this spec takes the first option.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Favourites **scope** → set of Jellyfin item types | Rust | Domain taxonomy. Changes when Jellyfin adds/renames a type, never when the UI is redesigned. Reuses the canonical `SearchScope::item_types()` ([types.rs:324](../../src-tauri/src/repository/types.rs#L324)) — the exact leak class of [scoped-search-boundary.md](scoped-search-boundary.md). |
|
||||
| Cross-library favourites query (`Filters=IsFavorite`, `Recursive`, paging, sort field) | Rust | Query shaping against the Jellyfin API is domain logic; the endpoint's contract changes with the server, not the UI. |
|
||||
| Offline favourites SQL (join `user_data`, downloaded/catalog gating) | Rust | Storage + domain. Must obey the existing catalog-browse gate (DR-080) which the frontend cannot see. |
|
||||
| Deserialising Jellyfin `UserData` into `MediaItem.user_data` | Rust | Provider payload mapping. |
|
||||
| Mirroring server favourite state into the local `user_data` table | Rust | Cache/sync policy. |
|
||||
| Conflict rule: a local row with `pending_sync = 1` beats the server value | Rust | Business rule about which write wins; nothing to do with rendering. |
|
||||
| Draining pending favourite toggles on reconnect | Rust | Sync policy, and it must run whether or not any view is mounted — a frontend-driven drain dies with the component. Consistent with *reachability from real traffic* (DR-055). |
|
||||
| Which surfaces show favourites, tab order, row placement on home | Frontend | Pure presentation; changes only if the UI is redesigned. |
|
||||
| Heart placement, animation, toast, haptics, empty-state copy | Frontend | Presentation. |
|
||||
| In-session optimistic heart state shared across views | Frontend | View state, not persisted truth; the durable write already goes to Rust. |
|
||||
|
||||
**Borderline, and the tie-breaker used:** *which* scopes appear as tabs (All /
|
||||
Movies / Shows / Music) is a presentation choice — the frontend picks which
|
||||
`SearchScope` values to offer. What each scope *means* is Rust's. The frontend
|
||||
sends the enum value and never names an item type in connection with favourites.
|
||||
Single-type pages (`itemType: "Movie"` on the Movies list page) stay as they are;
|
||||
this rule targets category taxonomy, not every mention of a type.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Server user data reaches `MediaItem` (Rust, DR-113, JA-034)
|
||||
|
||||
Jellyfin returns `UserData` on `/Users/{uid}/Items*` responses. Add the field to
|
||||
`JellyfinItem` and map it in `to_media_item`, replacing the hardcoded `None`:
|
||||
|
||||
```rust
|
||||
// in JellyfinItem
|
||||
#[serde(alias = "UserData")]
|
||||
pub user_data: Option<JellyfinUserData>,
|
||||
```
|
||||
|
||||
`JellyfinUserData` deserialises `IsFavorite`, `Played`, `PlaybackPositionTicks`,
|
||||
`PlayCount`, `LastPlayedDate` into the existing
|
||||
[`UserData`](../../src-tauri/src/repository/types.rs#L44) type (which already
|
||||
carries `is_favorite` and already serialises camelCase, so `bindings.ts` needs no
|
||||
new type — only regeneration). Add `UserData` to the `Fields=` list in `get_items`
|
||||
/ `get_item` so the shape is explicit rather than relying on the default.
|
||||
|
||||
Wire shape, unchanged from today's `UserData`:
|
||||
|
||||
```ts
|
||||
item.userData?.isFavorite // boolean | null | undefined
|
||||
```
|
||||
|
||||
Delete the now-false `// User data not included in basic item responses` comment.
|
||||
|
||||
### 2. Local mirror of server favourites (Rust, DR-114)
|
||||
|
||||
Choke point: `save_to_cache(parent_id, &items)` in
|
||||
[offline.rs](../../src-tauri/src/repository/offline.rs) — every server result
|
||||
that gets cached (including via
|
||||
[`cache_items_from_server`](../../src-tauri/src/repository/hybrid.rs#L124) and
|
||||
the background cache refresh) passes through it.
|
||||
|
||||
For each item carrying `user_data.is_favorite`, upsert:
|
||||
|
||||
```sql
|
||||
INSERT INTO user_data (user_id, item_id, is_favorite, synced_at, pending_sync)
|
||||
VALUES (?, ?, ?, ?, 0)
|
||||
ON CONFLICT(user_id, item_id) DO UPDATE SET
|
||||
is_favorite = excluded.is_favorite,
|
||||
synced_at = excluded.synced_at
|
||||
WHERE user_data.pending_sync = 0; -- local unsynced change wins
|
||||
```
|
||||
|
||||
The `WHERE` on the conflict clause is the whole conflict rule: a toggle made
|
||||
offline is never overwritten by a stale server value before it has been pushed.
|
||||
|
||||
### 3. Favourites queries (Rust, DR-115, DR-116, JA-033)
|
||||
|
||||
**(a) In-library filter** — one new field on `GetItemsOptions`:
|
||||
|
||||
```rust
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub favorites_only: Option<bool>,
|
||||
```
|
||||
|
||||
- online `get_items`: append `&Filters=IsFavorite` when true.
|
||||
- offline `get_items`: add `INNER JOIN user_data ud ON ud.item_id = i.id AND ud.user_id = ? AND ud.is_favorite = 1`, composed with the existing `available_items` CTE so the downloads-only gate still applies.
|
||||
|
||||
Frontend sends `{ favoritesOnly: true }` (camelCase — nested struct field, needs
|
||||
the existing `#[serde(rename_all = "camelCase")]` on `GetItemsOptions`, already
|
||||
present).
|
||||
|
||||
**(b) Cross-library favourites** — a new trait method, because favourites span
|
||||
libraries and `get_items` is `ParentId`-shaped:
|
||||
|
||||
```rust
|
||||
/// TRACES: UR-067 | DR-115 | JA-033
|
||||
async fn get_favorites(
|
||||
&self,
|
||||
scope: SearchScope,
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, RepoError>;
|
||||
```
|
||||
|
||||
```rust
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_favorites(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
scope: SearchScope,
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, String>
|
||||
```
|
||||
|
||||
Frontend call (command name matches the Rust fn exactly; top-level params
|
||||
auto-camelCase; `SearchScope` is `#[serde(rename_all = "camelCase")]` so the wire
|
||||
values are `"all" | "music" | "movies" | "tv"`):
|
||||
|
||||
```ts
|
||||
await commands.repositoryGetFavorites(handle, "movies", { limit: 100 });
|
||||
```
|
||||
|
||||
- **online**: `/Users/{uid}/Items?Filters=IsFavorite&Recursive=true&SortBy=SortName&SortOrder=Ascending` + `&IncludeItemTypes=…` from `scope.item_types()` (omit entirely on `None`, per that function's contract) + the standard `Fields=`.
|
||||
- **offline**: `items ⨝ user_data (is_favorite = 1)`, type filter from the same `scope.item_types()`, honouring `include_catalog_browse()`.
|
||||
- **hybrid**: same cache-first race as `get_items`, **including the DR-080 rule** — with the catalog-browse gate off, an empty offline result is authoritative and must not fall through to the server. Getting this wrong reproduces Defect B from [offline-downloaded-only-filter.md](offline-downloaded-only-filter.md).
|
||||
|
||||
**The cache-first result arrives stale, and there is no second payload.** On a
|
||||
cache hit, `hybrid::get_items` returns the local rows and refreshes the cache in
|
||||
a background task whose result the frontend never sees — fine for a library
|
||||
listing that changes daily, wrong for favourites, where the *point* is that
|
||||
another client just changed something. Favourites is the second read path (after
|
||||
search) that needs the deferred update, so the background refresh in
|
||||
`get_favorites` must emit the same `favorites-changed` event as §4 when the
|
||||
server's favourite set differs from what was returned:
|
||||
|
||||
```
|
||||
favorites-changed → { itemIds: string[] } // union of ids whose is_favorite flipped
|
||||
```
|
||||
|
||||
Both producers (background refresh, reconnect drain) emit the identical payload,
|
||||
and the frontend has one handler that refreshes the `favorites` store. Without
|
||||
this, a favourite marked on another client appears in JellyTau only on the
|
||||
*second* visit to the page.
|
||||
|
||||
### 4. Draining offline toggles (Rust, DR-120)
|
||||
|
||||
On the offline→online transition already detected by `ConnectivityMonitor`,
|
||||
select `user_data WHERE pending_sync = 1 AND is_favorite IS NOT NULL`, POST or
|
||||
DELETE `/Users/{uid}/FavoriteItems/{id}` per row, then set `pending_sync = 0` and
|
||||
`synced_at`. Failures leave the row pending for the next transition.
|
||||
|
||||
Emit a kebab-case event when anything changed, so open views refresh without
|
||||
polling:
|
||||
|
||||
```
|
||||
favorites-changed → { itemIds: string[] }
|
||||
```
|
||||
|
||||
`syncService.queueFavorite` is dead code once this lands — delete it or point it
|
||||
at the backend drain; do not leave two competing queues.
|
||||
|
||||
### 5. Frontend surfaces (DR-117, DR-118, DR-119)
|
||||
|
||||
**Favourites page** — new route `/library/favorites`:
|
||||
- Scope tabs *All / Movies / Shows / Music* via the existing `LibraryViewTabs`; each tab sends a `SearchScope` value, nothing more.
|
||||
- Renders through `LibraryGrid` + `MediaCard` (tracklist for Music→tracks if the tab is later split; not in this pass).
|
||||
- Entry points: a card on the library overview ([library/+page.svelte](../../src/routes/library/+page.svelte)) and "See all" on the home rows.
|
||||
- Empty state per tab: "Nothing favourited yet — tap the heart on anything you like."
|
||||
|
||||
**Home carousels** — `favoriteMovies`, `favoriteShows`, `favoriteMusic` added to
|
||||
[home.ts](../../src/lib/stores/home.ts), each `repositoryGetFavorites(scope, { limit: 20 })`,
|
||||
rendered after *Recently Added* and **only when non-empty** (no empty rows on a
|
||||
fresh install).
|
||||
|
||||
**In-library filter** — a favourites toggle in the header of
|
||||
[GenericMediaListPage](../../src/lib/components/library/GenericMediaListPage.svelte)
|
||||
and the Movies/TV landing pages, passing `favoritesOnly: true` into the existing
|
||||
`repo.getItems(...)` options. Session-scoped state; not persisted (a persisted
|
||||
filter that hides most of a library is a support call waiting to happen).
|
||||
|
||||
**Hearts** — mount `FavoriteButton`:
|
||||
- Movie / series detail hero button row, beside the download buttons ([library/[id]/+page.svelte:528-553](../../src/routes/library/%5Bid%5D/+page.svelte#L528-L553)) — closes ux-flows §5B.3.
|
||||
- `EpisodeFocusView`, `ArtistDetailView`, `PlaylistDetailView`, album detail — closes ux-flows §5.2.
|
||||
- `MediaCard` artwork overlay (top-right). Suppressed on `isServerOnly` cards, and must not fight the existing long-press/scroll-guard handlers ([MediaCard.svelte:60-70](../../src/lib/components/library/MediaCard.svelte#L60-L70)) — the heart is its own button and stops propagation.
|
||||
|
||||
**Shared optimistic state** — a small `favorites` store (`Map<string, boolean>`
|
||||
overlay + `favorites.set(id, value)`), so un-hearting an item on the Favourites
|
||||
page removes it from the grid and from any home row without a refetch, and a
|
||||
heart tapped on a card is reflected on the detail page. Resolution order:
|
||||
|
||||
```
|
||||
favorites store override ?? item.userData?.isFavorite ?? false
|
||||
```
|
||||
|
||||
`toggleFavorite()` updates the store alongside its existing local + server
|
||||
writes; the `favorites-changed` event refreshes it. This removes the mini
|
||||
player's per-track `storageGetPlaybackProgress` fetch once items carry
|
||||
`userData`.
|
||||
|
||||
### 6. Offline behaviour
|
||||
|
||||
Toggling offline keeps working exactly as now (local write + `pending_sync`), and
|
||||
now actually reaches the server on reconnect (§4). The Favourites page offline
|
||||
shows favourites among downloaded/cached items, subject to the existing
|
||||
catalog-browse gate. The offline repo's no-op `mark_favorite`/`unmark_favorite`
|
||||
stay no-ops — the local write plus the drain is the offline path.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Favouriting people, genres, or collections; favourite **playlists** are included only insofar as they fall under the Music scope.
|
||||
- Sorting by "date favourited" — Jellyfin does not expose it. Favourites sort by name.
|
||||
- A dedicated bottom-nav tab for favourites (reachable from library overview + home).
|
||||
- Building a playlist or download batch from favourites.
|
||||
- Reconciling favourites for items that no longer exist on the server.
|
||||
- Splitting the Music tab into albums/artists/tracks sub-tabs.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Favouriting is possible from movie, series, episode, album, artist and playlist detail pages, and from media cards in any grid.
|
||||
- [ ] A favourite marked in another Jellyfin client shows a filled heart in JellyTau without toggling it here.
|
||||
- [ ] `/library/favorites` lists favourites across libraries, filtered by the All/Movies/Shows/Music tabs.
|
||||
- [ ] Home shows favourite rows for movies, shows and music, and shows no row when a category has none.
|
||||
- [ ] Movies/TV/Music list pages can be filtered to favourites only.
|
||||
- [ ] Un-hearting an item on one surface updates the others without a manual refresh.
|
||||
- [ ] A favourite toggled while offline reaches the server after reconnect (verified against a real server or a fake repository).
|
||||
- [ ] Offline, the Favourites page respects the "Show all server media" gate — with it off, an empty result stays empty and does not fall through to the server.
|
||||
- [ ] No item-type set appears in `src/` in connection with favourites; the frontend sends `SearchScope` only.
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
||||
- [ ] `bun run check:boundary` passes (necessary, not sufficient — see CLAUDE.md).
|
||||
- [ ] New requirement-implementing code carries `// TRACES:` comments.
|
||||
- [ ] `bindings.ts` regenerated from Rust, not hand-edited.
|
||||
|
||||
## Testing
|
||||
|
||||
**🔴 §4 (the pending-sync drain) is a bug fix — failing test first.** Write a
|
||||
test that toggles a favourite with the repository offline, transitions to online,
|
||||
and asserts the server call happened; watch it fail before writing the drain.
|
||||
|
||||
Rust (`cd src-tauri && cargo test`):
|
||||
|
||||
| Test | Covers |
|
||||
|------|--------|
|
||||
| UT-099 | A Jellyfin item JSON fixture with `UserData.IsFavorite: true` maps to `MediaItem.user_data.is_favorite == Some(true)` |
|
||||
| UT-100 | `online::get_favorites` builds an endpoint with `Filters=IsFavorite`, `Recursive=true`, and the scope's `IncludeItemTypes`; `SearchScope::All` omits the type filter entirely |
|
||||
| UT-101 | `offline::get_favorites` returns only `is_favorite = 1` rows, respects the scope type filter, and returns nothing extra when the catalog-browse gate is off |
|
||||
| UT-102 | `save_to_cache` mirror does **not** overwrite a row with `pending_sync = 1` |
|
||||
| UT-103 | Drain pushes pending rows, clears `pending_sync`, sets `synced_at`, and leaves failed rows pending |
|
||||
| UT-104 | `get_items` with `favorites_only: true` filters both online (endpoint) and offline (SQL) |
|
||||
| UT-107 | The background refresh in `hybrid::get_favorites` emits `favorites-changed` with the flipped ids, and emits nothing when the server set matches the cache |
|
||||
|
||||
Frontend (`bun run test`):
|
||||
|
||||
| Test | Covers |
|
||||
|------|--------|
|
||||
| UT-105 | `favorites` store override precedence: store value beats `userData.isFavorite` beats `false` |
|
||||
| UT-106 | Un-hearting removes the item from a favourites list view (pure logic extracted to a `.ts` module, per the TrackList/episodeStrip pattern) |
|
||||
| IT-0xx | `repositoryGetFavorites` param naming — add to [tauriIntegration.test.ts](../../src/lib/utils/tauriIntegration.test.ts): camelCase top-level params, scope serialised as `"movies"` etc. |
|
||||
|
||||
Any component logic worth testing gets extracted into a plain `.ts` module first
|
||||
(`favoritesView.ts`), rather than tested through the component.
|
||||
|
||||
## TRACES
|
||||
|
||||
| Piece | Tag |
|
||||
|-------|-----|
|
||||
| `JellyfinUserData` + `to_media_item` mapping | `// TRACES: UR-069 \| DR-113, JA-034 \| UT-099` |
|
||||
| `save_to_cache` user_data mirror | `// TRACES: UR-069 \| DR-114 \| UT-102` |
|
||||
| `get_favorites` (trait, online, offline, hybrid) + command | `// TRACES: UR-067 \| DR-115, JA-033 \| UT-100, UT-101` |
|
||||
| `GetItemsOptions.favorites_only` handling | `// TRACES: UR-067 \| DR-116 \| UT-104` |
|
||||
| `/library/favorites` route + tabs | `// TRACES: UR-067 \| DR-117` |
|
||||
| Home favourite carousels | `// TRACES: UR-067 \| DR-118` |
|
||||
| `FavoriteButton` mounts + `favorites` store | `// TRACES: UR-068 \| DR-119 \| UT-105, UT-106` |
|
||||
| Pending-favourite drain + `favorites-changed` | `// TRACES: UR-069 \| DR-120 \| UT-103` |
|
||||
|
||||
New requirement rows to add to [requirements.md](../requirements.md):
|
||||
|
||||
- **UR-067** — Browse favourited media across libraries (page, home rows, in-library filter).
|
||||
- **UR-068** — Mark/unmark favourites from browse and detail surfaces, not only the player.
|
||||
- **UR-069** — Favourite state stays consistent with the server in both directions.
|
||||
- **DR-113 … DR-120** — as tabled above.
|
||||
- **JA-033** — Query favourite items (`Filters=IsFavorite`).
|
||||
- **JA-034** — Read `UserData` from item responses.
|
||||
|
||||
## Implementation notes (as built)
|
||||
|
||||
Two things landed differently from the design above, both forced by where the
|
||||
`AppHandle` lives:
|
||||
|
||||
1. **The `favorites-changed` event is emitted from the command layer, not the
|
||||
repository.** `HybridRepository` has no `AppHandle` — the same reason
|
||||
`search-event` is emitted from `repository_search`. `repository_get_favorites`
|
||||
therefore does the two-phase read itself (cache leg returned, server leg
|
||||
spawned) and diffs the two id sets via `changed_favorite_ids`, which is
|
||||
extracted and unit-tested (UT-107) rather than buried in the spawn.
|
||||
2. **The drain hooks the existing `connectivity:reconnected` event** via
|
||||
`app.listen` in `commands/favorites.rs`, rather than reaching into
|
||||
`ConnectivityMonitor` (which knows nothing about repositories). It drains
|
||||
through a narrow `FavoriteSink` trait so it can be tested against a recording
|
||||
double instead of a forty-method `MediaRepository` mock.
|
||||
|
||||
3. **The command falls back to `HybridRepository::get_favorites` when nothing is
|
||||
cached.** The two-phase read alone paints "Nothing favourited yet" on a fresh
|
||||
install and corrects it a server round trip later, which is a wrong answer
|
||||
shown to the user. An empty cache leg therefore defers to the repository's
|
||||
own cache-first-then-server read. That read was also fixed to *save through*
|
||||
on a server hit — without it the page re-queried the server on every visit
|
||||
and the DR-114 mirror was never filled by this path.
|
||||
|
||||
Also as built: `DatabaseService` is not object-safe (generic methods), so the
|
||||
drain takes `Arc<RusqliteService>` like the rest of the storage code, and
|
||||
`get_items`' endpoint construction was extracted to `build_get_items_endpoint`
|
||||
so the `favorites_only` filter could be asserted without an HTTP server.
|
||||
|
||||
**Not built:** the full-player heart. ux-flows §3.2 lists one among the full
|
||||
player's secondary controls and it remains unbuilt — recorded as a known
|
||||
deviation in ux-flows §5C.5 rather than silently dropped.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- A parallel Claude session may be active in this repo — `git diff` before "repairing" unexpected changes.
|
||||
- Do **not** try to reuse `get_items` with an empty `ParentId` for cross-library favourites; that endpoint is built as `?ParentId={}` ([online.rs:731](../../src-tauri/src/repository/online.rs#L731)) and an empty value is not a reliable "all libraries" request. Use `get_favorites`.
|
||||
- `SearchScope` is reused rather than a new `FavoritesScope` so there is one taxonomy expansion in the codebase, not two that can drift. If the name grates once favourites ship, rename the type across search + favourites in one commit — don't fork it.
|
||||
- `SearchScope::All` returns `None` from `item_types()` **on purpose**; callers must omit `IncludeItemTypes` entirely rather than sending a union (see the doc comment at [types.rs:316](../../src-tauri/src/repository/types.rs#L316)).
|
||||
- Ship order that keeps each step demonstrable: §1+§2 (state becomes visible) → §5 hearts (marking becomes possible) → §3+§5 browse surfaces (finding becomes possible) → §4 drain.
|
||||
- Regenerate `bindings.ts` after the Rust types change; never hand-edit it.
|
||||
@@ -0,0 +1,125 @@
|
||||
# Spec: Library mosaic (library overview + home shortcuts)
|
||||
|
||||
**Status:** Implemented
|
||||
**Requirements:** UR-075 → DR-174, DR-175 (with UR-067 → DR-117 extended)
|
||||
**UX spec:** [ux-flows.md](../ux-flows.md) §5C.2 (Favourites)
|
||||
|
||||
## Summary
|
||||
|
||||
The library overview and the home "Your Libraries" strip stop being fixed-shape
|
||||
grids and become a **mosaic**: rows share one height, and each tile is as wide as
|
||||
its own artwork is. A square music cover, a 16:9 library backdrop and a 2:3
|
||||
poster sit in the same row at their own proportions instead of all three being
|
||||
cropped into whichever box the grid picked. Favourites gain a tile per category,
|
||||
placed beside the library that category belongs to, alongside the existing
|
||||
cross-library entry.
|
||||
|
||||
## Motivation
|
||||
|
||||
Every surface here shows artwork of more than one shape. The grid resolved that
|
||||
by choosing one shape and cropping to it — and the home strip said so out loud:
|
||||
|
||||
> Uniform 16:9 artwork so music (square) and video libraries line up at the same
|
||||
> height in this mixed row.
|
||||
|
||||
Lining them up is right; cropping the covers to do it is not. Holding the
|
||||
**height** fixed and letting the **width** vary achieves the same alignment with
|
||||
no crop at all, which is the whole idea of a justified layout.
|
||||
|
||||
Favourites had one entry for everything. With per-category tiles, "my favourite
|
||||
albums" is one tap from the library page rather than a tap plus a tab.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Collection type → favourites category (`movies` → Movies, `livetv` → none) | **Rust** | Jellyfin vocabulary. It changes when Jellyfin renames a collection type, never when this page is redesigned — the same test that put `SearchScope::item_types` in Rust. Shipping it in Svelte would have re-created the leak [scoped-search-boundary.md](scoped-search-boundary.md) exists to document. |
|
||||
| Which scopes exist at all (`SearchScope`) | **Rust** | Already there; unchanged. |
|
||||
| Row packing: heights, widths, justification, clamping | Frontend | Geometry of a rendered page. It changes when the layout is redesigned and never when the API does. |
|
||||
| Assumed artwork shape before the image loads (music = square, else wide) | Frontend | The shape of a *picture*, not a taxonomy — and it is only a starting guess, overruled by the decoded image. |
|
||||
| Tile labels, order, and showing a category's tile once | Frontend | Pure presentation: wording and placement. |
|
||||
|
||||
Borderline row: the "assumed artwork shape" is a per-collection-type default, and
|
||||
any per-collection-type table deserves suspicion. The tie-breaker: it does not
|
||||
decide *what a category means* or what is fetched — it seeds a pixel dimension
|
||||
that the loaded bitmap immediately corrects. Getting it wrong costs one re-pack,
|
||||
not a wrong result. The scope mapping, which does decide what is fetched, went to
|
||||
Rust.
|
||||
|
||||
## Design
|
||||
|
||||
### Wire
|
||||
|
||||
`Library` gains one optional field, derived at construction:
|
||||
|
||||
```rust
|
||||
pub struct Library {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub collection_type: String,
|
||||
pub image_tag: Option<String>,
|
||||
pub favorites_scope: Option<SearchScope>, // ← new
|
||||
}
|
||||
|
||||
impl SearchScope {
|
||||
pub fn for_collection_type(collection_type: &str) -> Option<SearchScope>;
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
type Library = { …; favoritesScope?: SearchScope | null }
|
||||
```
|
||||
|
||||
`Library::new` derives it, so the four construction sites (online views, two
|
||||
offline cache reads, tests) cannot forget it. `None` is *omitted* from the JSON,
|
||||
not sent as null. No new command, no new event.
|
||||
|
||||
### Layout
|
||||
|
||||
`src/lib/components/library/mosaic.ts` — pure, no DOM:
|
||||
|
||||
- `layoutMosaic(items, { containerWidth, targetHeight, gap })` → rows of tiles
|
||||
with pixel boxes. Tiles join a row until the height needed to fill the width
|
||||
drops to the target; the row closes there and is justified to the container
|
||||
width, the rounding remainder absorbed by its widest tile. The **last row is
|
||||
not justified** (one leftover tile would inflate into a banner) — it sits at
|
||||
the target height, left-aligned.
|
||||
- `layoutMosaicStrip(items, height)` → the same rule as one fixed-height row, for
|
||||
a horizontally scrolling shelf.
|
||||
- `mosaicTargetHeight(containerWidth)` → the row height chosen when the caller
|
||||
doesn't pick one. Bounded so a phone still fits two tiles across and a desktop
|
||||
doesn't turn each library into a billboard.
|
||||
- Ratios are clamped to a band (0.5–2.5) so one panorama can't own a row.
|
||||
|
||||
`MosaicGrid.svelte` supplies the two things only the DOM knows — the measured
|
||||
container width (`bind:clientWidth`) and the artwork's decoded ratio — and
|
||||
renders the caller's `tile` snippet. `CachedImage` gained an `onNaturalSize`
|
||||
callback for the second. Measured ratios are committed in one debounced batch
|
||||
(120 ms): artwork arrives over several hundred milliseconds and re-packing per
|
||||
image would shuffle the grid under the pointer.
|
||||
|
||||
`MosaicTile.svelte` draws one tile at an exact pixel box, with its label written
|
||||
**over** the bottom of the artwork. A caption below the box would add height the
|
||||
layout didn't compute, and a caption that wrapped to two lines would break the
|
||||
row alignment the mosaic exists to provide.
|
||||
|
||||
### Composition
|
||||
|
||||
`libraryMosaic.ts` (pure, tested) builds the tile list: the cross-library
|
||||
favourites entry first, then each library followed by its own category tile. A
|
||||
category appears **once** — two movie libraries share one favourites list, so a
|
||||
tile each would be two tiles to the same place. A library whose `favoritesScope`
|
||||
is absent (Live TV, channels, books) gets no tile rather than one opening an
|
||||
unfiltered list.
|
||||
|
||||
Home uses the same tiles in `layout="strip"` but **without** the favourites tiles:
|
||||
home already carries Favourite Movies / Shows / Music rows of its own, and a
|
||||
second entry point in the strip above them would be redundant.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- The item grids inside a library (`/library/movies`, `/library/music/albums`, …).
|
||||
Those show one item type each, so a uniform grid crops nothing; the mosaic buys
|
||||
them nothing but reflow.
|
||||
- Backdrop/collage artwork for libraries with no image of their own.
|
||||
- Reordering or pinning libraries.
|
||||
@@ -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,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,146 @@
|
||||
# Spec: streaming bitrate cap
|
||||
|
||||
**Status:** Implemented
|
||||
**Requirements:** UR-074 → DR-162 (partially serves UR-070)
|
||||
**UX spec:** n/a — the controls reuse existing patterns (Settings → Video Playback, and the player's track menus).
|
||||
|
||||
## Summary
|
||||
|
||||
The viewer picks a bandwidth ceiling for video — from `Original` (no client
|
||||
limit) down to 720 kbps — and every video the app opens is fetched within it,
|
||||
live TV included. The choice is made once in Settings and persists across
|
||||
restarts; a single video can be moved to another ceiling from the player, which
|
||||
re-opens the stream and resumes where it was without changing the saved default.
|
||||
|
||||
## Motivation
|
||||
|
||||
Every video URL the app built carried a fixed allowance —
|
||||
`MaxStreamingBitrate=20000000`, `VideoBitrate=18000000` — the `PlaybackInfo`
|
||||
negotiation asked for 20 Mbps, and the device profile advertised
|
||||
`999999999`, which invites the server to direct-play a source of any size. On a
|
||||
metered or slow connection there was no lever at all short of not watching.
|
||||
|
||||
The related UR-070 asks for something adjacent but different: a list of the
|
||||
renditions *the server can produce for this item*. That needs per-item
|
||||
`MediaSources` negotiation and is still proposed. What was missing first is
|
||||
cruder and more valuable: a device-wide budget that holds regardless of what is
|
||||
playing.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| What a quality step *is* — total ceiling, audio share, resolution cap | Rust | Jellyfin encoding vocabulary. It changes if Jellyfin's transcoder or parameter binding changes, not if the UI is redesigned. Exactly the shape of `EqPreset::gains()`. |
|
||||
| Splitting the ceiling between video and audio | Rust | A domain rule about what the server is being asked to produce; getting it wrong overshoots the user's cap. |
|
||||
| Choosing `MaxHeight` for a bitrate | Rust | An encoding judgement (how many pixels a budget can carry), not a display preference. |
|
||||
| Where the cap is applied (URL builders, `PlaybackInfo`, live TV, audio handoff) | Rust | All four are backend concerns, and the frontend must not have to know that a cap has more than one enforcement point. |
|
||||
| Whether a mid-playback change needs a stream reload, and performing it | Rust | Same decision the audio-track switch already delegates: the backend knows the playback mode and owns the queue. |
|
||||
| Persisting the default | Rust | Application state in `app_settings`, alongside every other durable setting. |
|
||||
| Rendering the picker, menu placement, which control is highlighted | Frontend | Pure presentation. |
|
||||
|
||||
The frontend holds one string — the serde token for the chosen variant — and
|
||||
labels/details it received from Rust. It never encodes a bitrate, a resolution
|
||||
or a parameter name.
|
||||
|
||||
## Design
|
||||
|
||||
`StreamingQuality` (`src-tauri/src/settings.rs`) is the ladder: `Original`,
|
||||
`Mbps20`, `Mbps10`, `Mbps8`, `Mbps4`, `Mbps2`, `Mbps1`, `Kbps720`, serialised
|
||||
camelCase (`"mbps10"`). Each step answers `max_bitrate()`, `audio_bitrate()`,
|
||||
`video_bitrate()` (= total − audio), `max_height()`, `label()`, `detail()`.
|
||||
|
||||
The active ceiling is a process-wide `RwLock<StreamingQuality>` in
|
||||
`repository/online.rs`, read by every builder. Process-wide rather than a field
|
||||
on `OnlineRepository` because it is a preference about *this device's
|
||||
connection*: it must survive a repository rebuilt on re-login, and the URL
|
||||
builders and the negotiation have to agree on it or the cap leaks. This mirrors
|
||||
`offline::INCLUDE_CATALOG_BROWSE`.
|
||||
|
||||
Enforcement points — all four are required:
|
||||
|
||||
| Point | What the cap sets |
|
||||
|-------|-------------------|
|
||||
| `get_video_stream_url` (HLS transcode) | `MaxStreamingBitrate`, `VideoBitrate`, `AudioBitrate`, `MaxHeight` |
|
||||
| `get_playback_info` | request `MaxStreamingBitrate`, and the device profile's `MaxStreamingBitrate`/`MaxStaticBitrate` |
|
||||
| `open_live_stream` | `MaxStreamingBitrate` |
|
||||
| `build_audio_only_stream_url_for_video` | `min(cap audio, 384 kbps)` |
|
||||
|
||||
The negotiation is the one that matters most. `MaxStaticBitrate` is what makes
|
||||
the server refuse to *direct play* a source fatter than the ceiling; without it
|
||||
a 30 Mbps remux is served untouched and no URL parameter downstream can reduce
|
||||
it.
|
||||
|
||||
IPC:
|
||||
|
||||
```rust
|
||||
player_get_streaming_qualities() -> Vec<(StreamingQuality, String, String)> // variant, label, detail
|
||||
player_set_stream_quality(repository_handle, quality, use_html5,
|
||||
current_position, media_source_id, audio_stream_index)
|
||||
-> StreamQualityResponse // #[serde(tag = "strategy")]: native | reloadStream
|
||||
```
|
||||
|
||||
`VideoSettings` gains `streaming_quality` (`#[serde(default)]`, so settings
|
||||
persisted before the field existed load as uncapped).
|
||||
`player_set_video_settings` applies it and writes it to `app_settings`;
|
||||
`restore_streaming_quality` reads it back in the Tauri `setup` hook via
|
||||
`tauri::async_runtime::spawn`, defaulting to uncapped if anything fails.
|
||||
|
||||
`StreamQualityResponse` keeps its Rust field names on the wire (`new_url`) —
|
||||
tauri-specta only camelCases the `strategy` tag. The facade
|
||||
(`playerController.setStreamQuality`) dispatches `reloadSource` for
|
||||
`reloadStream` and does nothing for `native`, because the backend has already
|
||||
reloaded itself.
|
||||
|
||||
Mid-playback the change applies to the current video **and** becomes the process
|
||||
ceiling for what follows, but it is not persisted: the in-player menu is a "this
|
||||
film, this connection" control and Settings owns the durable default.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Per-item rendition lists from the server's `MediaSources` (UR-070's other half).
|
||||
- Connection-aware caps (separate WiFi/cellular ceilings). One cap, all connections.
|
||||
- Adaptive/automatic selection from measured throughput.
|
||||
- Download quality, which already has its own preset vocabulary (UR-071/DR-123).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `bun run check` passes.
|
||||
- [x] `cargo fmt` clean, `cargo clippy` clean, Rust tests pass.
|
||||
- [x] `bun run test` passes.
|
||||
- [x] `bun run check:boundary` passes — no bitrate/resolution numbers in `src/`.
|
||||
- [x] New code carries `// TRACES:` comments.
|
||||
- [x] `bindings.ts` regenerated from Rust.
|
||||
- [x] A capped step changes what the URL asks for; the uncapped default is byte-identical to the previous behaviour.
|
||||
|
||||
## Testing
|
||||
|
||||
Rust (`cargo test`):
|
||||
|
||||
- `test_video_stream_url_applies_bitrate_cap` — all four parameters at `Mbps2`.
|
||||
- `test_video_stream_url_uncapped_keeps_legacy_allowance` — `Original` is unchanged and adds no `MaxHeight`.
|
||||
- `test_audio_only_stream_url_takes_the_lower_of_cap_and_default`.
|
||||
- `test_streaming_quality_budget_is_internally_consistent`, `..._ladder_descends`, `..._round_trips_through_json`.
|
||||
|
||||
The ceiling is process-wide, so tests that depend on it serialise on a guard
|
||||
(`QualityFixture`) that restores `Original` on drop — including the two
|
||||
pre-existing stream-URL tests, which would otherwise see another test's cap.
|
||||
|
||||
`get_playback_info` and `open_live_stream` need a live server and are not unit
|
||||
tested; their behaviour is the enum's `max_bitrate()`, which is.
|
||||
|
||||
## TRACES
|
||||
|
||||
- `StreamingQuality`, `VideoSettings.streaming_quality` — `UR-074 | DR-162`
|
||||
- URL builders / negotiation / live TV — `UR-004, UR-074 | DR-140, DR-162`
|
||||
- Audio-only handoff — `UR-040, UR-074 | DR-162`
|
||||
- Commands, facade, Settings UI, player menu — `UR-074 | DR-162`
|
||||
- Tests — `UT-156`, `UT-157`
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- `videoBitRate` with a capital R is the *download* endpoint's binding quirk
|
||||
(DR-123). The streaming endpoint used here binds `VideoBitrate`/
|
||||
`MaxStreamingBitrate` as spelled above — do not "correct" one to the other.
|
||||
- A parallel Claude session may be active in this repo; `git diff` before
|
||||
repairing unexpected changes. DR-160/161 were claimed by such a session while
|
||||
this feature was in flight, which is why it is DR-162.
|
||||
+42
-10
@@ -15,7 +15,7 @@ The CI/CD pipeline automatically validates that code changes are properly traced
|
||||
Traceability validation lives in `.gitea/workflows/traceability-check.yml`:
|
||||
|
||||
- ✅ Automatic trace extraction
|
||||
- ✅ Coverage validation against minimum threshold (50%)
|
||||
- ✅ Coverage validation against minimum threshold (82%, ratcheted)
|
||||
- ✅ Modified file checking
|
||||
- ✅ Artifact preservation
|
||||
- ✅ Summary reports
|
||||
@@ -43,7 +43,7 @@ Extracts all TRACES comments from:
|
||||
|
||||
### 2. Coverage Thresholds
|
||||
The workflow checks:
|
||||
- **Minimum overall coverage:** 50%
|
||||
- **Minimum overall coverage:** 82% (`MIN_THRESHOLD`)
|
||||
|
||||
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
|
||||
@@ -61,8 +61,39 @@ 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.
|
||||
The workflow **fails** and blocks merge if coverage drops below the threshold —
|
||||
or if it computes above 100%, which can only mean the gate is miscounting.
|
||||
|
||||
#### Ratchet policy
|
||||
|
||||
`MIN_THRESHOLD` **only ever goes up.** It is deliberately set a few points below
|
||||
the coverage actually achieved (82 against a real 86%), so a genuine regression
|
||||
trips it. It previously sat at 50 while true coverage was 86%: nearly half the
|
||||
matrix could have rotted before CI objected.
|
||||
|
||||
When coverage rises durably, raise the threshold to just under the new figure.
|
||||
**Never lower it to make a red build pass** — add the missing TRACES comments
|
||||
instead. The same number lives in `MIN_COVERAGE_PERCENT` in
|
||||
`scripts/extract-traces.ts` (so `bun run traces:coverage` gates locally on the
|
||||
same bar); `scripts/extract-traces.test.ts` fails if the two drift apart.
|
||||
|
||||
### 2b. Dangling requirement IDs
|
||||
|
||||
```bash
|
||||
bun run traces:validate
|
||||
```
|
||||
|
||||
Every ID named by a `TRACES:` comment must be defined as a table row in
|
||||
`docs/requirements.md`. The extractor used to accept any well-formed ID
|
||||
silently, so a typo or a rename that missed a call site passed unnoticed —
|
||||
`DR-189` and `UT-188` were referenced from three source files, defined nowhere,
|
||||
for months.
|
||||
|
||||
This check spans **all six** ID types (UR/IR/DR/JA/UT/IT), unlike the coverage
|
||||
`orphaned` list above, which considers only the four requirement types so that
|
||||
UT/IT noise cannot bury a real typo in the ratio's reporting. The workflow step
|
||||
**fails the build** on any dangling ID and prints each offender with the files
|
||||
that reference it.
|
||||
|
||||
### 3. Modified File Checking
|
||||
On pull requests, the workflow:
|
||||
@@ -120,13 +151,13 @@ TRACES: [UR-###, ...] | [IR-###, ...] | [DR-###, ...] | [JA-###, ...]
|
||||
|
||||
### On Push to Main Branch
|
||||
1. ✅ Extracts all traces from code
|
||||
2. ✅ Validates coverage is >= 50%
|
||||
2. ✅ Validates coverage is >= 82%
|
||||
3. ✅ Generates full traceability report
|
||||
4. ✅ Saves report as artifact
|
||||
|
||||
### On Pull Request
|
||||
1. ✅ Extracts all traces
|
||||
2. ✅ Validates coverage >= 50%
|
||||
2. ✅ Validates coverage >= 82%
|
||||
3. ✅ Checks modified files for TRACES
|
||||
4. ✅ Warns if new code lacks TRACES
|
||||
5. ✅ Suggests proper format
|
||||
@@ -134,7 +165,8 @@ TRACES: [UR-###, ...] | [IR-###, ...] | [DR-###, ...] | [JA-###, ...]
|
||||
|
||||
### Failure Scenarios
|
||||
The workflow **fails** (blocks merge) if:
|
||||
- Coverage drops below 50%
|
||||
- Coverage drops below 82%
|
||||
- A `TRACES:` comment names an ID `docs/requirements.md` does not define
|
||||
- JSON extraction fails
|
||||
- Invalid trace format
|
||||
|
||||
@@ -174,7 +206,7 @@ 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
|
||||
- **Short term** (Sprint): Maintain ≥82% overall (the current ratchet)
|
||||
- **Medium term** (Month): Reach 70% overall coverage
|
||||
- **Long term** (Release): Reach 90% coverage with focus on:
|
||||
- IR requirements (API clients)
|
||||
@@ -209,14 +241,14 @@ When submitting a pull request:
|
||||
|
||||
- [ ] All new code has TRACES comments linking to requirements
|
||||
- [ ] TRACES format is correct: `// TRACES: UR-001 | DR-002`
|
||||
- [ ] Workflow passes (coverage ≥ 50%)
|
||||
- [ ] Workflow passes (coverage ≥ 82%)
|
||||
- [ ] No coverage regressions
|
||||
- [ ] Artifact traceability report was generated
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Coverage below minimum threshold"
|
||||
**Problem:** Workflow fails with coverage < 50%
|
||||
**Problem:** Workflow fails with coverage < 82%
|
||||
|
||||
**Solution:**
|
||||
1. Run `bun run traces:json` locally
|
||||
|
||||
+7547
-1403
File diff suppressed because it is too large
Load Diff
@@ -133,13 +133,14 @@ bun run traces:json | jq '.requirements."UR-005"'
|
||||
### Before Committing
|
||||
1. Ensure all new code has TRACES
|
||||
2. Format is correct: `// TRACES: ...`
|
||||
3. Requirements exist in README.md
|
||||
4. No typos in requirement IDs
|
||||
3. Requirements exist in `docs/requirements.md` — `bun run traces:validate`
|
||||
4. No typos in requirement IDs (same command catches them)
|
||||
|
||||
## CI/CD Validation
|
||||
|
||||
The workflow automatically checks:
|
||||
- ✅ Coverage stays >= 50%
|
||||
- ✅ Coverage stays >= 82% (a ratchet — raise it, never lower it)
|
||||
- ✅ Every traced ID is defined in `docs/requirements.md`
|
||||
- ✅ New files have TRACES
|
||||
- ✅ JSON format is valid
|
||||
- ✅ Reports are generated
|
||||
|
||||
+150
-3
@@ -634,7 +634,7 @@ episode strip.
|
||||
│ │ S2E4 • 48m • ★8.1 │ │
|
||||
│ │ Overview… │ │
|
||||
│ │ ▓▓▓▓▓░░░░░ 32m left │ │
|
||||
│ │ [▶ Play] │ │
|
||||
│ │ [▶ Play] [⬇] [♡] │ │
|
||||
│ └───────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ More Episodes │ ← 2. EPISODE STRIP
|
||||
@@ -688,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
|
||||
```
|
||||
@@ -700,6 +700,29 @@ 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
|
||||
@@ -734,6 +757,130 @@ opt-in. Grids and other surfaces keep tap-to-open with no long-press.
|
||||
|
||||
---
|
||||
|
||||
## 5C. Favourites
|
||||
|
||||
Favouriting is a two-sided promise: the heart takes the input, and the app must
|
||||
be able to give it back. This section covers both sides — where you can mark a
|
||||
favourite, and where marked favourites resurface.
|
||||
|
||||
See [specs/favorites-browsing.md](specs/favorites-browsing.md) for the layer
|
||||
assignment and wire shapes.
|
||||
|
||||
### 5C.1 The heart appears wherever an item does
|
||||
|
||||
A favourite is a property of an *item*, so the affordance follows the item
|
||||
rather than living on one privileged screen. Any surface that shows a whole
|
||||
item shows its heart.
|
||||
|
||||
| Surface | Heart position | Notes |
|
||||
|---------|----------------|-------|
|
||||
| Movie / Series detail hero | In the button row, after Play and Download | §5B.3, §5B.4 |
|
||||
| Episode Focus View hero | Same row as Play / Download | §5B.2 |
|
||||
| Album, Artist, Playlist detail | In the header button row | §5.2 |
|
||||
| Media card (any grid or carousel) | Top-right overlay on the artwork | Hidden on server-only (greyed) cards |
|
||||
| Mini player | Right of the track metadata | Existing behaviour, unchanged |
|
||||
| Full player | Secondary controls row | §3.2 — **not yet built**, see §5C.5 |
|
||||
|
||||
Rules:
|
||||
|
||||
- **The heart never competes with the card.** On a media card it is its own
|
||||
button and swallows the tap, so hearting an item never also opens or plays
|
||||
it, and never triggers the §5B.5 long-press.
|
||||
- **State is shown, not guessed.** A filled heart means the *server* considers
|
||||
the item a favourite (or you just tapped it). An item favourited in Jellyfin
|
||||
Web, on another device, or by another client renders filled here without
|
||||
being touched in JellyTau.
|
||||
- **Feedback is immediate.** The heart fills on tap and a toast confirms;
|
||||
neither waits for the server round-trip.
|
||||
|
||||
### 5C.2 Three ways back to what you favourited
|
||||
|
||||
Favourites are not one destination — they are a lens, and the right surface
|
||||
depends on whether the user is *browsing*, *deciding*, or *hunting*.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
User[User wants their favourites] --> How{Intent}
|
||||
|
||||
How -->|Passive: show me something| Home[Home carousels<br/>Favourite Movies / Shows / Music]
|
||||
How -->|Deliberate: my whole collection| Page[Favourites page<br/>/library/favorites]
|
||||
How -->|Narrowing: within this library| Filter[Favourites filter<br/>on a library page]
|
||||
|
||||
Home -->|See all| Page
|
||||
Page --> Detail[Item detail page]
|
||||
Filter --> Detail
|
||||
```
|
||||
|
||||
**Home carousels.** Rows for favourite movies, shows and music sit below
|
||||
*Recently Added*. A row with nothing in it **does not render** — a fresh install
|
||||
shows no empty favourite rows. Each row ends with *See all*, landing on the
|
||||
matching tab of the Favourites page.
|
||||
|
||||
**The Favourites page** (`/library/favorites`) is the complete collection,
|
||||
scoped by tabs:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ [←] Favourites │
|
||||
│ ┌─────┬────────┬───────┬───────┐ │
|
||||
│ │ All │ Movies │ Shows │ Music │ ← scope tabs │
|
||||
│ └─────┴────────┴───────┴───────┘ │
|
||||
│ │
|
||||
│ ┌────┐┌────┐┌────┐┌────┐┌────┐ │
|
||||
│ │ ♥ ││ ♥ ││ ♥ ││ ♥ ││ ♥ │ grid/list │
|
||||
│ └────┘└────┘└────┘└────┘└────┘ per §5A │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Cards obey §5A in full — shape follows the media, so a mixed *All* tab reads
|
||||
as posters, squares and thumbnails side by side rather than one forced shape.
|
||||
- Reached from a card on the library overview (`/library`) and from *See all*
|
||||
on any home favourites row.
|
||||
- Sorted by name. Jellyfin does not record *when* an item was favourited, so
|
||||
"recently favourited" is not offerable — see §5C.5.
|
||||
- Empty state, per tab: *"Nothing favourited yet — tap the heart on anything
|
||||
you like."*
|
||||
|
||||
**The in-library filter** is for narrowing where the user already is: a
|
||||
favourites toggle in the header of the Movies, TV and Music browse pages,
|
||||
filtering the current list in place. It is **session-scoped and not persisted** —
|
||||
a sticky filter that silently hides most of a library reads as data loss on the
|
||||
next launch.
|
||||
|
||||
### 5C.3 Removing a favourite removes it everywhere, at once
|
||||
|
||||
Un-hearting an item on the Favourites page removes its card from the grid
|
||||
immediately; the same item disappears from the home rows and shows an empty
|
||||
heart on its detail page without a manual refresh. The reverse holds for
|
||||
favouriting. There is no confirmation prompt — the action is one tap to undo.
|
||||
|
||||
### 5C.4 Offline
|
||||
|
||||
- **Marking works offline.** The heart fills, the toast confirms, and the change
|
||||
is held locally.
|
||||
- **It reaches the server on reconnect**, without the user returning to the
|
||||
screen where they made it.
|
||||
- **Browsing offline shows favourites among media on the device**, subject to
|
||||
the same "Show all server media" gate as every other browse surface (§7.2) —
|
||||
with the gate off, an empty Favourites tab means *nothing favourited is
|
||||
downloaded*, and the page does not quietly fall back to the server catalog.
|
||||
|
||||
### 5C.5 Known deviations
|
||||
|
||||
- **The full player has no heart.** §3.2 and §3.3 list a Favorite button among
|
||||
the full player's secondary controls; it was never built, and this pass does
|
||||
not add it. The mini player heart above it is the only in-player affordance.
|
||||
*(UR-067)*
|
||||
- **No "recently favourited" sort.** Jellyfin's API does not expose a favourite
|
||||
timestamp, so favourites can only be ordered by name. Recording the
|
||||
timestamp locally at toggle time would order *this device's* favourites only,
|
||||
which is worse than a consistent name sort.
|
||||
- **Music is one tab, not three.** The Music scope mixes albums, artists and
|
||||
tracks in a single grid rather than offering sub-tabs. Acceptable while
|
||||
favourite counts are small; revisit if the tab becomes unscannable.
|
||||
|
||||
---
|
||||
|
||||
## 6. Search Flow
|
||||
|
||||
Search is **context-scoped**: what you are looking at when you start a search
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellytau",
|
||||
"version": "0.2.8",
|
||||
"version": "0.8.2",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.5",
|
||||
@@ -39,6 +39,7 @@
|
||||
"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",
|
||||
"traces:validate": "bun run scripts/extract-traces.ts --format validate",
|
||||
"release:notes": "bun run scripts/release-notes.ts"
|
||||
},
|
||||
"license": "MIT",
|
||||
|
||||
+10
-2
@@ -69,7 +69,8 @@ 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%
|
||||
bun run traces:coverage # Coverage gate — exits non-zero below the ratchet
|
||||
bun run traces:validate # Dangling-ID gate — every traced ID must be defined
|
||||
```
|
||||
|
||||
The script scans all TypeScript, Svelte, and Rust files (plus `scripts/`)
|
||||
@@ -84,6 +85,12 @@ 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).
|
||||
|
||||
**`bun run traces:validate` is the dangling-ID gate.** It fails if any traced ID
|
||||
— including `UT`/`IT`, which coverage deliberately ignores — is not defined as a
|
||||
table row in `requirements.md`, printing each offender with the files that
|
||||
reference it. Without it the extractor accepted any well-formed ID silently, so
|
||||
typos and renames that missed a call site went unreported for months.
|
||||
|
||||
> **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/`
|
||||
@@ -104,7 +111,8 @@ See [docs/traceability.md](../docs/traceability.md) for the latest generated map
|
||||
|
||||
The traceability system is integrated with Gitea Actions CI/CD:
|
||||
- Automatically validates TRACES on every push and pull request
|
||||
- Enforces minimum 50% coverage threshold
|
||||
- Enforces a minimum coverage threshold (a ratchet: raise it, never lower it)
|
||||
- Fails on dangling IDs — traced but undefined in `requirements.md`
|
||||
- Warns if new code lacks TRACES comments
|
||||
- Generates traceability reports automatically
|
||||
|
||||
|
||||
@@ -11,11 +11,13 @@ echo ""
|
||||
|
||||
echo ""
|
||||
|
||||
# Deploy APK — extract build type (default debug), ignoring flags like --clean.
|
||||
BUILD_TYPE="debug"
|
||||
# Deploy APK — forward the build type and the side-by-side flag (which decides
|
||||
# which package to launch), ignoring build-only flags like --clean and --device.
|
||||
DEPLOY_ARGS=("debug")
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
debug|release) BUILD_TYPE="$arg" ;;
|
||||
debug|release) DEPLOY_ARGS[0]="$arg" ;;
|
||||
--debug|--side-by-side) DEPLOY_ARGS+=("--side-by-side") ;;
|
||||
esac
|
||||
done
|
||||
./scripts/deploy-android.sh "$BUILD_TYPE"
|
||||
./scripts/deploy-android.sh "${DEPLOY_ARGS[@]}"
|
||||
|
||||
@@ -23,9 +23,18 @@ echo ""
|
||||
# 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.
|
||||
#
|
||||
# Side-by-side: the `debug` build type always installs as
|
||||
# com.dtourolle.jellytau.debug ("JellyTau Debug"), so it never collides with a
|
||||
# real install. `release --debug` puts a *release* build — R8-minified, exactly
|
||||
# what ships — into that same slot, signed with the local debug keystore. That
|
||||
# is how you validate minification (R8 stripping JNI-loaded classes has broken
|
||||
# release APKs here before) without the real signing key and without
|
||||
# uninstalling the app you actually use.
|
||||
BUILD_TYPE="debug"
|
||||
CLEAN="${CLEAN:-0}"
|
||||
ABI="${ABI:-}"
|
||||
SIDE_BY_SIDE="${SIDE_BY_SIDE:-0}"
|
||||
next_is_abi=0
|
||||
for arg in "$@"; do
|
||||
if [ "$next_is_abi" = "1" ]; then
|
||||
@@ -37,10 +46,17 @@ for arg in "$@"; do
|
||||
--clean) CLEAN=1 ;;
|
||||
--abi) next_is_abi=1 ;;
|
||||
--device) ABI="device" ;;
|
||||
--debug|--side-by-side) SIDE_BY_SIDE=1 ;;
|
||||
debug|release) BUILD_TYPE="$arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# The debug build type is side-by-side unconditionally; the flag only means
|
||||
# something for a release build.
|
||||
if [ "$BUILD_TYPE" = "debug" ]; then
|
||||
SIDE_BY_SIDE=1
|
||||
fi
|
||||
|
||||
# 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')"
|
||||
@@ -78,7 +94,14 @@ echo "🎨 Building frontend..."
|
||||
bun run build
|
||||
|
||||
# Step 2: Build Android APK
|
||||
if [ "$BUILD_TYPE" = "release" ]; then
|
||||
if [ "$BUILD_TYPE" = "release" ] && [ "$SIDE_BY_SIDE" = "1" ]; then
|
||||
# A release build in the debug slot: R8 still runs, but the applicationId is
|
||||
# suffixed and the debug keystore signs it (read by build.gradle.kts from
|
||||
# JT_SIDE_BY_SIDE), so the real key is not needed and it replaces any other
|
||||
# .debug install cleanly. Deliberately does NOT write keystore.properties.
|
||||
echo "📦 Building side-by-side release APK (com.dtourolle.jellytau.debug)..."
|
||||
JT_SIDE_BY_SIDE=1 bun run tauri android build --apk true "${TARGET_ARGS[@]}"
|
||||
elif [ "$BUILD_TYPE" = "release" ]; then
|
||||
# Configure release signing from .env (single source of truth). Must run
|
||||
# after sync-android-sources.sh, since gen/android is (re)generated there.
|
||||
./scripts/write-keystore-properties.sh
|
||||
|
||||
@@ -13,25 +13,60 @@ if ! adb devices | grep -q "device$"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build type: debug or release (default: debug)
|
||||
BUILD_TYPE="${1:-debug}"
|
||||
# Build type: debug or release (default: debug). `--debug` alongside `release`
|
||||
# means the side-by-side release build — same APK path, but it was packaged
|
||||
# under the .debug applicationId, so the package to launch differs.
|
||||
BUILD_TYPE="debug"
|
||||
SIDE_BY_SIDE=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--debug|--side-by-side) SIDE_BY_SIDE=1 ;;
|
||||
debug|release) BUILD_TYPE="$arg" ;;
|
||||
esac
|
||||
done
|
||||
[ "$BUILD_TYPE" = "debug" ] && SIDE_BY_SIDE=1
|
||||
|
||||
# The .debug applicationId (see src-tauri/android/app/build.gradle.kts) is a
|
||||
# separate package, so it installs alongside a real release build — no
|
||||
# uninstall dance needed.
|
||||
if [ "$BUILD_TYPE" = "release" ]; then
|
||||
APK_PATH="src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk"
|
||||
else
|
||||
APK_PATH="src-tauri/gen/android/app/build/outputs/apk/universal/debug/app-universal-debug.apk"
|
||||
fi
|
||||
|
||||
if [ "$SIDE_BY_SIDE" = "1" ]; then
|
||||
APP_PACKAGE="com.dtourolle.jellytau.debug"
|
||||
else
|
||||
APP_PACKAGE="com.dtourolle.jellytau"
|
||||
fi
|
||||
|
||||
# Check if APK exists
|
||||
if [ ! -f "$APK_PATH" ]; then
|
||||
echo "❌ APK not found at: $APK_PATH"
|
||||
echo "Run './scripts/build-android.sh $BUILD_TYPE' first"
|
||||
if [ "$BUILD_TYPE" = "release" ] && [ "$SIDE_BY_SIDE" = "1" ]; then
|
||||
echo "Run './scripts/build-android.sh release --debug' first"
|
||||
else
|
||||
echo "Run './scripts/build-android.sh $BUILD_TYPE' first"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "📦 Installing APK: $APK_PATH"
|
||||
adb install -r "$APK_PATH"
|
||||
echo "📛 Package: $APP_PACKAGE"
|
||||
|
||||
if ! adb install -r "$APK_PATH"; then
|
||||
echo ""
|
||||
echo "❌ Install failed."
|
||||
echo " If it says INSTALL_FAILED_UPDATE_INCOMPATIBLE, an older build of"
|
||||
echo " '$APP_PACKAGE' signed with a different key is still installed."
|
||||
echo " Uninstall just that one and retry:"
|
||||
echo " adb uninstall $APP_PACKAGE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ Deployment complete!"
|
||||
echo "🚀 Launch the app on your device"
|
||||
echo "🚀 Launching..."
|
||||
adb shell monkey -p "$APP_PACKAGE" -c android.intent.category.LAUNCHER 1 > /dev/null 2>&1 \
|
||||
|| echo " (auto-launch failed — start it from the launcher)"
|
||||
|
||||
+110
-12
@@ -14,7 +14,17 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { countDefinedRequirements, computeCoverage } from "./extract-traces";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import {
|
||||
countDefinedRequirements,
|
||||
computeCoverage,
|
||||
findDanglingIds,
|
||||
MIN_COVERAGE_PERCENT,
|
||||
} from "./extract-traces";
|
||||
|
||||
// import.meta.dir is Bun-only; derive from import.meta.url under vitest.
|
||||
const HERE = path.dirname(new URL(import.meta.url).pathname);
|
||||
|
||||
describe("countDefinedRequirements", () => {
|
||||
it("counts a well-formed table row as a defined requirement", () => {
|
||||
@@ -82,6 +92,80 @@ Some prose explaining that UR-005 relates to DR-001 and JA-002.
|
||||
expect(defined.ids.has("DR-050")).toBe(true);
|
||||
expect(defined.ids.has("UR-999")).toBe(false);
|
||||
});
|
||||
|
||||
it("collects UT/IT rows separately, out of the coverage denominator", () => {
|
||||
// §4 defines the test taxonomy. Those rows must be known (so a TRACES
|
||||
// comment may name them) without ever moving the coverage ratio.
|
||||
const md = `
|
||||
| UR-001 | A | High | Done |
|
||||
| UT-001 | Player state transitions | DR-001 | Pending |
|
||||
| IT-004 | Playback end-to-end | DR-002 | Pending |
|
||||
`;
|
||||
const defined = countDefinedRequirements(md);
|
||||
expect(defined.total).toBe(1);
|
||||
expect(defined.ids.has("UT-001")).toBe(false);
|
||||
expect(defined.testIds.has("UT-001")).toBe(true);
|
||||
expect(defined.testIds.has("IT-004")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findDanglingIds", () => {
|
||||
const defined = {
|
||||
UR: 1,
|
||||
IR: 0,
|
||||
DR: 1,
|
||||
JA: 0,
|
||||
total: 2,
|
||||
ids: new Set(["UR-001", "DR-001"]),
|
||||
testIds: new Set(["UT-001"]),
|
||||
};
|
||||
|
||||
it("flags a requirement ID that requirements.md does not define", () => {
|
||||
expect(findDanglingIds(["UR-001", "DR-189"], defined)).toEqual(["DR-189"]);
|
||||
});
|
||||
|
||||
it("flags an undefined UT/IT id, which the coverage orphan list cannot", () => {
|
||||
// The gap this closes: computeCoverage deliberately ignores UT/IT, so
|
||||
// UT-188 sat in three source files, defined nowhere, entirely unreported.
|
||||
expect(computeCoverage(["UT-188"], defined).orphaned).toEqual([]);
|
||||
expect(findDanglingIds(["UT-188"], defined)).toEqual(["UT-188"]);
|
||||
});
|
||||
|
||||
it("accepts every ID that is defined, requirement or test", () => {
|
||||
expect(findDanglingIds(["UR-001", "DR-001", "UT-001"], defined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("deduplicates and sorts, so one typo is reported once", () => {
|
||||
expect(
|
||||
findDanglingIds(["DR-189", "DR-189", "UR-999", "DR-189"], defined)
|
||||
).toEqual(["DR-189", "UR-999"]);
|
||||
});
|
||||
|
||||
it("ignores IDs whose prefix is not a known trace type", () => {
|
||||
// e.g. an unrelated "AB-123" caught by the loose ID regex.
|
||||
expect(findDanglingIds(["AB-123"], defined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("coverage threshold", () => {
|
||||
it("matches MIN_THRESHOLD in the Gitea traceability workflow", () => {
|
||||
// Two files must agree on the gate: the script (local `traces:coverage`)
|
||||
// and the workflow. Drift means the local gate and CI disagree about what
|
||||
// passes, which is how the 50%-while-actually-86% slack went unnoticed.
|
||||
const workflow = fs.readFileSync(
|
||||
path.resolve(HERE, "../.gitea/workflows/traceability-check.yml"),
|
||||
"utf-8"
|
||||
);
|
||||
const match = workflow.match(/^\s*MIN_THRESHOLD=(\d+)\s*$/m);
|
||||
expect(match).not.toBeNull();
|
||||
expect(Number(match![1])).toBe(MIN_COVERAGE_PERCENT);
|
||||
});
|
||||
|
||||
it("is a ratchet: never lower it to make a red build pass", () => {
|
||||
// Sanity bound. If coverage genuinely climbs, raise both numbers together.
|
||||
expect(MIN_COVERAGE_PERCENT).toBeGreaterThanOrEqual(82);
|
||||
expect(MIN_COVERAGE_PERCENT).toBeLessThanOrEqual(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeCoverage", () => {
|
||||
@@ -92,6 +176,7 @@ describe("computeCoverage", () => {
|
||||
JA: 0,
|
||||
total: 4,
|
||||
ids: new Set(["UR-001", "UR-002", "DR-001", "DR-002"]),
|
||||
testIds: new Set<string>(),
|
||||
};
|
||||
|
||||
it("computes coverage as traced ∩ defined over defined", () => {
|
||||
@@ -138,7 +223,15 @@ describe("computeCoverage", () => {
|
||||
});
|
||||
|
||||
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 empty = {
|
||||
UR: 0,
|
||||
IR: 0,
|
||||
DR: 0,
|
||||
JA: 0,
|
||||
total: 0,
|
||||
ids: new Set<string>(),
|
||||
testIds: new Set<string>(),
|
||||
};
|
||||
const cov = computeCoverage([], empty);
|
||||
expect(cov.percent).toBe(0);
|
||||
expect(Number.isNaN(cov.percent)).toBe(false);
|
||||
@@ -163,20 +256,25 @@ describe("live requirements.md", () => {
|
||||
// (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"),
|
||||
path.resolve(HERE, "../docs/requirements.md"),
|
||||
"utf-8"
|
||||
);
|
||||
const defined = countDefinedRequirements(md);
|
||||
|
||||
expect(defined.UR).toBe(61);
|
||||
expect(defined.IR).toBe(29);
|
||||
expect(defined.DR).toBe(96);
|
||||
expect(defined.JA).toBe(32);
|
||||
expect(defined.total).toBe(218);
|
||||
expect(defined.UR).toBe(75);
|
||||
expect(defined.IR).toBe(32);
|
||||
// 192 = 187 + four requirements added independently on four audit branches,
|
||||
// plus DR-201 (lockscreen skip resolution). Originally 191 = 187 + four
|
||||
// that landed together: DR-189 (control-bar auto-hide), DR-198 (asset
|
||||
// scope/CSP), DR-199 (webview mixed-content) and DR-200 (the
|
||||
// POST_NOTIFICATIONS media-session exemption; renumbered from 198 on
|
||||
// merge, where it collided). Each branch bumped for its own — merged,
|
||||
// they sum. Resolve this by summing, never by taking one side. 193 adds
|
||||
// DR-202 (video keeps the display awake), 194 DR-203 (the handoff
|
||||
// transcode refusing the player's own load-error retry).
|
||||
expect(defined.DR).toBe(194);
|
||||
expect(defined.JA).toBe(36);
|
||||
expect(defined.total).toBe(337);
|
||||
});
|
||||
});
|
||||
|
||||
+103
-3
@@ -37,8 +37,27 @@ interface TracesData {
|
||||
/** Requirements *defined* in requirements.md — the coverage denominators. */
|
||||
defined?: { UR: number; IR: number; DR: number; JA: number; total: number };
|
||||
coverage?: CoverageResult;
|
||||
/** Traced IDs of any type that requirements.md does not define. */
|
||||
dangling?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum overall requirement coverage the traceability gate accepts.
|
||||
*
|
||||
* **Ratchet policy: this number only ever goes up.** It is set a few points
|
||||
* below the coverage actually achieved, so a real regression trips it instead of
|
||||
* being absorbed by slack. It sat at 50 while true coverage was 86%, which meant
|
||||
* half the matrix could rot before CI noticed. When coverage rises durably,
|
||||
* raise this to sit just under the new figure. Do **not** lower it to make a
|
||||
* failing build pass — add the missing TRACES comments instead.
|
||||
*
|
||||
* `.gitea/workflows/traceability-check.yml` carries the same number as
|
||||
* `MIN_THRESHOLD`; `scripts/extract-traces.test.ts` fails if the two drift.
|
||||
*
|
||||
* TRACES: | DR-093
|
||||
*/
|
||||
export const MIN_COVERAGE_PERCENT = 82;
|
||||
|
||||
// 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.
|
||||
//
|
||||
@@ -222,7 +241,10 @@ export interface DefinedRequirements {
|
||||
DR: number;
|
||||
JA: number;
|
||||
total: number;
|
||||
/** Requirement IDs (UR/IR/DR/JA) — the coverage denominator. */
|
||||
ids: Set<string>;
|
||||
/** Test IDs (UT/IT) from §4. A separate taxonomy: never part of coverage. */
|
||||
testIds: Set<string>;
|
||||
}
|
||||
|
||||
export interface CoverageResult {
|
||||
@@ -247,11 +269,18 @@ export interface CoverageResult {
|
||||
*/
|
||||
export function countDefinedRequirements(markdown: string): DefinedRequirements {
|
||||
const ids = new Set<string>();
|
||||
const ROW_ID = /^\|\s*(UR|IR|DR|JA)-(\d{3})\s*\|/;
|
||||
const testIds = new Set<string>();
|
||||
const ROW_ID = /^\|\s*(UR|IR|DR|JA|UT|IT)-(\d{3})\s*\|/;
|
||||
|
||||
for (const line of markdown.split("\n")) {
|
||||
const match = line.match(ROW_ID);
|
||||
if (match) ids.add(`${match[1]}-${match[2]}`);
|
||||
if (!match) continue;
|
||||
const id = `${match[1]}-${match[2]}`;
|
||||
// UT/IT rows live in §4 and are collected separately: they must not enter
|
||||
// the coverage denominator, but they still need to exist for a `TRACES:`
|
||||
// comment to be allowed to name them (see findDanglingIds).
|
||||
if (match[1] === "UT" || match[1] === "IT") testIds.add(id);
|
||||
else ids.add(id);
|
||||
}
|
||||
|
||||
const countOf = (type: string) =>
|
||||
@@ -264,9 +293,39 @@ export function countDefinedRequirements(markdown: string): DefinedRequirements
|
||||
JA: countOf("JA"),
|
||||
total: ids.size,
|
||||
ids,
|
||||
testIds,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every traced ID that requirements.md defines nowhere — a typo, a rename that
|
||||
* missed a call site, or a reference to a deleted requirement.
|
||||
*
|
||||
* This is broader than `CoverageResult.orphaned`, which only ever considers the
|
||||
* four requirement types because a UT/IT entry among the orphans would corrupt
|
||||
* the coverage ratio's reporting. Dangling detection has no such constraint, so
|
||||
* it checks all six ID types against both defined sets. Before it existed, the
|
||||
* extractor accepted any well-formed ID silently: `DR-189` and `UT-188` were
|
||||
* referenced from `controlsVisibility.ts` and `VideoPlayer.svelte` for months
|
||||
* without being defined anywhere, and nothing reported it.
|
||||
*
|
||||
* TRACES: | DR-093
|
||||
*/
|
||||
export function findDanglingIds(
|
||||
tracedIds: string[],
|
||||
defined: DefinedRequirements
|
||||
): string[] {
|
||||
const KNOWN_TYPE = /^(UR|IR|DR|JA|UT|IT)-\d{3}$/;
|
||||
|
||||
const dangling = new Set(
|
||||
tracedIds
|
||||
.filter((id) => KNOWN_TYPE.test(id))
|
||||
.filter((id) => !defined.ids.has(id) && !defined.testIds.has(id))
|
||||
);
|
||||
|
||||
return [...dangling].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Coverage is the *intersection* of traced and defined IDs over defined IDs.
|
||||
*
|
||||
@@ -408,6 +467,13 @@ function reportCoverage(data: TracesData, minThreshold: number): number {
|
||||
console.log(" Fix the TRACES comment or add the requirement.");
|
||||
}
|
||||
|
||||
if (data.dangling && data.dangling.length > 0) {
|
||||
console.log("");
|
||||
console.log(
|
||||
`⚠️ Dangling IDs (incl. UT/IT): ${data.dangling.join(", ")} — run \`bun run traces:validate\`.`
|
||||
);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -427,6 +493,37 @@ function reportCoverage(data: TracesData, minThreshold: number): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard gate on dangling IDs: a `TRACES:` comment may only name an ID that
|
||||
* requirements.md actually defines. Prints every offender with the files that
|
||||
* reference it, so the fix is mechanical.
|
||||
*
|
||||
* TRACES: | DR-093
|
||||
*/
|
||||
function reportDangling(data: TracesData): number {
|
||||
const dangling = data.dangling ?? [];
|
||||
|
||||
if (dangling.length === 0) {
|
||||
console.log("✅ All traced IDs are defined in docs/requirements.md");
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log("❌ TRACES reference IDs that docs/requirements.md does not define:");
|
||||
console.log("");
|
||||
for (const id of dangling) {
|
||||
const files = [
|
||||
...new Set((data.requirements[id] ?? []).map((e) => e.file)),
|
||||
].sort();
|
||||
console.log(` ${id}`);
|
||||
for (const file of files) console.log(` ${file}`);
|
||||
}
|
||||
console.log("");
|
||||
console.log("Fix each one by either:");
|
||||
console.log(" • correcting the ID in the TRACES comment (typo/rename), or");
|
||||
console.log(" • adding the requirement as a table row in docs/requirements.md.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Main — guarded so this module stays importable from extract-traces.test.ts.
|
||||
if (import.meta.main) {
|
||||
const args = process.argv.slice(2);
|
||||
@@ -447,11 +544,14 @@ if (import.meta.main) {
|
||||
total: defined.total,
|
||||
};
|
||||
data.coverage = computeCoverage(allTraced, defined);
|
||||
data.dangling = findDanglingIds(allTraced, defined);
|
||||
|
||||
if (format === "json") {
|
||||
console.log(generateJson(data));
|
||||
} else if (format === "coverage") {
|
||||
process.exit(reportCoverage(data, 50));
|
||||
process.exit(reportCoverage(data, MIN_COVERAGE_PERCENT));
|
||||
} else if (format === "validate") {
|
||||
process.exit(reportDangling(data));
|
||||
} else {
|
||||
console.log(generateMarkdown(data));
|
||||
}
|
||||
|
||||
+25
-4
@@ -1,13 +1,34 @@
|
||||
#!/bin/bash
|
||||
# View Android logcat output filtered for the app
|
||||
# View Android logcat output filtered for the app.
|
||||
#
|
||||
# Usage: ./scripts/logcat.sh [debug|release] (default: debug)
|
||||
#
|
||||
# The debug build has applicationIdSuffix ".debug" so it can be installed
|
||||
# alongside a release build; pick the package to follow accordingly.
|
||||
|
||||
set -e
|
||||
|
||||
APP_PACKAGE="com.jellytau.app"
|
||||
BUILD_TYPE="${1:-debug}"
|
||||
|
||||
if [ "$BUILD_TYPE" = "release" ]; then
|
||||
APP_PACKAGE="com.dtourolle.jellytau"
|
||||
else
|
||||
APP_PACKAGE="com.dtourolle.jellytau.debug"
|
||||
fi
|
||||
|
||||
echo "📱 Showing logcat for $APP_PACKAGE"
|
||||
echo "Press Ctrl+C to stop"
|
||||
echo ""
|
||||
|
||||
# Filter logcat for the app's package name
|
||||
adb logcat | grep -i "$APP_PACKAGE\|tauri\|rust"
|
||||
# Prefer PID-scoped output when the app is running — it drops the noise that a
|
||||
# text grep can't. Fall back to the old keyword filter when it isn't (so you can
|
||||
# start the script first and then launch the app).
|
||||
PID="$(adb shell pidof "$APP_PACKAGE" 2>/dev/null | tr -d '\r\n' | awk '{print $1}')"
|
||||
|
||||
if [ -n "$PID" ]; then
|
||||
echo " (attached to pid $PID)"
|
||||
adb logcat --pid="$PID"
|
||||
else
|
||||
echo " (app not running — falling back to keyword filter)"
|
||||
adb logcat | grep -i "$APP_PACKAGE\|jellytau\|tauri\|rust"
|
||||
fi
|
||||
|
||||
Executable
+119
@@ -0,0 +1,119 @@
|
||||
#!/bin/bash
|
||||
# Stamp the release version into every file that carries it.
|
||||
#
|
||||
# The git tag is the single source of truth for a release version. The versions
|
||||
# committed in package.json / tauri.conf.json / Cargo.toml are a placeholder for
|
||||
# dev builds; a tagged build overwrites all of them from the tag so they cannot
|
||||
# disagree with each other or with the tag.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/set-version.sh 0.5.0 # explicit
|
||||
# JELLYTAU_VERSION=0.5.0 ./scripts/set-version.sh
|
||||
# ./scripts/set-version.sh # derive from git describe (dev builds)
|
||||
#
|
||||
# Accepts the version with or without a leading "v".
|
||||
#
|
||||
# Why a script and not four sed lines in CI: the version lived in four files and
|
||||
# CI only ever rewrote one of them (tauri.conf.json), so a tagged release shipped
|
||||
# a matching installer name and mismatched package metadata. Keeping the write in
|
||||
# one place is what makes "the tag is authoritative" actually true.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
VERSION="${1:-${JELLYTAU_VERSION:-}}"
|
||||
|
||||
# CI passes "${GITHUB_REF#refs/tags/}" unconditionally, which on an untagged
|
||||
# build is still a full ref ("refs/heads/master"). Treat anything that is not a
|
||||
# bare version as "no version given" and fall through to git describe, so a
|
||||
# branch build gets a sane dev version instead of failing the job.
|
||||
case "$VERSION" in
|
||||
refs/*) VERSION="" ;;
|
||||
esac
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
# No explicit version: derive from the most recent tag. Dev builds land on
|
||||
# something like 0.5.0 (exact tag) or 0.5.0-3-gabc1234 (ahead of the tag).
|
||||
VERSION="$(git describe --tags --always --match 'v*' 2>/dev/null || echo "0.0.0")"
|
||||
fi
|
||||
|
||||
# Tags are written v0.5.0; the files carry a bare semver.
|
||||
VERSION="${VERSION#v}"
|
||||
|
||||
# Validate before writing anything — a malformed version silently propagated
|
||||
# into four files is far worse than a failed script.
|
||||
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$'; then
|
||||
echo "❌ Not a valid semver: '$VERSION'" >&2
|
||||
echo " Expected MAJOR.MINOR.PATCH with an optional -prerelease/+build suffix." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "📌 Setting version to $VERSION"
|
||||
|
||||
# --- The three committed manifests -----------------------------------------
|
||||
# Anchored to the first "version" key so a dependency's version is never hit.
|
||||
|
||||
# package.json — the top-level "version", which sits in the first few lines.
|
||||
perl -0pi -e 's/("version"\s*:\s*)"[^"]*"/$1"'"$VERSION"'"/' package.json
|
||||
|
||||
# tauri.conf.json — likewise; this is the one the bundler reads for installer
|
||||
# names, and the one CI used to patch alone.
|
||||
perl -0pi -e 's/("version"\s*:\s*)"[^"]*"/$1"'"$VERSION"'"/' src-tauri/tauri.conf.json
|
||||
|
||||
# Cargo.toml — only the [package] version, never a dependency's. Restricted to
|
||||
# the first occurrence of a line-anchored `version = "..."`.
|
||||
perl -0pi -e 's/^(version\s*=\s*)"[^"]*"/$1"'"$VERSION"'"/m' src-tauri/Cargo.toml
|
||||
|
||||
# Cargo.lock — the jellytau entry. Left alone if the lock has not been generated
|
||||
# yet; the next cargo invocation writes it. Cargo would otherwise rewrite the
|
||||
# lock mid-build and dirty the tree.
|
||||
if [ -f src-tauri/Cargo.lock ]; then
|
||||
perl -0pi -e 's/(name = "jellytau"\nversion = )"[^"]*"/$1"'"$VERSION"'"/' src-tauri/Cargo.lock
|
||||
fi
|
||||
|
||||
# --- Android versionCode ----------------------------------------------------
|
||||
# Only when the generated Android project exists (i.e. after `tauri android
|
||||
# init`); on Linux/Windows jobs there is nothing to stamp.
|
||||
#
|
||||
# `tauri android init` derives a versionCode from the semver (0.0.15 -> 15).
|
||||
# That is both tiny and NOT monotonic across our history: earlier local/dev
|
||||
# builds shipped versionCode 1000 (from a 0.1.0 config), so a plain 15 is a
|
||||
# *downgrade* and Android refuses the update.
|
||||
#
|
||||
# The floor has to clear the highest code actually in the field, which is not the
|
||||
# same as the highest this formula has produced. v0.5.2 shipped versionCode
|
||||
# **5002** under an earlier `minor*1000` scheme; the `minor*100` formula that
|
||||
# replaced it yields only 1502 for that same version, and 1503 for 0.5.3 — so
|
||||
# every 0.5.x release built from it was an un-installable downgrade for anyone
|
||||
# already on v0.5.2, which is exactly the failure this block exists to prevent.
|
||||
# The multipliers are widened and the floor raised past 5002 accordingly.
|
||||
#
|
||||
# code = 10000 + major*1000000 + minor*1000 + patch
|
||||
# e.g. 0.0.14 -> 10014, 0.1.0 -> 11000, 0.5.3 -> 15003, 1.0.0 -> 1010000.
|
||||
PROPS="src-tauri/gen/android/app/tauri.properties"
|
||||
if [ -f "$PROPS" ]; then
|
||||
# Strip any -rc1/+build suffix first: it is not numeric, and feeding it to
|
||||
# $(( )) would abort the script under `set -e`.
|
||||
CORE="${VERSION%%-*}"
|
||||
CORE="${CORE%%+*}"
|
||||
MAJ=$(echo "$CORE" | cut -d. -f1)
|
||||
MIN=$(echo "$CORE" | cut -d. -f2)
|
||||
PAT=$(echo "$CORE" | cut -d. -f3)
|
||||
: "${MAJ:=0}" "${MIN:=0}" "${PAT:=0}"
|
||||
CODE=$(( 10000 + MAJ*1000000 + MIN*1000 + PAT ))
|
||||
echo " versionCode=$CODE (from $CORE)"
|
||||
if grep -q '^tauri.android.versionCode=' "$PROPS"; then
|
||||
sed -i "s/^tauri.android.versionCode=.*/tauri.android.versionCode=$CODE/" "$PROPS"
|
||||
else
|
||||
echo "tauri.android.versionCode=$CODE" >> "$PROPS"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Report -----------------------------------------------------------------
|
||||
echo "✅ Version stamped:"
|
||||
grep -m1 '"version"' package.json | sed 's/^/ package.json: /'
|
||||
grep -m1 '"version"' src-tauri/tauri.conf.json | sed 's/^/ tauri.conf.json: /'
|
||||
grep -m1 '^version' src-tauri/Cargo.toml | sed 's/^/ Cargo.toml: /'
|
||||
[ -f "$PROPS" ] && grep '^tauri.android.versionCode=' "$PROPS" | sed 's/^/ tauri.properties: /'
|
||||
exit 0
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Guards for scripts/set-version.sh — the release version stamper.
|
||||
*
|
||||
* TRACES: DR-153 | UT-150
|
||||
*
|
||||
* These run the real script against a throwaway copy of the manifests, because
|
||||
* the failure modes are all in the shell, not in any TS logic: a regex that also
|
||||
* matches a dependency's version, arithmetic that aborts on a `-rc1` suffix, or
|
||||
* a CI ref reaching the validator verbatim.
|
||||
*
|
||||
* The versionCode formula matters most. Android refuses an update whose code is
|
||||
* lower than the installed one, and builds already in the field shipped code
|
||||
* 1000 — so any formula that can emit a smaller number for a *newer* release
|
||||
* bricks updates for those users, silently and irreversibly.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, beforeEach, afterEach } from "vitest";
|
||||
import { execFileSync } from "child_process";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
|
||||
const script = path.join(repoRoot, "scripts", "set-version.sh");
|
||||
|
||||
let tmp: string;
|
||||
|
||||
/** A minimal repo skeleton: just the files the script rewrites. */
|
||||
function seed(dir: string) {
|
||||
fs.mkdirSync(path.join(dir, "src-tauri", "gen", "android", "app"), { recursive: true });
|
||||
fs.mkdirSync(path.join(dir, "scripts"), { recursive: true });
|
||||
fs.copyFileSync(script, path.join(dir, "scripts", "set-version.sh"));
|
||||
fs.chmodSync(path.join(dir, "scripts", "set-version.sh"), 0o755);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "package.json"),
|
||||
JSON.stringify({ name: "jellytau", version: "0.0.1", dependencies: { hls: "1.2.3" } }, null, 2)
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "src-tauri", "tauri.conf.json"),
|
||||
JSON.stringify({ productName: "jellytau", version: "0.0.1" }, null, 2)
|
||||
);
|
||||
// A dependency carrying its own `version =` is the trap: a greedy regex
|
||||
// rewrites it too and the build then resolves the wrong crate.
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "src-tauri", "Cargo.toml"),
|
||||
['[package]', 'name = "jellytau"', 'version = "0.0.1"', '', '[dependencies]', 'serde = { version = "1.0.100" }', ''].join("\n")
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "src-tauri", "Cargo.lock"),
|
||||
['[[package]]', 'name = "serde"', 'version = "1.0.100"', '', '[[package]]', 'name = "jellytau"', 'version = "0.0.1"', ''].join("\n")
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "src-tauri", "gen", "android", "app", "tauri.properties"),
|
||||
"tauri.android.versionCode=1\n"
|
||||
);
|
||||
}
|
||||
|
||||
function run(version: string, dir = tmp) {
|
||||
return execFileSync("bash", [path.join(dir, "scripts", "set-version.sh"), version], {
|
||||
cwd: dir,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
}
|
||||
|
||||
function read(rel: string): string {
|
||||
return fs.readFileSync(path.join(tmp, rel), "utf-8");
|
||||
}
|
||||
|
||||
function versionCode(): number {
|
||||
const m = read("src-tauri/gen/android/app/tauri.properties").match(
|
||||
/^tauri\.android\.versionCode=(\d+)$/m
|
||||
);
|
||||
return m ? Number(m[1]) : NaN;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "setversion-"));
|
||||
seed(tmp);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("set-version.sh", () => {
|
||||
it("stamps the version into all four manifests", () => {
|
||||
run("0.5.0");
|
||||
expect(JSON.parse(read("package.json")).version).toBe("0.5.0");
|
||||
expect(JSON.parse(read("src-tauri/tauri.conf.json")).version).toBe("0.5.0");
|
||||
expect(read("src-tauri/Cargo.toml")).toContain('version = "0.5.0"');
|
||||
expect(read("src-tauri/Cargo.lock")).toMatch(/name = "jellytau"\nversion = "0\.5\.0"/);
|
||||
});
|
||||
|
||||
it("accepts a leading v, as git tags are written", () => {
|
||||
run("v0.5.0");
|
||||
expect(JSON.parse(read("package.json")).version).toBe("0.5.0");
|
||||
});
|
||||
|
||||
// The regression that motivates anchoring the patterns.
|
||||
it("does not rewrite dependency versions", () => {
|
||||
run("0.5.0");
|
||||
expect(read("src-tauri/Cargo.toml")).toContain('serde = { version = "1.0.100" }');
|
||||
expect(read("src-tauri/Cargo.lock")).toMatch(/name = "serde"\nversion = "1\.0\.100"/);
|
||||
expect(JSON.parse(read("package.json")).dependencies.hls).toBe("1.2.3");
|
||||
});
|
||||
|
||||
describe("Android versionCode", () => {
|
||||
// A newer release must never produce a smaller number than an older one, or
|
||||
// Android refuses the update. The floor tracks the highest code actually in
|
||||
// the field, which is NOT the same as the highest this formula has produced:
|
||||
// v0.5.2 shipped versionCode 5002 from an earlier `minor*1000` scheme, while
|
||||
// the `minor*100` formula that replaced it yields only 1502 for that same
|
||||
// version — so every 0.5.x release built from it was an un-installable
|
||||
// downgrade for anyone already on v0.5.2. The floor is raised to clear it.
|
||||
it("clears the highest code shipped by earlier builds", () => {
|
||||
run("0.0.1");
|
||||
// v0.5.2 shipped 5002; anything at or below that cannot install over it.
|
||||
expect(versionCode()).toBeGreaterThan(5002);
|
||||
});
|
||||
|
||||
it("keeps 0.5.3 installable over the 5002 that shipped as v0.5.2", () => {
|
||||
run("0.5.3");
|
||||
expect(versionCode()).toBeGreaterThan(5002);
|
||||
});
|
||||
|
||||
it("uses 10000 + major*1000000 + minor*1000 + patch", () => {
|
||||
const cases: Array<[string, number]> = [
|
||||
["0.0.14", 10014],
|
||||
["0.0.15", 10015],
|
||||
["0.1.0", 11000],
|
||||
["0.4.8", 14008],
|
||||
["0.5.0", 15000],
|
||||
["0.5.3", 15003],
|
||||
["1.0.0", 1010000],
|
||||
];
|
||||
for (const [version, code] of cases) {
|
||||
seed(tmp);
|
||||
run(version);
|
||||
expect(versionCode(), `versionCode for ${version}`).toBe(code);
|
||||
}
|
||||
});
|
||||
|
||||
it("increases monotonically across an upgrade sequence", () => {
|
||||
const ordered = ["0.0.14", "0.0.15", "0.1.0", "0.4.8", "0.5.0", "1.0.0"];
|
||||
const codes = ordered.map((v) => {
|
||||
seed(tmp);
|
||||
run(v);
|
||||
return versionCode();
|
||||
});
|
||||
const sorted = [...codes].sort((a, b) => a - b);
|
||||
expect(codes).toEqual(sorted);
|
||||
expect(new Set(codes).size).toBe(codes.length);
|
||||
});
|
||||
|
||||
// `$(( 0-rc1 ))` aborts the script under `set -e`, so the suffix has to be
|
||||
// stripped before the arithmetic.
|
||||
it("derives the code from the numeric core of a prerelease", () => {
|
||||
run("0.6.0-rc1");
|
||||
expect(versionCode()).toBe(16000);
|
||||
expect(JSON.parse(read("package.json")).version).toBe("0.6.0-rc1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("input validation", () => {
|
||||
it("rejects a malformed version without writing anything", () => {
|
||||
expect(() => run("not-a-version")).toThrow();
|
||||
// The manifests must be untouched, not half-written.
|
||||
expect(JSON.parse(read("package.json")).version).toBe("0.0.1");
|
||||
expect(JSON.parse(read("src-tauri/tauri.conf.json")).version).toBe("0.0.1");
|
||||
});
|
||||
|
||||
// CI passes "${GITHUB_REF#refs/tags/}" unconditionally; on a branch build
|
||||
// that is still a full ref, and must not fail the job.
|
||||
it("falls back to a dev version when handed a non-tag ref", () => {
|
||||
const out = run("refs/heads/master");
|
||||
expect(out).not.toMatch(/refs\/heads/);
|
||||
expect(JSON.parse(read("package.json")).version).not.toBe("0.0.1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,19 @@ rm -rf "$TARGET_DIR/player" "$TARGET_DIR/security"
|
||||
cp -r "$SOURCE_DIR/player" "$TARGET_DIR/"
|
||||
cp -r "$SOURCE_DIR/security" "$TARGET_DIR/"
|
||||
|
||||
# JVM unit tests (src/test). Plain JUnit over the pure decision helpers — no
|
||||
# Android framework classes — run with `./gradlew :app:testDebugUnitTest` from
|
||||
# gen/android. Mirrored here so the canonical tree stays the only place tests
|
||||
# are edited.
|
||||
TEST_SOURCE_DIR="$PROJECT_ROOT/src-tauri/android/src/test/java/com/dtourolle/jellytau"
|
||||
TEST_TARGET_DIR="$PROJECT_ROOT/src-tauri/gen/android/app/src/test/java/com/dtourolle/jellytau"
|
||||
if [ -d "$TEST_SOURCE_DIR" ]; then
|
||||
rm -rf "$TEST_TARGET_DIR"
|
||||
mkdir -p "$TEST_TARGET_DIR"
|
||||
cp -r "$TEST_SOURCE_DIR"/. "$TEST_TARGET_DIR/"
|
||||
echo " Copied unit tests: src/test"
|
||||
fi
|
||||
|
||||
# Copy individual Kotlin files (like VideoOverlayManager.kt)
|
||||
for kt_file in "$SOURCE_DIR"/*.kt; do
|
||||
if [ -f "$kt_file" ]; then
|
||||
@@ -87,6 +100,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 +131,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"
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Guards the shipped webview security configuration.
|
||||
*
|
||||
* `csp` was `null` and the asset protocol was scoped to the whole storage root,
|
||||
* which is the directory holding the SQLite database and the encrypted-token
|
||||
* fallback file. Both are one-character regressions away and neither is visible
|
||||
* in any behavioural test, so they are asserted here instead: the restrictive
|
||||
* half of the policy must stay restrictive, and the permissive half must keep
|
||||
* the schemes playback actually needs.
|
||||
*
|
||||
* TRACES: UR-012, UR-071 | DR-198 | UT-193
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
|
||||
const config = JSON.parse(
|
||||
readFileSync(resolve(__dirname, "../src-tauri/tauri.conf.json"), "utf-8")
|
||||
);
|
||||
|
||||
const security = config.app.security;
|
||||
|
||||
/** Split a CSP string into `directive -> sources`. */
|
||||
function directives(csp: string): Record<string, string[]> {
|
||||
const map: Record<string, string[]> = {};
|
||||
for (const part of csp.split(";")) {
|
||||
const [name, ...sources] = part.trim().split(/\s+/);
|
||||
if (name) map[name] = sources;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
describe("tauri.conf.json CSP", () => {
|
||||
it("is set at all — a null CSP hands any injected script the full IPC surface", () => {
|
||||
expect(typeof security.csp).toBe("string");
|
||||
expect(security.csp.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const csp = directives(security.csp as string);
|
||||
|
||||
it("locks down script execution", () => {
|
||||
// Tauri injects a nonce for SvelteKit's inline bootstrap script at build
|
||||
// time, so 'self' alone is enough and inline/eval must never be re-added.
|
||||
expect(csp["script-src"]).toEqual(["'self'"]);
|
||||
expect(csp["object-src"]).toEqual(["'none'"]);
|
||||
expect(csp["frame-src"]).toEqual(["'none'"]);
|
||||
expect(csp["base-uri"]).toEqual(["'self'"]);
|
||||
expect(csp["default-src"]).toEqual(["'self'"]);
|
||||
});
|
||||
|
||||
it("keeps the schemes playback and thumbnails depend on", () => {
|
||||
// The asset protocol under both names convertFileSrc emits.
|
||||
expect(csp["img-src"]).toContain("asset:");
|
||||
expect(csp["img-src"]).toContain("http://asset.localhost");
|
||||
expect(csp["media-src"]).toContain("asset:");
|
||||
// hls.js: MSE object URLs, and its demuxer worker built from a blob.
|
||||
expect(csp["media-src"]).toContain("blob:");
|
||||
expect(csp["worker-src"]).toContain("blob:");
|
||||
// The token-guarded loopback media server (DR-137).
|
||||
expect(csp["media-src"]).toContain("http://127.0.0.1:*");
|
||||
// Tauri's invoke transport.
|
||||
expect(csp["connect-src"]).toContain("ipc:");
|
||||
expect(csp["connect-src"]).toContain("http://ipc.localhost");
|
||||
// The user's Jellyfin server: an arbitrary run-time origin, http on a LAN.
|
||||
for (const directive of ["img-src", "media-src", "connect-src"]) {
|
||||
expect(csp[directive]).toContain("http:");
|
||||
expect(csp[directive]).toContain("https:");
|
||||
}
|
||||
});
|
||||
|
||||
it("never widens a data directive into script execution", () => {
|
||||
for (const [name, sources] of Object.entries(csp)) {
|
||||
if (name === "script-src" || name === "worker-src") {
|
||||
expect(sources).not.toContain("'unsafe-eval'");
|
||||
expect(sources).not.toContain("'unsafe-inline'");
|
||||
}
|
||||
// A bare `*` would re-admit every scheme, including file:.
|
||||
expect(sources).not.toContain("*");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("tauri.conf.json asset protocol scope", () => {
|
||||
const scope: string[] = security.assetProtocol.scope;
|
||||
|
||||
it("covers only the thumbnail cache, not the storage root", () => {
|
||||
expect(scope).toEqual(["$APPDATA/thumbnails/**"]);
|
||||
// The database and the encrypted-token fallback live directly in $APPDATA.
|
||||
expect(scope).not.toContain("$APPDATA/**");
|
||||
});
|
||||
});
|
||||
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.2.8"
|
||||
version = "0.8.2"
|
||||
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"
|
||||
|
||||
+12
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "jellytau"
|
||||
version = "0.2.8"
|
||||
version = "0.8.2"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
@@ -23,7 +23,16 @@ debug = "line-tables-only"
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
# protocol-asset serves cached thumbnails to the webview (asset://localhost on
|
||||
# Linux/macOS, http://asset.localhost on Windows/Android); without it
|
||||
# convertFileSrc yields a URL nothing answers. Paired with
|
||||
# app.security.assetProtocol in tauri.conf.json, which scopes it to
|
||||
# $APPDATA/thumbnails/** — the one directory still read through this protocol.
|
||||
# Downloaded media went the same way until DR-137 moved it to the loopback media
|
||||
# server, so the database, the encrypted-token fallback file and downloads/ are
|
||||
# all outside the grant now.
|
||||
# TRACES: UR-012, UR-071 | DR-134, DR-137, DR-198
|
||||
tauri = { version = "2", features = ["protocol-asset"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-os = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -54,6 +63,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]
|
||||
|
||||
@@ -41,6 +41,57 @@ When you need to modify Android/Kotlin files:
|
||||
- If you only edit `src-tauri/android/`, your changes won't be in the build
|
||||
- **You must edit both** (or edit source and copy to generated)
|
||||
|
||||
### Debug and release install side by side
|
||||
|
||||
The **debug** build type sets `applicationIdSuffix = ".debug"` in
|
||||
`app/build.gradle.kts`, so a debug build is a genuinely separate Android app:
|
||||
|
||||
| build | applicationId | launcher name | versionName | signed with |
|
||||
|---|---|---|---|---|
|
||||
| `release` | `com.dtourolle.jellytau` | jellytau | `0.5.5` | real key (`.env`) |
|
||||
| `release --debug` | `com.dtourolle.jellytau.debug` | JellyTau Debug | `0.5.5-debug-release` | debug keystore |
|
||||
| `debug` | `com.dtourolle.jellytau.debug` | JellyTau Debug | `0.5.5-debug` | debug keystore |
|
||||
|
||||
`release --debug` is the **side-by-side release**: fully R8-minified, exactly
|
||||
what ships, but packaged into the debug slot and signed with the local debug
|
||||
keystore. It exists because R8 has broken release APKs here before (stripping
|
||||
JNI-loaded player/security classes), and reproducing that previously meant
|
||||
building with the real key and clobbering your working install. It shares the
|
||||
applicationId *and* signature with the plain debug build, so the two replace
|
||||
each other cleanly; only the versionName suffix tells you which is installed.
|
||||
|
||||
```bash
|
||||
./scripts/build-and-deploy.sh release --device --debug # build + install it
|
||||
```
|
||||
|
||||
The flag is plumbed through as `JT_SIDE_BY_SIDE=1`, read by `build.gradle.kts`.
|
||||
CI never sets it, so distributable release builds are untouched.
|
||||
|
||||
That means:
|
||||
|
||||
- **No uninstall step.** Debug builds are signed with the local auto-generated
|
||||
`~/.android/debug.keystore`, release builds with the real key. Two different
|
||||
keys on the *same* package is `INSTALL_FAILED_UPDATE_INCOMPATIBLE`; two
|
||||
different packages is just two apps.
|
||||
- Each has **its own data directory** — separate settings, credentials,
|
||||
downloads and offline cache. A debug experiment cannot corrupt the state of
|
||||
the build you actually use. This is not optional and cannot be shared:
|
||||
Android gives each applicationId its own UID and enforces the boundary in the
|
||||
kernel. (`sharedUserId` is deprecated since API 29 and cannot be added to an
|
||||
already-installed app anyway.) You log in again in the debug app, once.
|
||||
- Only the *application* id changes. Kotlin classes stay in the `namespace`
|
||||
package `com.dtourolle.jellytau`, so the JNI class lookups in
|
||||
`src-tauri/src/player/android/mod.rs`, the manifest `<service>` entry and the
|
||||
R8 keep rules in `proguard-jellytau.pro` are all unaffected. The FileProvider
|
||||
authority is `${applicationId}.fileprovider`, so it follows the suffix
|
||||
automatically.
|
||||
- The launcher labels come from the `appLabel` / `activityLabel`
|
||||
manifestPlaceholders (`AndroidManifest.xml` uses `${appLabel}`), *not* from
|
||||
`resValue`, which would collide with Tauri's generated `strings.xml`.
|
||||
|
||||
Follow the right log stream with `./scripts/logcat.sh [debug|release]`
|
||||
(defaults to debug).
|
||||
|
||||
### Key Files
|
||||
|
||||
Player-related Kotlin files:
|
||||
|
||||
@@ -22,11 +22,25 @@ val keystoreProperties = Properties().apply {
|
||||
}
|
||||
}
|
||||
|
||||
// Side-by-side release: set by `scripts/build-android.sh release --debug`, which
|
||||
// exports JT_SIDE_BY_SIDE=1. It puts a fully R8-minified release build into the
|
||||
// debug applicationId slot, signed with the local debug keystore — so you can
|
||||
// test what minification actually produces (R8 stripping JNI-loaded classes has
|
||||
// broken release APKs here before) without the real signing key and without
|
||||
// uninstalling your working install. Unset in CI, so distributable release
|
||||
// builds are untouched.
|
||||
val sideBySideRelease = System.getenv("JT_SIDE_BY_SIDE").let { it == "1" || it == "true" }
|
||||
|
||||
android {
|
||||
compileSdk = 36
|
||||
namespace = "com.dtourolle.jellytau"
|
||||
defaultConfig {
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||
// Launcher/app names come from placeholders so the debug build can
|
||||
// rename itself without touching the generated strings.xml (a
|
||||
// resValue() override there would collide with Tauri's own entries).
|
||||
manifestPlaceholders["appLabel"] = "@string/app_name"
|
||||
manifestPlaceholders["activityLabel"] = "@string/main_activity_title"
|
||||
applicationId = "com.dtourolle.jellytau"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
@@ -45,6 +59,21 @@ android {
|
||||
}
|
||||
buildTypes {
|
||||
getByName("debug") {
|
||||
// Distinct applicationId so the debug build installs SIDE BY SIDE
|
||||
// with a release/store install instead of demanding an uninstall
|
||||
// (different signing keys on the same package = INSTALL_FAILED_
|
||||
// UPDATE_INCOMPATIBLE). It gets its own data dir, its own settings
|
||||
// and its own offline cache — the two are fully independent apps.
|
||||
//
|
||||
// This changes only the *application* id. The Kotlin/JNI classes
|
||||
// stay in the `namespace` package (com.dtourolle.jellytau), so the
|
||||
// fully-qualified class names Rust looks up over JNI, the manifest
|
||||
// <service> entry and the R8 keep rules are all unaffected. The
|
||||
// FileProvider authority is already ${applicationId}-relative.
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-debug"
|
||||
manifestPlaceholders["appLabel"] = "JellyTau Debug"
|
||||
manifestPlaceholders["activityLabel"] = "JellyTau Debug"
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
||||
isDebuggable = true
|
||||
isJniDebuggable = true
|
||||
@@ -56,7 +85,18 @@ android {
|
||||
}
|
||||
}
|
||||
getByName("release") {
|
||||
if (keystoreProperties.getProperty("storeFile") != null) {
|
||||
if (sideBySideRelease) {
|
||||
// Same slot, name and version scheme as the debug build type,
|
||||
// plus "-release" so you can tell from Settings > Apps which of
|
||||
// the two is currently sitting there. Signed with the debug
|
||||
// keystore: it shares a signature with the debug build, so the
|
||||
// two replace each other cleanly instead of colliding.
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-debug-release"
|
||||
manifestPlaceholders["appLabel"] = "JellyTau Debug"
|
||||
manifestPlaceholders["activityLabel"] = "JellyTau Debug"
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
} else if (keystoreProperties.getProperty("storeFile") != null) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
isMinifyEnabled = true
|
||||
@@ -67,8 +107,17 @@ android {
|
||||
)
|
||||
}
|
||||
}
|
||||
// Java 17 bytecode. AGP 8.11 already requires a JDK 17 toolchain to run
|
||||
// (the builder image ships openjdk-17), so "1.8" was only capping the
|
||||
// bytecode we emit, not the JDK in use. Kotlin's jvmTarget and javac's
|
||||
// source/targetCompatibility must agree or AGP 8 fails the build, so all
|
||||
// three move together.
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
jvmTarget = "17"
|
||||
}
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
(An earlier version of this file was a partial <application> fragment on the
|
||||
assumption that Tauri merged it. It did not: the hardwareAccelerated flag it
|
||||
declared never reached any built APK. It is folded in properly below.)
|
||||
|
||||
${appLabel} / ${activityLabel} are manifestPlaceholders set in
|
||||
app/build.gradle.kts: they resolve to @string/app_name and
|
||||
@string/main_activity_title for release, and to "JellyTau Debug" for the
|
||||
debug build type (which also carries applicationIdSuffix ".debug" so it
|
||||
installs alongside a release build).
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
@@ -19,21 +25,85 @@
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<!--
|
||||
Declared, and deliberately NEVER requested at runtime. That is not an
|
||||
oversight, and an audit has flagged it once already — please read before
|
||||
"fixing" it in either direction.
|
||||
|
||||
Nothing the app posts today needs it. The only notification it produces is
|
||||
the playback service's, which is a MediaStyle notification carrying a valid
|
||||
MediaSession token, and "Notifications related to media sessions are exempt
|
||||
from this behavior change". Verified on device (HONOR ROD2-W09, Android 16
|
||||
/ SDK 36): appops `POST_NOTIFICATION: ignore`, granted=false, and the
|
||||
transport notification simultaneously live with all three actions and
|
||||
working lockscreen controls. So there is no permission dialog, because a
|
||||
prompt the app does not need is a prompt that can be permanently denied for
|
||||
nothing. Media3 does not require the declaration either — media3-session's
|
||||
own manifest declares no permissions, and the MediaSessionService guide
|
||||
asks only for the two FOREGROUND_SERVICE permissions above.
|
||||
|
||||
It stays declared because the exemption is narrow: it is a property of the
|
||||
NOTIFICATION (MediaStyle *and* a non-null session token), not of the
|
||||
foreground service, and it covers media and self-managed-call notifications
|
||||
only. A download-completion notice (UR-011) would be an ordinary
|
||||
notification and would be silently dropped. Adding one means requesting
|
||||
this permission at runtime — AndroidX ActivityResultContracts.
|
||||
RequestPermission from MainActivity, at the point the feature is used — and
|
||||
handling refusal; keeping the declaration is what makes that a one-file
|
||||
change. See JellyTauPlaybackService.warnIfNotificationWillBeDropped.
|
||||
|
||||
TRACES: UR-006 | DR-198
|
||||
-->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- AndroidTV support -->
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
<!--
|
||||
Android TV is deliberately NOT declared here.
|
||||
|
||||
A LEANBACK_LAUNCHER category and an android.software.leanback uses-feature
|
||||
used to sit in this manifest, but nothing behind them: no D-pad focus
|
||||
model, no TV-sized layouts, and neither of the two declarations Play's TV
|
||||
validation also requires (android.hardware.touchscreen required="false"
|
||||
and an android:banner). That combination is the worst of both - it offers
|
||||
the app to TV launchers while failing TV review and shipping a UI that
|
||||
cannot be driven without a touchscreen.
|
||||
|
||||
Re-declare all four together (leanback feature, LEANBACK_LAUNCHER,
|
||||
touchscreen required="false", banner) once a focus pass has actually been
|
||||
done, not before.
|
||||
-->
|
||||
|
||||
<!--
|
||||
android:allowBackup / android:dataExtractionRules below:
|
||||
no cloud backup, no device-to-device transfer (UR-012).
|
||||
|
||||
Credentials are encrypted under an Android Keystore key, and Keystore keys
|
||||
are NEVER backed up. A restored install would therefore get the
|
||||
jellytau_secure_prefs ciphertext with no key to open it - the app would
|
||||
look signed in and silently fail every request, which is worse than a
|
||||
login screen. Everything else in the data dir (the SQLite catalogue:
|
||||
library metadata, watch history, download bookkeeping) is a rebuildable
|
||||
mirror of the Jellyfin server, so backing it up buys nothing and exports
|
||||
the user's library and viewing history to their Google account.
|
||||
|
||||
allowBackup covers API 24-30 completely, and kills *cloud* backup on API
|
||||
31+. It does NOT stop device-to-device transfer there, so
|
||||
@xml/data_extraction_rules (API 31+) excludes both channels explicitly. No
|
||||
android:fullBackupContent is needed: over the API 23-30 range where it
|
||||
would govern, allowBackup="false" has already turned backup off entirely.
|
||||
-->
|
||||
<application
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:label="${appLabel}"
|
||||
android:theme="@style/Theme.jellytau"
|
||||
android:hardwareAccelerated="true"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}"
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules">
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|density"
|
||||
android:launchMode="singleTask"
|
||||
android:label="@string/main_activity_title"
|
||||
android:label="${activityLabel}"
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:supportsPictureInPicture="true"
|
||||
@@ -41,8 +111,7 @@
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<!-- AndroidTV support -->
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
<!-- No LEANBACK_LAUNCHER: see the Android TV note above. -->
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.dtourolle.jellytau
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
|
||||
/**
|
||||
* Hides and restores the Android system bars for full-screen video.
|
||||
*
|
||||
* TRACES: UR-066 | DR-157
|
||||
*
|
||||
* ## Why the web layer cannot do this
|
||||
*
|
||||
* `document.documentElement.requestFullscreen()` is the only fullscreen control
|
||||
* the frontend has, and inside an Android WebView it does nothing to the
|
||||
* *Activity*: it expands the fullscreen element within the web viewport and
|
||||
* leaves the window exactly as it was. Combined with `enableEdgeToEdge()` — which
|
||||
* MainActivity must call, and which SDK 36 makes non-optional — the WebView
|
||||
* already spans the whole window, so "fullscreen" was a no-op that changed
|
||||
* nothing on screen while the status bar and navigation/gesture bar stayed
|
||||
* painted over the video.
|
||||
*
|
||||
* Hiding them requires `WindowInsetsControllerCompat` on the Activity's window,
|
||||
* which is reachable only from native code. Hence this bridge.
|
||||
*
|
||||
* ## Behaviour
|
||||
*
|
||||
* [enter] hides both bars and selects `BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE`, so
|
||||
* a swipe from either edge brings them back *transiently* — over the video,
|
||||
* auto-hiding again — rather than permanently resizing the window mid-playback.
|
||||
* That is the standard behaviour for immersive video and keeps the system's own
|
||||
* back/home gestures reachable.
|
||||
*
|
||||
* [exit] restores them. It must be called when leaving fullscreen **and** when
|
||||
* the player is torn down, or the bars stay hidden on the library screens behind
|
||||
* it.
|
||||
*
|
||||
* Both must run on the main thread; the callers in MainActivity post them there,
|
||||
* since `@JavascriptInterface` methods arrive on a WebView binder thread.
|
||||
*
|
||||
* Note the `--jt-inset-*` custom properties follow automatically: hiding the bars
|
||||
* fires the decor view's inset listener with zeroes, so [WindowInsetsBridge]
|
||||
* republishes them and the player's control layer stops reserving space it no
|
||||
* longer needs.
|
||||
*/
|
||||
object ImmersiveModeBridge {
|
||||
|
||||
private fun controller(activity: Activity): WindowInsetsControllerCompat =
|
||||
WindowCompat.getInsetsController(activity.window, activity.window.decorView)
|
||||
|
||||
/** Hide the status and navigation bars, swipe-to-reveal transiently. */
|
||||
fun enter(activity: Activity) {
|
||||
controller(activity).apply {
|
||||
systemBarsBehavior =
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
hide(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
android.util.Log.d("ImmersiveMode", "system bars hidden")
|
||||
}
|
||||
|
||||
/** Restore the system bars. Safe to call when they are already showing. */
|
||||
fun exit(activity: Activity) {
|
||||
controller(activity).show(WindowInsetsCompat.Type.systemBars())
|
||||
android.util.Log.d("ImmersiveMode", "system bars restored")
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import android.webkit.WebView
|
||||
import android.view.View
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
|
||||
|
||||
class MainActivity : TauriActivity() {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var configAttempts = 0
|
||||
@@ -52,10 +53,78 @@ class MainActivity : TauriActivity() {
|
||||
*/
|
||||
private var bridgesInstalledOn: WebView? = null
|
||||
|
||||
/**
|
||||
* wry hands us the WebView here, and this is the only point at which the
|
||||
* bridges can be installed *deterministically*.
|
||||
*
|
||||
* WebView binds an injected object into JS at **page-load time**: an
|
||||
* addJavascriptInterface call that lands after the page has loaded does not
|
||||
* appear to that page at all. The bridges used to be installed from
|
||||
* [configureWebViewForMedia], which finds the WebView by walking the view
|
||||
* tree 500 ms after onCreate — a race against Tauri's own page load, and one
|
||||
* that is *permanent* when lost, because the identity guard then declines to
|
||||
* re-inject on the resume passes. The whole set (`AndroidVideoSurface`,
|
||||
* `AndroidPictureInPicture`, `AndroidBackgroundAudio`, `AndroidNetworkType`,
|
||||
* `AndroidImmersive`, `AndroidInsets`) simply would not exist in `window`,
|
||||
* silently: every one of them is called through an optional chain, so a
|
||||
* missing bridge is a no-op rather than an error. That is a candidate
|
||||
* explanation for DR-172's central piece of evidence — native video shipped
|
||||
* with `WebView transparent = false` logged and `= true` never appearing,
|
||||
* i.e. the enable call never reaching Kotlin.
|
||||
*
|
||||
* `WryActivity.setWebView()` calls this immediately before wry issues the
|
||||
* first `loadUrl`, so a bridge installed here is bound by the time any page
|
||||
* runs. Note this can fire during `super.onCreate()`, i.e. *before* the rest
|
||||
* of our own onCreate — so only work that needs nothing but the WebView
|
||||
* belongs here. Insets are deliberately left to
|
||||
* [configureWebViewForMedia], which runs later and on every resume.
|
||||
*
|
||||
* TRACES: UR-003, UR-004 | DR-183
|
||||
*/
|
||||
override fun onWebViewCreate(webView: WebView) {
|
||||
super.onWebViewCreate(webView)
|
||||
android.util.Log.d("MainActivity", "onWebViewCreate - installing bridges before first page load")
|
||||
mediaWebView = webView
|
||||
// A new WebView means a new page, which reports no video yet. Anything the
|
||||
// previous one left held would otherwise pin the screen on for the life of
|
||||
// the process, since a page that goes away never sends its final
|
||||
// setHtml5VideoState(false, …). (DR-202)
|
||||
ScreenWakeManager.releaseAll()
|
||||
installJavascriptBridges(webView)
|
||||
configureWebViewSettings(webView)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// The window whose FLAG_KEEP_SCREEN_ON is toggled while video plays. Set on
|
||||
// every onCreate so a recreated Activity (rotation) re-applies the current
|
||||
// hold to its new window. (UR-003, DR-202)
|
||||
ScreenWakeManager.setActivity(this)
|
||||
|
||||
// Configure WebView for media playback after Tauri initialization
|
||||
handler.postDelayed({
|
||||
configureWebViewForMedia()
|
||||
@@ -129,6 +198,7 @@ class MainActivity : TauriActivity() {
|
||||
|
||||
override fun onDestroy() {
|
||||
NetworkTypeMonitor.stopWatching(this)
|
||||
ScreenWakeManager.clearActivity(this)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@@ -143,7 +213,9 @@ class MainActivity : TauriActivity() {
|
||||
|
||||
private fun configureWebViewForMedia() {
|
||||
try {
|
||||
val webView = findWebView(window.decorView)
|
||||
// onWebViewCreate normally got here first; the tree walk is the fallback
|
||||
// for a WebView we were never handed.
|
||||
val webView = mediaWebView ?: findWebView(window.decorView)
|
||||
|
||||
if (webView == null) {
|
||||
android.util.Log.w("MainActivity", "WebView not found (attempt ${configAttempts + 1}/$maxConfigAttempts)")
|
||||
@@ -161,27 +233,47 @@ class MainActivity : TauriActivity() {
|
||||
android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
|
||||
mediaWebView = webView
|
||||
|
||||
// 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.
|
||||
// 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)
|
||||
|
||||
// Normally already done by onWebViewCreate; this is the fallback path.
|
||||
installJavascriptBridges(webView)
|
||||
configureWebViewSettings(webView)
|
||||
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("MainActivity", "Failed to configure WebView for media", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the @JavascriptInterface bridges EXACTLY ONCE per WebView.
|
||||
*
|
||||
* This runs from [onWebViewCreate] — the only point early enough to be bound
|
||||
* before the first page load — and from [configureWebViewForMedia] as a
|
||||
* fallback. The latter runs from onCreate's delayed post AND from every
|
||||
* onResume (plus each WebView re-find), so without the identity guard this
|
||||
* re-injected every bridge 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.
|
||||
*
|
||||
* Settings/WebChromeClient work is idempotent and must keep running on
|
||||
* resume, so it lives in [configureWebViewSettings], not here.
|
||||
*
|
||||
* TRACES: UR-003, UR-004, UR-040, UR-041 | DR-183
|
||||
*/
|
||||
private fun installJavascriptBridges(webView: WebView) {
|
||||
try {
|
||||
if (webView === bridgesInstalledOn) {
|
||||
android.util.Log.d("MainActivity", "JS bridges already installed on this WebView - skipping re-injection")
|
||||
configureWebViewSettings(webView)
|
||||
return
|
||||
}
|
||||
bridgesInstalledOn = webView
|
||||
@@ -218,6 +310,23 @@ class MainActivity : TauriActivity() {
|
||||
fun setAutoEnterEnabled(enabled: Boolean) {
|
||||
autoEnterPipEnabled = enabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Report the WebView `<video>` state.
|
||||
*
|
||||
* Without this PiP only ever knew about the native ExoPlayer surface,
|
||||
* which is behind an experimental flag that defaults to off — so in the
|
||||
* shipping configuration nothing ever satisfied canEnterPip and the
|
||||
* button did nothing. (DR-160)
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun setHtml5VideoState(active: Boolean, width: Int, height: Int, playing: Boolean) {
|
||||
PictureInPictureManager.setHtml5VideoState(active, width, height, playing)
|
||||
// The same report is what keeps the display awake on the webview
|
||||
// rendering path — the WebView takes no display wake lock of its own
|
||||
// for `<video>`. (DR-202)
|
||||
ScreenWakeManager.onHtml5VideoState(active, playing)
|
||||
}
|
||||
}, "AndroidPictureInPicture")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidPictureInPicture' added")
|
||||
|
||||
@@ -257,16 +366,110 @@ 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 {
|
||||
mediaWebView?.setBackgroundColor(
|
||||
if (transparent) {
|
||||
android.graphics.Color.TRANSPARENT
|
||||
} else {
|
||||
android.graphics.Color.BLACK
|
||||
}
|
||||
)
|
||||
// The WINDOW background stays OPAQUE — including while compositing.
|
||||
// It is the only thing that paints the pixels the video does not
|
||||
// cover, and clearing it was the whole defect.
|
||||
//
|
||||
// This window's surface is opaque: the theme is not translucent and
|
||||
// `dumpsys window` shows no translucency flag on it. For an opaque
|
||||
// surface HWUI deliberately does NOT clear the damaged region before
|
||||
// replaying a frame — it assumes the view hierarchy paints every
|
||||
// pixel it owns. That hierarchy is: window background, then the video
|
||||
// TextureView, then this transparent WebView. `fitSurfaceToScreen`
|
||||
// sizes the TextureView to the *letterboxed* video rect, so the bars
|
||||
// around the video are painted by the window background and nothing
|
||||
// else.
|
||||
//
|
||||
// Setting that background TRANSPARENT therefore left the bars painted
|
||||
// by nobody, and stale framebuffer content simply survived in them:
|
||||
// a whole ghost copy of the control bar stranded in the top bar, and
|
||||
// each new clock digit composited over the one before it ("35:42"
|
||||
// with the 1 still showing through the 2). The rotation flash is the
|
||||
// same bug at full-screen scale — the pre-rotation image persisting
|
||||
// in what became the new bars — which is why neither
|
||||
// ROTATION_ANIMATION_JUMPCUT nor revealing on frame arrival ever
|
||||
// touched it. Both were aimed at the window animation; the pixels
|
||||
// were never the animation's.
|
||||
//
|
||||
// The WebView's own background, set above, is what lets the video
|
||||
// through. An opaque window background cannot hide it: the
|
||||
// TextureView is drawn on top of it, not under it.
|
||||
//
|
||||
// TRACES: UR-003, UR-066 | DR-194
|
||||
window.setBackgroundDrawable(
|
||||
android.graphics.drawable.ColorDrawable(android.graphics.Color.BLACK)
|
||||
)
|
||||
android.util.Log.d("MainActivity", "WebView transparent = $transparent")
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether native-video compositing is available on this platform. */
|
||||
@JavascriptInterface
|
||||
fun isSupported(): Boolean = true
|
||||
}, "AndroidVideoSurface")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidVideoSurface' added")
|
||||
|
||||
// Full-screen video: hide the system bars (UR-066). requestFullscreen()
|
||||
// inside a WebView cannot touch the Activity window, so without this the
|
||||
// status and navigation bars stayed painted over full-screen video.
|
||||
webView.addJavascriptInterface(object : Any() {
|
||||
/** Hide the system bars for full-screen playback. */
|
||||
@JavascriptInterface
|
||||
fun enter() {
|
||||
handler.post { ImmersiveModeBridge.enter(this@MainActivity) }
|
||||
}
|
||||
|
||||
/** Restore the system bars on leaving fullscreen or the player. */
|
||||
@JavascriptInterface
|
||||
fun exit() {
|
||||
handler.post { ImmersiveModeBridge.exit(this@MainActivity) }
|
||||
}
|
||||
|
||||
/** Whether native immersive mode exists (false on non-Android). */
|
||||
@JavascriptInterface
|
||||
fun isSupported(): Boolean = true
|
||||
}, "AndroidImmersive")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidImmersive' added")
|
||||
|
||||
// Window insets (safe areas). The push path above races the page load, so
|
||||
// the frontend pulls the current values on mount through this bridge.
|
||||
webView.addJavascriptInterface(WindowInsetsBridge.jsInterface(), "AndroidInsets")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidInsets' added")
|
||||
|
||||
// Push network changes into the WebView so a queue blocked on "waiting for
|
||||
// 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)
|
||||
android.util.Log.e("MainActivity", "Failed to install JavaScript bridges", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,9 +517,52 @@ class MainActivity : TauriActivity() {
|
||||
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
allowFileAccess = true
|
||||
allowContentAccess = true
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||
|
||||
// The three settings below used to read
|
||||
// allowFileAccess = true
|
||||
// allowContentAccess = true
|
||||
// mixedContentMode = MIXED_CONTENT_ALWAYS_ALLOW
|
||||
// which handed the webview a blanket cleartext opt-in and undid
|
||||
// res/xml/network_security_config.xml, whose whole point is that only
|
||||
// 127.0.0.1 is exempt from the cleartext ban and that this "must not
|
||||
// become a blanket cleartext opt-in" (DR-138). Nothing needed any of it:
|
||||
//
|
||||
// - `file://` is never loaded. Cached thumbnails go through
|
||||
// `convertFileSrc` (imageCache.ts), which on Android resolves to
|
||||
// `http://asset.localhost/...` — a Tauri custom protocol answered by
|
||||
// wry's request interceptor, not the filesystem. Downloaded media goes
|
||||
// through `media_local_url` → the loopback HTTP server on 127.0.0.1
|
||||
// (media_server.rs, DR-137), which exists precisely *because* the
|
||||
// asset/file route cannot stream a large file.
|
||||
// - `content://` is never loaded either. The manifest's FileProvider is
|
||||
// for outbound share intents, not for webview navigation.
|
||||
// - Mixed content never arises. Tauri serves the UI from
|
||||
// `http://tauri.localhost` (`use_https_scheme` is false by default and
|
||||
// is not set in tauri.conf.json), and both the loopback media server
|
||||
// and `asset.localhost` are loopback/`.localhost` origins, which
|
||||
// Chromium treats as potentially trustworthy — so they are not mixed
|
||||
// content in the first place. A plain-HTTP *remote* Jellyfin server
|
||||
// would be, but the network security config already rejects it before
|
||||
// the mixed-content check is ever reached, so ALWAYS_ALLOW bought
|
||||
// nothing and only widened the hole.
|
||||
//
|
||||
// COMPATIBILITY_MODE rather than NEVER_ALLOW is a deliberate hedge, not
|
||||
// the default: the platform default at targetSdk 21+ is NEVER_ALLOW, so
|
||||
// this is still one step looser than "stop overriding". It keeps passive
|
||||
// content (images) working if some path the analysis above missed turns
|
||||
// out to need it, which matters because this change cannot be verified
|
||||
// anywhere but a device. Tighten to NEVER_ALLOW once offline video and
|
||||
// cached artwork are confirmed on real hardware.
|
||||
//
|
||||
// `allowFileAccess = false` is the targetSdk-30+ platform default being
|
||||
// restored; `allowContentAccess = false` is a genuine tightening (its
|
||||
// default is true) and is the one to look at first if anything that used
|
||||
// to render stops.
|
||||
//
|
||||
// TRACES: UR-071 | DR-199
|
||||
allowFileAccess = false
|
||||
allowContentAccess = false
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
||||
|
||||
android.util.Log.d("MainActivity", "WebView fully configured for media playback")
|
||||
}
|
||||
|
||||
+141
-33
@@ -46,6 +46,62 @@ object PictureInPictureManager {
|
||||
private var receiver: BroadcastReceiver? = null
|
||||
private var hiddenWebView: WebView? = null
|
||||
|
||||
/**
|
||||
* State of an HTML5 `<video>` playing inside the WebView, reported by the
|
||||
* frontend.
|
||||
*
|
||||
* PiP was written for the native ExoPlayer surface only — [canEnterPip]
|
||||
* required a SurfaceView to be attached and rendering. But native video is
|
||||
* behind `experimentalNativeVideo`, which defaults to **off**, so in the
|
||||
* shipping configuration video plays in the WebView's `<video>` element and
|
||||
* every one of those conditions is false. `enterPip` therefore always bailed
|
||||
* with "no local video playing": PiP could not work at all, however the
|
||||
* button was pressed.
|
||||
*
|
||||
* On this path the WebView *is* the video, which inverts two things: the
|
||||
* WebView must stay visible in PiP rather than be hidden, and play/pause has
|
||||
* to reach the element rather than ExoPlayer. Both are handled below.
|
||||
*
|
||||
* TRACES: UR-041 | DR-160
|
||||
*/
|
||||
@Volatile
|
||||
private var html5VideoActive = false
|
||||
|
||||
@Volatile
|
||||
private var html5VideoPlaying = false
|
||||
|
||||
@Volatile
|
||||
private var html5AspectRatio: Rational? = null
|
||||
|
||||
/**
|
||||
* Report the WebView `<video>` state from the frontend.
|
||||
*
|
||||
* @param active whether a video element is currently the playback surface
|
||||
* @param width intrinsic video width, for the PiP window's aspect ratio
|
||||
* @param height intrinsic video height
|
||||
* @param playing whether it is playing right now, for the PiP play/pause action
|
||||
*/
|
||||
fun setHtml5VideoState(active: Boolean, width: Int, height: Int, playing: Boolean) {
|
||||
html5VideoActive = active
|
||||
html5VideoPlaying = playing
|
||||
html5AspectRatio = if (active && width > 0 && height > 0) {
|
||||
clampedRatio(width.toDouble() / height.toDouble())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** True when PiP would be showing the native surface rather than the WebView. */
|
||||
private fun isNativeVideoPath(): Boolean = try {
|
||||
val player = JellyTauPlayer.getInstance()
|
||||
player.isPlayingVideo() &&
|
||||
player.getSurfaceView() != null &&
|
||||
VideoOverlayManager.isVideoSurfaceAttached()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(TAG, "native video path check failed", e)
|
||||
false
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this device/OS can do PiP at all. Android 8.0 introduced the API,
|
||||
* and the user (or device manufacturer) can disable the feature per-app.
|
||||
@@ -64,15 +120,10 @@ object PictureInPictureManager {
|
||||
*/
|
||||
fun canEnterPip(activity: Activity): Boolean {
|
||||
if (!isPipSupported(activity)) return false
|
||||
return try {
|
||||
val player = JellyTauPlayer.getInstance()
|
||||
player.isPlayingVideo() &&
|
||||
player.getSurfaceView() != null &&
|
||||
VideoOverlayManager.isVideoSurfaceAttached()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(TAG, "canEnterPip check failed", e)
|
||||
false
|
||||
}
|
||||
// Either surface will do: the native one, or the WebView's `<video>`,
|
||||
// which is what actually plays while experimentalNativeVideo is off.
|
||||
// (DR-160)
|
||||
return isNativeVideoPath() || html5VideoActive
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,32 +176,47 @@ object PictureInPictureManager {
|
||||
val player = try {
|
||||
JellyTauPlayer.getInstance()
|
||||
} catch (e: Exception) {
|
||||
return null
|
||||
null
|
||||
}
|
||||
|
||||
val surface = player.getSurfaceView() ?: return null
|
||||
// The surface has already been letterboxed to the video's aspect ratio
|
||||
// by fitSurfaceToScreen(), so its measured bounds are the video shape.
|
||||
val width = surface.width
|
||||
val height = surface.height
|
||||
if (width <= 0 || height <= 0) return null
|
||||
val surface = player?.getSurfaceView()
|
||||
if (surface != null && surface.width > 0 && surface.height > 0) {
|
||||
return clampedRatio(surface.width.toDouble() / surface.height.toDouble())
|
||||
}
|
||||
|
||||
val ratio = width.toDouble() / height.toDouble()
|
||||
val minRatio = 1.0 / 2.39
|
||||
val maxRatio = 2.39
|
||||
val clamped = ratio.coerceIn(minRatio, maxRatio)
|
||||
// No native surface: the WebView is the video, so use the intrinsic size
|
||||
// the frontend reported. (DR-160)
|
||||
return html5AspectRatio
|
||||
}
|
||||
|
||||
// Scale to integers; Rational(width, height) directly can overflow for
|
||||
// large surfaces, and the clamped value may not match the raw pixels.
|
||||
/**
|
||||
* Clamp a ratio to the range Android accepts and express it as a [Rational].
|
||||
*
|
||||
* The platform rejects ratios outside roughly 1:2.39 - 2.39:1 with an
|
||||
* IllegalArgumentException, which would otherwise take down the Activity on
|
||||
* unusually tall or wide content. Scaled to integers because
|
||||
* `Rational(width, height)` can overflow for large surfaces, and the clamped
|
||||
* value may not match the raw pixels anyway.
|
||||
*/
|
||||
private fun clampedRatio(ratio: Double): Rational {
|
||||
val clamped = ratio.coerceIn(1.0 / 2.39, 2.39)
|
||||
return Rational((clamped * 1000).toInt(), 1000)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun buildPlayPauseAction(activity: Activity): RemoteAction {
|
||||
val isPlaying = try {
|
||||
JellyTauPlayer.getInstance().getExoPlayer().isPlaying
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
// On the HTML5 path ExoPlayer is idle, so its `isPlaying` is always false
|
||||
// and the button would be stuck showing "Play" mid-playback. (DR-160)
|
||||
val isPlaying = if (isNativeVideoPath()) {
|
||||
try {
|
||||
JellyTauPlayer.getInstance().getExoPlayer().isPlaying
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
html5VideoPlaying
|
||||
}
|
||||
|
||||
val (iconRes, title, controlType, requestCode) = if (isPlaying) {
|
||||
@@ -222,11 +288,20 @@ object PictureInPictureManager {
|
||||
*/
|
||||
fun onPipModeChanged(activity: Activity, isInPipMode: Boolean) {
|
||||
if (isInPipMode) {
|
||||
hideWebView(activity)
|
||||
// Hiding the WebView is correct only when the video is *behind* it on
|
||||
// the native surface. On the HTML5 path the WebView is the video, so
|
||||
// hiding it would leave an empty black PiP window — the frontend
|
||||
// instead strips its own chrome when it hears the event below.
|
||||
// (DR-160)
|
||||
if (isNativeVideoPath()) {
|
||||
hideWebView(activity)
|
||||
}
|
||||
registerReceiver(activity)
|
||||
dispatchWebEvent(activity, "jellytau-pip-entered")
|
||||
} else {
|
||||
unregisterReceiver(activity)
|
||||
showWebView()
|
||||
dispatchWebEvent(activity, "jellytau-pip-exited")
|
||||
// The surface was laid out against the tiny PiP bounds; re-fit it to
|
||||
// the restored full-screen bounds or the video stays postage-stamp sized.
|
||||
try {
|
||||
@@ -237,6 +312,23 @@ object PictureInPictureManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a DOM event into the WebView.
|
||||
*
|
||||
* The HTML5 PiP path is a conversation with the frontend rather than
|
||||
* something native can do alone: it has to be told to strip its chrome when
|
||||
* the window shrinks, and to play/pause the element. (DR-160)
|
||||
*/
|
||||
private fun dispatchWebEvent(activity: Activity, name: String) {
|
||||
val webView = findWebView(activity.window.decorView) ?: return
|
||||
webView.post {
|
||||
webView.evaluateJavascript(
|
||||
"window.dispatchEvent(new CustomEvent('$name'));",
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hideWebView(activity: Activity) {
|
||||
val webView = findWebView(activity.window.decorView)
|
||||
if (webView == null) {
|
||||
@@ -264,14 +356,30 @@ object PictureInPictureManager {
|
||||
val r = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action != ACTION_MEDIA_CONTROL) return
|
||||
val player = try {
|
||||
JellyTauPlayer.getInstance()
|
||||
} catch (e: Exception) {
|
||||
return
|
||||
}
|
||||
when (intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)) {
|
||||
CONTROL_PLAY -> player.play()
|
||||
CONTROL_PAUSE -> player.pause()
|
||||
val control = intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)
|
||||
|
||||
if (isNativeVideoPath()) {
|
||||
val player = try {
|
||||
JellyTauPlayer.getInstance()
|
||||
} catch (e: Exception) {
|
||||
return
|
||||
}
|
||||
when (control) {
|
||||
CONTROL_PLAY -> player.play()
|
||||
CONTROL_PAUSE -> player.pause()
|
||||
}
|
||||
} else {
|
||||
// The WebView owns playback here, so the command has to reach
|
||||
// the `<video>` element. Driving ExoPlayer instead would do
|
||||
// nothing at all, which is what a PiP button on the HTML5 path
|
||||
// used to do. (DR-160)
|
||||
val name = when (control) {
|
||||
CONTROL_PLAY -> "jellytau-pip-play"
|
||||
CONTROL_PAUSE -> "jellytau-pip-pause"
|
||||
else -> return
|
||||
}
|
||||
dispatchWebEvent(activity, name)
|
||||
html5VideoPlaying = control == CONTROL_PLAY
|
||||
}
|
||||
// Swap the button to reflect the new state.
|
||||
updatePipActions(activity)
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.dtourolle.jellytau
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.WindowManager
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
/**
|
||||
* Which playback paths currently want the screen kept awake.
|
||||
*
|
||||
* Pure state, deliberately free of any Android type so it can be unit-tested —
|
||||
* see ScreenWakeStateTest. Two independent holders, because video can be
|
||||
* rendered by either renderer and only one of them is active at a time:
|
||||
*
|
||||
* - **native** — ExoPlayer drawing into the TextureView (DR-192)
|
||||
* - **html5** — a `<video>` inside the WebView, reported by the frontend
|
||||
*
|
||||
* Audio is deliberately *not* a holder. Playing music with the screen off is the
|
||||
* point of the audio path; only video needs the display alive.
|
||||
*
|
||||
* TRACES: UR-003 | DR-202 | UT-199
|
||||
*/
|
||||
class ScreenWakeState {
|
||||
private var nativeVideoPlaying = false
|
||||
private var html5VideoPlaying = false
|
||||
|
||||
/** True while any video renderer is actively playing. */
|
||||
val keepScreenOn: Boolean
|
||||
get() = nativeVideoPlaying || html5VideoPlaying
|
||||
|
||||
/**
|
||||
* @param playing whether ExoPlayer is playing right now
|
||||
* @param isVideo whether what it is playing is video rather than audio
|
||||
*/
|
||||
fun updateNative(playing: Boolean, isVideo: Boolean) {
|
||||
nativeVideoPlaying = playing && isVideo
|
||||
}
|
||||
|
||||
/**
|
||||
* @param active whether a webview `<video>` is the current playback surface
|
||||
* @param playing whether that element is playing right now
|
||||
*/
|
||||
fun updateHtml5(active: Boolean, playing: Boolean) {
|
||||
html5VideoPlaying = active && playing
|
||||
}
|
||||
|
||||
/** Drop every hold (teardown, or a page that can no longer be trusted). */
|
||||
fun reset() {
|
||||
nativeVideoPlaying = false
|
||||
html5VideoPlaying = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the display awake while video is playing.
|
||||
*
|
||||
* TRACES: UR-003 | DR-202
|
||||
*
|
||||
* ## Why this is needed at all
|
||||
*
|
||||
* Android turns the screen off on its own display timeout, counted from the last
|
||||
* *user input*. Watching a film is precisely the case where there is none, so
|
||||
* without an explicit hold the screen dimmed and slept mid-playback and the user
|
||||
* had to keep tapping it. Nothing in the app held it: `FLAG_KEEP_SCREEN_ON`
|
||||
* appeared nowhere, and neither renderer supplies one for free — ExoPlayer's
|
||||
* `setWakeMode` is a *CPU/wifi* wake lock and says nothing about the display,
|
||||
* and it draws into a `TextureView` we own rather than a `PlayerView`, which is
|
||||
* the media3 widget that would otherwise set `keepScreenOn` itself. The WebView
|
||||
* `<video>` path does not either: the display wake lock Chrome takes for video
|
||||
* lives in the browser layer, not in an embedded WebView.
|
||||
*
|
||||
* ## Approach
|
||||
*
|
||||
* `FLAG_KEEP_SCREEN_ON` on the Activity window rather than a
|
||||
* `PowerManager.WakeLock`: the flag is scoped to the window, so it stops
|
||||
* applying the moment the app is not visible and cannot survive a crash or a
|
||||
* missed release the way an explicitly acquired wake lock can. It needs no
|
||||
* permission. (The manifest's `WAKE_LOCK` is the media service's, unrelated.)
|
||||
*
|
||||
* The two renderers report independently and are OR-ed together in
|
||||
* [ScreenWakeState]:
|
||||
*
|
||||
* - `JellyTauPlayer.onIsPlayingChanged` and its surface teardown drive the
|
||||
* native path — ExoPlayer is the authoritative source of playback state, so
|
||||
* the hold follows what it reports rather than what the UI intends.
|
||||
* - `MainActivity`'s `AndroidPictureInPicture.setHtml5VideoState` bridge drives
|
||||
* the webview path. The frontend already reports that state on every
|
||||
* play/pause and on player teardown for PiP, so no new bridge is needed.
|
||||
*
|
||||
* The Activity reference is weak and re-set on every `onCreate`, so a
|
||||
* recreation (rotation) re-applies the current hold to the new window.
|
||||
*/
|
||||
object ScreenWakeManager {
|
||||
|
||||
private const val TAG = "ScreenWakeManager"
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val state = ScreenWakeState()
|
||||
private var activityRef: WeakReference<Activity>? = null
|
||||
|
||||
/**
|
||||
* Adopt the Activity whose window carries the flag, and re-apply the current
|
||||
* hold to it. Called from `MainActivity.onCreate`, so a rotation-recreated
|
||||
* Activity keeps the screen awake without waiting for the next state report.
|
||||
*/
|
||||
@Synchronized
|
||||
fun setActivity(activity: Activity) {
|
||||
activityRef = WeakReference(activity)
|
||||
apply()
|
||||
}
|
||||
|
||||
/** Drop the Activity on destroy, unless a newer one has already replaced it. */
|
||||
@Synchronized
|
||||
fun clearActivity(activity: Activity) {
|
||||
if (activityRef?.get() === activity) {
|
||||
activityRef = null
|
||||
}
|
||||
}
|
||||
|
||||
/** ExoPlayer's playback state changed. */
|
||||
@Synchronized
|
||||
fun onNativePlaybackChanged(playing: Boolean, isVideo: Boolean) {
|
||||
state.updateNative(playing, isVideo)
|
||||
apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* The frontend reported the webview `<video>` state. Arrives on a WebView
|
||||
* binder thread, hence the synchronization and the post to the main thread.
|
||||
*/
|
||||
@Synchronized
|
||||
fun onHtml5VideoState(active: Boolean, playing: Boolean) {
|
||||
state.updateHtml5(active, playing)
|
||||
apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop every hold. Used when a new WebView/page load invalidates whatever the
|
||||
* previous page last reported — a page that goes away without a final
|
||||
* `setHtml5VideoState(false, …)` would otherwise leave the screen pinned on
|
||||
* for the life of the process.
|
||||
*/
|
||||
@Synchronized
|
||||
fun releaseAll() {
|
||||
state.reset()
|
||||
apply()
|
||||
}
|
||||
|
||||
private fun apply() {
|
||||
val desired = state.keepScreenOn
|
||||
val activity = activityRef?.get() ?: return
|
||||
mainHandler.post {
|
||||
try {
|
||||
if (activity.isFinishing || activity.isDestroyed) return@post
|
||||
if (desired) {
|
||||
activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
android.util.Log.d(TAG, "keepScreenOn = $desired")
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(TAG, "Failed to apply keep-screen-on flag", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.dtourolle.jellytau
|
||||
|
||||
import android.app.Activity
|
||||
import android.view.SurfaceView
|
||||
import android.view.TextureView
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import com.dtourolle.jellytau.player.JellyTauPlayer
|
||||
@@ -14,15 +14,18 @@ import com.dtourolle.jellytau.player.JellyTauPlayer
|
||||
*/
|
||||
object VideoOverlayManager {
|
||||
|
||||
private var attachedSurfaceView: SurfaceView? = null
|
||||
private var attachedSurfaceView: TextureView? = null
|
||||
private var contentLayoutListener: android.view.View.OnLayoutChangeListener? = null
|
||||
private var listenerContentView: ViewGroup? = null
|
||||
|
||||
/**
|
||||
* Attach the video SurfaceView to the Activity's content view.
|
||||
* Attach the video view to the Activity's content view.
|
||||
*
|
||||
* The SurfaceView is added at index 0 (bottom of z-order) so it renders
|
||||
* behind the Tauri WebView, allowing Svelte controls to overlay on top.
|
||||
* Added at index 0 (bottom of the z-order) so it renders behind the Tauri
|
||||
* WebView, allowing the Svelte controls to overlay on top. Since DR-192 this
|
||||
* is a TextureView, so "behind" is ordinary view z-order within one window
|
||||
* rather than a separate surface punched through it — which is what makes
|
||||
* the overlay above it repaint reliably.
|
||||
*
|
||||
* @param activity The Activity to attach the surface to
|
||||
*/
|
||||
@@ -77,16 +80,29 @@ object VideoOverlayManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach the video SurfaceView from the Activity's view hierarchy.
|
||||
* Detach the video SurfaceView from the view hierarchy.
|
||||
*
|
||||
* @param activity The Activity to detach the surface from
|
||||
* Must be called on the main thread.
|
||||
*
|
||||
* This had **no callers at all**, which made [attachVideoSurface] one-way:
|
||||
* `JellyTauPlayer.clearVideoSurface()` dropped its `surfaceView` reference
|
||||
* without removing the view, so every native video left its SurfaceView
|
||||
* parented to the content view for the life of the process and the next one
|
||||
* added another beneath it. The stack was invisible while the WebView was
|
||||
* opaque, and [isVideoSurfaceAttached] — which gates
|
||||
* `PictureInPictureManager.canEnterPip` — stayed true forever afterwards.
|
||||
*
|
||||
* Removes from the view's *own* parent rather than looking the content view
|
||||
* up from an Activity, so it cannot leave a view behind when the Activity
|
||||
* has been recreated under it.
|
||||
*
|
||||
* TRACES: UR-003, UR-041 | DR-184
|
||||
*/
|
||||
fun detachVideoSurface(activity: Activity) {
|
||||
fun detachVideoSurface() {
|
||||
try {
|
||||
removeLayoutListener()
|
||||
attachedSurfaceView?.let { surfaceView ->
|
||||
val contentView = activity.window.decorView.findViewById<ViewGroup>(android.R.id.content)
|
||||
contentView.removeView(surfaceView)
|
||||
(surfaceView.parent as? ViewGroup)?.removeView(surfaceView)
|
||||
attachedSurfaceView = null
|
||||
android.util.Log.d("VideoOverlayManager", "Video surface detached from view hierarchy")
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
+236
-21
@@ -27,6 +27,17 @@ import com.google.common.util.concurrent.ListenableFuture
|
||||
*
|
||||
* Media commands are routed back to Rust via JNI to ensure proper
|
||||
* queue management for next/previous track operations.
|
||||
*
|
||||
* This class owns both sessions: the media3 [MediaSession] the service contract
|
||||
* requires, and the legacy [MediaSessionCompat] that actually carries the
|
||||
* lockscreen transport. The compat session is flagged
|
||||
* FLAG_HANDLES_MEDIA_BUTTONS or FLAG_HANDLES_TRANSPORT_CONTROLS, which is what
|
||||
* makes a Bluetooth headset's AVRCP play/pause/skip arrive as a transport
|
||||
* callback; every one of those callbacks is forwarded to Rust through
|
||||
* nativeOnMediaCommand rather than acted on locally, so the player stays the
|
||||
* single source of truth and the session remains a consumer of its state.
|
||||
*
|
||||
* TRACES: UR-006 | IR-006
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
class JellyTauPlaybackService : MediaSessionService() {
|
||||
@@ -119,6 +130,54 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
nativeOnMediaCommand("seek:$positionSeconds")
|
||||
}
|
||||
|
||||
// media3 seeks by more routes than seekTo(long), and the ones below
|
||||
// reach the *real* ExoPlayer if they are not overridden — bypassing
|
||||
// Rust entirely and operating on the handoff stream's relative
|
||||
// timeline. That is the same mechanism as the truncation bug, reached
|
||||
// by a different door.
|
||||
//
|
||||
// seekToDefaultPosition is deliberately swallowed rather than
|
||||
// forwarded. Util.handlePlayButtonAction calls it on an ended or idle
|
||||
// player and then calls play(); on a handoff stream the seek lands at
|
||||
// stream zero — the point the screen was locked at — which is exactly
|
||||
// the reported jump-back. Sending "seek:0.0" instead would be worse
|
||||
// still, restarting the whole episode. Rust already owns what "play
|
||||
// after the stream ended" means (truncation recovery, or advancing to
|
||||
// the next episode), and the play() that follows reaches it, so the
|
||||
// right move here is to not move at all.
|
||||
//
|
||||
// TRACES: UR-040, UR-005 | DR-159
|
||||
override fun seekToDefaultPosition() {
|
||||
android.util.Log.d(
|
||||
"JellyTauPlaybackService",
|
||||
"Ignoring seekToDefaultPosition — Rust owns end-of-stream handling"
|
||||
)
|
||||
}
|
||||
|
||||
override fun seekToDefaultPosition(mediaItemIndex: Int) {
|
||||
android.util.Log.d(
|
||||
"JellyTauPlaybackService",
|
||||
"Ignoring seekToDefaultPosition(index) — Rust owns end-of-stream handling"
|
||||
)
|
||||
}
|
||||
|
||||
// `currentPosition` is ExoPlayer's own, so it is relative during a
|
||||
// handoff; the base makes the target absolute, which is what Rust
|
||||
// expects from every command on this boundary.
|
||||
override fun seekBack() {
|
||||
val target =
|
||||
((currentPosition + handoffBaseMs - seekBackIncrement) / 1000.0)
|
||||
.coerceAtLeast(0.0)
|
||||
nativeOnMediaCommand("seek:$target")
|
||||
}
|
||||
|
||||
override fun seekForward() {
|
||||
val target =
|
||||
((currentPosition + handoffBaseMs + seekForwardIncrement) / 1000.0)
|
||||
.coerceAtLeast(0.0)
|
||||
nativeOnMediaCommand("seek:$target")
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
nativeOnMediaCommand("stop")
|
||||
}
|
||||
@@ -180,6 +239,21 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
nativeOnMediaCommand("previous")
|
||||
}
|
||||
|
||||
// Fast-forward/rewind map onto the same two commands on purpose.
|
||||
// Rust decides whether a skip advances the queue or scrubs
|
||||
// +30s/-10s, based on whether a background-audio handoff owns
|
||||
// playback (DR-201); routing these separately would put that
|
||||
// decision in two places and let them disagree.
|
||||
override fun onFastForward() {
|
||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Fast-forward pressed")
|
||||
nativeOnMediaCommand("next")
|
||||
}
|
||||
|
||||
override fun onRewind() {
|
||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Rewind pressed")
|
||||
nativeOnMediaCommand("previous")
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Stop pressed")
|
||||
nativeOnMediaCommand("stop")
|
||||
@@ -197,9 +271,103 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this process could post an *ordinary* notification and have the
|
||||
* user see it.
|
||||
*
|
||||
* Deliberately **not** a gate on anything this service posts today — see
|
||||
* [warnIfNotificationWillBeDropped]. `POST_NOTIFICATIONS` is declared in the
|
||||
* manifest but never requested, so on Android 13+ this is normally `false`,
|
||||
* and that is the intended state. It is read only to decide whether a
|
||||
* token-less notification would be dropped.
|
||||
*/
|
||||
private fun hasPostNotificationsPermission(): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/**
|
||||
* The media-session token is what makes this service's notifications legal
|
||||
* without `POST_NOTIFICATIONS` — do not drop it.
|
||||
*
|
||||
* Android 13 (API 33) gates notifications behind the `POST_NOTIFICATIONS`
|
||||
* runtime permission, and a foreground-service notification is explicitly
|
||||
* **not** exempt: "Android 13 (API level 33) and higher supports a runtime
|
||||
* permission for sending non-exempt (including Foreground Services (FGS))
|
||||
* notifications from an app: POST_NOTIFICATIONS", and with it denied the
|
||||
* user "still see[s] notices related to foreground services in the Task
|
||||
* Manager but [doesn't] see them in the notification drawer".
|
||||
*
|
||||
* A *media-session* notification is exempt, however: "Notifications related
|
||||
* to media sessions are exempt from this behavior change." That exemption is
|
||||
* a property of the notification, not of the service — the platform decides
|
||||
* it from the posted `Notification` itself, which must carry `MediaStyle`
|
||||
* **and** a valid `MediaSession` token. Every notification this service
|
||||
* builds does (`MediaStyle().setMediaSession(mediaSessionCompat.sessionToken)`,
|
||||
* with `mediaSessionCompat` created in `onCreate`, i.e. before any post), so
|
||||
* the shade entry and the lockscreen transport controls behind UR-006 appear
|
||||
* whether or not the permission was ever granted. That is why this app asks
|
||||
* for nothing at runtime and shows the user no permission dialog.
|
||||
*
|
||||
* The trap it leaves is a silent one, and it is worse than a missing shade
|
||||
* entry — which is what this exists to make loud. The platform predicate is
|
||||
* `Notification.isMediaNotification()`, requiring MediaStyle **and** a
|
||||
* non-null `EXTRA_MEDIA_SESSION`; `NotificationManagerService` uses it to
|
||||
* decide whether to drop the post, and SystemUI's media carousel
|
||||
* (`MediaDataProcessor.onNotificationAdded`) is gated on *the same*
|
||||
* predicate. So a token-less notification is blocked before it reaches the
|
||||
* notification listener, and the lockscreen/Quick Settings transport
|
||||
* controls — the whole of UR-006 — never appear at all, with no error and no
|
||||
* log anywhere. `mediaSessionCompat?.sessionToken` is a null-safe call, so
|
||||
* that failure is one stray initialisation-order change away.
|
||||
*
|
||||
* The exemption also covers only media and self-managed-call notifications,
|
||||
* so a genuinely non-media notification — a download-completion notice
|
||||
* (UR-011), say — gets none of it. Adding one means requesting
|
||||
* `POST_NOTIFICATIONS` at runtime first (AndroidX
|
||||
* `ActivityResultContracts.RequestPermission`, launched from `MainActivity`
|
||||
* at the point the feature is used, handling refusal), not merely calling
|
||||
* `notify`; the manifest keeps the declaration so that stays a one-file
|
||||
* change. Verified unchanged across API 33–36.
|
||||
*
|
||||
* TRACES: UR-006 | DR-200
|
||||
*/
|
||||
private fun warnIfNotificationWillBeDropped(token: MediaSessionCompat.Token?) {
|
||||
if (token != null) return
|
||||
if (hasPostNotificationsPermission()) return
|
||||
android.util.Log.e(
|
||||
"JellyTauPlaybackService",
|
||||
"Posting a notification with NO MediaSession token while POST_NOTIFICATIONS " +
|
||||
"is denied: it is not exempt and Android will drop it silently. " +
|
||||
"Lockscreen/shade transport controls (UR-006) will be missing."
|
||||
)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
// Start as foreground service immediately to avoid crash
|
||||
// Media3 will replace this with its own notification
|
||||
//
|
||||
// startForeground() is deliberately NOT gated on POST_NOTIFICATIONS, and
|
||||
// an audit asking for such a guard has been answered once already — do
|
||||
// not re-raise it. Two independent reasons:
|
||||
//
|
||||
// 1. The notification does not need the permission. It is exempt because
|
||||
// it is a media-session notification (see
|
||||
// warnIfNotificationWillBeDropped). Device evidence, HONOR ROD2-W09 on
|
||||
// Android 16 / SDK 36: appops reports `POST_NOTIFICATION: ignore` and
|
||||
// `granted=false`, while the same dumpsys shows this service
|
||||
// isForeground=true with `foregroundNoti=Notification(category=
|
||||
// transport actions=3 vis=PUBLIC)` live and the lockscreen transport
|
||||
// controls working.
|
||||
// 2. Skipping this call after startForegroundService() is a hard contract
|
||||
// violation — the system kills the process with "did not then call
|
||||
// Service.startForeground()". So a guard here would convert a cosmetic
|
||||
// problem into a crash.
|
||||
//
|
||||
// A denied permission must degrade to a missing *notification*, never to
|
||||
// a missing startForeground.
|
||||
//
|
||||
// TRACES: UR-006 | DR-200
|
||||
val notification = createBasicNotification()
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
@@ -215,6 +383,11 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
// onCreate builds mediaSessionCompat, and onStartCommand cannot run
|
||||
// before onCreate, so this is expected to be non-null here.
|
||||
val sessionToken = mediaSessionCompat?.sessionToken
|
||||
warnIfNotificationWillBeDropped(sessionToken)
|
||||
|
||||
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setContentTitle("JellyTau")
|
||||
.setContentText("Playing")
|
||||
@@ -222,7 +395,7 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
.setContentIntent(pendingIntent)
|
||||
.setStyle(
|
||||
androidx.media.app.NotificationCompat.MediaStyle()
|
||||
.setMediaSession(mediaSessionCompat?.sessionToken)
|
||||
.setMediaSession(sessionToken)
|
||||
.setShowActionsInCompactView(0, 1, 2) // Show all 3 buttons in compact view
|
||||
)
|
||||
.addAction(
|
||||
@@ -262,23 +435,32 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
private var lastArtist: String = ""
|
||||
private var lastIsPlaying: Boolean = false
|
||||
|
||||
// Base offset (ms) added to every position reported to the lockscreen
|
||||
// MediaSession. During a background-audio handoff the audio stream is
|
||||
// requested with StartTimeTicks = the handoff point, so ExoPlayer reports
|
||||
// position RELATIVE to that point (starting at 0). The metadata duration,
|
||||
// however, is the full absolute length — so without this base the scrubber
|
||||
// thumb sits near 0:00 on a full-length bar. Set from the known handoff
|
||||
// position via setPositionOffset(); 0 for normal playback.
|
||||
private var positionOffsetMs: Long = 0L
|
||||
// The handoff base (ms): during a background-audio handoff the audio stream is
|
||||
// requested with StartTimeTicks = the handoff point, so ExoPlayer's timeline
|
||||
// starts at 0 *there* and every position it reports is relative to it. This
|
||||
// is the number that converts one back to a real position on the episode.
|
||||
//
|
||||
// It is deliberately read, not applied, here. This used to be a display-only
|
||||
// correction added at the two setPlaybackState calls below, which left every
|
||||
// other consumer — progress reporting to Jellyfin, the frontend, media3's own
|
||||
// seeks — working in the relative timeline while treating it as absolute, each
|
||||
// crossing silently losing exactly `base` seconds. The conversion now happens
|
||||
// once, in JellyTauPlayer's position tick, so everything downstream of it
|
||||
// speaks the episode's timeline; applying it again here would double-count.
|
||||
//
|
||||
// TRACES: UR-040 | DR-159
|
||||
@Volatile
|
||||
var handoffBaseMs: Long = 0L
|
||||
private set
|
||||
|
||||
/**
|
||||
* Set the base position offset (seconds) applied to lockscreen positions.
|
||||
* Called by the native layer when entering/exiting a background-audio handoff.
|
||||
* Pass 0 to clear (normal playback, where ExoPlayer's position is absolute).
|
||||
* Set the handoff base (seconds). Called by the native layer when entering or
|
||||
* leaving a background-audio handoff; 0 clears it for normal playback, where
|
||||
* ExoPlayer's position is already absolute.
|
||||
*/
|
||||
fun setPositionOffset(offsetSeconds: Double) {
|
||||
positionOffsetMs = (offsetSeconds * 1000.0).toLong().coerceAtLeast(0L)
|
||||
android.util.Log.d("JellyTauPlaybackService", "Position offset set to ${positionOffsetMs}ms")
|
||||
fun setHandoffBase(offsetSeconds: Double) {
|
||||
handoffBaseMs = (offsetSeconds * 1000.0).toLong().coerceAtLeast(0L)
|
||||
android.util.Log.d("JellyTauPlaybackService", "Handoff base set to ${handoffBaseMs}ms")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -314,8 +496,9 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
|
||||
session.setMetadata(metadataBuilder.build())
|
||||
|
||||
// Update MediaSession playback state (position made absolute via the base offset).
|
||||
session.setPlaybackState(buildPlaybackState(isPlaying, position + positionOffsetMs))
|
||||
// Already absolute: this call comes from Rust, whose stored position is on
|
||||
// the episode's timeline. (DR-159)
|
||||
session.setPlaybackState(buildPlaybackState(isPlaying, position))
|
||||
|
||||
// While casting, re-assert the remote volume provider. Metadata pushes
|
||||
// arrive on the session poller thread and can race with (or arrive
|
||||
@@ -337,15 +520,15 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
* notification. Without this, the lockscreen scrubber freezes at the position
|
||||
* from the last play/pause and drifts out of sync with actual playback.
|
||||
*
|
||||
* @param position Position in milliseconds
|
||||
* @param position Absolute position in milliseconds, on the item's own
|
||||
* timeline — the caller has already applied [handoffBaseMs].
|
||||
* @param isPlaying Whether playback is currently active
|
||||
*/
|
||||
fun updatePlaybackPosition(position: Long, isPlaying: Boolean) {
|
||||
val session = mediaSessionCompat ?: return
|
||||
val notificationStateChanged = isPlaying != lastIsPlaying
|
||||
lastIsPlaying = isPlaying
|
||||
// Absolute position for the scrubber = relative ExoPlayer position + base offset.
|
||||
session.setPlaybackState(buildPlaybackState(isPlaying, position + positionOffsetMs))
|
||||
session.setPlaybackState(buildPlaybackState(isPlaying, position))
|
||||
// Only rebuild the notification when the play/pause icon actually flips.
|
||||
if (notificationStateChanged) {
|
||||
updateNotification(lastTitle, lastArtist, isPlaying)
|
||||
@@ -375,6 +558,14 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
PlaybackStateCompat.ACTION_STOP or
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
|
||||
// Advertised so the system draws seek affordances alongside the
|
||||
// skip arrows: during a background-audio handoff the backend
|
||||
// resolves skip to a +30s/-10s scrub rather than a queue advance
|
||||
// (DR-201), and a control that scrubs should not look like one
|
||||
// that changes track. Rust owns which of the two a press means;
|
||||
// these only describe what the session can do.
|
||||
PlaybackStateCompat.ACTION_FAST_FORWARD or
|
||||
PlaybackStateCompat.ACTION_REWIND or
|
||||
PlaybackStateCompat.ACTION_SEEK_TO
|
||||
)
|
||||
.setState(
|
||||
@@ -388,6 +579,24 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
/**
|
||||
* Update the notification with current media metadata and playback state.
|
||||
* This should be called whenever metadata or playback state changes.
|
||||
*
|
||||
* This `notify()` reuses [NOTIFICATION_ID], so while the service is
|
||||
* foreground it updates the foreground notification in place. It is **not**
|
||||
* guarded on the service being foreground, and does not need to be, because
|
||||
* the exemption that keeps it postable is a property of the notification
|
||||
* (MediaStyle + session token) rather than of the foreground state — see
|
||||
* [warnIfNotificationWillBeDropped].
|
||||
*
|
||||
* That distinction is load-bearing, because this *is* reachable with the
|
||||
* service alive but not foreground. Every caller arrives over JNI from Rust
|
||||
* on a non-main thread against [getInstance], which is non-null from
|
||||
* `onCreate` to `onDestroy`: it can therefore interleave between `onCreate`
|
||||
* and `onStartCommand`, and a media3 `MediaSessionService` is also created
|
||||
* by a plain *bind* from a MediaController with no `startForeground` at all.
|
||||
* Were the exemption a foreground-service one, those windows would silently
|
||||
* drop the update; being a media-session one, they do not.
|
||||
*
|
||||
* TRACES: UR-006 | DR-200
|
||||
*/
|
||||
private fun updateNotification(title: String, artist: String, isPlaying: Boolean) {
|
||||
val intent = packageManager.getLaunchIntentForPackage(packageName)
|
||||
@@ -398,6 +607,12 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
// The token is what exempts this from POST_NOTIFICATIONS; losing it here
|
||||
// would make every metadata update vanish from the shade and lockscreen
|
||||
// while the service kept running. See warnIfNotificationWillBeDropped.
|
||||
val sessionToken = mediaSessionCompat?.sessionToken
|
||||
warnIfNotificationWillBeDropped(sessionToken)
|
||||
|
||||
val notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
.setContentText(artist)
|
||||
@@ -405,7 +620,7 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
.setContentIntent(pendingIntent)
|
||||
.setStyle(
|
||||
androidx.media.app.NotificationCompat.MediaStyle()
|
||||
.setMediaSession(mediaSessionCompat?.sessionToken)
|
||||
.setMediaSession(sessionToken)
|
||||
.setShowActionsInCompactView(0, 1, 2) // Show all 3 buttons in compact view
|
||||
)
|
||||
.addAction(
|
||||
|
||||
@@ -8,8 +8,7 @@ import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.TextureView
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.OptIn
|
||||
@@ -21,6 +20,9 @@ import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy
|
||||
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
/**
|
||||
@@ -130,7 +132,7 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
}
|
||||
|
||||
// Detect and report hardware codec capabilities to Rust
|
||||
detectAndReportCodecs()
|
||||
detectAndReportCodecs(context.applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,23 +143,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.
|
||||
@@ -217,9 +227,12 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
/** Media type enum */
|
||||
enum class MediaType { AUDIO, VIDEO }
|
||||
|
||||
/** SurfaceView for video playback */
|
||||
private var surfaceView: SurfaceView? = null
|
||||
private var surfaceHolder: SurfaceHolder? = null
|
||||
/** TextureView for video playback — see getOrCreateSurfaceView() for why. */
|
||||
private var videoView: TextureView? = null
|
||||
|
||||
/** The Surface handed to ExoPlayer, owned here rather than by the player. */
|
||||
private var videoSurface: android.view.Surface? = null
|
||||
|
||||
/** Last reported video frame size, used to fit the surface to the screen preserving aspect ratio */
|
||||
private var videoWidth: Int = 0
|
||||
private var videoHeight: Int = 0
|
||||
@@ -232,6 +245,59 @@ 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
|
||||
|
||||
/**
|
||||
* Whether the stream that is loaded may be retried by the player itself.
|
||||
*
|
||||
* Set from Rust on every load; see [StreamRetryDecision] for why the
|
||||
* background-audio handoff transcode must answer no. (DR-203)
|
||||
*/
|
||||
private val streamRetry = StreamRetryDecision()
|
||||
|
||||
/**
|
||||
* The default retry behaviour, except that a stream the player could only
|
||||
* restart is not retried at all.
|
||||
*
|
||||
* `C.TIME_UNSET` makes `ProgressiveMediaPeriod.onLoadError` return
|
||||
* `DONT_RETRY_FATAL` *before* it reaches `configureRetry`, which is the
|
||||
* method that would otherwise reset the sample queues and re-request the URL
|
||||
* from offset 0. The error then surfaces through [onPlayerError] as
|
||||
* recoverable, and Rust re-opens the stream at the position playback
|
||||
* actually reached (DR-129).
|
||||
*
|
||||
* TRACES: UR-040, UR-004 | DR-203
|
||||
*/
|
||||
private val loadErrorHandlingPolicy: LoadErrorHandlingPolicy =
|
||||
object : DefaultLoadErrorHandlingPolicy() {
|
||||
override fun getRetryDelayMsFor(
|
||||
loadErrorInfo: LoadErrorHandlingPolicy.LoadErrorInfo
|
||||
): Long {
|
||||
if (!streamRetry.playerMayRetry) {
|
||||
android.util.Log.w(
|
||||
"JellyTauPlayer",
|
||||
"Load error on a stream that cannot be resumed in place — " +
|
||||
"declining the player's retry so the backend can re-open it: " +
|
||||
"${loadErrorInfo.exception}"
|
||||
)
|
||||
return C.TIME_UNSET
|
||||
}
|
||||
return super.getRetryDelayMsFor(loadErrorInfo)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// Configure audio attributes for music playback with audio focus handling
|
||||
val audioAttributes = AudioAttributes.Builder()
|
||||
@@ -239,8 +305,23 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||
.build()
|
||||
|
||||
// Create ExoPlayer with audio focus handling
|
||||
// Create ExoPlayer with audio focus handling.
|
||||
//
|
||||
// For audio playback ExoPlayer manages focus itself: handleAudioFocus=true
|
||||
// makes it request AUDIOFOCUS_GAIN on play, duck on a transient loss, and
|
||||
// pause on a call or another app taking focus. Video re-applies this per
|
||||
// load with handleAudioFocus=false and drives focus manually instead (see
|
||||
// requestAudioFocus), because a video needs delayed-focus handling.
|
||||
//
|
||||
// TRACES: UR-004, UR-006 | IR-008
|
||||
exoPlayer = ExoPlayer.Builder(appContext)
|
||||
// Decline the player's own load-error retry for a stream it could
|
||||
// only restart (DR-203). Every other source keeps the default
|
||||
// behaviour, which resumes the failed load where it stopped.
|
||||
.setMediaSourceFactory(
|
||||
DefaultMediaSourceFactory(appContext)
|
||||
.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
|
||||
)
|
||||
.setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true)
|
||||
// Pause when the audio output is removed (wired headphones unplugged or
|
||||
// Bluetooth device disconnected). ExoPlayer listens for the system
|
||||
@@ -304,6 +385,14 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
val state = if (isPlaying) "playing" else "paused"
|
||||
nativeOnStateChanged(state, currentMediaId)
|
||||
|
||||
// Hold the display awake for video, release it for a pause or for
|
||||
// audio: the display timeout counts from the last user input, and
|
||||
// watching something is exactly when there is none. (DR-202)
|
||||
com.dtourolle.jellytau.ScreenWakeManager.onNativePlaybackChanged(
|
||||
isPlaying,
|
||||
currentMediaType == MediaType.VIDEO
|
||||
)
|
||||
|
||||
if (isPlaying) {
|
||||
startPositionUpdates()
|
||||
} else {
|
||||
@@ -314,6 +403,33 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
updatePlaybackServiceNotification(isPlaying)
|
||||
}
|
||||
|
||||
/**
|
||||
* A jump in the timeline nobody asked for.
|
||||
*
|
||||
* Logged rather than acted on: with the load-error retry declined for
|
||||
* streams that can only be restarted (DR-203), a backwards
|
||||
* `DISCONTINUITY_REASON_INTERNAL` here means the player rewound one
|
||||
* anyway, and this line is what would show it.
|
||||
*/
|
||||
override fun onPositionDiscontinuity(
|
||||
oldPosition: Player.PositionInfo,
|
||||
newPosition: Player.PositionInfo,
|
||||
reason: Int
|
||||
) {
|
||||
val message = "▶ Position discontinuity: ${oldPosition.positionMs}ms -> " +
|
||||
"${newPosition.positionMs}ms (reason=$reason)"
|
||||
if (reason == Player.DISCONTINUITY_REASON_INTERNAL) {
|
||||
// The player moved the timeline of its own accord — the
|
||||
// signature of the DR-203 rewind. Loud, because with the
|
||||
// retry declined it should no longer be reachable.
|
||||
android.util.Log.w("JellyTauPlayer", "$message — player-initiated")
|
||||
} else if (newPosition.positionMs < oldPosition.positionMs - 1000) {
|
||||
// Backwards, but asked for: a seek, or the re-prepare a
|
||||
// stream resume does (reason REMOVE). Normal, so quiet.
|
||||
android.util.Log.d("JellyTauPlayer", message)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
android.util.Log.e("JellyTauPlayer", "▶▶▶ PLAYER ERROR: ${error.errorCodeName}", error)
|
||||
android.util.Log.e("JellyTauPlayer", " Error code: ${error.errorCode}")
|
||||
@@ -361,29 +477,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)
|
||||
}
|
||||
@@ -423,6 +571,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()
|
||||
}
|
||||
}
|
||||
@@ -764,11 +921,16 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
artworkUrl: String?,
|
||||
durationMs: Long,
|
||||
mediaType: String = "audio",
|
||||
subtitlesJson: String = "[]"
|
||||
subtitlesJson: String = "[]",
|
||||
nonResumableStream: Boolean = false
|
||||
) {
|
||||
mainHandler.post {
|
||||
currentMediaId = mediaId
|
||||
endedNotified = false
|
||||
// Who owns recovery for this stream, decided in Rust (DR-203). Set
|
||||
// before prepare(), since the first load error can arrive as soon as
|
||||
// the player starts reading.
|
||||
streamRetry.onLoad(nonResumableStream)
|
||||
|
||||
// Store metadata for notification updates
|
||||
currentTitle = title
|
||||
@@ -811,14 +973,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)
|
||||
@@ -888,8 +1053,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()
|
||||
@@ -946,6 +1116,7 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
fun release() {
|
||||
mainHandler.post {
|
||||
stopPositionUpdates()
|
||||
com.dtourolle.jellytau.ScreenWakeManager.onNativePlaybackChanged(false, false)
|
||||
coroutineScope.cancel()
|
||||
releaseAudioEffects()
|
||||
exoPlayer.release()
|
||||
@@ -959,16 +1130,41 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
android.util.Log.d("JellyTauPlayer", "Started position updates coroutine")
|
||||
while (isActive) {
|
||||
if (exoPlayer.isPlaying) {
|
||||
val positionMs = exoPlayer.currentPosition.coerceAtLeast(0)
|
||||
// THE boundary between the two timelines, and the only place
|
||||
// the conversion happens.
|
||||
//
|
||||
// During a background-audio handoff the stream is requested
|
||||
// with StartTimeTicks = the handoff point, so ExoPlayer's zero
|
||||
// is that point and everything it reports is relative to it.
|
||||
// The base used to be added only where a position was *shown*
|
||||
// (the lockscreen scrubber), leaving progress reports to
|
||||
// Jellyfin, the frontend and the truncation maths all working
|
||||
// in the relative timeline while treating it as absolute —
|
||||
// each crossing losing exactly `base` seconds, which is why the
|
||||
// jump-back distance varied with where the screen was locked.
|
||||
// Shifting once, here, means every consumer downstream speaks
|
||||
// the episode's timeline and none of them needs to know a
|
||||
// handoff happened.
|
||||
//
|
||||
// The duration is shifted with it, so position and duration
|
||||
// stay on the same timeline — the stream's own length is only
|
||||
// what remains after the handoff point.
|
||||
//
|
||||
// TRACES: UR-040 | DR-159
|
||||
val service = JellyTauPlaybackService.getInstance()
|
||||
val baseMs = service?.handoffBaseMs ?: 0L
|
||||
|
||||
val positionMs = exoPlayer.currentPosition.coerceAtLeast(0) + baseMs
|
||||
val position = positionMs / 1000.0
|
||||
val duration = if (exoPlayer.duration > 0) exoPlayer.duration / 1000.0 else 0.0
|
||||
val duration =
|
||||
if (exoPlayer.duration > 0) (exoPlayer.duration + baseMs) / 1000.0 else 0.0
|
||||
android.util.Log.v("JellyTauPlayer", "Position update: $position / $duration")
|
||||
nativeOnPositionUpdate(position, duration)
|
||||
|
||||
// Keep the lockscreen scrubber live. Without this the
|
||||
// MediaSession position only refreshes on play/pause, so the
|
||||
// scrubber freezes mid-track and drifts out of sync.
|
||||
JellyTauPlaybackService.getInstance()?.updatePlaybackPosition(positionMs, true)
|
||||
service?.updatePlaybackPosition(positionMs, true)
|
||||
}
|
||||
delay(POSITION_UPDATE_INTERVAL_MS)
|
||||
}
|
||||
@@ -982,52 +1178,111 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the SurfaceView for video playback.
|
||||
* Returns the view ID that can be attached to the view hierarchy.
|
||||
* Get or create the video view, and hand it to ExoPlayer.
|
||||
*
|
||||
* Note: The surface is created but not automatically attached to the view hierarchy.
|
||||
* Call attachSurfaceToActivity() or use VideoOverlayManager to attach it.
|
||||
* This is a **TextureView**, not a SurfaceView, and that is the whole point.
|
||||
*
|
||||
* A SurfaceView renders on its own layer *outside* the app window and punches
|
||||
* a transparent hole through the window to show it. Anything drawn above
|
||||
* that hole — for us, the entire Svelte UI in a transparent WebView — is at
|
||||
* the mercy of that composition path, and Android's own graphics
|
||||
* documentation says plainly that "overlays do not currently work correctly
|
||||
* with SurfaceView or TextureView". On device that showed up as the WebView
|
||||
* overlay silently dropping its incremental damage: the clock text stopped
|
||||
* advancing on screen while the DOM kept updating (slider 476 → 479 across
|
||||
* three seconds behind a display showing neither), the control bar would not
|
||||
* fade, and rotation lost the transport UI. Only *structural* DOM changes
|
||||
* got through, which is why the play overlay — an `{#if}` block that is added
|
||||
* and removed — always appeared to work while the progress bar never did.
|
||||
*
|
||||
* A TextureView is an ordinary view: its frames are drawn as a texture inside
|
||||
* the window's normal rendering pass, so there is no second layer, no
|
||||
* transparent region, and the WebView above composites like it would over any
|
||||
* other view. This is the standard remedy for ExoPlayer overlay problems and
|
||||
* is why media3 offers `surface_type="texture_view"` at all.
|
||||
*
|
||||
* The cost is real and accepted: TextureView uses more power and memory than
|
||||
* SurfaceView and adds a frame of latency. Hardware decode through MediaCodec
|
||||
* is unaffected — only presentation changes — so the reason native video
|
||||
* exists survives the trade.
|
||||
*
|
||||
* `setVideoTextureView` installs ExoPlayer's own `SurfaceTextureListener`, so
|
||||
* there is deliberately no listener of ours here; adding one would displace
|
||||
* it and the video would never appear.
|
||||
*
|
||||
* Note: the view is created but not attached to the hierarchy. Call
|
||||
* attachSurfaceToActivity() or use VideoOverlayManager to attach it.
|
||||
*
|
||||
* TRACES: UR-003, UR-004 | DR-192
|
||||
*/
|
||||
fun getOrCreateSurfaceView(): Int {
|
||||
if (surfaceView == null) {
|
||||
surfaceView = SurfaceView(appContext).apply {
|
||||
if (videoView == null) {
|
||||
videoView = TextureView(appContext).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
// Render BEHIND WebView - video shows through transparent areas
|
||||
setZOrderMediaOverlay(false)
|
||||
// The view is opaque where video is drawn; the WebView above it
|
||||
// is what supplies transparency, exactly as before.
|
||||
isOpaque = true
|
||||
|
||||
// Set up SurfaceHolder callbacks
|
||||
holder.addCallback(object : SurfaceHolder.Callback {
|
||||
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||
android.util.Log.d("JellyTauPlayer", "Surface created")
|
||||
surfaceHolder = holder
|
||||
exoPlayer.setVideoSurfaceHolder(holder)
|
||||
// Own the listener rather than calling `setVideoTextureView`,
|
||||
// which installs ExoPlayer's own. Handing ExoPlayer the Surface
|
||||
// directly is the same wiring `setVideoTextureView` does
|
||||
// internally, and owning the listener keeps surface creation and
|
||||
// teardown symmetrical with `videoSurface` below.
|
||||
//
|
||||
// (This was originally introduced to observe frame arrival for
|
||||
// the letterbox artefact. That turned out to be the wrong lead —
|
||||
// see fitSurfaceToScreen — but the explicit wiring is worth
|
||||
// keeping on its own terms.)
|
||||
//
|
||||
// TRACES: UR-003, UR-004 | DR-194
|
||||
surfaceTextureListener = object : TextureView.SurfaceTextureListener {
|
||||
override fun onSurfaceTextureAvailable(
|
||||
texture: android.graphics.SurfaceTexture,
|
||||
width: Int,
|
||||
height: Int
|
||||
) {
|
||||
videoSurface?.release()
|
||||
videoSurface = android.view.Surface(texture)
|
||||
exoPlayer.setVideoSurface(videoSurface)
|
||||
android.util.Log.d("JellyTauPlayer", "Video surface attached to ExoPlayer")
|
||||
}
|
||||
|
||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
|
||||
android.util.Log.d("JellyTauPlayer", "Surface changed: ${width}x${height}")
|
||||
override fun onSurfaceTextureSizeChanged(
|
||||
texture: android.graphics.SurfaceTexture,
|
||||
width: Int,
|
||||
height: Int
|
||||
) {
|
||||
}
|
||||
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||
android.util.Log.d("JellyTauPlayer", "Surface destroyed")
|
||||
exoPlayer.clearVideoSurfaceHolder(holder)
|
||||
surfaceHolder = null
|
||||
override fun onSurfaceTextureDestroyed(
|
||||
texture: android.graphics.SurfaceTexture
|
||||
): Boolean {
|
||||
exoPlayer.setVideoSurface(null)
|
||||
videoSurface?.release()
|
||||
videoSurface = null
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
override fun onSurfaceTextureUpdated(
|
||||
texture: android.graphics.SurfaceTexture
|
||||
) {
|
||||
}
|
||||
}
|
||||
}
|
||||
android.util.Log.d("JellyTauPlayer", "Video TextureView created")
|
||||
}
|
||||
return surfaceView!!.hashCode()
|
||||
return videoView!!.hashCode()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the SurfaceView instance (for VideoOverlayManager).
|
||||
* Returns null if no surface has been created yet.
|
||||
* Get the video view instance (for VideoOverlayManager).
|
||||
* Returns null if none has been created yet.
|
||||
*/
|
||||
fun getSurfaceView(): SurfaceView? {
|
||||
return surfaceView
|
||||
fun getSurfaceView(): TextureView? {
|
||||
return videoView
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1042,7 +1297,7 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
* This should be called from MainActivity when video playback is active.
|
||||
*/
|
||||
fun attachSurfaceToActivity(activity: android.app.Activity) {
|
||||
if (surfaceView != null && currentMediaType == MediaType.VIDEO) {
|
||||
if (videoView != null && currentMediaType == MediaType.VIDEO) {
|
||||
com.dtourolle.jellytau.VideoOverlayManager.attachVideoSurface(activity)
|
||||
android.util.Log.d("JellyTauPlayer", "Surface attached to Activity")
|
||||
}
|
||||
@@ -1088,7 +1343,7 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
*/
|
||||
fun fitSurfaceToScreen() {
|
||||
mainHandler.post {
|
||||
val view = surfaceView ?: return@post
|
||||
val view = videoView ?: return@post
|
||||
val parent = view.parent as? ViewGroup
|
||||
// Available area: prefer the parent's measured size, fall back to the screen.
|
||||
val availW = parent?.width?.takeIf { it > 0 }
|
||||
@@ -1120,6 +1375,20 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
if (lp is FrameLayout.LayoutParams) {
|
||||
lp.gravity = android.view.Gravity.CENTER
|
||||
}
|
||||
|
||||
// Deliberately no alpha-hiding across the resize.
|
||||
//
|
||||
// Two earlier attempts hid the view here (and from
|
||||
// onConfigurationChanged) until a fresh frame landed, on the reading
|
||||
// that the letterbox flash was a retained TextureView frame drawn at
|
||||
// the old size. It was not: the bars were showing stale *framebuffer*
|
||||
// content because nothing painted them — see the window-background
|
||||
// note in MainActivity.setTransparent. Hiding the video view made
|
||||
// that strictly worse, since the TextureView is the one view in the
|
||||
// hierarchy that reliably paints its own rect; dropping its alpha to
|
||||
// 0 simply widened the un-painted area.
|
||||
//
|
||||
// TRACES: UR-003, UR-066 | DR-194
|
||||
lp.width = targetW
|
||||
lp.height = targetH
|
||||
view.layoutParams = lp
|
||||
@@ -1132,22 +1401,47 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the video surface when switching to audio playback.
|
||||
* Clear the video surface when switching to audio playback, or on stop.
|
||||
*
|
||||
* Detaching is not optional bookkeeping: dropping the reference without
|
||||
* removing the view left the SurfaceView parented to the content view for
|
||||
* the life of the process, and the next video stacked another one under it.
|
||||
* See VideoOverlayManager.detachVideoSurface.
|
||||
*
|
||||
* Always called on the main thread (every caller runs inside a
|
||||
* `mainHandler.post`), which is what touching the view hierarchy requires.
|
||||
*
|
||||
* TRACES: UR-003, UR-041 | DR-184
|
||||
*/
|
||||
private fun clearVideoSurface() {
|
||||
surfaceView?.let {
|
||||
// Whatever happens to the view, video is no longer what is on screen, so
|
||||
// the display hold goes with it. Outside the let: the hold must be
|
||||
// released even when no view was ever created. (DR-202)
|
||||
com.dtourolle.jellytau.ScreenWakeManager.onNativePlaybackChanged(false, false)
|
||||
videoView?.let {
|
||||
exoPlayer.clearVideoSurface()
|
||||
surfaceView = null
|
||||
surfaceHolder = null
|
||||
android.util.Log.d("JellyTauPlayer", "Video surface cleared")
|
||||
com.dtourolle.jellytau.VideoOverlayManager.detachVideoSurface()
|
||||
videoView = null
|
||||
android.util.Log.d("JellyTauPlayer", "Video surface cleared and detached")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request audio focus for video playback.
|
||||
* This is critical for video to have audio on Android.
|
||||
*
|
||||
* The listener installed here is the pause-on-call path: AUDIOFOCUS_LOSS and
|
||||
* AUDIOFOCUS_LOSS_TRANSIENT (an incoming call is the latter) both pause,
|
||||
* LOSS_TRANSIENT_CAN_DUCK lowers the volume instead, and GAIN restores —
|
||||
* resuming only what we paused, via pendingPlayOnFocusGain.
|
||||
*
|
||||
* TRACES: UR-004, UR-006 | IR-008, 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) {
|
||||
@@ -1164,17 +1458,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 -> {
|
||||
@@ -1185,16 +1491,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 {
|
||||
@@ -1206,10 +1521,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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1220,6 +1540,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)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.dtourolle.jellytau.player
|
||||
|
||||
/**
|
||||
* Whether the *player* is allowed to retry a failed load of what is currently
|
||||
* loaded, or whether recovery belongs to the backend instead.
|
||||
*
|
||||
* Pure state, deliberately free of any media3 or Android type so the decision is
|
||||
* unit-testable off-device — the same shape as `ScreenWakeState` (DR-202).
|
||||
*
|
||||
* ExoPlayer resumes a failed load in place only when it knows where "in place"
|
||||
* is: `ProgressiveMediaPeriod.configureRetry` keeps the load position when the
|
||||
* content length is known *or* the extractor produced a seek map with a
|
||||
* duration, and otherwise assumes the source is live — it resets every sample
|
||||
* queue and re-requests the URL from offset 0.
|
||||
*
|
||||
* The background-audio handoff transcode (UR-040) satisfies neither condition:
|
||||
* `/Audio/{id}/universal?Container=mp3&TranscodingProtocol=http` is chunked, so
|
||||
* there is no `Content-Length`, and a live mp3 encode carries no `Xing` header,
|
||||
* so the duration is unset — visible in logcat as every position tick reading
|
||||
* `<position> / 0.0`. Its URL carries `StartTimeTicks` = the handoff point, so a
|
||||
* restart from offset 0 drops playback back to where audio-only mode began and
|
||||
* carries on from there, and because that is a successful *retry* rather than a
|
||||
* failure, no error and no `STATE_ENDED` is ever reported: the app cannot see it
|
||||
* happen. That is the bug this exists to prevent (DR-203).
|
||||
*
|
||||
* Rust decides which streams those are and says so on every load; this only
|
||||
* remembers the answer for the load-error policy to read. Refusing the retry
|
||||
* turns the silent rewind into a recoverable error, which the backend answers by
|
||||
* re-opening the stream at the position playback actually reached (DR-129).
|
||||
*
|
||||
* TRACES: UR-040, UR-004 | DR-203 | UT-200
|
||||
*/
|
||||
class StreamRetryDecision {
|
||||
@Volatile
|
||||
private var nonResumableStream = false
|
||||
|
||||
/**
|
||||
* Record what is being loaded.
|
||||
*
|
||||
* @param nonResumable whether re-requesting this stream would restart it
|
||||
* rather than continue it — `player_retry_restarts_stream` in Rust.
|
||||
*/
|
||||
fun onLoad(nonResumable: Boolean) {
|
||||
nonResumableStream = nonResumable
|
||||
}
|
||||
|
||||
/** True while the player may handle a load error by retrying it itself. */
|
||||
val playerMayRetry: Boolean
|
||||
get() = !nonResumableStream
|
||||
}
|
||||
@@ -100,9 +100,27 @@ class SecureStorage private constructor(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a credential.
|
||||
*
|
||||
* Returns null for both "nothing stored" and "stored but undecryptable", but
|
||||
* treats them as distinct events. The second happens after a backup restore
|
||||
* or a device-to-device transfer: SharedPreferences travel, the Android
|
||||
* Keystore key that encrypted them never does, so the ciphertext can never
|
||||
* be read again on this install. That blob is discarded here rather than
|
||||
* left to fail on every subsequent read, which turns a permanently broken
|
||||
* credential into a clean logged-out state. (The app also declares
|
||||
* allowBackup="false" plus data-extraction rules so this should no longer
|
||||
* arise - this is the belt to that manifest's braces.)
|
||||
*/
|
||||
fun getCredential(key: String): String? {
|
||||
try {
|
||||
val encoded = prefs.getString(key, null) ?: return null
|
||||
val encoded = prefs.getString(key, null)
|
||||
if (encoded == null) {
|
||||
Log.d(TAG, "No credential stored for: $key")
|
||||
return null
|
||||
}
|
||||
|
||||
return try {
|
||||
val combined = Base64.decode(encoded, Base64.DEFAULT)
|
||||
|
||||
// Extract IV (first 12 bytes for GCM)
|
||||
@@ -114,10 +132,16 @@ class SecureStorage private constructor(context: Context) {
|
||||
cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec)
|
||||
|
||||
val decrypted = cipher.doFinal(encrypted)
|
||||
return String(decrypted, Charsets.UTF_8)
|
||||
String(decrypted, Charsets.UTF_8)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to get credential: $key", e)
|
||||
return null
|
||||
Log.w(
|
||||
TAG,
|
||||
"Credential '$key' is present but cannot be decrypted; discarding it and " +
|
||||
"reporting no credential. Signing in again will store a fresh one.",
|
||||
e
|
||||
)
|
||||
prefs.edit().remove(key).apply()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,45 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Backup / transfer policy for JellyTau (API 31+; see android:allowBackup in
|
||||
AndroidManifest.xml for API 24-30).
|
||||
|
||||
Nothing is eligible for extraction, from either channel:
|
||||
|
||||
* cloud-backup - already off via android:allowBackup="false".
|
||||
* device-transfer - NOT covered by allowBackup on Android 12+, which is why
|
||||
this file exists. A D2D transfer would otherwise copy the same data the
|
||||
cloud backup used to.
|
||||
|
||||
Why nothing is extractable:
|
||||
|
||||
* Credentials are unrecoverable off-device. jellytau_secure_prefs holds
|
||||
AES-GCM ciphertext encrypted under an Android Keystore key, and Keystore
|
||||
keys are never backed up or transferred. Restoring the prefs without the
|
||||
key produces ciphertext nothing can read - a silent auth failure that looks
|
||||
like a broken app rather than a logged-out one.
|
||||
* Everything else is a rebuildable cache. The SQLite catalogue is a mirror of
|
||||
the Jellyfin server (library metadata, watch history, offline downloads);
|
||||
signing in again reproduces it, and watch state lives on the server anyway.
|
||||
Backing it up would export a user's library and viewing history to their
|
||||
Google account for no gain.
|
||||
|
||||
Exclude rules are listed per domain rather than relying on "root" alone,
|
||||
because database/, shared_prefs/, files/ and external storage are addressed
|
||||
as their own domains by the extraction engine.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="root" />
|
||||
<exclude domain="file" />
|
||||
<exclude domain="database" />
|
||||
<exclude domain="sharedpref" />
|
||||
<exclude domain="external" />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="root" />
|
||||
<exclude domain="file" />
|
||||
<exclude domain="database" />
|
||||
<exclude domain="sharedpref" />
|
||||
<exclude domain="external" />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,28 @@
|
||||
<?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.
|
||||
|
||||
This file is only half the policy. MainActivity.configureWebViewSettings sets
|
||||
the webview's mixedContentMode and its file/content access flags; setting
|
||||
MIXED_CONTENT_ALWAYS_ALLOW there re-opened by hand what this config closes,
|
||||
which is DR-199. Change the two together, or not at all.
|
||||
|
||||
TRACES: UR-071 | DR-138, DR-199
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false" />
|
||||
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">127.0.0.1</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.dtourolle.jellytau
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The screen-wake decision, isolated from the Activity window it is applied to.
|
||||
*
|
||||
* TRACES: UR-003 | DR-202 | UT-199
|
||||
*/
|
||||
class ScreenWakeStateTest {
|
||||
|
||||
@Test
|
||||
fun `starts released`() {
|
||||
assertFalse(ScreenWakeState().keepScreenOn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native video playing holds the screen on`() {
|
||||
val state = ScreenWakeState()
|
||||
state.updateNative(playing = true, isVideo = true)
|
||||
assertTrue(state.keepScreenOn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pausing native video releases the screen`() {
|
||||
val state = ScreenWakeState()
|
||||
state.updateNative(playing = true, isVideo = true)
|
||||
state.updateNative(playing = false, isVideo = true)
|
||||
assertFalse(state.keepScreenOn)
|
||||
}
|
||||
|
||||
/** Music with the screen off is the whole point of the audio path. */
|
||||
@Test
|
||||
fun `native audio playing does not hold the screen on`() {
|
||||
val state = ScreenWakeState()
|
||||
state.updateNative(playing = true, isVideo = false)
|
||||
assertFalse(state.keepScreenOn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `webview video playing holds the screen on`() {
|
||||
val state = ScreenWakeState()
|
||||
state.updateHtml5(active = true, playing = true)
|
||||
assertTrue(state.keepScreenOn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `webview video paused releases the screen`() {
|
||||
val state = ScreenWakeState()
|
||||
state.updateHtml5(active = true, playing = true)
|
||||
state.updateHtml5(active = true, playing = false)
|
||||
assertFalse(state.keepScreenOn)
|
||||
}
|
||||
|
||||
/** The element going away must release even if it never reported a pause. */
|
||||
@Test
|
||||
fun `webview video going inactive while playing releases the screen`() {
|
||||
val state = ScreenWakeState()
|
||||
state.updateHtml5(active = true, playing = true)
|
||||
state.updateHtml5(active = false, playing = true)
|
||||
assertFalse(state.keepScreenOn)
|
||||
}
|
||||
|
||||
/** The two rendering paths are independent holders; either one is enough. */
|
||||
@Test
|
||||
fun `one path releasing does not release while the other still plays`() {
|
||||
val state = ScreenWakeState()
|
||||
state.updateNative(playing = true, isVideo = true)
|
||||
state.updateHtml5(active = true, playing = true)
|
||||
state.updateHtml5(active = false, playing = false)
|
||||
assertTrue(state.keepScreenOn)
|
||||
state.updateNative(playing = false, isVideo = true)
|
||||
assertFalse(state.keepScreenOn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `teardown releases both paths`() {
|
||||
val state = ScreenWakeState()
|
||||
state.updateNative(playing = true, isVideo = true)
|
||||
state.updateHtml5(active = true, playing = true)
|
||||
state.reset()
|
||||
assertFalse(state.keepScreenOn)
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.dtourolle.jellytau.player
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Who owns recovery for the stream that is loaded.
|
||||
*
|
||||
* TRACES: UR-040, UR-004 | DR-203 | UT-200
|
||||
*/
|
||||
class StreamRetryDecisionTest {
|
||||
|
||||
/** Nothing loaded yet is an ordinary stream: the player retries as it always has. */
|
||||
@Test
|
||||
fun `starts allowing the player to retry`() {
|
||||
assertTrue(StreamRetryDecision().playerMayRetry)
|
||||
}
|
||||
|
||||
/**
|
||||
* The reported bug: the length-less handoff transcode can only be "retried"
|
||||
* from its beginning, which replays the episode from the handoff point
|
||||
* without reporting anything. The player must not be allowed to try.
|
||||
*/
|
||||
@Test
|
||||
fun `a non-resumable stream refuses the player its retry`() {
|
||||
val decision = StreamRetryDecision()
|
||||
decision.onLoad(nonResumable = true)
|
||||
assertFalse(decision.playerMayRetry)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an ordinary stream keeps the player retry`() {
|
||||
val decision = StreamRetryDecision()
|
||||
decision.onLoad(nonResumable = false)
|
||||
assertTrue(decision.playerMayRetry)
|
||||
}
|
||||
|
||||
/** The next load decides for itself — the handoff must not outlive its item. */
|
||||
@Test
|
||||
fun `loading an ordinary stream after a handoff restores the retry`() {
|
||||
val decision = StreamRetryDecision()
|
||||
decision.onLoad(nonResumable = true)
|
||||
decision.onLoad(nonResumable = false)
|
||||
assertTrue(decision.playerMayRetry)
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,18 @@ impl AuthManager {
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
/// Normalize a username before it goes to the server.
|
||||
///
|
||||
/// Only surrounding whitespace is stripped — interior spaces are legal in
|
||||
/// Jellyfin usernames. Without this, a trailing space from a soft keyboard's
|
||||
/// autocorrect makes the server report an unknown user, which surfaces as a
|
||||
/// 401 that looks exactly like a wrong password.
|
||||
///
|
||||
/// TRACES: UR-042 | DR-054
|
||||
pub fn normalize_username(username: &str) -> String {
|
||||
username.trim().to_string()
|
||||
}
|
||||
|
||||
/// Connect to server and get server info
|
||||
pub async fn connect_to_server(&self, server_url: &str) -> Result<ServerInfo, String> {
|
||||
let normalized_url = Self::normalize_url(server_url)?;
|
||||
@@ -185,6 +197,7 @@ impl AuthManager {
|
||||
) -> Result<AuthResult, String> {
|
||||
let url = Self::normalize_url(server_url)?;
|
||||
let endpoint = format!("{}/Users/AuthenticateByName", url);
|
||||
let username = Self::normalize_username(username);
|
||||
|
||||
log::info!("[AuthManager] Authenticating user: {}", username);
|
||||
|
||||
@@ -443,6 +456,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Usernames must be trimmed before they reach the server: the Android soft
|
||||
/// keyboard appends a trailing space after autocorrect, and Jellyfin then
|
||||
/// reports an unknown user — a 401 indistinguishable from a wrong password.
|
||||
#[test]
|
||||
fn test_normalize_username_trims_whitespace() {
|
||||
assert_eq!(AuthManager::normalize_username("duncan "), "duncan");
|
||||
assert_eq!(AuthManager::normalize_username(" duncan"), "duncan");
|
||||
assert_eq!(AuthManager::normalize_username(" duncan "), "duncan");
|
||||
assert_eq!(AuthManager::normalize_username("duncan\n"), "duncan");
|
||||
}
|
||||
|
||||
/// Interior spaces are legal in Jellyfin usernames and must survive.
|
||||
#[test]
|
||||
fn test_normalize_username_preserves_interior_spaces() {
|
||||
assert_eq!(
|
||||
AuthManager::normalize_username(" duncan tourolle "),
|
||||
"duncan tourolle"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test URL normalization - real world case
|
||||
#[test]
|
||||
fn test_normalize_url_real_world_case() {
|
||||
|
||||
@@ -418,13 +418,13 @@ mod tests {
|
||||
#[test]
|
||||
fn test_auth_manager_wrapper_structure() {
|
||||
// Verify wrapper type exists and has correct structure
|
||||
assert_eq!(std::mem::size_of::<AuthManagerWrapper>() > 0, true);
|
||||
assert!(std::mem::size_of::<AuthManagerWrapper>() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_verifier_wrapper_structure() {
|
||||
// Verify wrapper type exists and has correct structure
|
||||
assert_eq!(std::mem::size_of::<SessionVerifierWrapper>() > 0, true);
|
||||
assert!(std::mem::size_of::<SessionVerifierWrapper>() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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,24 +471,111 @@ 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.
|
||||
/// The `resolve` closure receives `(item_id, media_type, quality_preset)`.
|
||||
///
|
||||
/// `only_ids` restricts the sweep to specific download rows. Reconnect passes
|
||||
/// `None` and heals everything; a bulk enqueue (an album, say) passes the rows
|
||||
/// it just created, so clicking download on one album cannot also start every
|
||||
/// unrelated row that has been sitting pending.
|
||||
pub(crate) async fn resolve_pending_download_urls<F, Fut>(
|
||||
db_service: &Arc<crate::storage::db_service::RusqliteService>,
|
||||
target_dir: &str,
|
||||
only_ids: Option<&[i64]>,
|
||||
resolve: F,
|
||||
) -> Result<ResumeQueuedResult, String>
|
||||
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",
|
||||
);
|
||||
if only_ids.is_some_and(|ids| ids.is_empty()) {
|
||||
return Ok(ResumeQueuedResult {
|
||||
resolved: 0,
|
||||
failed: 0,
|
||||
});
|
||||
}
|
||||
// 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 id_filter = match only_ids {
|
||||
Some(ids) => format!(
|
||||
" AND d.id IN ({})",
|
||||
ids.iter()
|
||||
.map(|id| id.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
),
|
||||
None => String::new(),
|
||||
};
|
||||
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{id_filter}"
|
||||
));
|
||||
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)?))
|
||||
@@ -285,8 +653,6 @@ pub async fn resume_queued_downloads(
|
||||
) -> Result<ResumeQueuedResult, String> {
|
||||
use crate::repository::MediaRepository;
|
||||
|
||||
use crate::repository::HybridRepository;
|
||||
|
||||
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
|
||||
|
||||
// The pump needs a target_dir; use the same storage root the other download
|
||||
@@ -317,22 +683,34 @@ 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(
|
||||
&db_service,
|
||||
&target_dir,
|
||||
None,
|
||||
move |item_id: String, media_type: String, quality: String| {
|
||||
let repo = Arc::clone(&repo_for_resolve);
|
||||
async move {
|
||||
if media_type == "video" {
|
||||
Some(
|
||||
<HybridRepository as MediaRepository>::get_video_download_url(
|
||||
crate::repository::resolve_video_download_url(
|
||||
repo.as_ref(),
|
||||
&item_id,
|
||||
&quality,
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await,
|
||||
)
|
||||
} else {
|
||||
match repo.get_audio_stream_url(&item_id).await {
|
||||
@@ -375,9 +753,47 @@ pub async fn resume_queued_downloads(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::storage::db_service::RusqliteService;
|
||||
use crate::utils::lock::MutexSafe;
|
||||
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 +805,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 +821,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,
|
||||
@@ -449,12 +885,14 @@ mod tests {
|
||||
// A completed row: irrelevant.
|
||||
insert_download(&db, "done", "completed", Some("http://done/url"), None).await;
|
||||
|
||||
let out =
|
||||
resolve_pending_download_urls(&db, "/data/downloads", |item_id, _mt, _q| async move {
|
||||
Some(format!("http://resolved/{item_id}"))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let out = resolve_pending_download_urls(
|
||||
&db,
|
||||
"/data/downloads",
|
||||
None,
|
||||
|item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}")) },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.resolved, 1);
|
||||
assert_eq!(out.failed, 0);
|
||||
@@ -470,15 +908,79 @@ mod tests {
|
||||
assert_eq!(url2.as_deref(), Some("http://existing/url"));
|
||||
}
|
||||
|
||||
/// A bulk enqueue resolves only the rows it just created. Downloading one
|
||||
/// album must not also start every unrelated row that has been sitting
|
||||
/// pending with no URL (the smart cache leaves plenty of those).
|
||||
///
|
||||
/// TRACES: UR-018, UR-055 | DR-173 | UT-171
|
||||
#[tokio::test]
|
||||
async fn only_ids_restricts_the_sweep_to_the_given_rows() {
|
||||
let db = test_db();
|
||||
insert_download(&db, "mine", "pending", None, Some("audio")).await;
|
||||
insert_download(&db, "someone-elses", "pending", None, Some("audio")).await;
|
||||
|
||||
let mine: i64 = db
|
||||
.query_one(
|
||||
Query::new("SELECT id FROM downloads WHERE item_id = 'mine'"),
|
||||
|row| row.get(0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let out = resolve_pending_download_urls(
|
||||
&db,
|
||||
"/data",
|
||||
Some(&[mine]),
|
||||
|item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}")) },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.resolved, 1);
|
||||
assert_eq!(out.failed, 0);
|
||||
|
||||
let (_s, url, _t) = get_row(&db, "mine").await;
|
||||
assert_eq!(url.as_deref(), Some("http://resolved/mine"));
|
||||
|
||||
let (status, other_url, _t) = get_row(&db, "someone-elses").await;
|
||||
assert_eq!(status, "pending");
|
||||
assert_eq!(
|
||||
other_url, None,
|
||||
"a scoped resolve must leave unrelated pending rows alone"
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty id list resolves nothing — it must not fall through to "sweep
|
||||
/// everything", which is what an unguarded `IN ()` would amount to.
|
||||
///
|
||||
/// TRACES: UR-018, UR-055 | DR-173 | UT-171
|
||||
#[tokio::test]
|
||||
async fn an_empty_id_list_resolves_nothing() {
|
||||
let db = test_db();
|
||||
insert_download(&db, "untouched", "pending", None, Some("audio")).await;
|
||||
|
||||
let out =
|
||||
resolve_pending_download_urls(&db, "/data", Some(&[]), |item_id, _mt, _q| async move {
|
||||
Some(format!("http://resolved/{item_id}"))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.resolved, 0);
|
||||
let (_s, url, _t) = get_row(&db, "untouched").await;
|
||||
assert_eq!(url, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn counts_unresolvable_rows_as_failed_and_leaves_them_pending() {
|
||||
let db = test_db();
|
||||
insert_download(&db, "bad", "pending", None, None).await;
|
||||
|
||||
// Resolver returns None (e.g. server lookup failed).
|
||||
let out = resolve_pending_download_urls(&db, "/data", |_id, _mt, _q| async move { None })
|
||||
.await
|
||||
.unwrap();
|
||||
let out =
|
||||
resolve_pending_download_urls(&db, "/data", None, |_id, _mt, _q| async move { None })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.resolved, 0);
|
||||
assert_eq!(out.failed, 1);
|
||||
@@ -489,18 +991,153 @@ 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", None, move |item_id, media_type, _q| {
|
||||
let seen = Arc::clone(&seen_c);
|
||||
async move {
|
||||
seen.lock_safe().push((item_id.clone(), media_type));
|
||||
Some(format!("http://resolved/{item_id}"))
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let seen = seen.lock_safe().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", None, move |_id, media_type, _q| {
|
||||
let seen = Arc::clone(&seen_c);
|
||||
async move {
|
||||
*seen.lock_safe() = media_type;
|
||||
Some("http://x".to_string())
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(*seen.lock_safe(), "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", None, move |_id, media_type, _q| {
|
||||
let seen = Arc::clone(&seen_c);
|
||||
async move {
|
||||
*seen.lock_safe() = media_type;
|
||||
Some("http://x".to_string())
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(*seen.lock_safe(), "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();
|
||||
insert_download(&db, "vid-1", "pending", None, Some("video")).await;
|
||||
|
||||
let out =
|
||||
resolve_pending_download_urls(&db, "/data", |item_id, media_type, _q| async move {
|
||||
let out = resolve_pending_download_urls(
|
||||
&db,
|
||||
"/data",
|
||||
None,
|
||||
|item_id, media_type, _q| async move {
|
||||
assert_eq!(media_type, "video");
|
||||
Some(format!("http://transcode/{item_id}"))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.resolved, 1);
|
||||
let (_s, url, _t) = get_row(&db, "vid-1").await;
|
||||
|
||||
@@ -97,6 +97,6 @@ mod tests {
|
||||
// due to its dependencies, so we just test the wrapper type structure
|
||||
|
||||
// This verifies the wrapper type exists and can hold Arc<Mutex>
|
||||
assert_eq!(std::mem::size_of::<ConnectivityMonitorWrapper>() > 0, true);
|
||||
assert!(std::mem::size_of::<ConnectivityMonitorWrapper>() > 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ mod smart_cache;
|
||||
pub use pinning::*;
|
||||
pub use smart_cache::*;
|
||||
|
||||
/// One row of the series episode listing used when queueing a whole series:
|
||||
/// `(id, name, season_name, index_number, parent_index_number)`.
|
||||
type EpisodeRow = (String, String, Option<String>, Option<i32>, Option<i32>);
|
||||
|
||||
/// Wrapper for DownloadManager to be used as Tauri state
|
||||
pub struct DownloadManagerWrapper(pub Mutex<DownloadManager>);
|
||||
|
||||
@@ -270,6 +274,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)
|
||||
@@ -337,57 +354,209 @@ pub async fn download_item(
|
||||
Ok(download_id)
|
||||
}
|
||||
|
||||
/// Queue an entire album for download
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn download_album(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
album_id: String,
|
||||
user_id: String,
|
||||
base_path: String,
|
||||
) -> Result<Vec<i64>, String> {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
};
|
||||
/// One track of an album, as the album-download path queues it.
|
||||
///
|
||||
/// `artist_name` carries whatever the catalog holds for the track's artists (a
|
||||
/// JSON array, as stored on `items.artists`); it is display metadata for the
|
||||
/// downloads list, not a lookup key.
|
||||
///
|
||||
/// TRACES: UR-018, UR-055 | DR-173
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct AlbumTrack {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub artist_name: Option<String>,
|
||||
pub album_name: Option<String>,
|
||||
pub index_number: Option<i32>,
|
||||
}
|
||||
|
||||
// Get all tracks in the album with metadata
|
||||
impl From<&crate::repository::types::MediaItem> for AlbumTrack {
|
||||
fn from(item: &crate::repository::types::MediaItem) -> Self {
|
||||
Self {
|
||||
id: item.id.clone(),
|
||||
name: item.name.clone(),
|
||||
artist_name: item
|
||||
.artists
|
||||
.as_ref()
|
||||
.and_then(|a| serde_json::to_string(a).ok()),
|
||||
album_name: item.album_name.clone(),
|
||||
index_number: item.index_number,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The album's tracks as the local catalog cache knows them.
|
||||
///
|
||||
/// Only a fallback for [`download_album`]: the cache links a track to its album
|
||||
/// through `items.album_id`, which Jellyfin does not populate on every listing
|
||||
/// endpoint, so this can legitimately return fewer tracks than the album has.
|
||||
///
|
||||
/// TRACES: UR-018, UR-055 | DR-173
|
||||
pub(crate) async fn cached_album_tracks(
|
||||
db_service: &Arc<crate::storage::db_service::RusqliteService>,
|
||||
album_id: &str,
|
||||
) -> Result<Vec<AlbumTrack>, String> {
|
||||
let tracks_query = Query::with_params(
|
||||
"SELECT id, name, artists, album_name FROM items
|
||||
WHERE album_id = ? AND item_type = 'Audio'
|
||||
"SELECT id, name, artists, album_name, index_number FROM items
|
||||
WHERE (album_id = ? OR parent_id = ?) AND item_type = 'Audio'
|
||||
ORDER BY index_number",
|
||||
vec![QueryParam::String(album_id)],
|
||||
vec![
|
||||
QueryParam::String(album_id.to_string()),
|
||||
QueryParam::String(album_id.to_string()),
|
||||
],
|
||||
);
|
||||
|
||||
let tracks: Vec<(String, String, Option<String>, Option<String>)> = db_service
|
||||
db_service
|
||||
.query_many(tracks_query, |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
|
||||
Ok(AlbumTrack {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
artist_name: row.get(2)?,
|
||||
album_name: row.get(3)?,
|
||||
index_number: row.get(4)?,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
let mut download_ids = Vec::new();
|
||||
/// Queue one download row per track and link every track to its album.
|
||||
///
|
||||
/// The linkage is the half that is easy to miss: offline browsing joins a track
|
||||
/// to its album on `items.album_id` (see `OfflineRepository::get_items`), so a
|
||||
/// track whose cached row lacks it stays invisible under the album even after
|
||||
/// its file is on disk. Queuing a track *is* the statement that it belongs to
|
||||
/// this album, so the link is written here rather than hoped for from whichever
|
||||
/// listing endpoint happened to cache the row.
|
||||
///
|
||||
/// Idempotent: re-queuing an album fills in what is missing and returns the same
|
||||
/// row ids, in the order the tracks were given.
|
||||
///
|
||||
/// A file name per track, unique within the album.
|
||||
///
|
||||
/// A title is not a unique name inside its own album: a deluxe edition carries
|
||||
/// the album version and a demo of the same song, and a two-disc set repeats
|
||||
/// titles across discs. Naming files after the title alone gave those tracks one
|
||||
/// path, and each download overwrote the previous one — an album that quietly
|
||||
/// ends up short by however many titles it repeats. The track number
|
||||
/// disambiguates the ordinary case; anything still colliding falls back to the
|
||||
/// item id, which is unique by construction.
|
||||
///
|
||||
/// TRACES: UR-018, UR-055 | DR-173 | UT-172
|
||||
pub(crate) fn album_file_names(tracks: &[AlbumTrack]) -> Vec<String> {
|
||||
let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
for track in tracks {
|
||||
*counts.entry(track.name.to_lowercase()).or_default() += 1;
|
||||
}
|
||||
|
||||
// Queue each track with album priority (100) and metadata
|
||||
for (track_id, track_name, artist_name, album_name) in tracks {
|
||||
let file_path = format!("{}/{}.mp3", base_path, sanitize_filename(&track_name));
|
||||
tracks
|
||||
.iter()
|
||||
.map(|track| {
|
||||
let title = sanitize_filename(&track.name);
|
||||
if counts.get(&track.name.to_lowercase()).copied().unwrap_or(0) <= 1 {
|
||||
return format!("{}.mp3", title);
|
||||
}
|
||||
match track.index_number {
|
||||
Some(n) => format!("{:02} - {} [{}].mp3", n, title, track.id),
|
||||
None => format!("{} [{}].mp3", title, track.id),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// TRACES: UR-018, UR-055 | DR-173 | UT-170
|
||||
pub(crate) async fn queue_album_tracks(
|
||||
db_service: &Arc<crate::storage::db_service::RusqliteService>,
|
||||
album_id: &str,
|
||||
tracks: &[AlbumTrack],
|
||||
user_id: &str,
|
||||
base_path: &str,
|
||||
) -> Result<Vec<i64>, String> {
|
||||
let mut download_ids = Vec::with_capacity(tracks.len());
|
||||
let file_names = album_file_names(tracks);
|
||||
|
||||
for (track, file_name) in tracks.iter().zip(file_names) {
|
||||
// Cache a row for a track the catalog has never seen, borrowing the
|
||||
// album's server. Nothing is inserted when the album itself is unknown,
|
||||
// which also keeps the parent_id foreign key satisfiable.
|
||||
let cache_query = Query::with_params(
|
||||
"INSERT OR IGNORE INTO items
|
||||
(id, server_id, parent_id, name, item_type, album_id, album_name, artists, index_number)
|
||||
SELECT ?, a.server_id, a.id, ?, 'Audio', a.id, ?, ?, ?
|
||||
FROM items a WHERE a.id = ?",
|
||||
vec![
|
||||
QueryParam::String(track.id.clone()),
|
||||
QueryParam::String(track.name.clone()),
|
||||
track
|
||||
.album_name
|
||||
.clone()
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
track
|
||||
.artist_name
|
||||
.clone()
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
track
|
||||
.index_number
|
||||
.map(QueryParam::Int)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
QueryParam::String(album_id.to_string()),
|
||||
],
|
||||
);
|
||||
db_service
|
||||
.execute(cache_query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Link an already-cached track to the album. The parent_id subquery
|
||||
// resolves to NULL when the album is not cached, so the foreign key
|
||||
// holds either way.
|
||||
let link_query = Query::with_params(
|
||||
"UPDATE items
|
||||
SET album_id = ?,
|
||||
parent_id = COALESCE(parent_id, (SELECT id FROM items WHERE id = ?))
|
||||
WHERE id = ?",
|
||||
vec![
|
||||
QueryParam::String(album_id.to_string()),
|
||||
QueryParam::String(album_id.to_string()),
|
||||
QueryParam::String(track.id.clone()),
|
||||
],
|
||||
);
|
||||
db_service
|
||||
.execute(link_query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let file_path = format!("{}/{}", base_path, file_name);
|
||||
|
||||
// Queue at album priority (100). A track already downloaded stays
|
||||
// completed — re-queuing an album must fill the gaps, not re-fetch it.
|
||||
let insert_query = Query::with_params(
|
||||
"INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at, item_name, artist_name, album_name)
|
||||
VALUES (?, ?, ?, 'pending', 100, CURRENT_TIMESTAMP, ?, ?, ?)
|
||||
"INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at, item_name, artist_name, album_name, media_type)
|
||||
VALUES (?, ?, ?, 'pending', 100, CURRENT_TIMESTAMP, ?, ?, ?, 'audio')
|
||||
ON CONFLICT(item_id, user_id) DO UPDATE SET
|
||||
priority = 100,
|
||||
status = 'pending',
|
||||
status = CASE WHEN downloads.status = 'completed' THEN 'completed' ELSE 'pending' END,
|
||||
media_type = 'audio',
|
||||
item_name = COALESCE(excluded.item_name, downloads.item_name),
|
||||
artist_name = COALESCE(excluded.artist_name, downloads.artist_name),
|
||||
album_name = COALESCE(excluded.album_name, downloads.album_name)",
|
||||
vec![
|
||||
QueryParam::String(track_id.clone()),
|
||||
QueryParam::String(user_id.clone()),
|
||||
QueryParam::String(track.id.clone()),
|
||||
QueryParam::String(user_id.to_string()),
|
||||
QueryParam::String(file_path),
|
||||
QueryParam::String(track_name),
|
||||
artist_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
album_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
QueryParam::String(track.name.clone()),
|
||||
track
|
||||
.artist_name
|
||||
.clone()
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
track
|
||||
.album_name
|
||||
.clone()
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -400,8 +569,8 @@ pub async fn download_album(
|
||||
let id_query = Query::with_params(
|
||||
"SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
|
||||
vec![
|
||||
QueryParam::String(track_id),
|
||||
QueryParam::String(user_id.clone()),
|
||||
QueryParam::String(track.id.clone()),
|
||||
QueryParam::String(user_id.to_string()),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -415,6 +584,133 @@ pub async fn download_album(
|
||||
Ok(download_ids)
|
||||
}
|
||||
|
||||
/// Queue an entire album for download.
|
||||
///
|
||||
/// Owns the whole operation: the album's track list comes from the server (the
|
||||
/// only place that knows all of it), every track is queued and linked to its
|
||||
/// album, each row's stream URL is resolved here, and the queue is pumped.
|
||||
///
|
||||
/// The frontend used to do the second half — resolve one URL per track and pair
|
||||
/// it with the returned ids **by position**. That pairing had no basis: the ids
|
||||
/// came back in the backend's own order over a different set of rows, so
|
||||
/// whenever the two lists disagreed a row was handed another track's URL, and
|
||||
/// any track past the end of the shorter list was never started at all. Nothing
|
||||
/// crosses the boundary now except the album id.
|
||||
///
|
||||
/// TRACES: UR-018, UR-055 | DR-173 | UT-170
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
// Three of the eight arguments are Tauri `State<'_, _>` injections plus the
|
||||
// `AppHandle`, not caller input. Folding the rest into a struct would change the
|
||||
// IPC contract and the generated TypeScript for no readability gain.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn download_album(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
repository: State<'_, crate::commands::repository::RepositoryManagerWrapper>,
|
||||
download_manager: State<'_, DownloadManagerWrapper>,
|
||||
app: tauri::AppHandle,
|
||||
handle: String,
|
||||
album_id: String,
|
||||
user_id: String,
|
||||
base_path: String,
|
||||
) -> Result<Vec<i64>, String> {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
let repo = repository.0.get(&handle);
|
||||
|
||||
// Ask the server what the album contains; the cache is only a fallback for
|
||||
// when it cannot answer.
|
||||
let tracks: Vec<AlbumTrack> = match &repo {
|
||||
Some(repo) => match repo.get_album_tracks(&album_id).await {
|
||||
Ok(items) if !items.is_empty() => items.iter().map(AlbumTrack::from).collect(),
|
||||
Ok(_) => cached_album_tracks(&db_service, &album_id).await?,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"[download_album] Could not list album {} from the repository ({:?}); \
|
||||
falling back to the cached track list",
|
||||
album_id, e
|
||||
);
|
||||
cached_album_tracks(&db_service, &album_id).await?
|
||||
}
|
||||
},
|
||||
None => cached_album_tracks(&db_service, &album_id).await?,
|
||||
};
|
||||
|
||||
if tracks.is_empty() {
|
||||
warn!("[download_album] No tracks found for album {}", album_id);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let download_ids =
|
||||
queue_album_tracks(&db_service, &album_id, &tracks, &user_id, &base_path).await?;
|
||||
|
||||
info!(
|
||||
"[download_album] Queued {} track(s) for album {}",
|
||||
download_ids.len(),
|
||||
album_id
|
||||
);
|
||||
|
||||
// Resolve each queued row's stream URL here, then pump. Without a
|
||||
// repository (or while offline) the rows stay pending with no URL and
|
||||
// `resume_queued_downloads` picks them up on reconnect.
|
||||
let Some(repo) = repo else {
|
||||
return Ok(download_ids);
|
||||
};
|
||||
|
||||
let target_dir = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
database
|
||||
.path()
|
||||
.parent()
|
||||
.ok_or_else(|| "Database path has no parent directory".to_string())?
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
};
|
||||
|
||||
let repo_for_resolve = Arc::clone(&repo);
|
||||
let outcome = crate::commands::catalog::resolve_pending_download_urls(
|
||||
&db_service,
|
||||
&target_dir,
|
||||
Some(&download_ids),
|
||||
move |item_id: String, _media_type: String, _quality: String| {
|
||||
let repo = Arc::clone(&repo_for_resolve);
|
||||
async move {
|
||||
use crate::repository::MediaRepository;
|
||||
match repo.get_audio_stream_url(&item_id).await {
|
||||
Ok(url) => Some(url),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"[download_album] Failed to resolve stream URL for {}: {:?}",
|
||||
item_id, e
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
if outcome.failed > 0 {
|
||||
warn!(
|
||||
"[download_album] {} track(s) could not be resolved and stay queued for the next \
|
||||
reconnect",
|
||||
outcome.failed
|
||||
);
|
||||
}
|
||||
|
||||
let active_downloads = {
|
||||
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
||||
manager.get_active_downloads()
|
||||
};
|
||||
pump_download_queue(app, db_service, active_downloads).await;
|
||||
|
||||
Ok(download_ids)
|
||||
}
|
||||
|
||||
/// Queue a video item (movie or episode) for download with quality preset
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
@@ -519,7 +815,7 @@ pub async fn download_series(
|
||||
vec![QueryParam::String(series_id)],
|
||||
);
|
||||
|
||||
let episodes: Vec<(String, String, Option<String>, Option<i32>, Option<i32>)> = db_service
|
||||
let episodes: Vec<EpisodeRow> = db_service
|
||||
.query_many(episodes_query, |row| {
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
@@ -624,6 +920,10 @@ pub async fn download_series(
|
||||
/// Queue all episodes of a specific season for download
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
// One of the eight arguments is a Tauri `State<'_, _>` injection; the rest are
|
||||
// the season's identifying fields. Folding them into a struct would change the
|
||||
// IPC contract and the generated TypeScript for no readability gain.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn download_season(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
season_id: String,
|
||||
@@ -832,7 +1132,19 @@ pub async fn get_downloads(
|
||||
Ok(DownloadsResponse { downloads, stats })
|
||||
}
|
||||
|
||||
/// Pause a download
|
||||
/// Pause a download.
|
||||
///
|
||||
/// Writing `status = 'paused'` is only half of it, and used to be all of it: the
|
||||
/// streaming task knew nothing about the row and kept running, then overwrote it
|
||||
/// with `completed`/`failed` when it finished. The row flicked to "paused" and
|
||||
/// undid itself — the reported "pause does not work". Signalling the worker is
|
||||
/// what actually stops the bytes; it leaves the `.part` file in place so
|
||||
/// [`resume_download`] can continue from it.
|
||||
///
|
||||
/// A queued (not yet started) download has no worker to signal, and the status
|
||||
/// write alone is enough — the pump skips anything that is not `pending`.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-168
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn pause_download(
|
||||
@@ -845,19 +1157,34 @@ pub async fn pause_download(
|
||||
};
|
||||
|
||||
let query = Query::with_params(
|
||||
"UPDATE downloads SET status = 'paused' WHERE id = ? AND status = 'downloading'",
|
||||
"UPDATE downloads SET status = 'paused' WHERE id = ? AND status IN ('downloading', 'pending')",
|
||||
vec![QueryParam::Int64(download_id)],
|
||||
);
|
||||
|
||||
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||
|
||||
let was_running = crate::download::stop::signal(download_id);
|
||||
info!(
|
||||
"[pause] Download {} paused (in flight: {})",
|
||||
download_id, was_running
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resume a paused download
|
||||
/// Resume a paused download.
|
||||
///
|
||||
/// Flipping the row back to `pending` is likewise not enough on its own: the
|
||||
/// pump is not a poller, it runs when something calls it, so a resumed download
|
||||
/// sat untouched until some unrelated event happened to pump the queue. That is
|
||||
/// the other half of "resume does not work".
|
||||
///
|
||||
/// TRACES: UR-055 | DR-168
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn resume_download(
|
||||
app: tauri::AppHandle,
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
download_manager: State<'_, DownloadManagerWrapper>,
|
||||
download_id: i64,
|
||||
) -> Result<(), String> {
|
||||
let db_service = {
|
||||
@@ -866,11 +1193,22 @@ pub async fn resume_download(
|
||||
};
|
||||
|
||||
let query = Query::with_params(
|
||||
"UPDATE downloads SET status = 'pending' WHERE id = ? AND status = 'paused'",
|
||||
"UPDATE downloads SET status = 'pending', error_message = NULL WHERE id = ? AND status IN ('paused', 'failed')",
|
||||
vec![QueryParam::Int64(download_id)],
|
||||
);
|
||||
|
||||
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||
|
||||
// Drop any stale stop flag before the pump can start this id again, or the
|
||||
// resumed run would read the pause that stopped it and halt immediately.
|
||||
crate::download::stop::clear(download_id);
|
||||
|
||||
let active_downloads = {
|
||||
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
||||
manager.get_active_downloads()
|
||||
};
|
||||
pump_download_queue(app, db_service, active_downloads).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -910,6 +1248,13 @@ pub async fn cancel_download(
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Stop the worker if this download is actually running. Without this the
|
||||
// task keeps streaming into a `.part` file whose `downloads` row has just
|
||||
// been deleted — bytes with nothing pointing at them, and the file below is
|
||||
// removed while still being written to. (DR-168)
|
||||
crate::download::stop::signal(download_id);
|
||||
crate::download::stop::clear(download_id);
|
||||
|
||||
// Unregister from download manager (in case it was active)
|
||||
{
|
||||
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
||||
@@ -921,10 +1266,12 @@ pub async fn cancel_download(
|
||||
);
|
||||
}
|
||||
|
||||
// Delete partial file if exists
|
||||
// Delete the partial file, and any completed file, if present. Both go
|
||||
// through `partial_path` so this cannot drift from what the worker writes —
|
||||
// it did, and every cancelled download leaked its partial. (DR-169)
|
||||
if let Some(path) = file_path {
|
||||
let partial_path = format!("{}.part", path);
|
||||
let _ = std::fs::remove_file(&partial_path); // Ignore errors
|
||||
let target = std::path::PathBuf::from(&path);
|
||||
let _ = std::fs::remove_file(crate::download::worker::partial_path(&target));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1231,8 +1578,6 @@ pub async fn enqueue_video_downloads(
|
||||
download_ids: Vec<i64>,
|
||||
target_dir: String,
|
||||
) -> Result<(), String> {
|
||||
use crate::repository::MediaRepository;
|
||||
|
||||
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
|
||||
|
||||
let db_service = {
|
||||
@@ -1257,10 +1602,12 @@ pub async fn enqueue_video_downloads(
|
||||
}
|
||||
};
|
||||
|
||||
// Build the transcode URL (pure URL builder, no server round-trip).
|
||||
let stream_url = repo
|
||||
.as_ref()
|
||||
.get_video_download_url(&item_id, &quality, None);
|
||||
// Build the download URL, resolving the source's audio codec first so a
|
||||
// track this device cannot decode is re-encoded on the way down rather
|
||||
// than saved as a silent file (DR-167).
|
||||
let stream_url =
|
||||
crate::repository::resolve_video_download_url(repo.as_ref(), &item_id, &quality, None)
|
||||
.await;
|
||||
|
||||
let update_query = Query::with_params(
|
||||
"UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
|
||||
@@ -1517,7 +1864,11 @@ fn spawn_download_worker(
|
||||
let _ = progress_app.emit("download-event", event);
|
||||
};
|
||||
|
||||
let result = worker.download(&task, on_progress).await;
|
||||
// Registering returns a fresh flag, so a download resumed after a pause
|
||||
// does not inherit the stop that ended its previous run. (DR-168)
|
||||
let stop_flag = crate::download::stop::register(download_id);
|
||||
let result = worker.download(&task, &stop_flag, on_progress).await;
|
||||
crate::download::stop::clear(download_id);
|
||||
|
||||
// Free the slot before pumping so the next download can take it.
|
||||
if let Ok(mut active) = active_downloads.lock() {
|
||||
@@ -1588,6 +1939,17 @@ fn spawn_download_worker(
|
||||
Err(e) => error!(" Completed event emit failed: {:?}", e),
|
||||
}
|
||||
}
|
||||
// A pause or cancel is not a failure. The row already says `paused`
|
||||
// (or the row is gone, for a cancel), and overwriting that with
|
||||
// `failed` is what made a pause look like an error and stranded the
|
||||
// download outside the resumable set. The `.part` file is deliberately
|
||||
// left alone — it is what the resume continues from. (DR-168)
|
||||
Err(e) if e.is_stopped() => {
|
||||
info!(
|
||||
"[pump] Download {} stopped by request; partial file kept for resume",
|
||||
download_id
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Download failed: {:?}", e);
|
||||
|
||||
@@ -1835,17 +2197,26 @@ pub async fn clear_stale_downloads(
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
// Get file paths for stale downloads (pending/paused/failed)
|
||||
// Ids as well as paths: a stale row may still have a worker attached (a
|
||||
// 'downloading' row that was paused mid-flight is 'paused' here), and
|
||||
// deleting the row without stopping the task leaves it writing to a file we
|
||||
// are about to remove. (DR-168)
|
||||
let file_query = Query::with_params(
|
||||
"SELECT file_path FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')",
|
||||
"SELECT id, file_path FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')",
|
||||
vec![QueryParam::String(user_id.clone())],
|
||||
);
|
||||
|
||||
let file_paths: Vec<String> = db_service
|
||||
.query_many(file_query, |row| row.get(0))
|
||||
let stale: Vec<(i64, String)> = db_service
|
||||
.query_many(file_query, |row| Ok((row.get(0)?, row.get(1)?)))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
for (id, _) in &stale {
|
||||
crate::download::stop::signal(*id);
|
||||
crate::download::stop::clear(*id);
|
||||
}
|
||||
let file_paths: Vec<String> = stale.into_iter().map(|(_, path)| path).collect();
|
||||
|
||||
// Delete all pending, paused, and failed downloads (but keep completed ones)
|
||||
let delete_query = Query::with_params(
|
||||
"DELETE FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')",
|
||||
@@ -1857,10 +2228,12 @@ pub async fn clear_stale_downloads(
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Delete any partial files
|
||||
// Delete any partial files, via the shared helper so this cannot drift from
|
||||
// what the worker actually writes. (DR-169)
|
||||
for path in file_paths {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(format!("{}.part", path));
|
||||
let target = std::path::PathBuf::from(&path);
|
||||
let _ = std::fs::remove_file(&target);
|
||||
let _ = std::fs::remove_file(crate::download::worker::partial_path(&target));
|
||||
}
|
||||
|
||||
Ok(deleted_count as i64)
|
||||
@@ -1946,7 +2319,7 @@ pub async fn delete_downloads_under(
|
||||
)";
|
||||
|
||||
let file_query = Query::with_params(
|
||||
&format!("SELECT d.file_path FROM downloads d WHERE {SCOPE}"),
|
||||
format!("SELECT d.file_path FROM downloads d WHERE {SCOPE}"),
|
||||
vec![
|
||||
QueryParam::String(user_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
@@ -1962,7 +2335,7 @@ pub async fn delete_downloads_under(
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let delete_query = Query::with_params(
|
||||
&format!("DELETE FROM downloads WHERE id IN (SELECT d.id FROM downloads d WHERE {SCOPE})"),
|
||||
format!("DELETE FROM downloads WHERE id IN (SELECT d.id FROM downloads d WHERE {SCOPE})"),
|
||||
vec![
|
||||
QueryParam::String(user_id),
|
||||
QueryParam::String(item_id.clone()),
|
||||
@@ -2572,4 +2945,294 @@ mod tests {
|
||||
download_source: "user".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Album download: track sourcing and album linkage =====
|
||||
|
||||
/// A database with just the tables the album-download path touches.
|
||||
fn album_test_db() -> Arc<crate::storage::db_service::RusqliteService> {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE items (
|
||||
id TEXT PRIMARY KEY,
|
||||
server_id TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
item_type TEXT NOT NULL,
|
||||
album_id TEXT,
|
||||
album_name TEXT,
|
||||
album_artist TEXT,
|
||||
artists TEXT,
|
||||
index_number INTEGER
|
||||
);
|
||||
CREATE TABLE downloads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending',
|
||||
priority INTEGER DEFAULT 0,
|
||||
progress REAL DEFAULT 0,
|
||||
queued_at TEXT,
|
||||
item_name TEXT,
|
||||
artist_name TEXT,
|
||||
album_name TEXT,
|
||||
media_type TEXT,
|
||||
stream_url TEXT,
|
||||
target_dir TEXT,
|
||||
UNIQUE(item_id, user_id)
|
||||
);
|
||||
INSERT INTO items (id, server_id, name, item_type)
|
||||
VALUES ('album1', 'server1', 'The Golden Age', 'MusicAlbum');
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
Arc::new(crate::storage::db_service::RusqliteService::new(Arc::new(
|
||||
Mutex::new(conn),
|
||||
)))
|
||||
}
|
||||
|
||||
fn album_track(id: &str, name: &str, index: i32) -> AlbumTrack {
|
||||
AlbumTrack {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
artist_name: Some("Woodkid".to_string()),
|
||||
album_name: Some("The Golden Age".to_string()),
|
||||
index_number: Some(index),
|
||||
}
|
||||
}
|
||||
|
||||
/// The album-download regression: every track the album actually has must be
|
||||
/// queued, and each queued track must be linked to its album.
|
||||
///
|
||||
/// `download_album` used to take its track list from
|
||||
/// `items WHERE album_id = ?`. Jellyfin does not return `AlbumId` on every
|
||||
/// listing endpoint, so tracks cached from those endpoints sit in `items`
|
||||
/// with a NULL `album_id` — invisible to that query. "Download album" then
|
||||
/// silently queued only the subset that happened to carry the link, which is
|
||||
/// the reported "only 4-5 songs downloaded". The same column is what offline
|
||||
/// browsing joins tracks to their album on (`i.album_id = ?` in
|
||||
/// `OfflineRepository::get_items`), so even a track that did download stayed
|
||||
/// invisible under its album offline.
|
||||
///
|
||||
/// TRACES: UR-018, UR-055 | DR-173 | UT-170
|
||||
#[tokio::test]
|
||||
async fn test_queue_album_tracks_queues_every_track_and_links_it_to_the_album() {
|
||||
let db = album_test_db();
|
||||
|
||||
// The cache holds all three tracks, but only one carries `album_id` —
|
||||
// exactly the state the bug report's database is in.
|
||||
for sql in [
|
||||
"INSERT INTO items (id, server_id, name, item_type, album_id) \
|
||||
VALUES ('t1', 'server1', 'Run Boy Run', 'Audio', 'album1')",
|
||||
"INSERT INTO items (id, server_id, name, item_type, album_id) \
|
||||
VALUES ('t2', 'server1', 'The Great Escape', 'Audio', NULL)",
|
||||
"INSERT INTO items (id, server_id, name, item_type, album_id) \
|
||||
VALUES ('t3', 'server1', 'Boat Song', 'Audio', NULL)",
|
||||
] {
|
||||
db.execute(Query::new(sql)).await.unwrap();
|
||||
}
|
||||
|
||||
let tracks = vec![
|
||||
album_track("t1", "Run Boy Run", 1),
|
||||
album_track("t2", "The Great Escape", 2),
|
||||
album_track("t3", "Boat Song", 3),
|
||||
];
|
||||
|
||||
let ids = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
ids.len(),
|
||||
3,
|
||||
"every track of the album must get a download row"
|
||||
);
|
||||
|
||||
let queued: i64 = db
|
||||
.query_one(
|
||||
Query::new("SELECT COUNT(*) FROM downloads WHERE status = 'pending'"),
|
||||
|row| row.get(0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(queued, 3);
|
||||
|
||||
// Each track is now linked to its album, so the offline album page can
|
||||
// find it once the download completes.
|
||||
let linked: i64 = db
|
||||
.query_one(
|
||||
Query::new("SELECT COUNT(*) FROM items WHERE album_id = 'album1'"),
|
||||
|row| row.get(0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
linked, 3,
|
||||
"queued tracks must be linked to their album; offline browsing joins on album_id"
|
||||
);
|
||||
}
|
||||
|
||||
/// The returned ids must line up with the tracks that were passed in. The
|
||||
/// frontend used to pair `downloadIds[i]` with its own `tracks[i]`, which is
|
||||
/// only sound if both lists agree — they did not, because the backend
|
||||
/// ordered by `index_number` over a different set of rows. Resolving URLs in
|
||||
/// Rust removes the pairing entirely, but the order is still the contract
|
||||
/// for anything that reads the ids back.
|
||||
///
|
||||
/// TRACES: UR-018, UR-055 | DR-173 | UT-170
|
||||
#[tokio::test]
|
||||
async fn test_queue_album_tracks_returns_ids_in_track_order() {
|
||||
let db = album_test_db();
|
||||
let tracks = vec![
|
||||
album_track("t1", "Run Boy Run", 1),
|
||||
album_track("t2", "The Great Escape", 2),
|
||||
];
|
||||
|
||||
let ids = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for (id, track) in ids.iter().zip(tracks.iter()) {
|
||||
let item_id: String = db
|
||||
.query_one(
|
||||
Query::with_params(
|
||||
"SELECT item_id FROM downloads WHERE id = ?",
|
||||
vec![QueryParam::Int64(*id)],
|
||||
),
|
||||
|row| row.get(0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(&item_id, &track.id, "id {} must be {}'s row", id, track.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-queueing an album already partly downloaded must not duplicate rows or
|
||||
/// reset a completed track — it fills in what is missing.
|
||||
///
|
||||
/// TRACES: UR-018, UR-055 | DR-173 | UT-170
|
||||
#[tokio::test]
|
||||
async fn test_queue_album_tracks_is_idempotent() {
|
||||
let db = album_test_db();
|
||||
let tracks = vec![
|
||||
album_track("t1", "Run Boy Run", 1),
|
||||
album_track("t2", "The Great Escape", 2),
|
||||
];
|
||||
|
||||
let first = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
|
||||
.await
|
||||
.unwrap();
|
||||
let second = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(first, second, "the same tracks must map to the same rows");
|
||||
|
||||
let rows: i64 = db
|
||||
.query_one(Query::new("SELECT COUNT(*) FROM downloads"), |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, 2, "re-queueing must not duplicate download rows");
|
||||
}
|
||||
|
||||
/// Two tracks of one album can share a title — a deluxe edition carrying the
|
||||
/// album version and a demo of the same song, or the same song on two discs.
|
||||
/// Naming the file after the title alone gave them one path, so the second
|
||||
/// download overwrote the first and the album ended up short however many
|
||||
/// duplicates it had.
|
||||
///
|
||||
/// TRACES: UR-018, UR-055 | DR-173 | UT-172
|
||||
#[test]
|
||||
fn test_album_file_names_are_unique_within_the_album() {
|
||||
let tracks = vec![
|
||||
album_track("t1", "Crucified Again", 5),
|
||||
album_track("t2", "Crucified Again", 5),
|
||||
album_track("t3", "Get Right", 7),
|
||||
];
|
||||
|
||||
let names = album_file_names(&tracks);
|
||||
|
||||
assert_eq!(names.len(), 3);
|
||||
let unique: std::collections::HashSet<_> = names.iter().collect();
|
||||
assert_eq!(
|
||||
unique.len(),
|
||||
3,
|
||||
"every track of an album needs its own file: {:?}",
|
||||
names
|
||||
);
|
||||
assert!(names.iter().all(|n| n.ends_with(".mp3")), "{:?}", names);
|
||||
assert!(
|
||||
names[2].contains("Get Right"),
|
||||
"an unambiguous title keeps its name: {}",
|
||||
names[2]
|
||||
);
|
||||
}
|
||||
|
||||
/// Path separators in a track title must not escape the album directory.
|
||||
///
|
||||
/// TRACES: UR-018, UR-055 | DR-173 | UT-172
|
||||
#[test]
|
||||
fn test_album_file_names_sanitize_the_title() {
|
||||
let names = album_file_names(&[album_track("t1", "AC/DC: Live?", 1)]);
|
||||
assert!(!names[0].contains('/'), "{}", names[0]);
|
||||
assert!(!names[0].contains(':'), "{}", names[0]);
|
||||
}
|
||||
|
||||
/// The offline fallback reads the catalog directly, not through the
|
||||
/// availability-gated offline listing: queueing an album while the server is
|
||||
/// unreachable is a supported flow (the rows resolve on reconnect), and
|
||||
/// gating it on what is already downloaded would queue only the tracks the
|
||||
/// device already has.
|
||||
///
|
||||
/// TRACES: UR-018, UR-055 | DR-173 | UT-170
|
||||
#[tokio::test]
|
||||
async fn test_cached_album_tracks_finds_tracks_by_either_album_link() {
|
||||
let db = album_test_db();
|
||||
for sql in [
|
||||
"INSERT INTO items (id, server_id, name, item_type, album_id, index_number) \
|
||||
VALUES ('t1', 'server1', 'Run Boy Run', 'Audio', 'album1', 1)",
|
||||
// Linked by parent_id only — how a track cached from a folder
|
||||
// listing lands in the catalog.
|
||||
"INSERT INTO items (id, server_id, name, item_type, parent_id, index_number) \
|
||||
VALUES ('t2', 'server1', 'The Great Escape', 'Audio', 'album1', 2)",
|
||||
// A different album's track must not be swept in.
|
||||
"INSERT INTO items (id, server_id, name, item_type, album_id) \
|
||||
VALUES ('other', 'server1', 'Iron', 'Audio', 'album2')",
|
||||
] {
|
||||
db.execute(Query::new(sql)).await.unwrap();
|
||||
}
|
||||
|
||||
let tracks = cached_album_tracks(&db, "album1").await.unwrap();
|
||||
let ids: Vec<_> = tracks.iter().map(|t| t.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["t1", "t2"]);
|
||||
}
|
||||
|
||||
/// Tracks the cache has never seen still get queued: the row is created and
|
||||
/// an `items` row is written for it, so the download is both startable and
|
||||
/// visible offline afterwards.
|
||||
///
|
||||
/// TRACES: UR-018, UR-055 | DR-173 | UT-170
|
||||
#[tokio::test]
|
||||
async fn test_queue_album_tracks_handles_tracks_absent_from_the_cache() {
|
||||
let db = album_test_db();
|
||||
let tracks = vec![album_track("never-cached", "Iron", 1)];
|
||||
|
||||
let ids = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ids.len(), 1);
|
||||
|
||||
let (item_type, album_id): (String, Option<String>) = db
|
||||
.query_one(
|
||||
Query::new("SELECT item_type, album_id FROM items WHERE id = 'never-cached'"),
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(item_type, "Audio");
|
||||
assert_eq!(album_id.as_deref(), Some("album1"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
//! 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 crate::utils::lock::MutexSafe;
|
||||
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_safe().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::*;
|
||||
|
||||
@@ -360,7 +360,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_playback_reporter_wrapper_structure() {
|
||||
// Verify wrapper type can hold Arc<TokioMutex<Option<T>>>
|
||||
assert_eq!(std::mem::size_of::<PlaybackReporterWrapper>() > 0, true);
|
||||
assert!(std::mem::size_of::<PlaybackReporterWrapper>() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,29 @@
|
||||
//! Audio and video playback settings commands.
|
||||
//!
|
||||
//! TRACES: UR-022, UR-027, UR-031, UR-032, UR-033 | DR-025, DR-030, DR-034, DR-035, DR-036, IR-020
|
||||
//! TRACES: UR-022, UR-027, UR-031, UR-032, UR-033, UR-074 | DR-025, DR-030, DR-034, DR-035, DR-036, DR-162, IR-020
|
||||
|
||||
use tauri::State;
|
||||
use std::sync::Arc;
|
||||
|
||||
use log::{info, warn};
|
||||
use tauri::{Manager, State};
|
||||
|
||||
use super::{PlayerStateWrapper, VideoSettingsWrapper};
|
||||
use crate::commands::storage::DatabaseWrapper;
|
||||
use crate::player::AutoplaySettings;
|
||||
use crate::settings::{AudioSettings, EqPreset, VideoSettings};
|
||||
use crate::settings::{AudioSettings, EqPreset, StreamingQuality, VideoSettings};
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||
use crate::utils::lock::MutexSafe;
|
||||
|
||||
/// `app_settings` key holding the persisted streaming bandwidth ceiling.
|
||||
///
|
||||
/// The cap is persisted (unlike the rest of `VideoSettings`, which is
|
||||
/// process-lifetime state) because forgetting it is the one failure that costs
|
||||
/// the user something real: a limit set for a metered connection that silently
|
||||
/// reverts to uncapped on the next launch spends their data allowance without
|
||||
/// ever showing them a changed setting.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162
|
||||
const STREAMING_QUALITY_KEY: &str = "streaming_quality";
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
@@ -54,6 +71,7 @@ pub async fn player_get_audio_settings(
|
||||
pub async fn player_set_video_settings(
|
||||
video_settings: State<'_, VideoSettingsWrapper>,
|
||||
player: State<'_, PlayerStateWrapper>,
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
settings: VideoSettings,
|
||||
) -> Result<VideoSettings, String> {
|
||||
let validated = settings.with_countdown_clamped();
|
||||
@@ -62,6 +80,12 @@ pub async fn player_set_video_settings(
|
||||
*current = validated.clone();
|
||||
} // Drop MutexGuard before await
|
||||
|
||||
// The bandwidth ceiling is read by the repository's URL builders and by the
|
||||
// PlaybackInfo negotiation, neither of which can see this wrapper.
|
||||
// TRACES: UR-074 | DR-162
|
||||
crate::repository::online::set_streaming_quality(validated.streaming_quality);
|
||||
persist_streaming_quality(&db, validated.streaming_quality).await;
|
||||
|
||||
// Sync to PlayerController's autoplay settings so on_playback_ended() uses current values
|
||||
let controller = player.0.lock().await;
|
||||
controller.set_autoplay_settings(AutoplaySettings {
|
||||
@@ -73,6 +97,110 @@ pub async fn player_set_video_settings(
|
||||
Ok(validated)
|
||||
}
|
||||
|
||||
/// The bandwidth ceilings the quality picker may offer, each with the label and
|
||||
/// one-line detail to show for it, highest first.
|
||||
///
|
||||
/// The ladder and its numbers are Jellyfin encoding domain vocabulary, so the
|
||||
/// frontend reads them here rather than encoding them — the same arrangement as
|
||||
/// [`player_get_eq_presets`].
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_get_streaming_qualities(
|
||||
) -> Result<Vec<(StreamingQuality, String, String)>, String> {
|
||||
Ok(StreamingQuality::ALL
|
||||
.iter()
|
||||
.map(|q| (*q, q.label().to_string(), q.detail().to_string()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Write the ceiling to `app_settings`. Failure is logged, not returned: the
|
||||
/// setting has already been applied in memory, and refusing the whole call
|
||||
/// because the write failed would leave the UI showing a cap that *is* active.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162
|
||||
async fn persist_streaming_quality(db: &State<'_, DatabaseWrapper>, quality: StreamingQuality) {
|
||||
let db_service = {
|
||||
let database = db.0.lock_safe();
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
let encoded = match serde_json::to_string(&quality) {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
warn!("[VideoSettings] Failed to encode streaming quality: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let query = Query::with_params(
|
||||
"INSERT OR REPLACE INTO app_settings (key, value, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)",
|
||||
vec![
|
||||
QueryParam::String(STREAMING_QUALITY_KEY.to_string()),
|
||||
QueryParam::String(encoded),
|
||||
],
|
||||
);
|
||||
|
||||
if let Err(e) = db_service.execute(query).await {
|
||||
warn!("[VideoSettings] Failed to persist streaming quality: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore the persisted bandwidth ceiling at startup, into both the repository
|
||||
/// (which enforces it) and `VideoSettings` (which the settings UI reads).
|
||||
///
|
||||
/// Called from the Tauri `setup` hook. A missing or unreadable row leaves the
|
||||
/// default — uncapped — in place, so a database problem degrades to the old
|
||||
/// behaviour rather than to an arbitrary limit.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162
|
||||
pub async fn restore_streaming_quality(app: &tauri::AppHandle) {
|
||||
let db_service = {
|
||||
let Some(db) = app.try_state::<DatabaseWrapper>() else {
|
||||
warn!("[VideoSettings] No database available; streaming quality stays uncapped");
|
||||
return;
|
||||
};
|
||||
let database = db.0.lock_safe();
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
let query = Query::with_params(
|
||||
"SELECT value FROM app_settings WHERE key = ?",
|
||||
vec![QueryParam::String(STREAMING_QUALITY_KEY.to_string())],
|
||||
);
|
||||
|
||||
let stored: Option<String> = match db_service.query_optional(query, |row| row.get(0)).await {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
warn!("[VideoSettings] Failed to read streaming quality: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(stored) = stored else { return };
|
||||
let quality: StreamingQuality = match serde_json::from_str(&stored) {
|
||||
Ok(quality) => quality,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"[VideoSettings] Ignoring unrecognised persisted streaming quality {:?}: {}",
|
||||
stored, e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
crate::repository::online::set_streaming_quality(quality);
|
||||
if let Some(video_settings) = app.try_state::<VideoSettingsWrapper>() {
|
||||
video_settings.0.lock_safe().streaming_quality = quality;
|
||||
}
|
||||
info!(
|
||||
"[VideoSettings] Restored streaming quality cap: {}",
|
||||
quality.label()
|
||||
);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_get_video_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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Tauri commands for repository access
|
||||
//! Uses handle-based system: UUID -> Arc<HybridRepository>
|
||||
//!
|
||||
//! TRACES: UR-007, UR-035, UR-036 | JA-004, JA-005, JA-029, JA-030, JA-031
|
||||
//! TRACES: UR-007, UR-008, UR-023, UR-034, UR-035, UR-036 | IR-022, IR-024, JA-004, JA-005, JA-006, JA-029, JA-030, JA-031
|
||||
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use std::collections::HashMap;
|
||||
@@ -15,7 +15,8 @@ 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
|
||||
@@ -40,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);
|
||||
@@ -53,6 +67,10 @@ pub struct RepositoryManagerWrapper(pub RepositoryManager);
|
||||
/// Returns a handle (UUID) for accessing the repository
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
// Four of the eight arguments are Tauri `State<'_, _>` injections, not caller
|
||||
// input. Folding the remaining four into a struct would change the IPC contract
|
||||
// and the generated TypeScript for no readability gain.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn repository_create(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
player: State<'_, crate::commands::player::PlayerStateWrapper>,
|
||||
@@ -280,7 +298,13 @@ pub async fn repository_get_latest_items(
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Get resume items (continue watching/listening)
|
||||
/// Get resume items (continue watching/listening).
|
||||
///
|
||||
/// The home screen's Continue Watching row and every library's "pick up where
|
||||
/// you left off" hero come through here; each item carries its own resume
|
||||
/// position in `UserData`.
|
||||
///
|
||||
/// TRACES: UR-019, UR-023, UR-034 | IR-024, JA-013, JA-015 | DR-026, DR-038
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_resume_items(
|
||||
@@ -304,7 +328,9 @@ pub async fn repository_get_resume_items(
|
||||
})
|
||||
}
|
||||
|
||||
/// Get next up episodes
|
||||
/// Get next up episodes.
|
||||
///
|
||||
/// TRACES: UR-023, UR-034 | IR-024, JA-014 | DR-026
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_next_up_episodes(
|
||||
@@ -320,6 +346,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]
|
||||
@@ -492,7 +583,13 @@ pub async fn repository_get_playback_info(
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Get video stream URL with optional seeking support
|
||||
/// Get a video stream URL.
|
||||
///
|
||||
/// There is no start-position parameter on purpose: the URL is an HLS playlist
|
||||
/// covering the whole item, and a position on it makes the server reject every
|
||||
/// segment with `400` (DR-181). Callers resume by seeking after load.
|
||||
///
|
||||
/// TRACES: UR-004 | DR-181 | UT-182
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_video_stream_url(
|
||||
@@ -500,17 +597,11 @@ pub async fn repository_get_video_stream_url(
|
||||
handle: String,
|
||||
item_id: String,
|
||||
media_source_id: Option<String>,
|
||||
start_time_seconds: Option<f64>,
|
||||
audio_stream_index: Option<i32>,
|
||||
) -> Result<String, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref()
|
||||
.get_video_stream_url(
|
||||
&item_id,
|
||||
media_source_id.as_deref(),
|
||||
start_time_seconds,
|
||||
audio_stream_index,
|
||||
)
|
||||
.get_video_stream_url(&item_id, media_source_id.as_deref(), audio_stream_index)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -633,9 +724,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,
|
||||
@@ -644,10 +745,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
|
||||
@@ -686,7 +816,7 @@ pub fn repository_get_subtitle_url(
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
#[allow(dead_code)]
|
||||
pub fn repository_get_video_download_url(
|
||||
pub async fn repository_get_video_download_url(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
item_id: String,
|
||||
@@ -694,9 +824,16 @@ pub fn repository_get_video_download_url(
|
||||
media_source_id: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
Ok(repo
|
||||
.as_ref()
|
||||
.get_video_download_url(&item_id, &quality, media_source_id.as_deref()))
|
||||
// Async because the audio-codec policy has to know what the source's audio
|
||||
// is before it can decide whether the file may be copied verbatim (DR-171).
|
||||
// The frontend calls this exactly as before — the decision stays in Rust.
|
||||
Ok(crate::repository::resolve_video_download_url(
|
||||
repo.as_ref(),
|
||||
&item_id,
|
||||
&quality,
|
||||
media_source_id.as_deref(),
|
||||
)
|
||||
.await)
|
||||
}
|
||||
|
||||
/// Mark an item as favorite
|
||||
@@ -714,6 +851,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]
|
||||
@@ -787,6 +1043,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();
|
||||
@@ -809,7 +1103,6 @@ mod tests {
|
||||
let handle = format!("{}", uuid);
|
||||
// UUID should convert to a non-empty string
|
||||
assert!(!handle.is_empty());
|
||||
assert!(handle.len() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -89,7 +89,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_session_poller_wrapper_structure() {
|
||||
// Test that wrapper type structure is correct
|
||||
assert_eq!(std::mem::size_of::<SessionPollerWrapper>() > 0, true);
|
||||
assert!(std::mem::size_of::<SessionPollerWrapper>() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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]
|
||||
@@ -843,6 +867,86 @@ pub async fn storage_mark_played(
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the watched flag locally for an item **and everything inside it**.
|
||||
///
|
||||
/// This backs the watched toggle, and is deliberately separate from
|
||||
/// [`storage_mark_played`] — which reports a single track/episode finishing and
|
||||
/// increments `play_count` — because the toggle has two directions and applies
|
||||
/// to containers.
|
||||
///
|
||||
/// The recursion is what makes the toggle honest offline. Jellyfin applies
|
||||
/// `POST`/`DELETE /PlayedItems/{id}` recursively over a season or series, so
|
||||
/// online the server fixes up the children on the next read; with no server to
|
||||
/// ask, marking a season watched would otherwise tick the season and leave every
|
||||
/// episode inside it unwatched. Targets are drawn from `items` by the same link
|
||||
/// columns the rest of the offline layer uses, so an id that is not cached
|
||||
/// selects nothing and the statement is a no-op rather than a foreign-key error.
|
||||
///
|
||||
/// Un-marking clears the resume position too, matching the server, so an item
|
||||
/// un-marked offline does not come back offering to resume from a position it is
|
||||
/// no longer meant to have.
|
||||
///
|
||||
/// `pending_sync = 1` hands the rows to the sync drain.
|
||||
///
|
||||
/// TRACES: UR-073 | DR-158
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn storage_set_watched(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
user_id: String,
|
||||
item_id: String,
|
||||
watched: bool,
|
||||
) -> Result<(), String> {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
// The item itself plus its descendants: a season's episodes reach it by
|
||||
// season_id, a series' by series_id, its seasons by parent_id, an album's
|
||||
// tracks by album_id.
|
||||
let targets = "SELECT id FROM items
|
||||
WHERE id = ? OR parent_id = ? OR album_id = ?
|
||||
OR season_id = ? OR series_id = ?";
|
||||
|
||||
let sql = if watched {
|
||||
format!(
|
||||
"INSERT INTO user_data (user_id, item_id, is_played, play_count, last_played_at, pending_sync)
|
||||
SELECT ?, id, 1, 1, CURRENT_TIMESTAMP, 1 FROM ({targets})
|
||||
ON CONFLICT(user_id, item_id) DO UPDATE SET
|
||||
is_played = 1,
|
||||
play_count = MAX(user_data.play_count, 1),
|
||||
last_played_at = CURRENT_TIMESTAMP,
|
||||
pending_sync = 1"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"INSERT INTO user_data (user_id, item_id, is_played, play_count, playback_position_ticks, pending_sync)
|
||||
SELECT ?, id, 0, 0, 0, 1 FROM ({targets})
|
||||
ON CONFLICT(user_id, item_id) DO UPDATE SET
|
||||
is_played = 0,
|
||||
play_count = 0,
|
||||
playback_position_ticks = 0,
|
||||
pending_sync = 1"
|
||||
)
|
||||
};
|
||||
|
||||
let query = Query::with_params(
|
||||
sql,
|
||||
vec![
|
||||
QueryParam::String(user_id),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
],
|
||||
);
|
||||
|
||||
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get playback progress for an item
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
@@ -1554,19 +1658,19 @@ mod tests {
|
||||
#[test]
|
||||
fn test_database_wrapper_structure() {
|
||||
// Verify DatabaseWrapper can be created and holds Mutex<Database>
|
||||
assert_eq!(std::mem::size_of::<DatabaseWrapper>() > 0, true);
|
||||
assert!(std::mem::size_of::<DatabaseWrapper>() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credential_store_wrapper_structure() {
|
||||
// Verify CredentialStoreWrapper can be created
|
||||
assert_eq!(std::mem::size_of::<CredentialStoreWrapper>() > 0, true);
|
||||
assert!(std::mem::size_of::<CredentialStoreWrapper>() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thumbnail_cache_wrapper_structure() {
|
||||
// Verify ThumbnailCacheWrapper holds Arc<ThumbnailCache>
|
||||
assert_eq!(std::mem::size_of::<ThumbnailCacheWrapper>() > 0, true);
|
||||
assert!(std::mem::size_of::<ThumbnailCacheWrapper>() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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
+107
-10
@@ -20,9 +20,6 @@ use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use hostname;
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
const SERVICE_NAME: &str = "com.dtourolle.jellytau";
|
||||
|
||||
@@ -203,15 +200,12 @@ impl CredentialStore {
|
||||
|
||||
// secret-tool doesn't support --version, so we test with a search command
|
||||
// that will succeed even if no items are found
|
||||
match Command::new("secret-tool")
|
||||
Command::new("secret-tool")
|
||||
.arg("search")
|
||||
.arg("service")
|
||||
.arg("__nonexistent_test__")
|
||||
.output()
|
||||
{
|
||||
Ok(_) => true, // If command runs (even with no results), secret-tool is available
|
||||
Err(_) => false, // Command not found or can't execute
|
||||
}
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_os = "android"), not(target_os = "linux")))]
|
||||
@@ -471,6 +465,19 @@ impl CredentialStore {
|
||||
hasher.finalize().into()
|
||||
}
|
||||
|
||||
/// Load and decrypt the credential map.
|
||||
///
|
||||
/// A file that is present but **undecryptable** is deliberately reported as
|
||||
/// an *empty* credential set rather than as an error. The key never leaves
|
||||
/// the device it was derived on (Android Keystore keys are never backed up,
|
||||
/// and the file fallback's key is derived from machine identifiers), so a
|
||||
/// restored/transferred install gets ciphertext with no key and every read
|
||||
/// would fail *permanently*. Surfacing that as an error made session restore
|
||||
/// throw instead of falling back to the login screen: an unrecoverable app
|
||||
/// rather than a clean logged-out one. The next successful login re-encrypts
|
||||
/// the file with the current key, so the state self-heals.
|
||||
///
|
||||
/// TRACES: UR-012 | IR-014
|
||||
fn load_credentials_file(&self) -> Result<serde_json::Value, CredentialError> {
|
||||
if !self.credentials_path.exists() {
|
||||
return Ok(serde_json::json!({}));
|
||||
@@ -483,8 +490,31 @@ impl CredentialStore {
|
||||
return Ok(serde_json::json!({}));
|
||||
}
|
||||
|
||||
let decrypted = self.decrypt(&encrypted_data)?;
|
||||
serde_json::from_str(&decrypted).map_err(|e| CredentialError::Encryption(e.to_string()))
|
||||
let decrypted = match self.decrypt(&encrypted_data) {
|
||||
Ok(decrypted) => decrypted,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Credentials file at {:?} exists but cannot be decrypted ({}); \
|
||||
treating as no stored credentials. This is expected after a \
|
||||
backup restore or device transfer - the encryption key does \
|
||||
not travel with the data. Signing in again will rewrite it.",
|
||||
self.credentials_path, e
|
||||
);
|
||||
return Ok(serde_json::json!({}));
|
||||
}
|
||||
};
|
||||
|
||||
match serde_json::from_str(&decrypted) {
|
||||
Ok(value) => Ok(value),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Credentials file at {:?} decrypted to invalid JSON ({}); \
|
||||
treating as no stored credentials.",
|
||||
self.credentials_path, e
|
||||
);
|
||||
Ok(serde_json::json!({}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn save_credentials_file(&self, data: &serde_json::Value) -> Result<(), CredentialError> {
|
||||
@@ -856,6 +886,73 @@ pub use android_keystore::{
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a store pinned to the encrypted-file backend with an explicit key,
|
||||
/// so a test can simulate "same file, different machine key" (which is what
|
||||
/// a restored backup looks like).
|
||||
fn file_backed_store(credentials_path: PathBuf, encryption_key: [u8; 32]) -> CredentialStore {
|
||||
CredentialStore {
|
||||
using_keyring: false,
|
||||
credentials_path,
|
||||
encryption_key,
|
||||
}
|
||||
}
|
||||
|
||||
/// A credentials file we cannot decrypt must read as *no credentials stored*,
|
||||
/// not as a hard error. This is the restored-backup case: the ciphertext comes
|
||||
/// back but the key that encrypted it (Android Keystore / the machine-derived
|
||||
/// key) does not, so every read fails forever.
|
||||
///
|
||||
/// TRACES: UR-012 | IR-014
|
||||
#[test]
|
||||
fn undecryptable_credentials_file_reads_as_not_found() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(CREDENTIALS_FILENAME);
|
||||
|
||||
let original = file_backed_store(path.clone(), [1u8; 32]);
|
||||
original.save_to_file("user-1", "token-abc").unwrap();
|
||||
|
||||
// Restored onto a device whose derived key differs: same bytes, no key.
|
||||
let restored = file_backed_store(path.clone(), [2u8; 32]);
|
||||
match restored.get_token("user-1") {
|
||||
Err(CredentialError::NotFound) => {}
|
||||
other => panic!("expected NotFound for undecryptable ciphertext, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Garbage in the file (truncation, partial restore) is the same story.
|
||||
///
|
||||
/// TRACES: UR-012 | IR-014
|
||||
#[test]
|
||||
fn corrupt_credentials_file_reads_as_not_found() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(CREDENTIALS_FILENAME);
|
||||
fs::write(&path, "not base64 at all !!!").unwrap();
|
||||
|
||||
let store = file_backed_store(path, [3u8; 32]);
|
||||
match store.get_token("user-1") {
|
||||
Err(CredentialError::NotFound) => {}
|
||||
other => panic!("expected NotFound for corrupt file, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// …and the logged-out state must be recoverable: signing in again has to be
|
||||
/// able to write over the unreadable file rather than failing on load.
|
||||
///
|
||||
/// TRACES: UR-012 | IR-014
|
||||
#[test]
|
||||
fn login_after_undecryptable_file_rewrites_it() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(CREDENTIALS_FILENAME);
|
||||
|
||||
let original = file_backed_store(path.clone(), [1u8; 32]);
|
||||
original.save_to_file("user-1", "token-abc").unwrap();
|
||||
|
||||
let restored = file_backed_store(path.clone(), [2u8; 32]);
|
||||
restored.save_to_file("user-1", "token-fresh").unwrap();
|
||||
|
||||
assert_eq!(restored.get_from_file("user-1").unwrap(), "token-fresh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encryption_roundtrip() {
|
||||
let store = CredentialStore::new();
|
||||
|
||||
@@ -119,11 +119,12 @@ 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
|
||||
MediaItem {
|
||||
id: format!("id-{}-{:?}", name, kind),
|
||||
name: name.to_string(),
|
||||
kind,
|
||||
..MediaItem::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn names(items: &[MediaItem]) -> Vec<&str> {
|
||||
|
||||
@@ -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())],
|
||||
);
|
||||
@@ -278,14 +389,18 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_queue_precache_config() {
|
||||
let mut config = CacheConfig::default();
|
||||
config.queue_precache_enabled = false;
|
||||
let config = CacheConfig {
|
||||
queue_precache_enabled: false,
|
||||
..CacheConfig::default()
|
||||
};
|
||||
|
||||
let cache = SmartCache::new(config);
|
||||
assert!(!cache.should_precache_queue());
|
||||
|
||||
let mut new_config = CacheConfig::default();
|
||||
new_config.wifi_only = false;
|
||||
let new_config = CacheConfig {
|
||||
wifi_only: false,
|
||||
..CacheConfig::default()
|
||||
};
|
||||
cache.update_config(new_config);
|
||||
|
||||
assert!(cache.should_precache_queue());
|
||||
@@ -296,14 +411,215 @@ mod tests {
|
||||
// wifi_only must not short-circuit precaching: the network gate lives in
|
||||
// the download pump, which checks the *actual* transport. Enabling
|
||||
// WiFi-only while on WiFi should still precache.
|
||||
let mut config = CacheConfig::default();
|
||||
config.queue_precache_enabled = true;
|
||||
config.wifi_only = true;
|
||||
let config = CacheConfig {
|
||||
queue_precache_enabled: true,
|
||||
wifi_only: true,
|
||||
..CacheConfig::default()
|
||||
};
|
||||
|
||||
let cache = SmartCache::new(config);
|
||||
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::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;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
pub mod cache;
|
||||
pub mod events;
|
||||
pub mod network;
|
||||
pub mod stop;
|
||||
pub mod worker;
|
||||
|
||||
use crate::utils::lock::MutexSafe;
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Stop signalling for in-flight downloads.
|
||||
//!
|
||||
//! TRACES: UR-055 | DR-168
|
||||
//!
|
||||
//! Pausing and cancelling used to be database-only: `pause_download` wrote
|
||||
//! `status = 'paused'` and nothing else. No cancellation existed anywhere in the
|
||||
//! download stack — no token, no flag, no abort — so the streaming task kept
|
||||
//! running, kept writing bytes, and on finishing overwrote the row with
|
||||
//! `completed` or `failed`. The row flicked to "paused" and then undid itself,
|
||||
//! which is precisely the reported "pause does not work".
|
||||
//!
|
||||
//! This is the missing half: a flag per in-flight download that the worker reads
|
||||
//! between chunks. Setting it makes the worker return [`Stopped`] promptly and
|
||||
//! leave the `.part` file **intact**, which is what lets a resume pick up from
|
||||
//! where it stopped via the existing HTTP Range request.
|
||||
//!
|
||||
//! Kept as a module-level registry rather than on `DownloadManager` because the
|
||||
//! two sides never meet: the command handler holds the manager's lock, while the
|
||||
//! worker runs detached inside `tauri::async_runtime::spawn` with no access to
|
||||
//! Tauri state. A registry both can reach is the smallest thing that works.
|
||||
//!
|
||||
//! [`Stopped`]: crate::download::worker::DownloadError::Stopped
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use crate::utils::lock::MutexSafe;
|
||||
|
||||
/// download id → its stop flag, for downloads currently in flight.
|
||||
fn registry() -> &'static Mutex<HashMap<i64, Arc<AtomicBool>>> {
|
||||
static REGISTRY: OnceLock<Mutex<HashMap<i64, Arc<AtomicBool>>>> = OnceLock::new();
|
||||
REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Register `download_id` as in-flight and hand back its stop flag.
|
||||
///
|
||||
/// Called by the worker as it starts. A previous flag for the same id is
|
||||
/// replaced, so a download that is paused and later resumed does not inherit the
|
||||
/// set flag from its last run and stop immediately.
|
||||
pub fn register(download_id: i64) -> Arc<AtomicBool> {
|
||||
let flag = Arc::new(AtomicBool::new(false));
|
||||
registry().lock_safe().insert(download_id, flag.clone());
|
||||
flag
|
||||
}
|
||||
|
||||
/// Ask an in-flight download to stop.
|
||||
///
|
||||
/// Returns whether one was actually in flight — the caller uses this to tell a
|
||||
/// running download (which will stop shortly) from a merely queued one (which
|
||||
/// the database update alone has already handled).
|
||||
pub fn signal(download_id: i64) -> bool {
|
||||
match registry().lock_safe().get(&download_id) {
|
||||
Some(flag) => {
|
||||
flag.store(true, Ordering::SeqCst);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget a download's flag. Called when its task finishes, however it ended.
|
||||
pub fn clear(download_id: i64) {
|
||||
registry().lock_safe().remove(&download_id);
|
||||
}
|
||||
|
||||
/// Whether a stop has been requested for `download_id`.
|
||||
///
|
||||
/// The worker reads its own `Arc<AtomicBool>` directly rather than looking the id
|
||||
/// up, so this exists for the tests that assert the registry's behaviour.
|
||||
#[cfg(test)]
|
||||
pub fn is_stopping(download_id: i64) -> bool {
|
||||
registry()
|
||||
.lock_safe()
|
||||
.get(&download_id)
|
||||
.map(|f| f.load(Ordering::SeqCst))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Ids are per-test so the shared registry cannot leak between them.
|
||||
fn unique_id(seed: i64) -> i64 {
|
||||
900_000 + seed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_registered_download_starts_unflagged() {
|
||||
let id = unique_id(1);
|
||||
let flag = register(id);
|
||||
assert!(!flag.load(Ordering::SeqCst));
|
||||
assert!(!is_stopping(id));
|
||||
clear(id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_signal_sets_the_flag_the_worker_reads() {
|
||||
let id = unique_id(2);
|
||||
let flag = register(id);
|
||||
|
||||
assert!(signal(id), "a registered download reports as in flight");
|
||||
assert!(
|
||||
flag.load(Ordering::SeqCst),
|
||||
"the worker's own handle sees it"
|
||||
);
|
||||
assert!(is_stopping(id));
|
||||
|
||||
clear(id);
|
||||
}
|
||||
|
||||
/// The pump only needs to abort a task that exists; a queued row is handled
|
||||
/// by its database status alone.
|
||||
#[test]
|
||||
fn test_signalling_an_unregistered_download_reports_not_in_flight() {
|
||||
assert!(!signal(unique_id(3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_forgets_the_download() {
|
||||
let id = unique_id(4);
|
||||
register(id);
|
||||
signal(id);
|
||||
clear(id);
|
||||
|
||||
assert!(!is_stopping(id));
|
||||
assert!(!signal(id), "a cleared download is no longer in flight");
|
||||
}
|
||||
|
||||
/// The bug this guards: pause sets the flag, and resume re-runs the same
|
||||
/// download id. If registering reused the old flag, the resumed run would see
|
||||
/// a set flag and stop instantly — a download that could never be resumed.
|
||||
#[test]
|
||||
fn test_reregistering_clears_a_previous_stop() {
|
||||
let id = unique_id(5);
|
||||
register(id);
|
||||
signal(id);
|
||||
assert!(is_stopping(id));
|
||||
|
||||
let fresh = register(id);
|
||||
assert!(!fresh.load(Ordering::SeqCst));
|
||||
assert!(
|
||||
!is_stopping(id),
|
||||
"a resumed download must not inherit the pause"
|
||||
);
|
||||
|
||||
clear(id);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Download worker for HTTP streaming with progress tracking and retry logic
|
||||
|
||||
use log::warn;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
@@ -31,10 +32,18 @@ impl DownloadWorker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Download a file with retry logic and progress tracking
|
||||
/// Download a file with retry logic and progress tracking.
|
||||
///
|
||||
/// `stop` is the pause/cancel flag (see [`crate::download::stop`]). It is
|
||||
/// checked between chunks and again between retries, so a paused download
|
||||
/// stops promptly rather than after its next backoff — up to 45 seconds
|
||||
/// away, which reads as the pause having done nothing.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-168
|
||||
pub async fn download<F>(
|
||||
&self,
|
||||
task: &DownloadTask,
|
||||
stop: &AtomicBool,
|
||||
on_progress: F,
|
||||
) -> Result<DownloadResult, DownloadError>
|
||||
where
|
||||
@@ -43,7 +52,10 @@ impl DownloadWorker {
|
||||
let mut retries = 0;
|
||||
|
||||
loop {
|
||||
match self.try_download(task, &on_progress).await {
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
return Err(DownloadError::Stopped);
|
||||
}
|
||||
match self.try_download(task, stop, &on_progress).await {
|
||||
Ok(result) => return Ok(result),
|
||||
Err(e) if retries < self.max_retries && e.is_retryable() => {
|
||||
retries += 1;
|
||||
@@ -63,6 +75,7 @@ impl DownloadWorker {
|
||||
async fn try_download<F>(
|
||||
&self,
|
||||
task: &DownloadTask,
|
||||
stop: &AtomicBool,
|
||||
on_progress: &F,
|
||||
) -> Result<DownloadResult, DownloadError>
|
||||
where
|
||||
@@ -76,7 +89,7 @@ impl DownloadWorker {
|
||||
}
|
||||
|
||||
// Check for partial download
|
||||
let temp_path = task.target_path.with_extension("part");
|
||||
let temp_path = partial_path(&task.target_path);
|
||||
let existing_bytes = if temp_path.exists() {
|
||||
fs::metadata(&temp_path).await.map(|m| m.len()).unwrap_or(0)
|
||||
} else {
|
||||
@@ -100,22 +113,32 @@ impl DownloadWorker {
|
||||
return Err(DownloadError::Http(response.status().as_u16()));
|
||||
}
|
||||
|
||||
// Get content length
|
||||
// Did the server actually honour the Range? A transcode does not, and
|
||||
// answers 200 with the whole stream — appending that would duplicate what
|
||||
// we already hold. (DR-170)
|
||||
let resume_from = resume_offset(existing_bytes, response.status().as_u16());
|
||||
if existing_bytes > 0 && resume_from == 0 {
|
||||
warn!(
|
||||
"Server ignored the Range request (HTTP {}) — restarting {} from the beginning \
|
||||
instead of appending to {} existing bytes",
|
||||
response.status().as_u16(),
|
||||
task.target_path.display(),
|
||||
existing_bytes
|
||||
);
|
||||
}
|
||||
|
||||
// Get content length. Absent on a chunked transcode, which is why progress
|
||||
// for a non-`original` preset has no percentage to show.
|
||||
let _total_bytes = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_LENGTH)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.map(|len| {
|
||||
if existing_bytes > 0 {
|
||||
len + existing_bytes
|
||||
} else {
|
||||
len
|
||||
}
|
||||
});
|
||||
.map(|len| len + resume_from);
|
||||
|
||||
// Open file for appending
|
||||
let mut file = if existing_bytes > 0 {
|
||||
// Append only when resuming a range the server agreed to; otherwise
|
||||
// create/truncate so the restarted stream replaces the stale bytes.
|
||||
let mut file = if resume_from > 0 {
|
||||
fs::OpenOptions::new().append(true).open(&temp_path).await
|
||||
} else {
|
||||
fs::File::create(&temp_path).await
|
||||
@@ -123,11 +146,22 @@ impl DownloadWorker {
|
||||
.map_err(|e| DownloadError::FileSystem(e.to_string()))?;
|
||||
|
||||
// Stream download with progress tracking
|
||||
let mut downloaded = existing_bytes;
|
||||
let mut downloaded = resume_from;
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut last_progress_emit = std::time::Instant::now();
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
// Checked before writing, so a paused download stops on a byte
|
||||
// boundary the `.part` file already accounts for — the Range request
|
||||
// on resume then asks for exactly what is missing. Flushing what we
|
||||
// have and leaving the file in place is the whole mechanism behind
|
||||
// "resume", so this must never delete it. (DR-168)
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
let _ = file.flush().await;
|
||||
let _ = file.sync_all().await;
|
||||
return Err(DownloadError::Stopped);
|
||||
}
|
||||
|
||||
let chunk = chunk.map_err(|e| DownloadError::Network(e.to_string()))?;
|
||||
|
||||
file.write_all(&chunk)
|
||||
@@ -138,7 +172,7 @@ impl DownloadWorker {
|
||||
|
||||
// Emit progress every 500ms or every MB
|
||||
if last_progress_emit.elapsed() > Duration::from_millis(500)
|
||||
|| downloaded % (1024 * 1024) == 0
|
||||
|| downloaded.is_multiple_of(1024 * 1024)
|
||||
{
|
||||
last_progress_emit = std::time::Instant::now();
|
||||
on_progress(downloaded, _total_bytes);
|
||||
@@ -167,6 +201,56 @@ impl DownloadWorker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where to resume writing a partial download, given how the server answered.
|
||||
///
|
||||
/// A byte offset of 0 means "start the file again"; anything else means "append
|
||||
/// from here".
|
||||
///
|
||||
/// This is what makes non-`original` downloads survive. Those presets ask
|
||||
/// Jellyfin to **transcode**, and a live transcode is chunked with no
|
||||
/// `Content-Length` and cannot be byte-seeked: the server ignores `Range` and
|
||||
/// answers `200` with the whole stream from the beginning, not `206` with the
|
||||
/// requested tail. The worker sent the header and appended the body regardless,
|
||||
/// so every retry — and every resume — concatenated a fresh copy of the whole
|
||||
/// transcode onto the bytes already on disk. The file grew past its real size
|
||||
/// and would not play. Only a `206` actually promises the tail; a `200` means we
|
||||
/// must discard what we have and take the stream from the top.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-170
|
||||
pub fn resume_offset(existing_bytes: u64, status: u16) -> u64 {
|
||||
if existing_bytes == 0 {
|
||||
return 0;
|
||||
}
|
||||
// 206 Partial Content is the only answer that honours the Range request.
|
||||
if status == 206 {
|
||||
existing_bytes
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// The partial-download sidecar for `target`.
|
||||
///
|
||||
/// **Appends** `.part` rather than replacing the extension. The worker used
|
||||
/// `Path::with_extension("part")`, which replaces: `movie.mp4` became
|
||||
/// `movie.part`. Every cleanup path meanwhile deleted `"{file_path}.part"` —
|
||||
/// `movie.mp4.part` — so nothing ever matched and the partial file of every
|
||||
/// cancelled or failed download was left on disk forever, invisible to the
|
||||
/// disk-usage totals because no `downloads` row pointed at it. That is the
|
||||
/// reported "failure is not cleaned".
|
||||
///
|
||||
/// Appending also removes a collision the old form had: `movie.mp4` and
|
||||
/// `movie.mkv` both mapped to `movie.part` and would have fought over one file.
|
||||
///
|
||||
/// One function so the writer and the cleaners cannot disagree again.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-169
|
||||
pub fn partial_path(target: &std::path::Path) -> std::path::PathBuf {
|
||||
let mut s = target.as_os_str().to_os_string();
|
||||
s.push(".part");
|
||||
std::path::PathBuf::from(s)
|
||||
}
|
||||
|
||||
/// Result of a successful download
|
||||
#[derive(Debug)]
|
||||
pub struct DownloadResult {
|
||||
@@ -179,6 +263,10 @@ pub enum DownloadError {
|
||||
Network(String),
|
||||
Http(u16),
|
||||
FileSystem(String),
|
||||
/// The download was asked to stop (paused or cancelled). Not a failure: the
|
||||
/// row's status already says what happened, and the partial file is kept so a
|
||||
/// resume can continue from it.
|
||||
Stopped,
|
||||
}
|
||||
|
||||
impl DownloadError {
|
||||
@@ -188,8 +276,16 @@ impl DownloadError {
|
||||
DownloadError::Network(_) => true,
|
||||
DownloadError::Http(status) => *status >= 500, // Retry server errors
|
||||
DownloadError::FileSystem(_) => false,
|
||||
// Retrying would restart the very download the user just paused.
|
||||
DownloadError::Stopped => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this outcome means "the user stopped it", rather than a failure to
|
||||
/// record and report.
|
||||
pub fn is_stopped(&self) -> bool {
|
||||
matches!(self, DownloadError::Stopped)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DownloadError {
|
||||
@@ -198,6 +294,7 @@ impl std::fmt::Display for DownloadError {
|
||||
DownloadError::Network(msg) => write!(f, "Network error: {}", msg),
|
||||
DownloadError::Http(status) => write!(f, "HTTP error {}", status),
|
||||
DownloadError::FileSystem(msg) => write!(f, "File system error: {}", msg),
|
||||
DownloadError::Stopped => write!(f, "Download stopped by request"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +305,64 @@ impl std::error::Error for DownloadError {}
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The bitrate-download corruption: a transcode ignores `Range` and answers
|
||||
/// `200` with the whole stream. Appending that to the bytes already on disk
|
||||
/// duplicated them, so every retry grew the file past its real size and left
|
||||
/// it unplayable. Only `206` promises the requested tail.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-170 | UT-164
|
||||
#[test]
|
||||
fn test_resume_offset_only_appends_when_the_server_honoured_the_range() {
|
||||
// Nothing on disk: start at the beginning either way.
|
||||
assert_eq!(resume_offset(0, 200), 0);
|
||||
assert_eq!(resume_offset(0, 206), 0);
|
||||
|
||||
// The server agreed to the range — append to what we have.
|
||||
assert_eq!(resume_offset(5_000, 206), 5_000);
|
||||
|
||||
// The server ignored it and is sending the whole file (a transcode).
|
||||
// Restart, or the bytes are duplicated.
|
||||
assert_eq!(
|
||||
resume_offset(5_000, 200),
|
||||
0,
|
||||
"a 200 carries the whole stream; appending it corrupts the file"
|
||||
);
|
||||
}
|
||||
|
||||
/// The regression: `with_extension` replaced the extension, so the worker
|
||||
/// wrote `movie.part` while every cleanup path deleted `movie.mp4.part`.
|
||||
/// Nothing matched, and partial files accumulated forever.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-169 | UT-163
|
||||
#[test]
|
||||
fn test_partial_path_appends_rather_than_replacing_the_extension() {
|
||||
use std::path::Path;
|
||||
|
||||
assert_eq!(
|
||||
partial_path(Path::new("/media/movie.mp4")),
|
||||
Path::new("/media/movie.mp4.part"),
|
||||
"the cleanup paths delete \"{{file_path}}.part\"; this must produce it"
|
||||
);
|
||||
|
||||
// Two sources for one title must not fight over a single partial file.
|
||||
assert_ne!(
|
||||
partial_path(Path::new("/media/movie.mp4")),
|
||||
partial_path(Path::new("/media/movie.mkv")),
|
||||
);
|
||||
|
||||
// Extension-less targets still get a sidecar rather than being clobbered.
|
||||
assert_eq!(
|
||||
partial_path(Path::new("/media/track")),
|
||||
Path::new("/media/track.part"),
|
||||
);
|
||||
|
||||
// A dotted name keeps every part of its own name.
|
||||
assert_eq!(
|
||||
partial_path(Path::new("/media/S01.E02.episode.mkv")),
|
||||
Path::new("/media/S01.E02.episode.mkv.part"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exponential_backoff() {
|
||||
assert_eq!(
|
||||
@@ -231,5 +386,9 @@ mod tests {
|
||||
assert!(DownloadError::Http(503).is_retryable());
|
||||
assert!(!DownloadError::Http(404).is_retryable());
|
||||
assert!(!DownloadError::FileSystem("disk full".to_string()).is_retryable());
|
||||
// Retrying a paused download would restart what the user just stopped.
|
||||
assert!(!DownloadError::Stopped.is_retryable());
|
||||
assert!(DownloadError::Stopped.is_stopped());
|
||||
assert!(!DownloadError::Network("timeout".to_string()).is_stopped());
|
||||
}
|
||||
}
|
||||
|
||||
+165
-14
@@ -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,13 +123,17 @@ 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_streaming_qualities,
|
||||
player_get_video_settings,
|
||||
// Preload commands
|
||||
player_local_media_path,
|
||||
player_move_in_queue,
|
||||
player_next,
|
||||
player_on_playback_ended,
|
||||
@@ -138,9 +144,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,
|
||||
@@ -154,6 +160,7 @@ use commands::{
|
||||
player_set_cache_config,
|
||||
// Sleep timer and autoplay commands
|
||||
player_set_sleep_timer,
|
||||
player_set_stream_quality,
|
||||
player_set_subtitle_track,
|
||||
player_set_video_settings,
|
||||
player_set_volume,
|
||||
@@ -178,6 +185,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,
|
||||
@@ -186,6 +194,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,
|
||||
@@ -201,6 +210,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,
|
||||
@@ -256,6 +267,7 @@ use commands::{
|
||||
storage_save_user,
|
||||
storage_search_items,
|
||||
storage_set_active_user,
|
||||
storage_set_watched,
|
||||
storage_toggle_favorite,
|
||||
storage_update_playback_context,
|
||||
storage_update_playback_progress,
|
||||
@@ -267,6 +279,7 @@ use commands::{
|
||||
sync_mark_completed,
|
||||
sync_mark_failed,
|
||||
sync_mark_processing,
|
||||
sync_process_pending,
|
||||
// Sync queue commands
|
||||
sync_queue_mutation,
|
||||
thumbnail_clear_cache,
|
||||
@@ -301,6 +314,10 @@ use download::DownloadManager;
|
||||
use jellyfin::{HttpClient, HttpConfig};
|
||||
#[cfg(target_os = "android")]
|
||||
use playback_mode::PlaybackModeManager;
|
||||
// Only the Android MediaSessionHandler resolves lockscreen skips; on other
|
||||
// targets this would be an unused import.
|
||||
#[cfg(target_os = "android")]
|
||||
use player::seek::{resolve_skip_action, SkipAction};
|
||||
use player::{MediaSessionManager, PlayerBackend, PlayerController, TauriEventEmitter};
|
||||
// NullBackend is used both for platforms without a native backend AND as a graceful
|
||||
// fallback when a native backend (MPV/ExoPlayer) fails to initialize, so the app can
|
||||
@@ -414,22 +431,78 @@ impl MediaSessionHandler {
|
||||
|
||||
/// Drive the local player for a transport command.
|
||||
fn handle_local_command(&self, command: &str) {
|
||||
// A lockscreen scrub is an ABSOLUTE position — the scrubber shows the
|
||||
// whole episode — and resolving it during a background-audio handoff means
|
||||
// re-opening the stream, which is async. So it runs on the runtime and,
|
||||
// critically, is handled *before* the blocking lock below: taking that
|
||||
// guard and then spawning a task that waits for the same mutex would
|
||||
// deadlock the media session. (DR-159)
|
||||
if let Some(raw) = command.strip_prefix("seek:") {
|
||||
match raw.parse::<f64>() {
|
||||
Ok(position) => {
|
||||
let player = self.player.clone();
|
||||
tokio::spawn(async move {
|
||||
let controller = player.lock().await;
|
||||
if let Err(e) = controller.seek_absolute(position).await {
|
||||
error!("[MediaSession] Seek to {:.1}s failed: {}", position, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(_) => warn!("[MediaSession] Bad seek command: {}", command),
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip means different things depending on what is actually playing, so
|
||||
// the decision belongs here rather than in the Kotlin that drew the
|
||||
// button: music advances the queue, while a video whose audio is running
|
||||
// through a background-audio handoff scrubs instead (UR-040). Routed
|
||||
// through the same spawn-and-seek path as "seek:" above, because
|
||||
// `seek_absolute` rebuilds the stream during a handoff and must not run
|
||||
// under the blocking lock (DR-159).
|
||||
//
|
||||
// TRACES: UR-040, UR-006 | DR-201
|
||||
if command == "next" || command == "previous" {
|
||||
let is_next = command == "next";
|
||||
let player = self.player.clone();
|
||||
tokio::spawn(async move {
|
||||
let controller = player.lock().await;
|
||||
let action = resolve_skip_action(
|
||||
is_next,
|
||||
controller.is_background_audio_active(),
|
||||
controller.position(),
|
||||
controller.duration(),
|
||||
);
|
||||
let label = if is_next { "next" } else { "previous" };
|
||||
let result: Result<(), String> = match action {
|
||||
SkipAction::Advance => if is_next {
|
||||
controller.next()
|
||||
} else {
|
||||
controller.previous()
|
||||
}
|
||||
.map_err(|e| e.to_string()),
|
||||
SkipAction::SeekTo(position) => {
|
||||
info!(
|
||||
"[MediaSession] Background audio: '{}' scrubs to {:.1}s",
|
||||
label, position
|
||||
);
|
||||
controller.seek_absolute(position).await
|
||||
}
|
||||
};
|
||||
if let Err(e) = result {
|
||||
error!("[MediaSession] Skip '{}' failed: {}", label, e);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Use blocking_lock since this is called from a non-async JNI callback
|
||||
let controller = self.player.blocking_lock();
|
||||
|
||||
let result = match command {
|
||||
"play" => controller.play(),
|
||||
"pause" => controller.pause(),
|
||||
"next" => controller.next(),
|
||||
"previous" => controller.previous(),
|
||||
"stop" => controller.stop(),
|
||||
cmd if cmd.starts_with("seek:") => match cmd[5..].parse::<f64>() {
|
||||
Ok(pos) => controller.seek(pos),
|
||||
Err(_) => {
|
||||
warn!("[MediaSession] Bad seek command: {}", command);
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
warn!("[MediaSession] Unknown command: {}", command);
|
||||
Ok(())
|
||||
@@ -592,7 +665,7 @@ fn create_player_backend(
|
||||
match MpvBackend::new(Some(_event_emitter), playback_reporter, position_throttler) {
|
||||
Ok(backend) => {
|
||||
info!("Successfully initialized MPV backend for Linux");
|
||||
return Box::new(backend);
|
||||
Box::new(backend)
|
||||
}
|
||||
Err(e) => {
|
||||
error!("\n========================================");
|
||||
@@ -617,7 +690,7 @@ fn create_player_backend(
|
||||
// still browse the library and manage downloads, and the frontend
|
||||
// can show a "playback unavailable" notice via this event.
|
||||
emit_backend_init_failed(&app_handle, "mpv", e.to_string());
|
||||
return Box::new(NullBackend::new());
|
||||
Box::new(NullBackend::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -673,6 +746,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,
|
||||
@@ -684,6 +758,8 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
player_get_eq_presets,
|
||||
player_set_video_settings,
|
||||
player_get_video_settings,
|
||||
player_get_streaming_qualities,
|
||||
player_set_stream_quality,
|
||||
// Sleep timer and autoplay commands
|
||||
player_set_sleep_timer,
|
||||
player_cancel_sleep_timer,
|
||||
@@ -693,10 +769,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,
|
||||
@@ -776,6 +854,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
storage_update_playback_progress,
|
||||
storage_update_playback_context,
|
||||
storage_mark_played,
|
||||
storage_set_watched,
|
||||
storage_get_playback_progress,
|
||||
storage_mark_synced,
|
||||
storage_toggle_favorite,
|
||||
@@ -798,6 +877,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,
|
||||
@@ -838,6 +918,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
|
||||
@@ -869,6 +950,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,
|
||||
@@ -887,6 +971,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,
|
||||
@@ -989,6 +1074,23 @@ fn set_env_if_unset(key: &str, value: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached thumbnails are handed to the webview as asset-protocol URLs by
|
||||
/// `convertFileSrc` (`asset://localhost/…` on Linux/macOS,
|
||||
/// `http://asset.localhost/…` on Windows/Android). 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`. 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.
|
||||
///
|
||||
/// The scope is `$APPDATA/thumbnails/**`, not the storage root: downloaded media
|
||||
/// moved to the loopback media server in DR-137, so `imageCache` is the only
|
||||
/// remaining `convertFileSrc` caller and the database and the encrypted-token
|
||||
/// fallback file — which share that root — never need to be readable by the
|
||||
/// webview. Widen it only if something other than thumbnails starts resolving
|
||||
/// through `convertFileSrc` again.
|
||||
///
|
||||
/// TRACES: UR-012, UR-071 | DR-134, DR-137, DR-198
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
// Initialize logger
|
||||
@@ -1196,8 +1298,19 @@ 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());
|
||||
// Restore the persisted streaming bandwidth ceiling. Deferred to the
|
||||
// async runtime because the read is async, and ordered after the
|
||||
// wrapper above because it writes into it. Until it lands, streams
|
||||
// are uncapped — the pre-existing behaviour — and no playback can
|
||||
// have started this early anyway (login happens after setup).
|
||||
//
|
||||
// TRACES: UR-074 | DR-162
|
||||
{
|
||||
let handle = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
crate::commands::restore_streaming_quality(&handle).await;
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize thumbnail cache
|
||||
info!("[INIT] Initializing thumbnail cache...");
|
||||
@@ -1219,6 +1332,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");
|
||||
@@ -1285,6 +1414,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
|
||||
@@ -541,7 +624,7 @@ impl PlaybackModeManager {
|
||||
);
|
||||
|
||||
// Log first few track IDs for debugging
|
||||
if queue_ids.len() > 0 {
|
||||
if !queue_ids.is_empty() {
|
||||
let preview: Vec<&str> = queue_ids.iter().take(3).map(|s| s.as_str()).collect();
|
||||
debug!("[PlaybackMode] First track IDs: {:?}...", preview);
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -839,7 +914,7 @@ mod tests {
|
||||
|
||||
impl PlayerEventEmitter for CapturingEmitter {
|
||||
fn emit(&self, event: PlayerStatusEvent) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
self.events.lock_safe().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -867,7 +942,7 @@ mod tests {
|
||||
manager.set_mode(PlaybackMode::Local);
|
||||
manager.set_mode(PlaybackMode::Idle);
|
||||
|
||||
let events = emitter.events.lock().unwrap();
|
||||
let events = emitter.events.lock_safe();
|
||||
assert_eq!(events.len(), 3, "one event per real mode change");
|
||||
|
||||
match &events[0] {
|
||||
@@ -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_safe().push("enable");
|
||||
}
|
||||
fn disable(&self) {
|
||||
self.calls.lock_safe().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_safe(),
|
||||
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_safe(),
|
||||
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_safe().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_safe(),
|
||||
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]
|
||||
@@ -904,7 +1091,7 @@ mod tests {
|
||||
manager.set_mode(PlaybackMode::Local);
|
||||
|
||||
assert_eq!(
|
||||
emitter.events.lock().unwrap().len(),
|
||||
emitter.events.lock_safe().len(),
|
||||
1,
|
||||
"repeated identical mode set emits only once"
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ use super::backend::{PlayerBackend, PlayerError};
|
||||
use super::events::{PlayerStatusEvent, SharedEventEmitter};
|
||||
use super::media::{MediaItem, MediaType};
|
||||
use super::state::PlayerState;
|
||||
use super::stream_end;
|
||||
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
|
||||
use crate::settings::{audio_settings_jni_payload, AudioSettings};
|
||||
use crate::utils::conversions::seconds_to_ticks;
|
||||
@@ -58,11 +59,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(',')
|
||||
@@ -74,6 +84,7 @@ impl DetectedCodecs {
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.collect(),
|
||||
max_audio_channels,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,11 +99,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.
|
||||
@@ -330,6 +349,9 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
let artwork_url = media.artwork_url.clone();
|
||||
// Convert duration from seconds to milliseconds
|
||||
let duration_ms = media.duration.map(|d| (d * 1000.0) as i64).unwrap_or(0);
|
||||
// A stream the player could only "retry" by restarting it must not be
|
||||
// retried by the player at all — recovery is ours. (DR-203)
|
||||
let player_retry_restarts_stream = stream_end::player_retry_restarts_stream(media);
|
||||
|
||||
// Update local state
|
||||
{
|
||||
@@ -402,7 +424,18 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
None => JValue::Object(&null_obj),
|
||||
};
|
||||
|
||||
// Determine media type string for JNI
|
||||
// Determine media type string for JNI.
|
||||
//
|
||||
// This is not cosmetic: the string decides *which audio-focus mechanism*
|
||||
// runs on the Kotlin side. `JellyTauPlayer.load()` re-applies
|
||||
// `setAudioAttributes(attrs, handleAudioFocus = mediaType == AUDIO)`, so
|
||||
// "audio" leaves focus to ExoPlayer (request on play, duck on transient
|
||||
// loss, pause on a call) while "video" switches it to the manual
|
||||
// `AudioFocusRequest` path, which needs delayed-focus handling. Either
|
||||
// way the resulting pause comes back through `nativeOnStateChanged`, so
|
||||
// the Rust controller — not the focus listener — stays authoritative.
|
||||
//
|
||||
// TRACES: UR-004, UR-006 | IR-008
|
||||
let media_type_str = match media.media_type {
|
||||
MediaType::Video => "video",
|
||||
MediaType::Audio => "audio",
|
||||
@@ -425,7 +458,7 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
let result = env.call_method(
|
||||
&self.player_ref,
|
||||
"loadWithMetadata",
|
||||
"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;)V",
|
||||
"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;Z)V",
|
||||
&[
|
||||
JValue::Object(&url_jstring),
|
||||
JValue::Object(&media_id_jstring),
|
||||
@@ -436,6 +469,7 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
JValue::Long(duration_ms),
|
||||
JValue::Object(&media_type_jstring),
|
||||
JValue::Object(&subtitles_jstring),
|
||||
JValue::Bool(player_retry_restarts_stream as u8),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -915,38 +949,37 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
}
|
||||
|
||||
if auto_advance {
|
||||
// Background audio-only episode: the frontend that normally
|
||||
// performs the advance (goto /player/<id>) is suspended, so
|
||||
// the backend must load the next episode's audio-only stream
|
||||
// itself — otherwise playback just stops at the boundary.
|
||||
let is_bg_audio_episode =
|
||||
controller.lock().await.current_is_audio_episode();
|
||||
if is_bg_audio_episode {
|
||||
log::info!(
|
||||
"[Autoplay] Background audio episode — advancing to {} in backend",
|
||||
next_episode.id
|
||||
);
|
||||
let ctrl = controller.lock().await;
|
||||
if let Err(e) = ctrl
|
||||
.advance_to_next_episode_audio_only(&next_episode.id)
|
||||
.await
|
||||
{
|
||||
log::error!(
|
||||
"[Autoplay] Background audio advance failed: {} — stopping",
|
||||
e
|
||||
);
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
}
|
||||
} else {
|
||||
ctrl.emit_queue_changed();
|
||||
}
|
||||
} else {
|
||||
// Foreground: frontend drives the advance off the countdown.
|
||||
controller
|
||||
.lock()
|
||||
.await
|
||||
.start_autoplay_countdown(next_episode, countdown_seconds);
|
||||
// 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
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -994,11 +1027,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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1029,6 +1112,15 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
///
|
||||
/// Commands from lockscreen controls, notification buttons, and Bluetooth
|
||||
/// devices are routed through here to the Rust PlayerController.
|
||||
///
|
||||
/// This is the inbound half of UR-006: `MediaSessionCompat` is flagged
|
||||
/// `FLAG_HANDLES_MEDIA_BUTTONS`, so an AVRCP play/pause/skip from a headset
|
||||
/// arrives at the service's transport callback and lands here as a command
|
||||
/// string. The player stays authoritative — the session is a consumer that
|
||||
/// *requests*, and the resulting state comes back out through
|
||||
/// [`update_lockscreen_metadata`].
|
||||
///
|
||||
/// TRACES: UR-006 | IR-006
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlaybackService_nativeOnMediaCommand(
|
||||
mut env: JNIEnv,
|
||||
@@ -1076,6 +1168,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)
|
||||
@@ -1087,7 +1180,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: {}",
|
||||
@@ -1099,6 +1195,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() {
|
||||
@@ -1321,6 +1421,8 @@ use crate::player::LockscreenMetadata;
|
||||
/// running (in remote mode it is started via [`enable_remote_volume`]); if it
|
||||
/// isn't, this is a no-op rather than an error so it can be called freely on
|
||||
/// every poll tick.
|
||||
///
|
||||
/// TRACES: UR-006 | IR-006
|
||||
pub fn update_lockscreen_metadata(meta: &LockscreenMetadata) -> Result<(), String> {
|
||||
let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?;
|
||||
let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?;
|
||||
@@ -1399,9 +1501,11 @@ pub fn update_lockscreen_metadata(meta: &LockscreenMetadata) -> Result<(), Strin
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the base position offset (seconds) on the lockscreen MediaSession.
|
||||
/// Set the background-audio handoff base (seconds) on the playback service.
|
||||
///
|
||||
/// Calls `JellyTauPlaybackService.setPositionOffset(double)`. No-op if the
|
||||
/// The service holds it for `JellyTauPlayer`'s position tick, which is the one
|
||||
/// place the relative handoff timeline is converted to the episode's own — see
|
||||
/// DR-159. Calls `JellyTauPlaybackService.setHandoffBase(double)`. No-op if the
|
||||
/// service isn't running yet, so it's safe to call unconditionally.
|
||||
pub fn set_position_offset(offset_seconds: f64) -> Result<(), String> {
|
||||
let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?;
|
||||
@@ -1450,11 +1554,11 @@ pub fn set_position_offset(offset_seconds: f64) -> Result<(), String> {
|
||||
|
||||
env.call_method(
|
||||
&service_obj,
|
||||
"setPositionOffset",
|
||||
"setHandoffBase",
|
||||
"(D)V",
|
||||
&[JValue::Double(offset_seconds)],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set position offset: {}", e))?;
|
||||
.map_err(|e| format!("Failed to set handoff base: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -6,11 +6,21 @@ use serde::{Deserialize, Serialize};
|
||||
/// Autoplay decision result - determines what happens after playback ends
|
||||
#[derive(specta::Type, Debug, Clone, Serialize)]
|
||||
#[serde(tag = "action", rename_all = "camelCase")]
|
||||
// `ShowNextEpisodePopup` carries two `MediaItem`s, so it dwarfs the unit
|
||||
// variants. Boxing them is not worth it here: this enum is constructed once per
|
||||
// end-of-item (never in a hot loop or a large collection), and it is an IPC type
|
||||
// — the indirection would have to stay invisible to serde/specta while every
|
||||
// match arm gained a deref, for no measurable gain.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum AutoplayDecision {
|
||||
/// Stop playback (no next item or timer expired)
|
||||
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,
|
||||
|
||||
@@ -98,9 +98,12 @@ pub trait PlayerBackend: Send + Sync {
|
||||
|
||||
/// Set the active audio track by stream index
|
||||
///
|
||||
/// @req-planned: UR-021 - Select audio track for video content
|
||||
/// @req-planned: IR-019 - libmpv audio track selection
|
||||
/// @req-planned: DR-024 - Audio track selection UI in video player
|
||||
/// Overridden by the Android (ExoPlayer) backend. `MpvBackend` deliberately
|
||||
/// does **not** override it — MPV is the audio-only backend here, so it keeps
|
||||
/// this `not_implemented()` default and the Linux video path switches track by
|
||||
/// re-opening the stream instead (`player_switch_audio_track`).
|
||||
///
|
||||
/// TRACES: UR-021 | IR-019, DR-024
|
||||
fn set_audio_track(&mut self, _stream_index: i32) -> Result<(), PlayerError> {
|
||||
// Default implementation does nothing - override in platform-specific backends
|
||||
Err(PlayerError::not_implemented())
|
||||
@@ -108,9 +111,12 @@ pub trait PlayerBackend: Send + Sync {
|
||||
|
||||
/// Set the active subtitle track by stream index (None to disable subtitles)
|
||||
///
|
||||
/// @req-planned: UR-020 - Select subtitles for video content
|
||||
/// @req-planned: IR-018 - libmpv subtitle rendering and selection
|
||||
/// @req-planned: DR-023 - Subtitle selection UI in video player
|
||||
/// Overridden by the Android (ExoPlayer) backend. `MpvBackend` deliberately
|
||||
/// does **not** override it, so it keeps this `not_implemented()` default;
|
||||
/// the Linux video path renders subtitles as `<track>` children of the
|
||||
/// WebKitGTK HTML5 `<video>` element and never calls this.
|
||||
///
|
||||
/// TRACES: UR-020 | IR-018, DR-023
|
||||
fn set_subtitle_track(&mut self, _stream_index: Option<i32>) -> Result<(), PlayerError> {
|
||||
// Default implementation does nothing - override in platform-specific backends
|
||||
Err(PlayerError::not_implemented())
|
||||
|
||||
@@ -30,6 +30,12 @@ use super::{MediaSessionType, SleepTimerMode};
|
||||
// queue_changed never reach the frontend, so the mini player never appears).
|
||||
// Keep serde and specta agreeing: snake_case fields, snake_case variant tags.
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
// `ShowNextEpisodePopup` carries two `MediaItem`s, so it dwarfs the small
|
||||
// position/state variants. Boxing them is rejected deliberately: this is a
|
||||
// serde + specta wire type whose generated TypeScript must not shift, and the
|
||||
// events are emitted a few times a second at most — never bulk-allocated — so
|
||||
// the size difference costs nothing measurable.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum PlayerStatusEvent {
|
||||
/// Playback position updated (emitted periodically during playback)
|
||||
PositionUpdate {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
+1826
-111
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ 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, VolumeLevel, EQ_BANDS};
|
||||
use crate::utils::conversions::{seconds_to_ticks, volume_to_percent};
|
||||
@@ -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
|
||||
@@ -209,7 +243,7 @@ impl MpvBackend {
|
||||
});
|
||||
}
|
||||
}
|
||||
libmpv::events::Event::PropertyChange { name, .. } if name == "pause" => {
|
||||
libmpv::events::Event::PropertyChange { name: "pause", .. } => {
|
||||
// Handle pause state changes
|
||||
if let Ok(is_paused) = mpv.get_property::<bool>("pause") {
|
||||
let media_id = state
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
/// Tests for MpvBackend to prevent regressions
|
||||
///
|
||||
/// These tests are designed to catch common issues like:
|
||||
/// - Tokio runtime panics when spawning async tasks from std::thread
|
||||
/// - Position update thread failures
|
||||
/// - Event emission issues
|
||||
///
|
||||
/// TRACES: UR-003, UR-004 | IR-003 | IT-003, IT-004
|
||||
//! Tests for MpvBackend to prevent regressions
|
||||
//!
|
||||
//! These tests are designed to catch common issues like:
|
||||
//! - Tokio runtime panics when spawning async tasks from std::thread
|
||||
//! - Position update thread failures
|
||||
//! - Event emission issues
|
||||
//!
|
||||
//! TRACES: UR-003, UR-004 | IR-003 | IT-003, IT-004
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
|
||||
@@ -67,14 +68,14 @@ mod tests {
|
||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
||||
// Has runtime (shouldn't happen in this test)
|
||||
handle.spawn(async move {
|
||||
*counter_clone.lock().unwrap() += 1;
|
||||
*counter_clone.lock_safe() += 1;
|
||||
});
|
||||
} else {
|
||||
// No runtime - use fallback (should happen in this test)
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(async move {
|
||||
*counter_clone.lock().unwrap() += 1;
|
||||
*counter_clone.lock_safe() += 1;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -85,7 +86,7 @@ mod tests {
|
||||
// Wait for async task to complete
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
|
||||
let count = *counter.lock().unwrap();
|
||||
let count = *counter.lock_safe();
|
||||
assert_eq!(
|
||||
count, 1,
|
||||
"Fallback pattern should execute async code successfully"
|
||||
@@ -109,13 +110,13 @@ mod tests {
|
||||
let position = i as f64 * 0.25;
|
||||
|
||||
// Store position (simulating event emission)
|
||||
positions_clone.lock().unwrap().push(position);
|
||||
positions_clone.lock_safe().push(position);
|
||||
}
|
||||
});
|
||||
|
||||
handle.join().unwrap();
|
||||
|
||||
let recorded_positions = positions.lock().unwrap();
|
||||
let recorded_positions = positions.lock_safe();
|
||||
assert_eq!(
|
||||
recorded_positions.len(),
|
||||
5,
|
||||
|
||||
@@ -806,11 +806,10 @@ mod tests {
|
||||
assert_eq!(queue.current_index(), Some(first_shuffled_index));
|
||||
|
||||
// Move through shuffle order
|
||||
for i in 1..shuffle_order.len() {
|
||||
for &expected_index in &shuffle_order[1..] {
|
||||
assert!(queue.has_next());
|
||||
let result = queue.next();
|
||||
assert!(result.is_some());
|
||||
let expected_index = shuffle_order[i];
|
||||
assert_eq!(queue.current_index(), Some(expected_index));
|
||||
}
|
||||
|
||||
|
||||
@@ -59,10 +59,149 @@ pub fn determine_video_seek_strategy(
|
||||
}
|
||||
}
|
||||
|
||||
// The four items below are consumed by the Android MediaSessionHandler; on other
|
||||
// targets only the tests exercise them, so dead-code analysis would flag them.
|
||||
|
||||
/// How far a lockscreen skip-forward jumps while background audio owns playback.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub const SKIP_FORWARD_SECONDS: f64 = 30.0;
|
||||
|
||||
/// How far a lockscreen skip-back jumps while background audio owns playback.
|
||||
///
|
||||
/// Deliberately shorter than the forward jump: the back button is used to replay
|
||||
/// dialogue just missed, not to travel.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub const SKIP_BACK_SECONDS: f64 = 10.0;
|
||||
|
||||
/// What a lockscreen skip button means for the playback that is actually running.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum SkipAction {
|
||||
/// Move to the next/previous queue entry — a track, or an episode.
|
||||
Advance,
|
||||
/// Scrub within the current item, to this absolute position in seconds.
|
||||
SeekTo(f64),
|
||||
}
|
||||
|
||||
/// Decide whether a lockscreen skip advances the queue or scrubs the current item.
|
||||
///
|
||||
/// Music gets queue advance, which is what the buttons look like they do. A video
|
||||
/// whose audio is playing through a background-audio handoff (UR-040) gets a
|
||||
/// relative scrub instead: there is no meaningful "next track" inside a film, and
|
||||
/// jumping to the next *episode* because the user wanted to re-hear a line is a
|
||||
/// much worse outcome than a scrub.
|
||||
///
|
||||
/// `is_background_audio` is the whole test, and it is sufficient on its own —
|
||||
/// the handoff exists only for video, and an episode played through it reports
|
||||
/// `MediaType::Audio`, so media type cannot distinguish this case (see the note
|
||||
/// at `PlayerController::auto_advance_to_next_episode`).
|
||||
///
|
||||
/// Clamped to `[0, duration]` so a skip near either end lands in the item rather
|
||||
/// than at a negative offset or past the end, which some backends treat as EOF
|
||||
/// and would turn a scrub into an unintended advance.
|
||||
///
|
||||
/// TRACES: UR-040, UR-006 | DR-201
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub fn resolve_skip_action(
|
||||
is_next: bool,
|
||||
is_background_audio: bool,
|
||||
position: f64,
|
||||
duration: Option<f64>,
|
||||
) -> SkipAction {
|
||||
if !is_background_audio {
|
||||
return SkipAction::Advance;
|
||||
}
|
||||
|
||||
let target = if is_next {
|
||||
position + SKIP_FORWARD_SECONDS
|
||||
} else {
|
||||
position - SKIP_BACK_SECONDS
|
||||
};
|
||||
|
||||
let clamped = match duration {
|
||||
Some(d) if d > 0.0 => target.clamp(0.0, d),
|
||||
_ => target.max(0.0),
|
||||
};
|
||||
|
||||
SkipAction::SeekTo(clamped)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Music (no background-audio handoff) keeps queue advance on both buttons.
|
||||
///
|
||||
/// TRACES: UR-006 | DR-201 | UT-194
|
||||
#[test]
|
||||
fn test_skip_advances_queue_for_normal_audio() {
|
||||
assert_eq!(
|
||||
resolve_skip_action(true, false, 42.0, Some(300.0)),
|
||||
SkipAction::Advance
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_skip_action(false, false, 42.0, Some(300.0)),
|
||||
SkipAction::Advance
|
||||
);
|
||||
}
|
||||
|
||||
/// The reported bug: in background-audio mode the lockscreen skip buttons
|
||||
/// advanced to the next/previous episode instead of scrubbing, so trying to
|
||||
/// re-hear a line jumped out of the film entirely.
|
||||
///
|
||||
/// TRACES: UR-040 | DR-201 | UT-195
|
||||
#[test]
|
||||
fn test_skip_scrubs_in_background_audio_mode() {
|
||||
assert_eq!(
|
||||
resolve_skip_action(true, true, 100.0, Some(3600.0)),
|
||||
SkipAction::SeekTo(130.0)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_skip_action(false, true, 100.0, Some(3600.0)),
|
||||
SkipAction::SeekTo(90.0)
|
||||
);
|
||||
}
|
||||
|
||||
/// Skipping back near the start clamps to zero rather than going negative,
|
||||
/// which backends reject (the "Raw(-10)" class of error).
|
||||
///
|
||||
/// TRACES: UR-040 | DR-201 | UT-196
|
||||
#[test]
|
||||
fn test_skip_back_clamps_at_start() {
|
||||
assert_eq!(
|
||||
resolve_skip_action(false, true, 4.0, Some(3600.0)),
|
||||
SkipAction::SeekTo(0.0)
|
||||
);
|
||||
}
|
||||
|
||||
/// Skipping forward near the end clamps to the duration instead of running
|
||||
/// past it, which would read as end-of-stream and advance — the very thing
|
||||
/// this function exists to prevent.
|
||||
///
|
||||
/// TRACES: UR-040 | DR-201 | UT-197
|
||||
#[test]
|
||||
fn test_skip_forward_clamps_at_end() {
|
||||
assert_eq!(
|
||||
resolve_skip_action(true, true, 3590.0, Some(3600.0)),
|
||||
SkipAction::SeekTo(3600.0)
|
||||
);
|
||||
}
|
||||
|
||||
/// An unknown duration still scrubs, and still refuses to go negative.
|
||||
///
|
||||
/// TRACES: UR-040 | DR-201 | UT-198
|
||||
#[test]
|
||||
fn test_skip_without_duration_still_scrubs() {
|
||||
assert_eq!(
|
||||
resolve_skip_action(true, true, 10.0, None),
|
||||
SkipAction::SeekTo(40.0)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_skip_action(false, true, 3.0, None),
|
||||
SkipAction::SeekTo(0.0)
|
||||
);
|
||||
}
|
||||
|
||||
/// Test video seek strategy for local files
|
||||
#[test]
|
||||
fn test_seek_strategy_local_file() {
|
||||
|
||||
@@ -159,6 +159,9 @@ mod tests {
|
||||
#[test]
|
||||
fn test_end_reason_clone() {
|
||||
let reason = EndReason::Finished;
|
||||
// Deliberately exercising the derived `Clone` impl, not a plain copy:
|
||||
// `EndReason` is also `Copy`, so clippy flags the call as redundant.
|
||||
#[allow(clippy::clone_on_copy)]
|
||||
let cloned = reason.clone();
|
||||
assert_eq!(reason, cloned);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
//! 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.
|
||||
|
||||
use crate::player::media::{MediaItem, MediaSource, MediaType};
|
||||
|
||||
/// 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;
|
||||
|
||||
/// A video item played through the native *audio* path — i.e. the background
|
||||
/// audio-only handoff, the only place a length-less progressive transcode is
|
||||
/// used. Jellyfin's item-type taxonomy stays in Rust (CLAUDE.md).
|
||||
///
|
||||
/// TRACES: UR-040 | DR-129, DR-203 | UT-117, UT-200
|
||||
pub fn is_audio_only_video(item: &MediaItem) -> bool {
|
||||
item.media_type == MediaType::Audio
|
||||
&& matches!(item.item_type.as_deref(), Some("Episode") | Some("Movie"))
|
||||
}
|
||||
|
||||
/// Would the *player's own* load-error retry restart this stream from its
|
||||
/// beginning? If so the retry must be switched off and recovery left to
|
||||
/// [`crate::player::PlayerController::recoverable_error_resume`].
|
||||
///
|
||||
/// ExoPlayer resumes a failed load in place only when it knows where "in place"
|
||||
/// is: `ProgressiveMediaPeriod.configureRetry` keeps the load position when the
|
||||
/// content length is known *or* the extractor produced a seek map with a
|
||||
/// duration, and otherwise treats the source as live — the data at the URL is
|
||||
/// assumed to have changed, so it resets every sample queue and re-requests the
|
||||
/// URL from offset 0.
|
||||
///
|
||||
/// The handoff transcode satisfies neither condition: it is chunked (no
|
||||
/// `Content-Length`) and a live mp3 encode carries no `Xing` header, so the
|
||||
/// player reports its duration as unset — visible in logcat as every position
|
||||
/// tick reading `<position> / 0.0`. Its URL carries `StartTimeTicks` = the
|
||||
/// handoff point, so restarting it from offset 0 restarts the *episode* at the
|
||||
/// handoff point, and playback then runs on from there. Nothing surfaces: no
|
||||
/// error, no `STATE_ENDED`, so neither the truncation path nor the error path of
|
||||
/// DR-129 is consulted, and the app's only sign of it is a position that jumps
|
||||
/// backwards. That is the "it randomly jumps back to where audio-only started"
|
||||
/// the user sees, and how random it is depends on whether a network blip happens
|
||||
/// to land while a load is in flight rather than while the ~50s buffer covers it.
|
||||
///
|
||||
/// A retry that can only restart the stream is worth less than no retry at all:
|
||||
/// declining it turns the silent rewind into a recoverable error, which
|
||||
/// `recoverable_error_resume` answers by re-opening the stream at the position
|
||||
/// playback actually reached (`StartTimeTicks` rewritten, backoff and attempt
|
||||
/// budget included). Every other source keeps the player's retry: a static file
|
||||
/// and an HLS playlist both declare their timeline, so ExoPlayer resumes them
|
||||
/// exactly where the load failed.
|
||||
///
|
||||
/// TRACES: UR-040, UR-004 | DR-203 | UT-200
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub fn player_retry_restarts_stream(item: &MediaItem) -> bool {
|
||||
is_audio_only_video(item) && matches!(item.source, MediaSource::Remote { .. })
|
||||
}
|
||||
|
||||
/// 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 last observed position, with no live reading to prefer — the case
|
||||
/// where the *reporter* is the only source there is (webview-rendered media,
|
||||
/// which the native backend cannot see at all).
|
||||
pub fn last_position(&self) -> f64 {
|
||||
self.position
|
||||
}
|
||||
|
||||
/// The last observed duration, if one was ever established.
|
||||
pub fn last_duration(&self) -> Option<f64> {
|
||||
self.duration
|
||||
}
|
||||
|
||||
/// 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::*;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The background-audio handoff item, as `player_enter_background_audio`
|
||||
/// builds it: the episode replayed as AUDIO off a remote stream URL whose
|
||||
/// `StartTimeTicks` is the handoff point.
|
||||
fn handoff_item() -> MediaItem {
|
||||
MediaItem {
|
||||
id: "ep2".to_string(),
|
||||
title: "Episode 2".to_string(),
|
||||
name: None,
|
||||
artist: None,
|
||||
album: None,
|
||||
album_name: None,
|
||||
album_id: None,
|
||||
artist_items: None,
|
||||
artists: None,
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: Some("Episode".to_string()),
|
||||
playlist_id: None,
|
||||
duration: Some(1500.0),
|
||||
artwork_url: None,
|
||||
media_type: MediaType::Audio,
|
||||
source: MediaSource::Remote {
|
||||
stream_url: "http://s/Audio/ep2/universal?Container=mp3&StartTimeTicks=1250000000"
|
||||
.to_string(),
|
||||
jellyfin_item_id: "ep2".to_string(),
|
||||
},
|
||||
video_codec: None,
|
||||
needs_transcoding: false,
|
||||
video_width: None,
|
||||
video_height: None,
|
||||
subtitles: vec![],
|
||||
series_id: Some("series1".to_string()),
|
||||
server_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The reported bug: a load error on the length-less handoff transcode let
|
||||
/// ExoPlayer "retry" the only way it can — from offset 0 — which re-opens
|
||||
/// the URL at its `StartTimeTicks` and drops playback back to the handoff
|
||||
/// point, silently. This item must never be left to the player's own retry.
|
||||
#[test]
|
||||
fn test_handoff_transcode_must_not_use_the_players_own_retry() {
|
||||
assert!(player_retry_restarts_stream(&handoff_item()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_music_keeps_the_players_retry() {
|
||||
// `/Audio/{id}/stream?Static=true` — a real Content-Length and byte
|
||||
// ranges, so ExoPlayer resumes it where the load failed.
|
||||
let track = MediaItem {
|
||||
item_type: Some("Audio".to_string()),
|
||||
..handoff_item()
|
||||
};
|
||||
assert!(!player_retry_restarts_stream(&track));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_video_keeps_the_players_retry() {
|
||||
// An HLS playlist declares its segments, so a failed segment load is
|
||||
// retried at that segment, not at the start of the episode.
|
||||
let video = MediaItem {
|
||||
media_type: MediaType::Video,
|
||||
..handoff_item()
|
||||
};
|
||||
assert!(!player_retry_restarts_stream(&video));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_downloaded_episode_keeps_the_players_retry() {
|
||||
// A local file has no length problem and no network to lose.
|
||||
let local = MediaItem {
|
||||
source: MediaSource::Local {
|
||||
file_path: PathBuf::from("/data/ep2.mkv"),
|
||||
jellyfin_item_id: Some("ep2".to_string()),
|
||||
},
|
||||
..handoff_item()
|
||||
};
|
||||
assert!(!player_retry_restarts_stream(&local));
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
@@ -196,7 +196,7 @@ mod tests {
|
||||
|
||||
impl PlayerEventEmitter for RecordingEmitter {
|
||||
fn emit(&self, event: PlayerStatusEvent) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
self.events.lock_safe().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,7 +244,7 @@ mod tests {
|
||||
let (mut b, events) = backend();
|
||||
b.load(&test_media()).unwrap();
|
||||
|
||||
let ev = events.lock().unwrap();
|
||||
let ev = events.lock_safe();
|
||||
let load = ev
|
||||
.iter()
|
||||
.find(|e| matches!(e, PlayerStatusEvent::WebviewAudioLoad { .. }))
|
||||
@@ -263,7 +263,7 @@ mod tests {
|
||||
b.pause().unwrap();
|
||||
b.seek(42.0).unwrap();
|
||||
|
||||
let ev = events.lock().unwrap();
|
||||
let ev = events.lock_safe();
|
||||
assert!(ev.iter().any(|e| matches!(
|
||||
e,
|
||||
PlayerStatusEvent::ControlCommand { action, .. } if action == "pause"
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
//! 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";
|
||||
|
||||
/// Jellyfin's sentinel for "negotiate no subtitle stream at all".
|
||||
///
|
||||
/// Omitting `SubtitleStreamIndex` does **not** mean this: the server then applies
|
||||
/// the source's default/forced flags and picks a track itself. See
|
||||
/// [`playback_subtitle_stream_index`] for why that is never what we want.
|
||||
pub const NO_SUBTITLE_STREAM: i32 = -1;
|
||||
|
||||
/// Subtitle formats we can render ourselves, delivered as an external sidecar
|
||||
/// track rather than painted into the video.
|
||||
///
|
||||
/// Every entry here is *text*. Image-based subtitles (PGS, DVD, DVB) are
|
||||
/// deliberately absent: they are bitmaps, so the only way a server can show them
|
||||
/// on a client that cannot composite them is to burn them into the picture.
|
||||
const EXTERNAL_SUBTITLE_FORMATS: &[&str] = &["srt", "subrip", "ass", "ssa", "vtt"];
|
||||
|
||||
/// The `SubtitleProfile` entries to advertise, as `(format, method)`.
|
||||
///
|
||||
/// All `External`: the app fetches subtitle tracks itself and renders them over
|
||||
/// the video (UR-020), so it never needs the server to composite them.
|
||||
///
|
||||
/// TRACES: UR-020 | DR-176 | UT-168
|
||||
pub fn subtitle_profiles() -> Vec<(&'static str, &'static str)> {
|
||||
EXTERNAL_SUBTITLE_FORMATS
|
||||
.iter()
|
||||
.map(|format| (*format, "External"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether asking the server to serve this subtitle codec forces it to burn the
|
||||
/// subtitle into the picture.
|
||||
///
|
||||
/// Burn-in is not a subtitle cost — it is a *video* cost. It rules out remuxing
|
||||
/// the video stream, so a source we would otherwise have passed through untouched
|
||||
/// gets fully re-encoded frame by frame.
|
||||
///
|
||||
/// TRACES: UR-020 | DR-176 | UT-168
|
||||
pub fn subtitle_forces_burn_in(codec: &str) -> bool {
|
||||
!EXTERNAL_SUBTITLE_FORMATS
|
||||
.iter()
|
||||
.any(|format| format.eq_ignore_ascii_case(codec.trim()))
|
||||
}
|
||||
|
||||
/// The `SubtitleStreamIndex` to negotiate with: always "none".
|
||||
///
|
||||
/// The reported bug: a source with an E-AC-3 track and a **PGSSUB** default
|
||||
/// subtitle track. Sending no index let the server honour that default, and since
|
||||
/// PGS cannot go out as a sidecar it chose `SubtitleMethod=Encode` — burn-in.
|
||||
/// That turned an audio-only transcode (the HEVC video was directly supported)
|
||||
/// into a full HEVC→h264 re-encode, which the server could not sustain in real
|
||||
/// time: the buffer never grew beyond one segment and playback stalled every few
|
||||
/// seconds, taking seeking down with it.
|
||||
///
|
||||
/// Asking for no subtitle stream costs nothing, because the app never wanted the
|
||||
/// server's composited version — it fetches the text tracks separately and
|
||||
/// renders them itself (UR-020).
|
||||
///
|
||||
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
|
||||
pub fn playback_subtitle_stream_index() -> i32 {
|
||||
NO_SUBTITLE_STREAM
|
||||
}
|
||||
|
||||
/// Query keys through which a stream URL can carry a subtitle decision.
|
||||
///
|
||||
/// Jellyfin binds query keys case-insensitively, so the match has to be too —
|
||||
/// the server itself mixes casing (`SubtitleStreamIndex` but
|
||||
/// `alwaysBurnInSubtitleWhenTranscoding`).
|
||||
const SUBTITLE_QUERY_KEYS: &[&str] = &[
|
||||
"subtitlestreamindex",
|
||||
"subtitlemethod",
|
||||
"subtitlecodec",
|
||||
"alwaysburninsubtitlewhentranscoding",
|
||||
];
|
||||
|
||||
/// Rewrite a stream URL so it asks for no subtitle, whoever built it.
|
||||
///
|
||||
/// [`playback_subtitle_stream_index`] only governs the URLs *this app* builds.
|
||||
/// When `PlaybackInfo` answers with a `TranscodingUrl`, the URL was built by the
|
||||
/// server from its own subtitle verdict, and we play it verbatim — so a server
|
||||
/// that picked a track anyway (a live channel opened without an index, a source
|
||||
/// whose default is image-based) hands us `SubtitleMethod=Encode`, and the
|
||||
/// burn-in the negotiation just declined comes back through the URL. Burn-in is
|
||||
/// a *video* cost: it rules out remuxing and forces a full re-encode.
|
||||
///
|
||||
/// Stripping the keys is not enough on its own — an absent index is not "none",
|
||||
/// it is "you choose" — so the sentinel is always appended.
|
||||
///
|
||||
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
|
||||
pub fn without_server_chosen_subtitle(url: &str) -> String {
|
||||
let (path, query) = match url.split_once('?') {
|
||||
Some((path, query)) => (path, query),
|
||||
None => (url, ""),
|
||||
};
|
||||
|
||||
let mut kept: Vec<&str> = query
|
||||
.split('&')
|
||||
.filter(|param| !param.is_empty())
|
||||
.filter(|param| {
|
||||
let key = param.split_once('=').map_or(*param, |(key, _)| key);
|
||||
!SUBTITLE_QUERY_KEYS
|
||||
.iter()
|
||||
.any(|subtitle_key| key.eq_ignore_ascii_case(subtitle_key))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let sentinel = format!("SubtitleStreamIndex={}", NO_SUBTITLE_STREAM);
|
||||
kept.push(&sentinel);
|
||||
|
||||
format!("{}?{}", path, kept.join("&"))
|
||||
}
|
||||
|
||||
/// Whether a subtitle in this format can reach the app as a sidecar it draws
|
||||
/// itself — the same verdict as [`subtitle_forces_burn_in`], from the reader's
|
||||
/// side, and the one a subtitle picker needs.
|
||||
///
|
||||
/// Since the app asks for burn-in nowhere (see
|
||||
/// [`playback_subtitle_stream_index`]), a format that only burn-in could deliver
|
||||
/// is one it can never display. An unnamed format is treated as undeliverable
|
||||
/// rather than guessed at: offering a track and drawing nothing is worse than
|
||||
/// not offering it.
|
||||
///
|
||||
/// TRACES: UR-020 | DR-176 | UT-168
|
||||
pub fn subtitle_supports_external_delivery(codec: Option<&str>) -> bool {
|
||||
codec.is_some_and(|codec| !subtitle_forces_burn_in(codec))
|
||||
}
|
||||
|
||||
/// Narrow a detected audio-codec list to what the renderer that will actually
|
||||
/// play the **video** can decode.
|
||||
///
|
||||
/// The platform list comes from `MediaCodecList`, which describes ExoPlayer —
|
||||
/// but the webview `<video>` element may be what renders the video, and
|
||||
/// Chromium/WebKit decode a much smaller set than the platform does. Advertising
|
||||
/// the raw list makes Jellyfin direct-play a track the webview cannot decode, and
|
||||
/// the user gets picture with no sound.
|
||||
///
|
||||
/// Which renderer gets it is not fixed: Linux is always the element, and Android
|
||||
/// follows `experimentalNativeVideo`, which took ExoPlayer as its default in
|
||||
/// DR-161 but is a user setting either way. So the *narrow* list is the only one
|
||||
/// that holds on both sides of that switch. The cost is a Dolby-licensed Android
|
||||
/// device transcoding an E-AC-3 track its ExoPlayer could have direct-played;
|
||||
/// the alternative is silence for everyone the switch lands the other way, which
|
||||
/// is the bug this exists to prevent.
|
||||
///
|
||||
/// The gap is widest on devices whose vendor licenses Dolby: a phone with
|
||||
/// `c2.dolby.eac3.decoder` reports `eac3`, so it — and only it — gets a silent
|
||||
/// direct play where a leaner device is transcoded to AAC and plays fine.
|
||||
///
|
||||
/// This applies to the *video* direct-play profile only. Audio-only playback
|
||||
/// really is ExoPlayer's, so its profile keeps the full platform list.
|
||||
///
|
||||
/// TRACES: UR-004 | DR-148 | UT-142
|
||||
pub fn video_audio_codecs(detected: &str) -> String {
|
||||
let kept: Vec<&str> = detected
|
||||
.split(',')
|
||||
.filter_map(|codec| {
|
||||
let codec = codec.trim();
|
||||
// Match case-insensitively but emit our own spelling: the platform
|
||||
// list is assembled from MIME strings and its casing is not ours to
|
||||
// forward to the server.
|
||||
WEBVIEW_AUDIO_CODECS
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|supported| supported.eq_ignore_ascii_case(codec))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if kept.is_empty() {
|
||||
FALLBACK_AUDIO_CODEC.to_string()
|
||||
} else {
|
||||
kept.join(",")
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the webview `<video>` element can decode this audio codec.
|
||||
///
|
||||
/// TRACES: UR-004 | DR-149 | UT-148
|
||||
pub fn webview_can_decode_audio(codec: &str) -> bool {
|
||||
WEBVIEW_AUDIO_CODECS
|
||||
.iter()
|
||||
.any(|supported| supported.eq_ignore_ascii_case(codec.trim()))
|
||||
}
|
||||
|
||||
/// Decide whether we must transcode *regardless of what the server negotiated*,
|
||||
/// given the source's audio streams as `(codec, is_default)` in source order.
|
||||
///
|
||||
/// Advertising a narrow profile ([`video_audio_codecs`]) is necessary but not
|
||||
/// sufficient: Jellyfin 10.11.5 enforces a `DirectPlayProfile`'s container and
|
||||
/// video codec but **ignores its audio codec** — an E-AC-3 track is offered for
|
||||
/// direct play even when the profile lists only AAC, and neither a `VideoAudio`
|
||||
/// `CodecProfile` nor `MaxAudioChannels` changes that. So the client cannot
|
||||
/// delegate this decision; it knows what its own renderer can decode and must
|
||||
/// apply that itself.
|
||||
///
|
||||
/// The track that matters is the one the server will actually serve (see
|
||||
/// [`served_audio_codec`]). An unknown codec is left alone — forcing a transcode
|
||||
/// on a guess would burn server CPU for files that play.
|
||||
///
|
||||
/// TRACES: UR-004 | DR-149 | UT-148
|
||||
pub fn audio_forces_transcode(streams: &[(Option<&str>, bool)]) -> bool {
|
||||
match served_audio_codec(streams) {
|
||||
Some(codec) => !webview_can_decode_audio(codec),
|
||||
// No audio at all, or a codec the server did not name: leave it alone.
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The codec of the audio track the server will actually serve, given the
|
||||
/// source's audio streams as `(codec, is_default)` in source order: the default,
|
||||
/// or the first when none is marked.
|
||||
///
|
||||
/// `None` means "nothing to judge" — no audio streams, or the server named no
|
||||
/// codec for the one it would serve. Both callers of this rule treat that as
|
||||
/// leave-well-alone, never as a licence to assume compatibility.
|
||||
///
|
||||
/// TRACES: UR-004, UR-071 | DR-149, DR-171 | UT-148, UT-166
|
||||
pub fn served_audio_codec<'a>(streams: &[(Option<&'a str>, bool)]) -> Option<&'a str> {
|
||||
streams
|
||||
.iter()
|
||||
.find(|(_, is_default)| *is_default)
|
||||
.or_else(|| streams.first())
|
||||
.and_then(|(codec, _)| *codec)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The reported bug, at the level it was decided: a source whose default
|
||||
/// subtitle track is PGSSUB must not drag the video into a re-encode.
|
||||
///
|
||||
/// TRACES: UR-020 | DR-176 | UT-168
|
||||
#[test]
|
||||
fn no_subtitle_stream_is_negotiated_so_the_server_never_burns_one_in() {
|
||||
assert_eq!(playback_subtitle_stream_index(), NO_SUBTITLE_STREAM);
|
||||
// Not `None`/omitted: that is what let the server pick the PGS track.
|
||||
assert_eq!(playback_subtitle_stream_index(), -1);
|
||||
}
|
||||
|
||||
/// A transcode URL the *server* built carries the server's own subtitle
|
||||
/// verdict. Adopting it verbatim re-introduces the burn-in
|
||||
/// [`playback_subtitle_stream_index`] exists to prevent — the negotiation
|
||||
/// asks for no subtitle, and then we play a URL that asks for one anyway.
|
||||
///
|
||||
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
|
||||
#[test]
|
||||
fn a_server_built_transcode_url_has_its_burn_in_stripped() {
|
||||
// Shape taken from Jellyfin's `StreamInfo.ToUrl`: it appends
|
||||
// `SubtitleStreamIndex` and `SubtitleMethod` whenever it picked a track.
|
||||
let served = "/videos/abc/master.m3u8?DeviceId=jt&MediaSourceId=src1\
|
||||
&VideoCodec=h264&SubtitleMethod=Encode&SubtitleStreamIndex=2\
|
||||
&PlaySessionId=xyz";
|
||||
|
||||
let url = without_server_chosen_subtitle(served);
|
||||
|
||||
assert!(
|
||||
url.contains("SubtitleStreamIndex=-1"),
|
||||
"the adopted URL must ask for no subtitle: {url}"
|
||||
);
|
||||
assert!(
|
||||
!url.contains("SubtitleStreamIndex=2"),
|
||||
"the server's chosen track must not survive: {url}"
|
||||
);
|
||||
assert!(
|
||||
!url.contains("SubtitleMethod"),
|
||||
"burn-in must not be requested: {url}"
|
||||
);
|
||||
// Everything else identifies the job and must survive untouched.
|
||||
for kept in [
|
||||
"DeviceId=jt",
|
||||
"MediaSourceId=src1",
|
||||
"VideoCodec=h264",
|
||||
"PlaySessionId=xyz",
|
||||
] {
|
||||
assert!(url.contains(kept), "{kept} must survive: {url}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The server may also be told to burn in unconditionally
|
||||
/// (`alwaysBurnInSubtitleWhenTranscoding`), which is appended to the URL
|
||||
/// rather than expressed as a method — and its keys are not PascalCase.
|
||||
///
|
||||
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
|
||||
#[test]
|
||||
fn an_unconditional_burn_in_flag_is_stripped_whatever_its_casing() {
|
||||
let url = without_server_chosen_subtitle(
|
||||
"/videos/abc/master.m3u8?api_key=k&alwaysBurnInSubtitleWhenTranscoding=true\
|
||||
&subtitlestreamindex=3&SubtitleCodec=ass",
|
||||
);
|
||||
|
||||
assert!(!url.to_lowercase().contains("alwaysburnin"), "{url}");
|
||||
assert!(!url.to_lowercase().contains("subtitlecodec"), "{url}");
|
||||
assert!(!url.contains("subtitlestreamindex=3"), "{url}");
|
||||
assert!(url.contains("SubtitleStreamIndex=-1"), "{url}");
|
||||
assert!(url.contains("api_key=k"), "{url}");
|
||||
}
|
||||
|
||||
/// A URL the server built without any subtitle in it still has to *say* so:
|
||||
/// omitting the index is what makes the server apply the source's default.
|
||||
///
|
||||
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
|
||||
#[test]
|
||||
fn a_url_with_no_subtitle_params_is_still_made_to_ask_for_none() {
|
||||
let url = without_server_chosen_subtitle("/videos/abc/master.m3u8?api_key=k");
|
||||
assert_eq!(
|
||||
url,
|
||||
"/videos/abc/master.m3u8?api_key=k&SubtitleStreamIndex=-1"
|
||||
);
|
||||
|
||||
// A bare URL is rare but must not come out malformed.
|
||||
let bare = without_server_chosen_subtitle("/videos/abc/master.m3u8");
|
||||
assert_eq!(bare, "/videos/abc/master.m3u8?SubtitleStreamIndex=-1");
|
||||
}
|
||||
|
||||
/// TRACES: UR-020 | DR-176 | UT-168
|
||||
#[test]
|
||||
fn text_subtitles_are_advertised_as_external_sidecars() {
|
||||
let profiles = subtitle_profiles();
|
||||
for format in ["srt", "subrip", "ass", "ssa", "vtt"] {
|
||||
let entry = profiles.iter().find(|(f, _)| *f == format);
|
||||
assert!(
|
||||
entry.is_some(),
|
||||
"{format} must be advertised or the server burns it into the picture"
|
||||
);
|
||||
assert_eq!(entry.unwrap().1, "External");
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: UR-020 | DR-176 | UT-168
|
||||
#[test]
|
||||
fn text_subtitles_never_force_burn_in_but_image_ones_do() {
|
||||
// Text: deliverable as a sidecar, so the video can still be remuxed.
|
||||
assert!(!subtitle_forces_burn_in("subrip"));
|
||||
assert!(!subtitle_forces_burn_in("ASS"));
|
||||
assert!(!subtitle_forces_burn_in("ssa"));
|
||||
// Image formats are bitmaps — the server can only composite them.
|
||||
assert!(subtitle_forces_burn_in("PGSSUB"));
|
||||
assert!(subtitle_forces_burn_in("dvdsub"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_undecodable_default_track_forces_a_transcode() {
|
||||
// The reported bug: one E-AC-3 track, which the webview cannot decode.
|
||||
assert!(audio_forces_transcode(&[(Some("eac3"), false)]));
|
||||
assert!(audio_forces_transcode(&[(Some("ac3"), true)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_decodable_track_is_left_to_direct_play() {
|
||||
// Never spend server CPU on a file that already plays.
|
||||
assert!(!audio_forces_transcode(&[(Some("aac"), true)]));
|
||||
assert!(!audio_forces_transcode(&[(Some("mp3"), false)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_track_decides_not_the_first() {
|
||||
// The webview plays the default track, so that is the one that has to be
|
||||
// decodable — a supported track further down does not save us.
|
||||
assert!(audio_forces_transcode(&[
|
||||
(Some("aac"), false),
|
||||
(Some("eac3"), true)
|
||||
]));
|
||||
assert!(!audio_forces_transcode(&[
|
||||
(Some("eac3"), false),
|
||||
(Some("aac"), true)
|
||||
]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_no_default_marked_the_first_track_decides() {
|
||||
// Jellyfin leaves IsDefault false on every stream for some files; the
|
||||
// server then serves the first, so judge that one.
|
||||
assert!(audio_forces_transcode(&[
|
||||
(Some("eac3"), false),
|
||||
(Some("aac"), false)
|
||||
]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_source_with_no_audio_is_not_transcoded() {
|
||||
// Nothing to rescue, and a transcode would not create audio.
|
||||
assert!(!audio_forces_transcode(&[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_codec_is_not_second_guessed() {
|
||||
// The server did not tell us the codec; assuming the worst would
|
||||
// transcode files that play perfectly.
|
||||
assert!(!audio_forces_transcode(&[(None, true)]));
|
||||
}
|
||||
|
||||
/// The download path needs the codec itself, not just the verdict, so it can
|
||||
/// tell the server what to re-encode. It picks the same track the streaming
|
||||
/// verdict is formed from — one rule, one place.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-171 | UT-166
|
||||
#[test]
|
||||
fn the_served_codec_is_the_one_the_verdict_is_formed_from() {
|
||||
assert_eq!(
|
||||
served_audio_codec(&[(Some("aac"), false), (Some("eac3"), true)]),
|
||||
Some("eac3")
|
||||
);
|
||||
assert_eq!(
|
||||
served_audio_codec(&[(Some("eac3"), false), (Some("aac"), false)]),
|
||||
Some("eac3")
|
||||
);
|
||||
assert_eq!(served_audio_codec(&[]), None);
|
||||
assert_eq!(served_audio_codec(&[(None, true)]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dolby_device_does_not_advertise_dolby_for_video() {
|
||||
// The bug: a Motorola reporting c2.dolby.eac3.decoder direct-played
|
||||
// E-AC-3 into a webview that cannot decode it — silent video, on that
|
||||
// device only.
|
||||
let codecs = video_audio_codecs("aac,ac3,amrnb,amrwb,eac3,flac,mp3,opus,pcm,vorbis");
|
||||
assert_eq!(codecs, "aac,flac,mp3,opus,vorbis");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codecs_the_webview_cannot_decode_are_dropped() {
|
||||
// AMR and raw PCM come from the AOSP set, so this is not a Dolby-only
|
||||
// problem — it is just rarer content.
|
||||
assert_eq!(video_audio_codecs("amrnb,amrwb,pcm,aac"), "aac");
|
||||
assert_eq!(video_audio_codecs("dts,truehd,mp3"), "mp3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_list_the_webview_fully_supports_is_untouched() {
|
||||
assert_eq!(
|
||||
video_audio_codecs("aac,mp3,opus,vorbis,flac"),
|
||||
"aac,mp3,opus,vorbis,flac"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_decodable_still_claims_aac() {
|
||||
// Claiming an empty list invites the server to give up rather than
|
||||
// transcode. AAC is universally decodable, so ask for it.
|
||||
assert_eq!(video_audio_codecs("eac3,dts"), "aac");
|
||||
assert_eq!(video_audio_codecs(""), "aac");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spacing_and_case_in_the_platform_list_are_tolerated() {
|
||||
// The list is assembled from MediaCodecList strings; do not let
|
||||
// whitespace decide whether the user gets sound.
|
||||
assert_eq!(video_audio_codecs("aac, EAC3 , Mp3"), "aac,mp3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_route_falls_back_to_stereo() {
|
||||
// Codec detection has not run yet, or the platform has no answer. Never
|
||||
// claim surround we have not seen — every sink can do stereo.
|
||||
assert_eq!(clamp_max_audio_channels(None), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_reading_is_not_a_capability() {
|
||||
// A route that has not been established reports 0; taking that literally
|
||||
// would advertise a device with no audio at all.
|
||||
assert_eq!(clamp_max_audio_channels(Some(0)), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stereo_sink_is_reported_as_stereo() {
|
||||
// The phone speaker / Bluetooth headset case: the server must downmix
|
||||
// 5.1 rather than direct-play it.
|
||||
assert_eq!(clamp_max_audio_channels(Some(2)), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_surround_route_keeps_its_channels() {
|
||||
// HDMI to an AVR: 5.1 and 7.1 direct play stay available.
|
||||
assert_eq!(clamp_max_audio_channels(Some(6)), 6);
|
||||
assert_eq!(clamp_max_audio_channels(Some(8)), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_absurd_reading_is_capped_rather_than_forwarded() {
|
||||
// Some drivers report the AudioTrack maximum rather than the route's.
|
||||
assert_eq!(clamp_max_audio_channels(Some(32)), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mono_route_is_taken_at_its_word() {
|
||||
assert_eq!(clamp_max_audio_channels(Some(1)), 1);
|
||||
}
|
||||
}
|
||||
@@ -41,12 +41,34 @@ impl HybridRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// The signed-in user this repository acts for.
|
||||
///
|
||||
/// TRACES: UR-069 | DR-120
|
||||
pub fn user_id(&self) -> &str {
|
||||
self.online.user_id()
|
||||
}
|
||||
|
||||
/// Download raw bytes from a URL using the shared authenticated HTTP client.
|
||||
/// 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(
|
||||
@@ -57,22 +79,20 @@ impl HybridRepository {
|
||||
self.online.get_jray_actors(item_id, t).await
|
||||
}
|
||||
|
||||
/// Get video stream URL with optional seeking support.
|
||||
/// This method is online-only since offline playback uses local file paths.
|
||||
/// Get video stream URL. This method is online-only since offline playback
|
||||
/// uses local file paths.
|
||||
///
|
||||
/// Takes no start position: the URL is an HLS playlist spanning the whole
|
||||
/// item, and a position on it would 400 every segment — see
|
||||
/// `OnlineRepository::get_video_stream_url`. Resume by seeking after load.
|
||||
pub async fn get_video_stream_url(
|
||||
&self,
|
||||
item_id: &str,
|
||||
media_source_id: Option<&str>,
|
||||
start_time_seconds: Option<f64>,
|
||||
audio_stream_index: Option<i32>,
|
||||
) -> Result<String, RepoError> {
|
||||
self.online
|
||||
.get_video_stream_url(
|
||||
item_id,
|
||||
media_source_id,
|
||||
start_time_seconds,
|
||||
audio_stream_index,
|
||||
)
|
||||
.get_video_stream_url(item_id, media_source_id, audio_stream_index)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -97,6 +117,40 @@ impl HybridRepository {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Every track of an album, asked of the **server** rather than the cache.
|
||||
///
|
||||
/// Deliberately not `get_items`, which is cache-first: it answers from SQLite
|
||||
/// the moment the cache has any content. That is right for browsing and wrong
|
||||
/// for deciding what to download, because a partial or unlinked cache then
|
||||
/// decides how much of the album gets queued while the user is told the whole
|
||||
/// album is downloading. Downloading is the one operation that must know the
|
||||
/// album's *complete* contents.
|
||||
///
|
||||
/// Errors when the server cannot answer (offline); the caller falls back to
|
||||
/// the local catalog and the rows are queued either way, resolving on
|
||||
/// reconnect. Server results are written back to the cache, so browsing
|
||||
/// benefits from the round trip too.
|
||||
///
|
||||
/// TRACES: UR-018, UR-055 | DR-173
|
||||
pub async fn get_album_tracks(&self, album_id: &str) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let options = Some(GetItemsOptions {
|
||||
include_item_types: Some(vec!["Audio".to_string()]),
|
||||
sort_by: Some("ParentIndexNumber,IndexNumber,SortName".to_string()),
|
||||
limit: Some(1000),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let result = self.online.get_items(album_id, options).await?;
|
||||
|
||||
if !result.items.is_empty() {
|
||||
if let Err(e) = self.offline.save_to_cache(album_id, &result.items).await {
|
||||
warn!("[HybridRepo] Failed to cache album tracks: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result.items)
|
||||
}
|
||||
|
||||
/// Search only the local SQLite cache (downloaded content).
|
||||
///
|
||||
/// Fast (100ms timeout) — used to render instant results before the server
|
||||
@@ -113,6 +167,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 +351,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 +564,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 +587,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(
|
||||
@@ -734,10 +904,11 @@ impl MediaRepository for HybridRepository {
|
||||
item_id: &str,
|
||||
quality: &str,
|
||||
media_source_id: Option<&str>,
|
||||
source_audio_codec: Option<&str>,
|
||||
) -> String {
|
||||
// Always use online URL for downloads
|
||||
self.online
|
||||
.get_video_download_url(item_id, quality, media_source_id)
|
||||
.get_video_download_url(item_id, quality, media_source_id, source_audio_codec)
|
||||
}
|
||||
|
||||
async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
@@ -750,6 +921,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);
|
||||
@@ -785,6 +966,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,
|
||||
@@ -918,6 +1134,16 @@ impl MediaRepository for HybridRepository {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
// `GATE_TEST_LOCK` below serialises the tests that flip the process-global
|
||||
// `INCLUDE_CATALOG_BROWSE` flag, so its guard is deliberately held across
|
||||
// the `.await` of the repository call under test — that await *is* the
|
||||
// critical section. This is not the production deadlock hazard the lint
|
||||
// targets: the lock is test-only, uncontended outside these tests, and each
|
||||
// `#[tokio::test]` runs on its own single-threaded runtime, so a held guard
|
||||
// cannot block another task on the same worker. Restructuring around it
|
||||
// would reintroduce the flag race the lock exists to prevent.
|
||||
#![allow(clippy::await_holding_lock)]
|
||||
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
@@ -1116,6 +1342,7 @@ mod tests {
|
||||
_item_id: &str,
|
||||
_quality: &str,
|
||||
_media_source_id: Option<&str>,
|
||||
_source_audio_codec: Option<&str>,
|
||||
) -> String {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -1128,6 +1355,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!()
|
||||
}
|
||||
@@ -1374,6 +1617,7 @@ mod tests {
|
||||
_item_id: &str,
|
||||
_quality: &str,
|
||||
_media_source_id: Option<&str>,
|
||||
_source_audio_codec: Option<&str>,
|
||||
) -> String {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -1386,6 +1630,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;
|
||||
@@ -195,14 +197,24 @@ pub trait MediaRepository: Send + Sync {
|
||||
format: &str,
|
||||
) -> String;
|
||||
|
||||
/// Get video download URL (synchronous - just constructs URL)
|
||||
/// Called by frontend via Tauri invoke (getVideoDownloadUrl in VideoDownloadButton.svelte)
|
||||
/// Build the URL a video download is fetched from. Synchronous — it only
|
||||
/// constructs a URL, so it stays testable without a server. Reach it through
|
||||
/// [`resolve_video_download_url`] rather than calling it directly.
|
||||
///
|
||||
/// `source_audio_codec` is the codec of the audio track the server would
|
||||
/// serve (see [`served_audio_codec`]); `None` when it is not known. At
|
||||
/// `original` quality it decides whether the file can be copied byte-for-byte
|
||||
/// or has to have its audio re-encoded on the way down — a downloaded file is
|
||||
/// played back with no server in reach, so it has to be decodable *here*.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-171
|
||||
#[allow(dead_code)]
|
||||
fn get_video_download_url(
|
||||
&self,
|
||||
item_id: &str,
|
||||
quality: &str,
|
||||
media_source_id: Option<&str>,
|
||||
source_audio_codec: Option<&str>,
|
||||
) -> String;
|
||||
|
||||
/// Mark item as favorite
|
||||
@@ -211,6 +223,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>;
|
||||
|
||||
@@ -291,3 +333,44 @@ pub trait MediaRepository: Send + Sync {
|
||||
new_index: u32,
|
||||
) -> Result<(), RepoError>;
|
||||
}
|
||||
|
||||
/// The audio codec the server would serve for `item_id` — the default track, or
|
||||
/// the first when none is marked, matching the track Jellyfin picks.
|
||||
///
|
||||
/// `None` when the item has no audio, names no codec, or cannot be fetched. A
|
||||
/// caller must read that as "unknown", never as "fine": it is the input to a
|
||||
/// policy that only *adds* a transcode, so an unknown codec leaves behaviour
|
||||
/// exactly as it was.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-171 | UT-166
|
||||
pub async fn served_audio_codec(repo: &dyn MediaRepository, item_id: &str) -> Option<String> {
|
||||
let item = repo.get_item(item_id).await.ok()?;
|
||||
let audio: Vec<(Option<&str>, bool)> = item
|
||||
.media_streams
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter(|s| s.stream_type == "Audio")
|
||||
.map(|s| (s.codec.as_deref(), s.is_default))
|
||||
.collect();
|
||||
|
||||
device_profile::served_audio_codec(&audio).map(str::to_string)
|
||||
}
|
||||
|
||||
/// Resolve the download URL for a video, applying the audio-codec policy that
|
||||
/// keeps the saved file playable offline (DR-171).
|
||||
///
|
||||
/// Every video download goes through here rather than calling the builder
|
||||
/// directly: the builder is pure and cannot look the codec up, and a caller that
|
||||
/// forgets to is exactly how the silent downloads shipped.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-171
|
||||
pub async fn resolve_video_download_url(
|
||||
repo: &dyn MediaRepository,
|
||||
item_id: &str,
|
||||
quality: &str,
|
||||
media_source_id: Option<&str>,
|
||||
) -> String {
|
||||
let codec = served_audio_codec(repo, item_id).await;
|
||||
repo.get_video_download_url(item_id, quality, media_source_id, codec.as_deref())
|
||||
}
|
||||
|
||||
+1523
-77
File diff suppressed because it is too large
Load Diff
+1489
-179
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");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user