Compare commits
36
Commits
v0.7.0
...
ae26d5356a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae26d5356a | ||
|
|
b025ed05f2 | ||
|
|
2de91ae76c | ||
|
|
35157a6c59 | ||
|
|
3b55810a0e | ||
|
|
bf72f9869a | ||
|
|
4567c63797 | ||
|
|
46a5219f8e | ||
|
|
1518d92ef4 | ||
|
|
662cb3cd85 | ||
|
|
d54d8cc7c4 | ||
|
|
4c82a0a025 | ||
|
|
51d914777a | ||
|
|
61df2730bc | ||
|
|
c18d79c656 | ||
|
|
69c2498cf7 | ||
|
|
73dd0ef68b | ||
|
|
caebf2d139 | ||
|
|
d5d0e35bca | ||
|
|
a1cb142df4 | ||
|
|
2c52077b1d | ||
|
|
6dfc6b259a | ||
|
|
42e7d86ec4 | ||
|
|
4e451bb534 | ||
|
|
889289286b | ||
|
|
8500da1a42 | ||
|
|
88e15e3e12 | ||
|
|
c9f33ae6a4 | ||
|
|
a93cee9241 | ||
|
|
4996727ca9 | ||
|
|
e3cdb12967 | ||
|
|
2d21f092d5 | ||
|
|
ebf9a99b80 | ||
|
|
38dd1129e5 | ||
|
|
4c9361d020 | ||
|
|
b9dab56379 |
@@ -16,6 +16,12 @@ on:
|
||||
jobs:
|
||||
test:
|
||||
name: Run Tests
|
||||
# A release push triggers build-release.yml on the tag, which runs this exact
|
||||
# test suite itself — and on a single-slot runner the two ~1h workflows would
|
||||
# otherwise serialize/contend. Skip the duplicate for chore(release) commits.
|
||||
# (head_commit is absent on pull_request/workflow_dispatch; startsWith(null,…)
|
||||
# is false there, so those events still run.)
|
||||
if: "!startsWith(github.event.head_commit.message, 'chore(release)')"
|
||||
runs-on: linux/amd64
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||
@@ -61,6 +67,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
|
||||
@@ -72,6 +88,11 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# ⚠️ Key must NOT collide with the test job's `cargo-host` key: the test
|
||||
# job runs first and saves debug/clippy artifacts under its key, and
|
||||
# actions/cache skips saving on an exact-key hit — so a shared key meant
|
||||
# this job's *release* artifacts were never cached and every Linux release
|
||||
# build compiled cold (~31min vs ~9min for the correctly-keyed Windows job).
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
@@ -79,9 +100,9 @@ jobs:
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
|
||||
key: ${{ runner.os }}-cargo-linux-release-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-host-
|
||||
${{ runner.os }}-cargo-linux-release-
|
||||
|
||||
- name: Cache Node dependencies
|
||||
uses: actions/cache@v3
|
||||
@@ -204,9 +225,13 @@ jobs:
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}
|
||||
# `-release` suffix keeps this distinct from build-and-test.yml's
|
||||
# android-check key, whose `cargo check` artifacts would otherwise
|
||||
# claim the key first and block this job's release cache from ever
|
||||
# being saved (same collision as the Linux job above).
|
||||
key: ${{ runner.os }}-cargo-android-release-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-android-
|
||||
${{ runner.os }}-cargo-android-release-
|
||||
|
||||
- name: Cache Node dependencies
|
||||
uses: actions/cache@v3
|
||||
|
||||
@@ -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: |
|
||||
|
||||
+111
@@ -9,6 +9,117 @@ generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
|
||||
For how long each fixed defect had been shipping before it was found, see
|
||||
[docs/defect-windows.md](docs/defect-windows.md).
|
||||
|
||||
## v0.8.2
|
||||
|
||||
A single fix, for Android background audio.
|
||||
|
||||
### 🐛 Fixes
|
||||
|
||||
- **Listening to a video in the background no longer jumps back to where you
|
||||
started.** Handing a video off to background audio streams a live mp3
|
||||
transcode, which is chunked — no length, and no duration the player can read.
|
||||
ExoPlayer resumes a failed load in place only when it knows one of those two
|
||||
things; with neither it assumes the source is live and re-requests the URL from
|
||||
the beginning. That URL starts at the moment you locked the screen, so a
|
||||
network blip left a retry armed, and when the buffer eventually ran dry —
|
||||
minutes later, with nothing in between — playback silently resumed from the
|
||||
handoff point and carried on. No error was raised and nothing ended, so none of
|
||||
the existing stream-recovery paths could see it; the only sign was a position
|
||||
that went backwards, which is why it looked random. The player is now refused
|
||||
its own retry for exactly that kind of stream, so the failure surfaces and the
|
||||
backend re-opens the stream at the position playback actually reached, keeping
|
||||
your selected audio track. Music and video are untouched: both declare their
|
||||
timeline, and the player resumes them where the load stopped.
|
||||
(UR-040, UR-004 → DR-203)
|
||||
|
||||
## v0.8.1
|
||||
|
||||
A single fix, for Android.
|
||||
|
||||
### 🐛 Fixes
|
||||
|
||||
- **The screen no longer sleeps while you are watching something.** Android
|
||||
counts its display timeout from the last time you touched the phone, and
|
||||
watching a film is exactly when you do not — so the picture dimmed and the
|
||||
screen went out mid-playback unless you kept tapping it. Nothing in the app
|
||||
ever asked the display to stay on, and neither video renderer does so by
|
||||
itself: ExoPlayer's wake mode keeps the CPU and wifi alive but says nothing
|
||||
about the screen, and an embedded WebView does not take the display wake lock
|
||||
that a browser takes for `<video>`. Both rendering paths now hold the screen
|
||||
awake for as long as video is actually playing, and release it on pause, on
|
||||
stop, and when the player goes away. Audio is deliberately untouched — playing
|
||||
music with the screen off is the point of it. (UR-003, UR-004 → DR-202)
|
||||
|
||||
## v0.8.0
|
||||
|
||||
A security and correctness release, from an audit of the codebase against its own
|
||||
requirements and against current Android/Tauri practice. Most of it is invisible
|
||||
in use; three things change behaviour you can see, listed first.
|
||||
|
||||
### ✨ Changes
|
||||
|
||||
- **The app no longer backs its data up to your Google account.** It never
|
||||
should have: `allowBackup` was on by default, which sent the library catalogue
|
||||
and watch history off the device — and the credentials went with it in a form
|
||||
that could never be read again, because they are encrypted under an Android
|
||||
Keystore key and Keystore keys are never backed up. Restoring onto a new phone
|
||||
therefore produced ciphertext with no key: an authentication failure with no
|
||||
explanation. Backup is now off, for device-to-device transfer as well as cloud
|
||||
(a separate channel with the identical failure), and an unreadable credential
|
||||
blob is now treated as "logged out" rather than an error, so the next sign-in
|
||||
repairs it. (UR-012 → DR-135)
|
||||
|
||||
- **The app no longer offers itself as an Android TV app.** (This is about the
|
||||
app icon on a TV device's home screen — your TV shows library is untouched.)
|
||||
It advertised a leanback launcher entry without any of what makes a TV app work — no D-pad focus model,
|
||||
no banner, and a missing touchscreen declaration that fails Play's TV
|
||||
validation. Launching it on a TV would have landed you in a UI you could not
|
||||
navigate. It can be re-declared when TV support is actually built.
|
||||
|
||||
- **Lockscreen skip scrubs a film instead of leaving it.** While a video's audio
|
||||
plays in the background, the skip buttons jump 30 seconds forward and 10
|
||||
seconds back, rather than advancing to the next episode. There is no "next
|
||||
track" inside a film, and pressing skip to re-hear a line should not eject you
|
||||
from what you are watching. Music is unchanged: skip still moves through the
|
||||
queue. (UR-040, UR-006 → DR-201)
|
||||
|
||||
### 🔒 Security
|
||||
|
||||
- **The webview now runs under a Content-Security-Policy.** It had none, so any
|
||||
script reaching the web layer inherited the full IPC surface. `script-src` is
|
||||
now `'self'` with no inline or eval, and plugins and frames are refused
|
||||
outright. (UR-071 → DR-198)
|
||||
|
||||
- **The webview stops undoing the network security config.** It set a blanket
|
||||
cleartext opt-in by hand, along with file and content access it never used —
|
||||
defeating the config that exists to block exactly that, and whose own comment
|
||||
warned against it. (UR-071 → DR-199)
|
||||
|
||||
- **The asset protocol no longer reaches the database or the credential store.**
|
||||
Its scope was the whole app data directory; it is now the one subdirectory it
|
||||
serves. (UR-012, UR-071 → DR-198)
|
||||
|
||||
### 🐛 Fixes
|
||||
|
||||
- **A credential store that could not be read is now recoverable.** The decrypt
|
||||
failure surfaced as a hard error rather than a logged-out state, so the app got
|
||||
stuck instead of offering the login screen. (UR-012 → DR-135)
|
||||
|
||||
### 🔧 Internal
|
||||
|
||||
- CI now enforces the checks the contributor rules already required —
|
||||
`cargo fmt --check` and clippy — neither of which had ever run there. The
|
||||
traceability gate was also raised from 50% to 82%, a floor low enough that half
|
||||
the matrix could rot before it fired, and a new check fails the build on a
|
||||
requirement ID that no longer exists.
|
||||
- Twelve requirements marked "Done" carried no implementation trace at all;
|
||||
they are now tagged, and stale integration requirements that named a backend
|
||||
never built have been re-scoped to the ones that actually deliver them.
|
||||
Coverage moved 86% → 90%.
|
||||
- The Rust lint backlog is cleared (51 warnings → 0), and a flaky test that
|
||||
intermittently reddened CI is fixed — it was paying a cold module-transform
|
||||
cost inside a test body, not waiting on a timer.
|
||||
|
||||
## v0.7.0
|
||||
|
||||
### ✨ Changes
|
||||
|
||||
@@ -101,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
|
||||
|
||||
@@ -162,7 +170,7 @@ canonical, maintained source; this file only summarizes. See
|
||||
| [09-security.md](docs/architecture/09-security.md) | Token storage, secure storage, network security |
|
||||
|
||||
Release process lives in [docs/release-checklist.md](docs/release-checklist.md)
|
||||
and [docs/build-release.md](docs/build-release.md).
|
||||
and [docs/build/build-release.md](docs/build/build-release.md).
|
||||
|
||||
### Core principles (from the architecture docs)
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ For the full set of build, test, and Android helper scripts, see
|
||||
|-------|----------|
|
||||
| Architecture overview & subsystem docs | [docs/architecture/](docs/architecture/) |
|
||||
| Requirements, traceability & technical debt | [docs/requirements.md](docs/requirements.md) |
|
||||
| Build & release process | [docs/build-release.md](docs/build-release.md) |
|
||||
| Build & release process | [docs/build/build-release.md](docs/build/build-release.md) |
|
||||
| Docker builds | [docs/build/docker.md](docs/build/docker.md) |
|
||||
| Traceability tooling & CI | [docs/traceability.md](docs/traceability.md), [docs/traceability-ci.md](docs/traceability-ci.md) |
|
||||
| Release checklist | [docs/release-checklist.md](docs/release-checklist.md) |
|
||||
|
||||
+47
-2
@@ -22,15 +22,60 @@
|
||||
- [Database Design](architecture/08-database-design.md)
|
||||
- [Security](architecture/09-security.md)
|
||||
|
||||
# UX & Specs
|
||||
# UX
|
||||
|
||||
- [UX Flows](ux-flows.md)
|
||||
|
||||
# Specs — Writing One
|
||||
|
||||
- [Spec Template](specs/SPEC-TEMPLATE.md)
|
||||
- [Spec Review Checklist](specs/SPEC-REVIEW-CHECKLIST.md)
|
||||
|
||||
# Specs — Playback & Player
|
||||
|
||||
- [Playback Backend Unification](specs/playback-backend-unification.md)
|
||||
- [Player Facade Enforcement](specs/player-facade-enforcement.md)
|
||||
- [Playback Documentation Corrections](specs/playback-docs-corrections.md)
|
||||
- [Video Background Audio](specs/video-background-audio.md)
|
||||
- [Android Native Video Spike](specs/android-native-video-spike.md)
|
||||
- [Android Audio Settings Parity](specs/android-audio-settings-parity.md)
|
||||
- [Audio Equalizer](specs/audio-equalizer.md)
|
||||
- [Windows Native Audio Backend](specs/windows-native-audio-backend.md)
|
||||
- [libmpv2 Migration](specs/libmpv2-migration.md)
|
||||
- [Streaming Bitrate Cap](specs/streaming-bitrate-cap.md)
|
||||
- [Read-Through Media Cache](specs/read-through-media-cache.md)
|
||||
|
||||
# Specs — Library & Browsing
|
||||
|
||||
- [Scoped Search](specs/scoped-search.md)
|
||||
- [Scoped Search Boundary](specs/scoped-search-boundary.md)
|
||||
- [Scoped Search Boundary — Implementation](specs/scoped-search-boundary-implementation.md)
|
||||
- [Locally-Indexed Search](specs/catalog-index-search.md)
|
||||
- [Favourites Browsing](specs/favorites-browsing.md)
|
||||
- [Library Mosaic](specs/library-mosaic.md)
|
||||
- [Series Current-Episode Navigation](specs/series-current-episode-navigation.md)
|
||||
- [Account Menu](specs/account-menu.md)
|
||||
- [Frontend Domain Model](specs/frontend-domain-model.md)
|
||||
|
||||
# Specs — Downloads & Offline
|
||||
|
||||
- [Downloads as an Offline Library](specs/downloads-as-offline-library.md)
|
||||
- [Offline Downloaded-Only Filter](specs/offline-downloaded-only-filter.md)
|
||||
|
||||
# Specs — Tooling & Build
|
||||
|
||||
- [Traceability Gate Repair](specs/traceability-gate-repair.md)
|
||||
- [Boundary Tripwire Hardening](specs/boundary-tripwire-hardening.md)
|
||||
- [Requirement-Coverage Script Removal](specs/req-coverage-script-removal.md)
|
||||
- [Build Provenance](specs/build-provenance.md)
|
||||
|
||||
# Build & Release
|
||||
|
||||
- [Build & Release](build-release.md)
|
||||
- [Build & Release](build/build-release.md)
|
||||
- [Release Checklist](release-checklist.md)
|
||||
- [Desktop Packaging](build/build-desktop-packages.md)
|
||||
- [Windows Build](build/build-windows.md)
|
||||
- [Defect Windows](defect-windows.md)
|
||||
- [Docker](build/docker.md)
|
||||
- [Builder Image](build/build-builder-image.md)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -6,14 +6,14 @@ run in Docker so no host toolchain setup is required. Outputs land in `./dist`.
|
||||
## One builder image (shared with CI)
|
||||
|
||||
The deb/rpm and Windows-cross flows build on the **unified registry builder**
|
||||
([../Dockerfile.builder](../Dockerfile.builder) →
|
||||
([../Dockerfile.builder](../../Dockerfile.builder) →
|
||||
`gitea.tourolle.paris/dtourolle/jellytau-builder`), the same image CI uses. It
|
||||
carries every packaging tool: Android SDK/NDK, `rpm`/`file` (Linux bundler),
|
||||
`cargo-xwin` + `lld` + `llvm` + `nsis` + the `x86_64-pc-windows-msvc` rust target
|
||||
(Windows). There is **one** dependency source of truth — no per-stage tool
|
||||
installs.
|
||||
|
||||
The desktop stages in [../Dockerfile](../Dockerfile) are thin `FROM
|
||||
The desktop stages in [../Dockerfile](../../Dockerfile) are thin `FROM
|
||||
${BUILDER_IMAGE}` environments; the actual build runs at container-run time on
|
||||
your bind-mounted source (like the `dev` service), so source edits need no image
|
||||
rebuild.
|
||||
@@ -28,7 +28,7 @@ docker build -f Dockerfile.builder -t jellytau-builder:latest .
|
||||
BUILDER_IMAGE=jellytau-builder:latest bun run docker:build:windows
|
||||
```
|
||||
|
||||
Arch uses a separate `archlinux` image ([../Dockerfile.arch](../Dockerfile.arch))
|
||||
Arch uses a separate `archlinux` image ([../Dockerfile.arch](../../Dockerfile.arch))
|
||||
because `makepkg` is Arch-specific — it is not part of the unified builder.
|
||||
|
||||
| Target | Format | Docker command | Functional? |
|
||||
@@ -40,7 +40,7 @@ because `makepkg` is Arch-specific — it is not part of the unified builder.
|
||||
## Linux: deb + rpm
|
||||
|
||||
Tauri's bundler produces these natively. The build runs on the existing Ubuntu
|
||||
builder image ([../Dockerfile](../Dockerfile), `desktop-linux-build` stage):
|
||||
builder image ([../Dockerfile](../../Dockerfile), `desktop-linux-build` stage):
|
||||
|
||||
```bash
|
||||
bun run docker:build:linux # deb + rpm -> ./dist
|
||||
@@ -58,8 +58,8 @@ transcoded video). The deb/rpm declare these.
|
||||
|
||||
**Tauri has no `pacman` bundle target** (as of tauri-cli 2.9.x — valid targets
|
||||
are deb/rpm/appimage/msi/nsis/app/dmg). So we ship a hand-written PKGBUILD in
|
||||
[../packaging/arch/PKGBUILD](../packaging/arch/PKGBUILD) and build it with
|
||||
`makepkg` on an Arch base image ([../Dockerfile.arch](../Dockerfile.arch)):
|
||||
[../packaging/arch/PKGBUILD](../../packaging/arch/PKGBUILD) and build it with
|
||||
`makepkg` on an Arch base image ([../Dockerfile.arch](../../Dockerfile.arch)):
|
||||
|
||||
```bash
|
||||
bun run docker:build:arch # .pkg.tar.zst -> ./dist
|
||||
+2
-2
@@ -294,8 +294,8 @@ bun run tauri build # Local build test
|
||||
```
|
||||
|
||||
### Documentation
|
||||
1. Update [CHANGELOG.md](../CHANGELOG.md) with changes
|
||||
2. Update [README.md](../README.md) with new features
|
||||
1. Update [CHANGELOG.md](../../CHANGELOG.md) with changes
|
||||
2. Update [README.md](../../README.md) with new features
|
||||
3. Document breaking changes
|
||||
4. Add migration guide if needed
|
||||
|
||||
+3
-3
@@ -12,10 +12,10 @@ job / SMTC lockscreen), but it runs and plays media.
|
||||
h264 fine. No Windows-specific code.
|
||||
- **Audio-only (music)** — the native audio backends are libmpv (Linux) and
|
||||
ExoPlayer (Android); neither exists on Windows. Instead
|
||||
`create_player_backend()` in [../src-tauri/src/lib.rs](../src-tauri/src/lib.rs)
|
||||
`create_player_backend()` in [../src-tauri/src/lib.rs](../../src-tauri/src/lib.rs)
|
||||
uses `WebviewAudioBackend` on non-Linux/non-Android targets: it hands the stream
|
||||
URL to a webview `<audio>` element (see
|
||||
[../src/lib/services/webviewAudio.ts](../src/lib/services/webviewAudio.ts)),
|
||||
[../src/lib/services/webviewAudio.ts](../../src/lib/services/webviewAudio.ts)),
|
||||
which reports state back through the same `player_report_*` round-trip the video
|
||||
path uses. Pure Rust + Tauri events.
|
||||
|
||||
@@ -33,7 +33,7 @@ Tauri CLI bundle the **NSIS installer from a Linux host**.
|
||||
> `--runner cargo-xwin --target x86_64-pc-windows-msvc` is what flips it into
|
||||
> Windows mode and enables the `nsis`/`msi` bundlers on Linux.
|
||||
|
||||
The builder image ([../Dockerfile.builder](../Dockerfile.builder)) bakes in the
|
||||
The builder image ([../Dockerfile.builder](../../Dockerfile.builder)) bakes in the
|
||||
whole toolchain: the `x86_64-pc-windows-msvc` rust target, `cargo-xwin`, `lld`,
|
||||
`llvm`, and `nsis`.
|
||||
|
||||
@@ -80,6 +80,7 @@ silently correct an out-of-range index — which is exactly why it was reported
|
||||
| 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**:
|
||||
|
||||
@@ -255,9 +255,8 @@ First build takes longer (cache warming). Subsequent releases are faster due to
|
||||
**Android:** 8.0+
|
||||
|
||||
### 🔗 Links
|
||||
- [Changelog](../../CHANGELOG.md)
|
||||
- [Issues](../../issues)
|
||||
- [Discussion](../../discussions)
|
||||
- [Changelog](https://gitea.tourolle.paris/dtourolle/jellytau/src/branch/master/CHANGELOG.md)
|
||||
- [Issues](https://gitea.tourolle.paris/dtourolle/jellytau/issues)
|
||||
|
||||
---
|
||||
Built with Tauri, SvelteKit, and Rust 🦀
|
||||
|
||||
+106
-15
@@ -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 |
|
||||
@@ -85,6 +85,7 @@ For a narrative overview of the system design, see
|
||||
| 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-076 | Music browsing shows only what the listener considers music. A Jellyfin server commonly keeps podcasts, audiobooks, sound effects or sample packs in their own folders inside a music library; those folders can be **excluded by choice**, once, and every music surface — library grids, artist and album listings, genre rows, search and the home screen — then agrees on what is in scope. The choice is by folder, not by a name the app happens to recognise, so a folder called anything at all can be excluded and an item is never dropped because its title matched a word | Medium | Proposed |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
@@ -101,7 +102,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 |
|
||||
@@ -115,8 +116,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 |
|
||||
@@ -130,6 +131,26 @@ External system integrations and platform-specific implementations.
|
||||
| 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
|
||||
|
||||
API endpoints and data contracts required for Jellyfin integration.
|
||||
@@ -308,7 +329,7 @@ Internal architecture, components, and application logic.
|
||||
| DR-131 | The offline mutation queue is drained. `sync_queue` had producers and no consumer: `PlaybackReporter::queue_for_sync` writes a row for every start/stop/mark-played that cannot reach the server, `sync_mark_processing`/`_completed`/`_failed` were registered commands with no callers, and no Rust task processed the table — so queued watch positions never reached Jellyfin and the offline banner's count only ever grew. A drain hangs off the same `connectivity:reconnected` transition as DR-120 (in Rust, because a drain started by a component dies with it) and replays rows oldest-first, so a stale start cannot move the server's resume position backwards after a later stop. `update_progress` replays as *stopped at N* rather than as progress — replaying a mid-playback report hours later would claim the item is still playing — and payloads are read in both dialects that exist in users' databases (`position_ticks` from Rust, camelCase `positionMs` from the frontend helper). A failed row stays queued for the next reconnect; after `MAX_SYNC_ATTEMPTS` it is `abandoned` and stops counting, because a row nothing can ever push is what turns the queue into a counter that only grows. An *unreachable* server is not counted as an attempt at all — the row goes back to `pending` untouched — so opening the app offline a few times cannot abandon good rows; only a server that answers and refuses spends the budget. The drain also runs once at startup, because a queue built in a previous session would otherwise sit untouched for a whole run whenever the server was reachable the entire time and no offline→online transition ever fired. Requires `MediaRepository::mark_played` (JA-035) — the previous stand-in reported a stop at `i64::MAX` | Backend | UR-025, UR-002 | Done |
|
||||
| DR-132 | The pending-sync count is answerable. The offline banner's badge read "N pending sync(s)" and led nowhere, so it was taken for pending *transfers* and looked for on the Downloads page — which lists the `downloads` table and structurally cannot show `sync_queue` rows. The badge becomes a button opening the queue it counts: each row's operation, the item's title (resolved by a `LEFT JOIN items` in `sync_get_pending`, not a per-row frontend fetch), when it was queued, and the error of anything failing, plus a "Sync now" that runs the DR-131 drain on demand. The same list is a Settings section, because a row that keeps failing is still queued when the server is reachable and no banner is on screen. The drain emits `sync-queue-changed` so the badge updates on reconnect instead of lagging by up to one 10s poll | UI | UR-025 | Done |
|
||||
| DR-133 | A downloaded file has exactly one on-disk path, and the row that names it is authoritative. `downloads.file_path` starts relative to the storage root, but the worker rewrites it to the absolute path it actually wrote when the transfer completes — so a *completed* row is already rooted. The video player's offline branch rooted it a second time, handing the asset protocol `/data/user/0/app//data/user/0/app/videos/x.mp4`; the webview reported `MEDIA_ERR_SRC_NOT_SUPPORTED` with `NETWORK_NO_SOURCE`, so every downloaded video failed to play while audio — which resolves the same column through Rust's `resolve_local_media_path`, without re-rooting — played fine. The join is absolute-aware (POSIX, Windows drive letters and UNC) so rows written before completion still resolve | Playback | UR-071 | Done |
|
||||
| DR-134 | The webview can actually fetch the local files it is handed. `convertFileSrc` rewrites a path to `http://asset.localhost/…` unconditionally, but Tauri only answers that origin when the `protocol-asset` cargo feature is compiled in *and* `app.security.assetProtocol.enable` is set — neither was, so every such URL reached a protocol with no handler and the webview reported `NETWORK_NO_SOURCE`. This silently defeated both offline video (`<video src>`) and the cached-thumbnail path in `imageCache`, which fails soft to the server copy and so hid the breakage whenever the server was reachable. The scope is `$APPDATA/**` — the storage root under which the database, `downloads/` and the thumbnail cache all live — rather than an unrestricted grant, so the webview can read the app's own media and nothing else | Security | UR-071 | Done |
|
||||
| DR-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 |
|
||||
@@ -348,10 +369,16 @@ Internal architecture, components, and application logic.
|
||||
| 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 |
|
||||
@@ -366,6 +393,13 @@ Internal architecture, components, and application logic.
|
||||
| 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-204 | A leveled logging facade for the frontend, replacing raw `console.*` calls. One module owns the log sinks, so a level (error/warn/info/debug) decides at run time what is emitted rather than every call site deciding permanently at authoring time: a release build stays quiet, a developer chasing a playback bug turns the player's debug output on without editing and rebuilding, and nothing that reaches the console is written by a `console.log` nobody can find again. Scoped loggers carry the subsystem in the message, so a filtered console is usable while a player, a download worker and a store are all talking | Tooling | - | Proposed |
|
||||
| DR-205 | ESLint + Prettier run as a gate over the frontend, so lint and formatting are decided once by configuration rather than per reviewer. Formatting is not a matter of opinion at review time, and the classes of bug a linter sees (unused bindings, floating promises, accidental globals) should never reach a human reviewer at all. Wired as an npm script so the same command runs locally and in CI, matching how `check:boundary` and the traceability gate already work | Tooling | - | Proposed |
|
||||
| DR-206 | The Rust toolchain is pinned in-repo (`rust-toolchain.toml`) and the pin is what both a developer's machine and CI use. Without it, `cargo fmt --check` and `cargo clippy` are run by whatever version each host happens to have, so a formatting or lint result differs between a laptop and the builder image and CI fails on a diff that was clean locally — the failure mode is a red build nobody can reproduce. The builder image carries the pinned toolchain, so pinning is a *declaration*, not a CI-time install (see the no-toolchain-installs rule) | Tooling | - | Proposed |
|
||||
| DR-207 | A pre-commit hook runs the "Before Committing" gates — frontend checks and tests, `cargo fmt`, clippy, the boundary tripwire and the traceability checks — so the gates are enforced at the commit rather than discovered in CI. The gates already exist and are already documented; what is missing is that nothing runs them, which makes compliance a matter of memory. The hook is the mechanism that makes the documented list actually binding | Tooling | - | Proposed |
|
||||
| DR-208 | Documentation link integrity is checked mechanically (`scripts/check-doc-links.sh`): every relative markdown link in every tracked `.md` must resolve to a file that exists on disk. This is a real defect class, not hygiene — the generated traceability matrix shipped ~2,800 dead file links because it was written to `docs/` while its hrefs were repo-root-relative, and nothing noticed for months because no check existed and nobody clicks 2,800 links. The check validates *paths*, deliberately not anchors or external URLs: anchor resolution needs a markdown renderer's slug rules and network checks make the gate flaky, so both are out of scope and stated as such in the script | Tooling | - | Done |
|
||||
| DR-209 | Library folders are excluded from music browsing **server-side, by folder id**, replacing a hardcoded frontend filter that dropped anything whose name contained "Podcasts". The name filter was wrong in three separate ways: it encoded a domain classification in the presentation layer, it matched on a title rather than on what an item *is* (so an album legitimately called "Podcasts" vanished while a podcast folder named anything else did not), and it applied only where someone had remembered to call it, so the same library was in scope on one screen and out of scope on the next. Excluded folder ids are stored as user configuration and applied by the repository layer to every music query — libraries, artists, albums, genres, search and the home rows — so scope is decided in one place and is the same everywhere | Repository | UR-076 | Proposed |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -378,15 +412,15 @@ 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, 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 |
|
||||
| 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 | - |
|
||||
| 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 |
|
||||
@@ -394,8 +428,8 @@ Internal architecture, components, and application logic.
|
||||
| UR-017 | - | DR-014, DR-021 |
|
||||
| UR-018 | IR-013 | DR-015, DR-018, DR-173 |
|
||||
| UR-019 | IR-015 | DR-022 |
|
||||
| UR-020 | IR-016, IR-018 | DR-023, DR-176 |
|
||||
| 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 |
|
||||
@@ -414,7 +448,7 @@ Internal architecture, components, and application logic.
|
||||
| UR-037 | IR-010 | DR-042 |
|
||||
| UR-038 | IR-010 | DR-043 |
|
||||
| UR-039 | - | DR-045, DR-046 |
|
||||
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190, DR-196 |
|
||||
| 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 |
|
||||
@@ -444,11 +478,12 @@ Internal architecture, components, and application logic.
|
||||
| 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 |
|
||||
| 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 |
|
||||
| UR-076 | - | DR-209 |
|
||||
|
||||
---
|
||||
|
||||
@@ -636,6 +671,7 @@ Internal architecture, components, and application logic.
|
||||
| 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 |
|
||||
@@ -643,6 +679,17 @@ Internal architecture, components, and application logic.
|
||||
| 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-201 | The logging facade gates by level: a message below the active level is not emitted at all, one at or above it reaches the sink, changing the level at run time changes what passes without touching the call sites, and a scoped logger tags its output with the subsystem | DR-204 | Proposed |
|
||||
| UT-202 | Generated traceability-matrix file links resolve from `docs/`: an emitted href, resolved against the directory `traceability.md` is written to, points at a file that exists on disk; the visible link text stays repo-root-relative; the `#Lnn` anchor survives; and a bare repo-root href — the regression that made every link 404 as `docs/<path>` — is rejected | DR-093 | Done |
|
||||
| UT-203 | Library folder exclusion filters by id, not by name: an excluded folder's items are absent from a music query, an item whose *title* merely contains an excluded folder's name is kept, and clearing the exclusion restores the items | DR-209 | Proposed |
|
||||
| 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
|
||||
|
||||
@@ -655,8 +702,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 |
|
||||
@@ -668,6 +715,50 @@ Internal architecture, components, and application logic.
|
||||
|
||||
## 5. Technical Debt
|
||||
|
||||
### Open items carried over from the v0.6.0 codebase audit
|
||||
|
||||
The 2026-08-16 audit (v0.6.0, commit `be907b49`) was a point-in-time snapshot
|
||||
with no status markers, and by v0.8.2 most of it had been either fixed or
|
||||
overtaken. It was **retired** rather than left to rot into a document that
|
||||
half-describes the code: what survived it is the table below, which is now the
|
||||
record. Each row is self-contained — the audit is not needed to act on it.
|
||||
|
||||
What was dropped as demonstrably closed, so it is not re-raised: the CSP and
|
||||
asset-protocol scope findings (now DR-198), cloud backup and credential restore,
|
||||
the WebView mixed-content override (DR-199), `POST_NOTIFICATIONS` and the
|
||||
media-session exemption (DR-200), the `jvmTarget` 1.8 pin (now 17), the
|
||||
half-declared Android TV leanback category (removed), the untraced-but-Done
|
||||
requirements and the contradictory UR/IR statuses (re-scoped in §2.1), the 50%
|
||||
traceability gate (ratcheted, and gated on a live denominator by DR-093), the
|
||||
flaky `offlineCatalog` test, the clippy warning backlog (cleared, and `cargo
|
||||
fmt --check` plus clippy now run in CI), and the "820 production `unwrap()`s"
|
||||
figure — a measurement error that counted test modules, corrected in the audit
|
||||
itself to ~19 and standing at 27 today, none of them in a command handler. The
|
||||
three `Runtime::new().unwrap()` sites that genuinely matter survive as row 5.
|
||||
|
||||
Ordered by what would hurt most if left.
|
||||
|
||||
> **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** | 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** | `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** | `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. (Predictive back, raised alongside this, was verified working on device and needs nothing) | S |
|
||||
| 7 | **Media3 is several minor versions behind** | 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** | 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 | **Six modules carry a disproportionate share of the complexity** | `src-tauri/src/player/mod.rs` (4,732 lines), `src-tauri/src/repository/offline.rs` (4,705), `src-tauri/src/repository/online.rs` (3,760), `src-tauri/src/commands/player/mod.rs` (3,327), `src-tauri/src/commands/download/mod.rs` (3,238) and `src/lib/components/player/VideoPlayer.svelte` (2,786) — all still growing. The cost is not the line count itself, it is that **these are the same modules `CLAUDE.md`'s Gotchas section keeps having to warn about**: the deadlock rule about locking in event callbacks, the `AutoplayDecision` scrutinee, the "no lifecycle calls after an `await` in `onMount`" rule, the HLS `master.m3u8` rule, the download concurrency cap. A file that needs a standing warning in the project's onboarding document is a file whose invariants are no longer local to it, and every such warning is a rule a newcomer has to be *told* rather than one the structure enforces. **Recorded, not scheduled** — a speculative refactor of six files this size buys nothing on its own. The trigger is the next time one of them needs substantial work: splitting it then is likely cheaper than growing it, and each rule that moves from Gotchas into a module boundary is one fewer thing to remember | 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.
|
||||
@@ -779,7 +870,7 @@ deprecated in current Media3.)
|
||||
**Affected Files**:
|
||||
- [src/lib/components/player/AudioPlayer.svelte](../src/lib/components/player/AudioPlayer.svelte) - Duplicate handlers
|
||||
- [src/lib/components/player/MiniPlayer.svelte](../src/lib/components/player/MiniPlayer.svelte) - Duplicate handlers
|
||||
- [src/lib/services/playbackControl.ts](../src/lib/services/playbackControl.ts) - Position conversion
|
||||
- [src/lib/utils/playbackUnits.ts](../src/lib/utils/playbackUnits.ts) - Position conversion (the shared helper the "Future Fix" below called for; `playbackControl.ts`, previously listed here, has since been removed)
|
||||
- [src/lib/stores/playbackMode.ts](../src/lib/stores/playbackMode.ts) - Position conversion
|
||||
- [src/lib/services/playbackReporting.ts](../src/lib/services/playbackReporting.ts) - Position conversion
|
||||
|
||||
|
||||
@@ -50,7 +50,8 @@ Copy the boxes into the review comment (or the PR) and tick them.
|
||||
- [ ] Linked to existing URs, or new URs/DRs are allocated in
|
||||
[requirements.md](../requirements.md).
|
||||
- [ ] Requirement-implementing code will carry `// TRACES:` comments (CLAUDE.md).
|
||||
- [ ] Traceability coverage stays ≥ 50% (the CI gate).
|
||||
- [ ] Traceability coverage stays ≥ 88% (the CI gate — a ratchet, so check
|
||||
`bun run traces:coverage` rather than trusting this number).
|
||||
|
||||
## Conflicts & hygiene
|
||||
|
||||
|
||||
@@ -332,7 +332,7 @@ Frontend (`bun run test`):
|
||||
|------|--------|
|
||||
| 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. |
|
||||
| IT-0xx | `repositoryGetFavorites` param naming — add to the IPC param-naming suite under `src/lib/utils/` (`tauriIntegration.test.ts` no longer exists — see the current camelCase guards in `src/lib/stores/`): 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.
|
||||
|
||||
+46
-13
@@ -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 (88%, 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:** 88% (`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,40 @@ 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 (88 against a real ~90%), 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. It was ratcheted 50 → 82 when that
|
||||
was found, and 82 → 88 once coverage had held above 88% for several releases.
|
||||
|
||||
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 +152,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 >= 88%
|
||||
3. ✅ Generates full traceability report
|
||||
4. ✅ Saves report as artifact
|
||||
|
||||
### On Pull Request
|
||||
1. ✅ Extracts all traces
|
||||
2. ✅ Validates coverage >= 50%
|
||||
2. ✅ Validates coverage >= 88%
|
||||
3. ✅ Checks modified files for TRACES
|
||||
4. ✅ Warns if new code lacks TRACES
|
||||
5. ✅ Suggests proper format
|
||||
@@ -134,7 +166,8 @@ TRACES: [UR-###, ...] | [IR-###, ...] | [DR-###, ...] | [JA-###, ...]
|
||||
|
||||
### Failure Scenarios
|
||||
The workflow **fails** (blocks merge) if:
|
||||
- Coverage drops below 50%
|
||||
- Coverage drops below 88%
|
||||
- A `TRACES:` comment names an ID `docs/requirements.md` does not define
|
||||
- JSON extraction fails
|
||||
- Invalid trace format
|
||||
|
||||
@@ -171,12 +204,12 @@ below threshold. Numbers are deliberately not pinned here; the previous snapshot
|
||||
in this section (51%, 56/114) was stale by roughly 100 requirements and was what
|
||||
made the broken CI arithmetic look plausible for so long.
|
||||
|
||||
As of July 2026 overall coverage is ~86% (182/212).
|
||||
As of August 2026 overall coverage is ~90%.
|
||||
|
||||
### Targets
|
||||
- **Short term** (Sprint): Maintain ≥50% overall
|
||||
- **Medium term** (Month): Reach 70% overall coverage
|
||||
- **Long term** (Release): Reach 90% coverage with focus on:
|
||||
- **Short term** (Sprint): Maintain ≥88% overall (the current ratchet)
|
||||
- **Medium term** (Month): Hold above 90% and ratchet the gate to match
|
||||
- **Long term** (Release): Reach 95% coverage with focus on:
|
||||
- IR requirements (API clients)
|
||||
- JA requirements (Jellyfin API endpoints)
|
||||
- Remaining UR/DR requirements
|
||||
@@ -209,14 +242,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 ≥ 88%)
|
||||
- [ ] No coverage regressions
|
||||
- [ ] Artifact traceability report was generated
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Coverage below minimum threshold"
|
||||
**Problem:** Workflow fails with coverage < 50%
|
||||
**Problem:** Workflow fails with coverage < 88%
|
||||
|
||||
**Solution:**
|
||||
1. Run `bun run traces:json` locally
|
||||
|
||||
+7888
-7233
File diff suppressed because it is too large
Load Diff
+13
-12
@@ -52,10 +52,10 @@ fn test_queue_next() {
|
||||
|
||||
## Where to Find Requirements
|
||||
|
||||
1. **User Requirements (UR):** [README.md](README.md#1-user-requirements)
|
||||
2. **Integration Requirements (IR):** [README.md](README.md#21-integration-requirements)
|
||||
3. **Development Requirements (DR):** [README.md](README.md#23-development-requirements)
|
||||
4. **Jellyfin API (JA):** [README.md](README.md#22-jellyfin-api-requirements)
|
||||
1. **User Requirements (UR):** [requirements.md](requirements.md#1-user-requirements)
|
||||
2. **Integration Requirements (IR):** [requirements.md](requirements.md#21-integration-requirements)
|
||||
3. **Development Requirements (DR):** [requirements.md](requirements.md#23-development-requirements)
|
||||
4. **Jellyfin API (JA):** [requirements.md](requirements.md#22-jellyfin-api-requirements)
|
||||
|
||||
## How to Add TRACES
|
||||
|
||||
@@ -133,18 +133,19 @@ 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 >= 88% (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
|
||||
|
||||
See [traceability-ci.md](docs/traceability-ci.md) for details.
|
||||
See [traceability-ci.md](traceability-ci.md) for details.
|
||||
|
||||
## Tips & Tricks
|
||||
|
||||
@@ -198,10 +199,10 @@ A: Yes! TRACES show your implementation plan.
|
||||
|
||||
## See Also
|
||||
|
||||
- [Full Traceability Matrix](docs/traceability.md)
|
||||
- [CI/CD Pipeline Guide](docs/traceability-ci.md)
|
||||
- [Requirements Specification](README.md)
|
||||
- [Extraction Script](scripts/README.md#extract-tracests)
|
||||
- [Full Traceability Matrix](traceability.md)
|
||||
- [CI/CD Pipeline Guide](traceability-ci.md)
|
||||
- [Requirements Specification](requirements.md)
|
||||
- [Extraction Script](../scripts/README.md#extract-tracests)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellytau",
|
||||
"version": "0.7.0",
|
||||
"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
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
# it can bundle the NSIS installer from a Linux host.
|
||||
#
|
||||
# Playback on Windows: video renders via WebView2 and audio via the webview
|
||||
# <audio> backend (WebviewAudioBackend) — see docs/build-windows.md.
|
||||
# <audio> backend (WebviewAudioBackend) — see docs/build/build-windows.md.
|
||||
#
|
||||
# Requirements (present in the Docker windows-cross target / unified builder):
|
||||
# - rustup target x86_64-pc-windows-msvc
|
||||
|
||||
Executable
+176
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env bash
|
||||
# Documentation link integrity: every relative markdown link must point at a
|
||||
# file that exists.
|
||||
#
|
||||
# Implements DR-208 (see docs/requirements.md).
|
||||
#
|
||||
# Why this exists: docs/traceability.md is generated into docs/ while its file
|
||||
# links were emitted repo-root-relative, so all ~2,800 of them resolved to
|
||||
# docs/src-tauri/… and 404'd — in the Gitea repo browser and on the published
|
||||
# mdBook site alike. Nobody clicks 2,800 links, so it went unnoticed for months.
|
||||
# Several hand-written docs had the same defect at smaller scale: links to files
|
||||
# that had been deleted, and links written as if the doc lived at the repo root.
|
||||
# A link that does not resolve is a documentation defect of the same kind as a
|
||||
# compile error, and a grep is enough to catch the whole class.
|
||||
#
|
||||
# What it checks: for every tracked `.md` file, every inline markdown link
|
||||
# `[text](target)` whose target is a *path* — the target is resolved relative to
|
||||
# the directory of the file containing it, and must exist on disk.
|
||||
#
|
||||
# ⚠️ It validates PATHS, NOT ANCHORS. A green run does not mean the links land
|
||||
# where the text claims.
|
||||
#
|
||||
# 🔴 What it deliberately CANNOT see (do not read a green run as proof):
|
||||
# - **Anchor fragments.** `foo.md#some-heading` is checked only as `foo.md`.
|
||||
# Resolving the fragment needs a markdown renderer's heading-slug rules
|
||||
# (which differ between Gitea, GitHub and mdBook), so a link to a heading
|
||||
# that was renamed still passes here. That is a deliberate scope cut, not an
|
||||
# oversight.
|
||||
# - **External URLs.** http(s):// and mailto: are skipped. Checking them means
|
||||
# network I/O in a gate, which makes the gate flaky and slow; link rot in an
|
||||
# external URL is also not something a commit can break.
|
||||
# - **Reference-style links** (`[text][ref]` with a separate `[ref]: target`
|
||||
# definition) and bare autolinks. This project writes inline links; add the
|
||||
# pattern here if that changes.
|
||||
# - **Links inside fenced code blocks**, which are intentionally skipped —
|
||||
# a template being *shown* to the reader (e.g. the release-notes template in
|
||||
# docs/release-checklist.md) is sample text, not a live link, and its targets
|
||||
# are resolved wherever it is eventually pasted, not from the docs tree.
|
||||
# - **A link that resolves to the wrong existing file.** Existence is not
|
||||
# correctness.
|
||||
#
|
||||
# Usage: bash scripts/check-doc-links.sh
|
||||
# Exits non-zero, listing file:line and the unresolved target, on any failure.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Generated, vendored or build-output trees. Their markdown is not authored here
|
||||
# and their link targets are not ours to fix.
|
||||
EXCLUDES=(
|
||||
"./node_modules/*"
|
||||
"./.svelte-kit/*"
|
||||
"./build/*"
|
||||
"./dist/*"
|
||||
"./src-tauri/gen/*"
|
||||
"./src-tauri/target/*"
|
||||
"./.git/*"
|
||||
# Agent/dev scratch worktrees (.claude/worktrees is itself git-ignored). These
|
||||
# are full checkouts of the repo, so without this the checker walks every
|
||||
# in-flight branch and reports its links as if they were ours.
|
||||
"./.claude/*"
|
||||
)
|
||||
|
||||
# Targets that do not exist in the repo *by design* because the publish-docs job
|
||||
# writes them into docs/ at build time (see .gitea/workflows/publish-docs.yml).
|
||||
# Keep this list to genuinely generated pages — anything else here is a broken
|
||||
# link being hidden.
|
||||
GENERATED_TARGETS=(
|
||||
"./docs/README.md" # the site's landing page, written by publish-docs
|
||||
"./docs/api-redirect.md" # the rustdoc redirect stub, likewise
|
||||
)
|
||||
|
||||
is_generated() {
|
||||
local candidate="$1"
|
||||
for generated in "${GENERATED_TARGETS[@]}"; do
|
||||
[[ "$candidate" == "$generated" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "🔎 Checking relative markdown links resolve to files on disk…"
|
||||
|
||||
# Build the find(1) prune expression from EXCLUDES.
|
||||
find_args=(. )
|
||||
for pattern in "${EXCLUDES[@]}"; do
|
||||
find_args+=(-path "$pattern" -prune -o)
|
||||
done
|
||||
find_args+=(-name "*.md" -type f -print)
|
||||
|
||||
mapfile -t md_files < <(find "${find_args[@]}" | sort)
|
||||
|
||||
echo " ${#md_files[@]} markdown files"
|
||||
|
||||
broken=""
|
||||
checked=0
|
||||
|
||||
for md in "${md_files[@]}"; do
|
||||
dir="$(dirname "$md")"
|
||||
|
||||
# One documented exception: docs-site/SUMMARY.md is mdBook's table of
|
||||
# contents, and the publish-docs job copies it *into* docs/ before rendering
|
||||
# (book.toml sets src = "../docs"). Its links are therefore written relative
|
||||
# to docs/, not to the directory the file is stored in. Resolving it from
|
||||
# docs/ is what actually validates it — and it is the check that catches a
|
||||
# SUMMARY entry pointing at a page that does not exist, which mdBook itself
|
||||
# only warns about.
|
||||
if [[ "$md" == "./docs-site/SUMMARY.md" ]]; then
|
||||
dir="./docs"
|
||||
fi
|
||||
|
||||
# Strip fenced code blocks (``` and ~~~) before extracting links, so sample
|
||||
# markdown shown to the reader is not checked as if it were a live link.
|
||||
# Line numbers are preserved by blanking the lines rather than deleting them.
|
||||
#
|
||||
# Then emit "lineno<TAB>target" for each inline link on each surviving line.
|
||||
while IFS=$'\t' read -r lineno target; do
|
||||
[[ -z "${target:-}" ]] && continue
|
||||
|
||||
# Skip external schemes and pure-anchor links.
|
||||
case "$target" in
|
||||
http://*|https://*|mailto:*|ftp://*|"#"*|"") continue ;;
|
||||
# A protocol-relative or scheme-ish target we do not resolve.
|
||||
//*) continue ;;
|
||||
esac
|
||||
|
||||
# Drop any anchor fragment and query string — we check the path only.
|
||||
path="${target%%#*}"
|
||||
path="${path%%\?*}"
|
||||
[[ -z "$path" ]] && continue
|
||||
|
||||
# Percent-decode: SvelteKit route directories are literally named `[id]`,
|
||||
# which docs link as `%5Bid%5D`, and spaces appear as `%20`.
|
||||
if [[ "$path" == *%* ]]; then
|
||||
path="$(printf '%b' "${path//%/\\x}")"
|
||||
fi
|
||||
|
||||
checked=$((checked + 1))
|
||||
|
||||
if is_generated "$dir/$path"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ ! -e "$dir/$path" ]]; then
|
||||
broken+="${md}:${lineno} -> ${target}"$'\n'
|
||||
fi
|
||||
done < <(
|
||||
awk '
|
||||
/^[[:space:]]*(```|~~~)/ { fence = !fence; print ""; next }
|
||||
fence { print ""; next }
|
||||
{ print }
|
||||
' "$md" |
|
||||
grep -noE '\]\([^)[:space:]]+' |
|
||||
sed -E 's/^([0-9]+):\]\(/\1\t/'
|
||||
)
|
||||
done
|
||||
|
||||
echo " $checked relative links checked"
|
||||
|
||||
if [[ -n "$broken" ]]; then
|
||||
echo ""
|
||||
echo "❌ Broken documentation links — these targets do not exist on disk:"
|
||||
echo ""
|
||||
echo "$broken" | sed 's/^/ /'
|
||||
echo " Each link is resolved relative to the directory of the file it is in."
|
||||
echo " The usual causes:"
|
||||
echo " • the target file was moved or deleted — update or drop the link;"
|
||||
echo " • the link was written as if the doc lived at the repo root — a doc"
|
||||
echo " in docs/ needs '../' to reach src/, scripts/ or CHANGELOG.md;"
|
||||
echo " • a generated doc emits repo-root-relative hrefs — fix the"
|
||||
echo " generator, not the output (see scripts/extract-traces.ts)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ All relative documentation links resolve."
|
||||
echo " (Reminder: paths only — anchors and external URLs are NOT checked.)"
|
||||
+183
-10
@@ -11,10 +11,24 @@
|
||||
*
|
||||
* @req-test: UT-089 - Requirement definitions parsed from requirements.md
|
||||
* @req-test: UT-090 - Coverage is the intersection of traced and defined IDs
|
||||
* @req-test: UT-202 - Generated matrix links resolve from docs/
|
||||
*/
|
||||
|
||||
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,
|
||||
formatMatrixFileLink,
|
||||
generateMarkdown,
|
||||
MIN_COVERAGE_PERCENT,
|
||||
type TracesData,
|
||||
} 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 +96,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 +180,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 +227,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);
|
||||
@@ -157,26 +254,102 @@ describe("computeCoverage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("generated matrix file links", () => {
|
||||
// Regression: the generator emitted the repo-root-relative path as the href
|
||||
// (`](src-tauri/src/…)`), but writes its output to docs/traceability.md — so
|
||||
// every one of the ~2,800 links resolved to docs/src-tauri/… and 404'd, in
|
||||
// the repo browser and on the published mdBook site. The markdown generator
|
||||
// had no test at all, which is why it survived. UT-202.
|
||||
//
|
||||
// @req-test: UT-202
|
||||
|
||||
/** A minimal TracesData whose single entry points at a file that really exists. */
|
||||
function fixture(file: string, line = 12): TracesData {
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
totalFiles: 1,
|
||||
totalTraces: 1,
|
||||
requirements: {
|
||||
"DR-093": [{ file, line, context: "export function x() {}" }],
|
||||
},
|
||||
byType: { UR: [], IR: [], DR: ["DR-093"], JA: [] },
|
||||
} as TracesData;
|
||||
}
|
||||
|
||||
/** Pull the href out of the first `- **File:** [`x`](href)` line. */
|
||||
function firstHref(md: string): string {
|
||||
const m = md.match(/^- \*\*File:\*\* \[`[^`]+`\]\(([^)]+)\)/m);
|
||||
expect(m).not.toBeNull();
|
||||
return m![1];
|
||||
}
|
||||
|
||||
it("emits an href that resolves, from docs/, to a file that exists", () => {
|
||||
// Use a real repo file so "exists on disk" is a genuine assertion.
|
||||
const target = "scripts/extract-traces.ts";
|
||||
const md = generateMarkdown(fixture(target));
|
||||
|
||||
const href = firstHref(md);
|
||||
const [relPath] = href.split("#");
|
||||
|
||||
// traceability.md is written to docs/, so links resolve from there.
|
||||
const resolved = path.resolve(HERE, "../docs", relPath);
|
||||
expect(fs.existsSync(resolved)).toBe(true);
|
||||
expect(resolved).toBe(path.resolve(HERE, "..", target));
|
||||
});
|
||||
|
||||
it("keeps the repo-root-relative path as the visible link text", () => {
|
||||
// The text is what a developer copies into an editor or a grep; only the
|
||||
// href is rewritten for the docs/ location.
|
||||
const md = generateMarkdown(fixture("src-tauri/src/lib.rs"));
|
||||
expect(md).toContain("[`src-tauri/src/lib.rs`]");
|
||||
expect(md).not.toContain("[`../src-tauri/src/lib.rs`]");
|
||||
});
|
||||
|
||||
it("keeps the #Lnn line anchor on the href", () => {
|
||||
const link = formatMatrixFileLink("scripts/extract-traces.ts", 427);
|
||||
expect(link).toBe(
|
||||
"[`scripts/extract-traces.ts`](../scripts/extract-traces.ts#L427)"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not produce a bare repo-root href, which resolves to docs/<path>", () => {
|
||||
const md = generateMarkdown(fixture("scripts/extract-traces.ts"));
|
||||
const href = firstHref(md);
|
||||
expect(href.startsWith("../")).toBe(true);
|
||||
// The pre-fix output — the exact shape that produced docs/scripts/….
|
||||
expect(href.startsWith("scripts/")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("live requirements.md", () => {
|
||||
it("parses the real file to the counts the CI gate must use", () => {
|
||||
// Guards the specific regression: CI hardcoded UR/39, IR/24, DR/48, JA/3
|
||||
// (total 114) while the real file had grown to 211. Update these numbers
|
||||
// deliberately when requirements are added — that edit is the signal the
|
||||
// denominator is live rather than frozen.
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
// import.meta.dir is Bun-only; derive from import.meta.url under vitest.
|
||||
const here = path.dirname(new URL(import.meta.url).pathname);
|
||||
const md = fs.readFileSync(
|
||||
path.resolve(here, "../docs/requirements.md"),
|
||||
path.resolve(HERE, "../docs/requirements.md"),
|
||||
"utf-8"
|
||||
);
|
||||
const defined = countDefinedRequirements(md);
|
||||
|
||||
expect(defined.UR).toBe(75);
|
||||
expect(defined.UR).toBe(76);
|
||||
expect(defined.IR).toBe(32);
|
||||
expect(defined.DR).toBe(187);
|
||||
// 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). 200 adds the
|
||||
// six tooling/quality requirements DR-204..DR-209 (logging facade, lint
|
||||
// gate, pinned toolchain, pre-commit hook, doc-link check, server-side
|
||||
// library folder exclusion); UR rises to 76 with UR-076, which DR-209
|
||||
// serves.
|
||||
expect(defined.DR).toBe(200);
|
||||
expect(defined.JA).toBe(36);
|
||||
expect(defined.total).toBe(330);
|
||||
expect(defined.total).toBe(344);
|
||||
});
|
||||
});
|
||||
|
||||
+133
-6
@@ -23,7 +23,7 @@ interface RequirementMapping {
|
||||
[reqId: string]: TraceEntry[];
|
||||
}
|
||||
|
||||
interface TracesData {
|
||||
export interface TracesData {
|
||||
timestamp: string;
|
||||
totalFiles: number;
|
||||
totalTraces: number;
|
||||
@@ -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.
|
||||
*
|
||||
@@ -307,7 +366,34 @@ export function readDefinedRequirements(): DefinedRequirements {
|
||||
return countDefinedRequirements(fs.readFileSync(reqPath, "utf-8"));
|
||||
}
|
||||
|
||||
function generateMarkdown(data: TracesData): string {
|
||||
/**
|
||||
* Path prefix that turns a repo-root-relative file path into a link target that
|
||||
* resolves from `docs/traceability.md`, where this markdown is written.
|
||||
*
|
||||
* The generated matrix lives one directory below the repo root, so a bare
|
||||
* `src-tauri/src/player/mod.rs` href resolves to `docs/src-tauri/…` and 404s —
|
||||
* in the repo browser and on the published mdBook site alike. Every file link
|
||||
* in the matrix was dead for this reason. The *display text* stays
|
||||
* repo-root-relative (that is the path a developer types and greps for); only
|
||||
* the href is rewritten.
|
||||
*
|
||||
* TRACES: | DR-093 | UT-202
|
||||
*/
|
||||
export const MATRIX_LINK_PREFIX = "../";
|
||||
|
||||
/**
|
||||
* Build the ``[`path`](href#Lnn)`` link used for one trace entry in the matrix.
|
||||
*
|
||||
* Exported so extract-traces.test.ts can resolve a generated href against
|
||||
* `docs/` and assert the target exists on disk.
|
||||
*
|
||||
* TRACES: | DR-093 | UT-202
|
||||
*/
|
||||
export function formatMatrixFileLink(file: string, line: number): string {
|
||||
return `[\`${file}\`](${MATRIX_LINK_PREFIX}${file}#L${line})`;
|
||||
}
|
||||
|
||||
export function generateMarkdown(data: TracesData): string {
|
||||
let md = `# Code Traceability Matrix
|
||||
|
||||
**Generated:** ${new Date(data.timestamp).toLocaleString()}
|
||||
@@ -365,7 +451,7 @@ ${data.byType.JA.join(", ")}
|
||||
md += `**Locations:** ${entries.length} file(s)\n\n`;
|
||||
|
||||
for (const entry of entries) {
|
||||
md += `- **File:** [\`${entry.file}\`](${entry.file}#L${entry.line})\n`;
|
||||
md += `- **File:** ${formatMatrixFileLink(entry.file, entry.line)}\n`;
|
||||
md += ` - **Line:** ${entry.line}\n`;
|
||||
const contextPreview = entry.context.substring(0, 70);
|
||||
md += ` - **Context:** \`${contextPreview}${entry.context.length > 70 ? "..." : ""}\`\n`;
|
||||
@@ -408,6 +494,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 +520,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 +571,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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
+1
-1
@@ -2018,7 +2018,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jellytau"
|
||||
version = "0.7.0"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
|
||||
+10
-6
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "jellytau"
|
||||
version = "0.7.0"
|
||||
version = "0.8.2"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
@@ -23,11 +23,15 @@ debug = "line-tables-only"
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
# protocol-asset serves downloaded media and cached thumbnails to the webview
|
||||
# over http://asset.localhost; without it convertFileSrc yields a URL nothing
|
||||
# answers. Paired with app.security.assetProtocol in tauri.conf.json, which
|
||||
# scopes it to $APPDATA/**.
|
||||
# TRACES: UR-071 | DR-134
|
||||
# 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"
|
||||
|
||||
@@ -107,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
|
||||
|
||||
@@ -25,18 +25,81 @@
|
||||
<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="${appLabel}"
|
||||
android:theme="@style/Theme.jellytau"
|
||||
android:hardwareAccelerated="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||
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"
|
||||
@@ -48,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>
|
||||
|
||||
|
||||
@@ -85,6 +85,11 @@ class MainActivity : TauriActivity() {
|
||||
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)
|
||||
}
|
||||
@@ -115,6 +120,11 @@ class MainActivity : TauriActivity() {
|
||||
// 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()
|
||||
@@ -188,6 +198,7 @@ class MainActivity : TauriActivity() {
|
||||
|
||||
override fun onDestroy() {
|
||||
NetworkTypeMonitor.stopWatching(this)
|
||||
ScreenWakeManager.clearActivity(this)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@@ -311,6 +322,10 @@ class MainActivity : TauriActivity() {
|
||||
@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")
|
||||
@@ -502,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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+159
-2
@@ -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() {
|
||||
@@ -228,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")
|
||||
@@ -245,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)
|
||||
@@ -263,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")
|
||||
@@ -270,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(
|
||||
@@ -433,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(
|
||||
@@ -446,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)
|
||||
@@ -456,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)
|
||||
@@ -463,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(
|
||||
|
||||
@@ -20,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.*
|
||||
|
||||
/**
|
||||
@@ -256,6 +259,45 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
* (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()
|
||||
@@ -263,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
|
||||
@@ -328,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 {
|
||||
@@ -338,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}")
|
||||
@@ -829,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
|
||||
@@ -1019,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()
|
||||
@@ -1316,6 +1414,10 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
* TRACES: UR-003, UR-041 | DR-184
|
||||
*/
|
||||
private fun clearVideoSurface() {
|
||||
// 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()
|
||||
com.dtourolle.jellytau.VideoOverlayManager.detachVideoSurface()
|
||||
@@ -1328,7 +1430,12 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
* Request audio focus for video playback.
|
||||
* This is critical for video to have audio on Android.
|
||||
*
|
||||
* TRACES: UR-004 | DR-145
|
||||
* 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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -12,7 +12,12 @@
|
||||
remote server still has to be HTTPS — this must not become a blanket
|
||||
cleartext opt-in.
|
||||
|
||||
TRACES: UR-071 | DR-138
|
||||
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" />
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -496,7 +496,7 @@ pub(crate) async fn requeue_mistyped_video_downloads(
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
let query = Query::new(&format!(
|
||||
let query = Query::new(format!(
|
||||
"UPDATE downloads
|
||||
SET status = 'pending', stream_url = NULL, progress = 0,
|
||||
bytes_downloaded = 0, started_at = NULL, completed_at = NULL
|
||||
@@ -563,7 +563,7 @@ where
|
||||
),
|
||||
None => String::new(),
|
||||
};
|
||||
let rows_query = Query::new(&format!(
|
||||
let rows_query = Query::new(format!(
|
||||
"SELECT d.id, d.item_id,
|
||||
COALESCE(
|
||||
d.media_type,
|
||||
@@ -753,6 +753,7 @@ 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;
|
||||
|
||||
@@ -1012,14 +1013,14 @@ mod tests {
|
||||
resolve_pending_download_urls(&db, "/data", None, move |item_id, media_type, _q| {
|
||||
let seen = Arc::clone(&seen_c);
|
||||
async move {
|
||||
seen.lock().unwrap().push((item_id.clone(), media_type));
|
||||
seen.lock_safe().push((item_id.clone(), media_type));
|
||||
Some(format!("http://resolved/{item_id}"))
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let seen = seen.lock().unwrap().clone();
|
||||
let seen = seen.lock_safe().clone();
|
||||
let of = |id: &str| {
|
||||
seen.iter()
|
||||
.find(|(i, _)| i == id)
|
||||
@@ -1045,14 +1046,14 @@ mod tests {
|
||||
resolve_pending_download_urls(&db, "/data", None, move |_id, media_type, _q| {
|
||||
let seen = Arc::clone(&seen_c);
|
||||
async move {
|
||||
*seen.lock().unwrap() = media_type;
|
||||
*seen.lock_safe() = media_type;
|
||||
Some("http://x".to_string())
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(*seen.lock().unwrap(), "audio");
|
||||
assert_eq!(*seen.lock_safe(), "audio");
|
||||
}
|
||||
|
||||
/// An explicit `media_type` on the row always wins over the item's type.
|
||||
@@ -1069,14 +1070,14 @@ mod tests {
|
||||
resolve_pending_download_urls(&db, "/data", None, move |_id, media_type, _q| {
|
||||
let seen = Arc::clone(&seen_c);
|
||||
async move {
|
||||
*seen.lock().unwrap() = media_type;
|
||||
*seen.lock_safe() = media_type;
|
||||
Some("http://x".to_string())
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(*seen.lock().unwrap(), "video");
|
||||
assert_eq!(*seen.lock_safe(), "video");
|
||||
}
|
||||
|
||||
/// Rows already downloaded under the audio default hold an audio-only
|
||||
|
||||
@@ -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>);
|
||||
|
||||
@@ -596,6 +600,10 @@ pub(crate) async fn queue_album_tracks(
|
||||
/// 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>,
|
||||
@@ -807,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)?,
|
||||
@@ -912,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,
|
||||
@@ -2307,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()),
|
||||
@@ -2323,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()),
|
||||
|
||||
@@ -186,6 +186,7 @@ async fn run_drain(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use rusqlite::Connection;
|
||||
use std::sync::Mutex;
|
||||
|
||||
@@ -211,7 +212,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn calls(&self) -> Vec<(String, bool)> {
|
||||
let mut calls = self.calls.lock().unwrap().clone();
|
||||
let mut calls = self.calls.lock_safe().clone();
|
||||
calls.sort();
|
||||
calls
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1478,8 +1478,22 @@ pub async fn player_seek_video(
|
||||
|
||||
/// Switch audio track - handles both HTML5 (stream reload) and native (direct switch)
|
||||
/// Note: Frontend should handle saving series preferences after this command succeeds
|
||||
///
|
||||
/// The split is the requirement: an HTML5 `<video>` element cannot be told to
|
||||
/// change audio track, so the stream is re-opened at the chosen
|
||||
/// `AudioStreamIndex` and the frontend seeks the reloaded element back to
|
||||
/// `position`; a native backend (ExoPlayer) switches in place by track-group
|
||||
/// index. libmpv implements neither — it is the audio-only backend here and
|
||||
/// leaves `PlayerBackend::set_audio_track` at its `not_implemented()` default,
|
||||
/// which is why IR-019 is met by these two paths rather than by MPV.
|
||||
///
|
||||
/// TRACES: UR-021 | IR-019, DR-024
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
// Two of the eight arguments are Tauri `State<'_, _>` injections, 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 player_switch_audio_track(
|
||||
player: State<'_, PlayerStateWrapper>,
|
||||
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
||||
@@ -1559,6 +1573,10 @@ pub async fn player_switch_audio_track(
|
||||
/// TRACES: UR-074 | DR-162
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
// Three of the nine arguments are Tauri `State<'_, _>` injections, 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 player_set_stream_quality(
|
||||
player: State<'_, PlayerStateWrapper>,
|
||||
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
||||
@@ -1650,6 +1668,9 @@ pub async fn player_set_stream_quality(
|
||||
Ok(StreamQualityResponse::Native { position })
|
||||
}
|
||||
|
||||
/// Set the active audio track on a native backend directly.
|
||||
///
|
||||
/// TRACES: UR-021 | IR-019, DR-024
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_set_audio_track(
|
||||
@@ -1663,6 +1684,14 @@ pub async fn player_set_audio_track(
|
||||
Ok(get_player_status(&controller))
|
||||
}
|
||||
|
||||
/// Set (or clear, with `None`) the active subtitle track on a native backend.
|
||||
///
|
||||
/// On Android this indexes ExoPlayer's *text track groups* — i.e. the position
|
||||
/// of the sideloaded `MediaItem.SubtitleConfiguration`, not the Jellyfin stream
|
||||
/// index. The HTML5 path never reaches here; it toggles its own `<track>`
|
||||
/// children. libmpv implements neither, leaving the trait default in place.
|
||||
///
|
||||
/// TRACES: UR-020 | IR-018, DR-023
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_set_subtitle_track(
|
||||
@@ -1784,7 +1813,7 @@ pub async fn player_get_status(
|
||||
let local_media = {
|
||||
let queue_arc = controller.queue();
|
||||
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
|
||||
queue.current().map(|item| MergedMediaItem::from(item))
|
||||
queue.current().map(MergedMediaItem::from)
|
||||
};
|
||||
|
||||
let local_is_playing = status.state.is_playing();
|
||||
@@ -1808,10 +1837,7 @@ pub async fn player_get_status(
|
||||
log::info!("[PlayerCommands] Merging remote session state");
|
||||
|
||||
// Merge media item
|
||||
status.merged_media = session
|
||||
.now_playing_item
|
||||
.as_ref()
|
||||
.map(|item| MergedMediaItem::from(item));
|
||||
status.merged_media = session.now_playing_item.as_ref().map(MergedMediaItem::from);
|
||||
|
||||
// Merge isPlaying (NOT isPaused!)
|
||||
status.merged_is_playing = session
|
||||
@@ -2741,6 +2767,8 @@ pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> R
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::utils::lock::MutexSafe;
|
||||
|
||||
/// The subtitle list the frontend resolved must survive the IPC hop and end
|
||||
/// up on the `MediaItem` the native backend loads.
|
||||
///
|
||||
@@ -3063,7 +3091,7 @@ mod tests {
|
||||
let database = Database::open_in_memory().unwrap();
|
||||
{
|
||||
let conn = database.connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
let conn = conn.lock_safe();
|
||||
conn.execute_batch(&format!(
|
||||
r#"
|
||||
INSERT INTO servers (id, name, url) VALUES ('srv', 'Test', 'http://test');
|
||||
@@ -3119,7 +3147,7 @@ mod tests {
|
||||
assert_eq!(switched, 1, "only the download whose file exists switches");
|
||||
|
||||
let queue = controller.queue();
|
||||
let queue_lock = queue.lock().unwrap();
|
||||
let queue_lock = queue.lock_safe();
|
||||
match &queue_lock.items()[0].source {
|
||||
MediaSource::Local {
|
||||
file_path,
|
||||
@@ -3148,7 +3176,7 @@ mod tests {
|
||||
index_number: Option<i32>,
|
||||
}
|
||||
|
||||
let mut tracks = vec![
|
||||
let mut tracks = [
|
||||
MockTrack {
|
||||
id: "track1".to_string(),
|
||||
name: "Song 1".to_string(),
|
||||
@@ -3241,7 +3269,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// Create tracks in random order (not sorted)
|
||||
let mut tracks = vec![
|
||||
let mut tracks = [
|
||||
MockTrack {
|
||||
id: "id5".to_string(),
|
||||
name: "Track 5".to_string(),
|
||||
|
||||
@@ -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;
|
||||
@@ -67,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>,
|
||||
@@ -294,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(
|
||||
@@ -318,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(
|
||||
@@ -1091,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]
|
||||
|
||||
@@ -1658,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]
|
||||
|
||||
@@ -481,6 +481,7 @@ pub async fn sync_process_pending(app: tauri::AppHandle) -> Result<DrainReport,
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use rusqlite::Connection;
|
||||
use std::sync::Mutex;
|
||||
|
||||
@@ -517,7 +518,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn calls(&self) -> Vec<QueuedOp> {
|
||||
self.calls.lock().unwrap().clone()
|
||||
self.calls.lock_safe().clone()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,7 +528,7 @@ mod tests {
|
||||
if let Some(err) = &self.fail_with {
|
||||
return Err(err.clone());
|
||||
}
|
||||
self.calls.lock().unwrap().push(op.clone());
|
||||
self.calls.lock_safe().push(op.clone());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
+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> {
|
||||
|
||||
@@ -389,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());
|
||||
@@ -407,9 +411,11 @@ 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());
|
||||
@@ -422,7 +428,7 @@ mod tests {
|
||||
/// TRACES: UR-071 | DR-127 | UT-120
|
||||
#[tokio::test]
|
||||
async fn test_reclaim_expired_only_takes_expired_temporary_entries() {
|
||||
use crate::storage::db_service::{DatabaseService, RusqliteService};
|
||||
use crate::storage::db_service::RusqliteService;
|
||||
use rusqlite::Connection;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
|
||||
+64
-12
@@ -314,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
|
||||
@@ -449,14 +453,55 @@ impl MediaSessionHandler {
|
||||
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(),
|
||||
_ => {
|
||||
warn!("[MediaSession] Unknown command: {}", command);
|
||||
@@ -620,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========================================");
|
||||
@@ -645,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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1029,16 +1074,23 @@ fn set_env_if_unset(key: &str, value: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloaded media and cached thumbnails are handed to the webview as
|
||||
/// `http://asset.localhost/…` URLs by `convertFileSrc`. Tauri only answers that
|
||||
/// 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`, which also
|
||||
/// scopes it to `$APPDATA/**` — the storage root holding the database,
|
||||
/// `downloads/` and the thumbnail cache. Both are required together: with either
|
||||
/// missing the URL resolves to nothing and the webview reports
|
||||
/// `NETWORK_NO_SOURCE`, which is how offline video came to fail silently.
|
||||
/// `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.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-134
|
||||
/// 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
|
||||
|
||||
@@ -624,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);
|
||||
}
|
||||
@@ -914,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -942,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] {
|
||||
@@ -975,10 +975,10 @@ mod tests {
|
||||
|
||||
impl RemoteVolumeControl for RecordingVolumeControl {
|
||||
fn enable(&self, _initial_volume: i32) {
|
||||
self.calls.lock().unwrap().push("enable");
|
||||
self.calls.lock_safe().push("enable");
|
||||
}
|
||||
fn disable(&self) {
|
||||
self.calls.lock().unwrap().push("disable");
|
||||
self.calls.lock_safe().push("disable");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1014,7 +1014,7 @@ mod tests {
|
||||
manager.set_mode(PlaybackMode::Idle);
|
||||
|
||||
assert_eq!(
|
||||
*volume.calls.lock().unwrap(),
|
||||
*volume.calls.lock_safe(),
|
||||
vec!["enable", "disable"],
|
||||
"remote->idle must return volume control to the local speaker"
|
||||
);
|
||||
@@ -1033,7 +1033,7 @@ mod tests {
|
||||
manager.set_mode(PlaybackMode::Local);
|
||||
|
||||
assert_eq!(
|
||||
*volume.calls.lock().unwrap(),
|
||||
*volume.calls.lock_safe(),
|
||||
vec!["enable", "disable"],
|
||||
"remote->local must return volume control to the local speaker"
|
||||
);
|
||||
@@ -1053,7 +1053,7 @@ mod tests {
|
||||
manager.set_mode(PlaybackMode::Local);
|
||||
|
||||
assert!(
|
||||
volume.calls.lock().unwrap().is_empty(),
|
||||
volume.calls.lock_safe().is_empty(),
|
||||
"local/idle transitions must not touch remote volume routing"
|
||||
);
|
||||
}
|
||||
@@ -1074,7 +1074,7 @@ mod tests {
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
*volume.calls.lock().unwrap(),
|
||||
*volume.calls.lock_safe(),
|
||||
vec!["enable", "enable"],
|
||||
"remote->remote re-arms control without releasing it to local"
|
||||
);
|
||||
@@ -1091,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;
|
||||
@@ -348,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
|
||||
{
|
||||
@@ -420,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",
|
||||
@@ -443,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),
|
||||
@@ -454,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),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1096,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,
|
||||
@@ -1396,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())?;
|
||||
|
||||
@@ -6,6 +6,12 @@ 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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -140,6 +140,8 @@ const RESUME_BACKOFF_STEP_SECS: u64 = 2;
|
||||
/// the local ExoPlayer is idle and so can't supply now-playing info. The session
|
||||
/// poller fills this in from the remote Jellyfin session and pushes it to the
|
||||
/// notification so the lockscreen stays in sync while casting.
|
||||
///
|
||||
/// TRACES: UR-006 | IR-006
|
||||
#[derive(Debug, Clone)]
|
||||
// Fields are read only by the Android MediaSession bridge; on other platforms
|
||||
// `update_lockscreen_metadata` is a no-op, so they're constructed but unread.
|
||||
@@ -157,6 +159,11 @@ pub struct LockscreenMetadata {
|
||||
|
||||
/// Push now-playing metadata to the Android lockscreen. No-op off Android, so the
|
||||
/// session poller can call it unconditionally and stay platform-agnostic.
|
||||
///
|
||||
/// No-op on Linux specifically because there is no MPRIS/D-Bus publisher — see
|
||||
/// IR-005, which is still Planned.
|
||||
///
|
||||
/// TRACES: UR-006 | IR-006
|
||||
pub fn update_lockscreen_metadata(_meta: &LockscreenMetadata) -> Result<(), String> {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
@@ -1663,8 +1670,7 @@ impl PlayerController {
|
||||
/// audio-only handoff, the only place a length-less progressive transcode is
|
||||
/// used. Jellyfin's item-type taxonomy stays in Rust (CLAUDE.md).
|
||||
fn is_audio_only_video(item: &MediaItem) -> bool {
|
||||
item.media_type == MediaType::Audio
|
||||
&& matches!(item.item_type.as_deref(), Some("Episode") | Some("Movie"))
|
||||
stream_end::is_audio_only_video(item)
|
||||
}
|
||||
|
||||
/// Claim a resume attempt for the current stream, returning the absolute
|
||||
|
||||
@@ -243,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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
//! 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.
|
||||
///
|
||||
@@ -45,6 +47,53 @@ pub const MAX_STALLED_RESUME_ATTEMPTS: u32 = 3;
|
||||
/// 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?
|
||||
///
|
||||
@@ -202,6 +251,88 @@ impl ResumeTracker {
|
||||
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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1134,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;
|
||||
|
||||
|
||||
@@ -2510,6 +2510,16 @@ impl MediaRepository for OfflineRepository {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
// `CATALOG_BROWSE_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 query 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 crate::storage::db_service::RusqliteService;
|
||||
use rusqlite::Connection;
|
||||
@@ -2524,9 +2534,8 @@ mod tests {
|
||||
static CATALOG_BROWSE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn lock_catalog_browse() -> std::sync::MutexGuard<'static, ()> {
|
||||
CATALOG_BROWSE_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
use crate::utils::lock::MutexSafe;
|
||||
CATALOG_BROWSE_LOCK.lock_safe()
|
||||
}
|
||||
|
||||
/// TRACES: UR-065 | DR-108 | UT-111
|
||||
|
||||
@@ -760,7 +760,11 @@ struct JellyfinItem {
|
||||
/// `UserData` in the `Fields=` list so the shape is explicit rather than
|
||||
/// dependent on the server's default field set.
|
||||
///
|
||||
/// TRACES: UR-069 | DR-113, JA-034 | UT-099
|
||||
/// `PlaybackPositionTicks` is the server's resume position for the item, and the
|
||||
/// only place it is published — Jellyfin has no per-item "resume position"
|
||||
/// endpoint, so reading `UserData` *is* how a resume point is obtained.
|
||||
///
|
||||
/// TRACES: UR-019, UR-069 | DR-113, JA-013, JA-034 | UT-099
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct JellyfinUserData {
|
||||
@@ -854,6 +858,8 @@ fn build_get_items_endpoint(
|
||||
///
|
||||
/// Pulled out of `get_latest_items` so the query can be asserted without an
|
||||
/// HTTP server, matching `build_favorites_endpoint`.
|
||||
///
|
||||
/// TRACES: UR-024, UR-034 | IR-024, JA-016
|
||||
fn build_latest_items_endpoint(user_id: &str, parent_id: &str, limit: Option<usize>) -> String {
|
||||
format!(
|
||||
"/Users/{}/Items/Latest?ParentId={}&Limit={}&GroupItems=true&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
||||
@@ -875,7 +881,7 @@ fn build_latest_items_endpoint(user_id: &str, parent_id: &str, limit: Option<usi
|
||||
/// Pulled out of `get_next_up_episodes` so the query can be asserted without an
|
||||
/// HTTP server, matching `build_favorites_endpoint`.
|
||||
///
|
||||
/// TRACES: UR-059 | DR-197, JA-036 | UT-190, UT-191
|
||||
/// TRACES: UR-023, UR-059 | DR-197, JA-014, JA-036 | UT-190, UT-191
|
||||
fn build_next_up_endpoint(user_id: &str, series_id: Option<&str>, limit: Option<usize>) -> String {
|
||||
let mut endpoint = format!(
|
||||
"/Shows/NextUp?UserId={}&Limit={}&EnableResumable=false&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
||||
@@ -984,7 +990,7 @@ struct JellyfinMediaSource {
|
||||
}
|
||||
|
||||
impl JellyfinItem {
|
||||
fn to_media_item(self, server_id: String) -> MediaItem {
|
||||
fn into_media_item(self, server_id: String) -> MediaItem {
|
||||
// Extract image tags before consuming self
|
||||
let primary_tag = self.image_tags.as_ref().and_then(|tags| tags.primary());
|
||||
let backdrop_tags = self.backdrop_image_tags;
|
||||
@@ -1123,17 +1129,28 @@ impl MediaRepository for OnlineRepository {
|
||||
items: response
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| item.to_media_item(self.user_id.clone()))
|
||||
.map(|item| item.into_media_item(self.user_id.clone()))
|
||||
.collect(),
|
||||
total_record_count: response.total_record_count,
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch one item with every field the detail and player screens need.
|
||||
///
|
||||
/// The `Fields=` list is the load-bearing part: Jellyfin omits these unless
|
||||
/// they are named. `MediaStreams` is what makes the item's **audio and
|
||||
/// subtitle tracks** knowable at all — there is no separate "tracks"
|
||||
/// endpoint, so this single call is how the player learns which audio tracks
|
||||
/// an item offers (`to_media_item` maps them, and the player's selector
|
||||
/// filters them by `kind`). `People` is likewise how **cast and crew** are
|
||||
/// obtained.
|
||||
///
|
||||
/// TRACES: UR-021, UR-035 | IR-016, IR-022, JA-005, JA-009
|
||||
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
|
||||
let endpoint = format!("/Users/{}/Items/{}?Fields=BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources,PremiereDate,UserData", self.user_id, item_id);
|
||||
|
||||
let item: JellyfinItem = self.get_json(&endpoint).await?;
|
||||
let media_item = item.to_media_item(self.user_id.clone());
|
||||
let media_item = item.into_media_item(self.user_id.clone());
|
||||
|
||||
Ok(media_item)
|
||||
}
|
||||
@@ -1148,10 +1165,18 @@ impl MediaRepository for OnlineRepository {
|
||||
let items: Vec<JellyfinItem> = self.get_json(&endpoint).await?;
|
||||
Ok(items
|
||||
.into_iter()
|
||||
.map(|item| item.to_media_item(self.user_id.clone()))
|
||||
.map(|item| item.into_media_item(self.user_id.clone()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Continue Watching: the items this user has started and not finished.
|
||||
///
|
||||
/// `/Users/{uid}/Items/Resume` is the server-side answer to both "what goes
|
||||
/// in the Continue Watching row" and "where was this left off" — each item
|
||||
/// carries its own `UserData.PlaybackPositionTicks`, which is why `UserData`
|
||||
/// is named in `Fields=` rather than left to the server's default field set.
|
||||
///
|
||||
/// TRACES: UR-019, UR-023 | IR-024, JA-013, JA-015
|
||||
async fn get_resume_items(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
@@ -1171,10 +1196,14 @@ impl MediaRepository for OnlineRepository {
|
||||
Ok(response
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| item.to_media_item(self.user_id.clone()))
|
||||
.map(|item| item.into_media_item(self.user_id.clone()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// "Next Up": the episode that follows the ones this user has finished,
|
||||
/// per series — the Shows-scoped counterpart to Continue Watching.
|
||||
///
|
||||
/// TRACES: UR-023, UR-059 | IR-024, JA-014
|
||||
async fn get_next_up_episodes(
|
||||
&self,
|
||||
series_id: Option<&str>,
|
||||
@@ -1186,7 +1215,7 @@ impl MediaRepository for OnlineRepository {
|
||||
Ok(response
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| item.to_media_item(self.user_id.clone()))
|
||||
.map(|item| item.into_media_item(self.user_id.clone()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
@@ -1206,7 +1235,7 @@ impl MediaRepository for OnlineRepository {
|
||||
let items: Vec<MediaItem> = response
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| item.to_media_item(self.user_id.clone()))
|
||||
.map(|item| item.into_media_item(self.user_id.clone()))
|
||||
.collect();
|
||||
|
||||
debug!("[get_recently_played_audio] Fetched {} items", items.len());
|
||||
@@ -1229,7 +1258,7 @@ impl MediaRepository for OnlineRepository {
|
||||
"[get_recently_played_audio] Grouping item '{}' into album '{}'",
|
||||
item.name, key
|
||||
);
|
||||
album_map.entry(key).or_insert_with(Vec::new).push(item);
|
||||
album_map.entry(key).or_default().push(item);
|
||||
} else {
|
||||
debug!(
|
||||
"[get_recently_played_audio] No album_id or album_name for item: '{}'",
|
||||
@@ -1344,10 +1373,15 @@ impl MediaRepository for OnlineRepository {
|
||||
Ok(response
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| item.to_media_item(self.user_id.clone()))
|
||||
.map(|item| item.into_media_item(self.user_id.clone()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Continue Watching, narrowed to movies — the home screen's movie row and
|
||||
/// the movie library's own hero both want the unfinished films without the
|
||||
/// episodes mixed in.
|
||||
///
|
||||
/// TRACES: UR-019, UR-034 | IR-024, JA-013, JA-015
|
||||
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let limit_str = limit.unwrap_or(16);
|
||||
let endpoint = format!(
|
||||
@@ -1359,7 +1393,7 @@ impl MediaRepository for OnlineRepository {
|
||||
Ok(response
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| item.to_media_item(self.user_id.clone()))
|
||||
.map(|item| item.into_media_item(self.user_id.clone()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
@@ -1423,6 +1457,14 @@ impl MediaRepository for OnlineRepository {
|
||||
Ok(genres)
|
||||
}
|
||||
|
||||
/// Search every library the user can see.
|
||||
///
|
||||
/// `Recursive=true` with no `ParentId` is what makes this cross-library
|
||||
/// rather than folder-scoped; a caller narrowing the search passes the item
|
||||
/// types through `SearchOptions` (already expanded from an opaque
|
||||
/// `SearchScope` on this side of the boundary).
|
||||
///
|
||||
/// TRACES: UR-008 | IR-010, JA-006
|
||||
async fn search(
|
||||
&self,
|
||||
query: &str,
|
||||
@@ -1461,7 +1503,7 @@ impl MediaRepository for OnlineRepository {
|
||||
items: response
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| item.to_media_item(self.user_id.clone()))
|
||||
.map(|item| item.into_media_item(self.user_id.clone()))
|
||||
.collect(),
|
||||
total_record_count: response.total_record_count,
|
||||
})
|
||||
@@ -1839,7 +1881,7 @@ impl MediaRepository for OnlineRepository {
|
||||
Ok(response
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| item.to_media_item(self.server_url.clone()))
|
||||
.map(|item| item.into_media_item(self.server_url.clone()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
@@ -1852,7 +1894,7 @@ impl MediaRepository for OnlineRepository {
|
||||
let items = response
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| item.to_media_item(self.server_url.clone()))
|
||||
.map(|item| item.into_media_item(self.server_url.clone()))
|
||||
.collect();
|
||||
Ok(SearchResult {
|
||||
items,
|
||||
@@ -2189,12 +2231,18 @@ impl MediaRepository for OnlineRepository {
|
||||
items: response
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| item.to_media_item(self.user_id.clone()))
|
||||
.map(|item| item.into_media_item(self.user_id.clone()))
|
||||
.collect(),
|
||||
total_record_count: response.total_record_count,
|
||||
})
|
||||
}
|
||||
|
||||
/// Un-favourite an item: the same `/Users/{uid}/FavoriteItems/{id}` resource
|
||||
/// as [`Self::mark_favorite`], removed rather than posted. Written out by
|
||||
/// hand rather than through `post_json` because it is the one favourite call
|
||||
/// that needs `DELETE`.
|
||||
///
|
||||
/// TRACES: UR-017 | JA-018, DR-021
|
||||
async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
let endpoint = format!("/Users/{}/FavoriteItems/{}", self.user_id, item_id);
|
||||
let url = format!("{}{}", self.server_url, endpoint);
|
||||
@@ -2316,12 +2364,22 @@ impl MediaRepository for OnlineRepository {
|
||||
result
|
||||
}
|
||||
|
||||
/// A single Person item (actor, director, …) by id.
|
||||
///
|
||||
/// Jellyfin models people as ordinary items, so this is the plain item
|
||||
/// endpoint rather than anything under `/Persons`; the cast entries returned
|
||||
/// on an item's `People` field carry the ids this is called with.
|
||||
///
|
||||
/// TRACES: UR-035, UR-036 | IR-022, JA-030
|
||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
let endpoint = format!("/Users/{}/Items/{}", self.user_id, person_id);
|
||||
let item: JellyfinItem = self.get_json(&endpoint).await?;
|
||||
Ok(item.to_media_item(self.user_id.clone()))
|
||||
Ok(item.into_media_item(self.user_id.clone()))
|
||||
}
|
||||
|
||||
/// A person's filmography — every item they are credited on.
|
||||
///
|
||||
/// TRACES: UR-036 | IR-022, JA-031
|
||||
async fn get_items_by_person(
|
||||
&self,
|
||||
person_id: &str,
|
||||
@@ -2349,7 +2407,7 @@ impl MediaRepository for OnlineRepository {
|
||||
items: response
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| item.to_media_item(self.user_id.clone()))
|
||||
.map(|item| item.into_media_item(self.user_id.clone()))
|
||||
.collect(),
|
||||
total_record_count: response.total_record_count,
|
||||
})
|
||||
@@ -2373,7 +2431,7 @@ impl MediaRepository for OnlineRepository {
|
||||
items: response
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| item.to_media_item(self.user_id.clone()))
|
||||
.map(|item| item.into_media_item(self.user_id.clone()))
|
||||
.collect(),
|
||||
total_record_count: response.total_record_count,
|
||||
})
|
||||
@@ -2461,7 +2519,7 @@ impl MediaRepository for OnlineRepository {
|
||||
.into_iter()
|
||||
.map(|pi| PlaylistEntry {
|
||||
playlist_item_id: pi.playlist_item_id,
|
||||
item: pi.item.to_media_item(self.user_id.clone()),
|
||||
item: pi.item.into_media_item(self.user_id.clone()),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -2959,7 +3017,7 @@ mod tests {
|
||||
}))
|
||||
.expect("fixture must deserialize");
|
||||
|
||||
let streams = item.to_media_item("server-1".to_string()).media_streams;
|
||||
let streams = item.into_media_item("server-1".to_string()).media_streams;
|
||||
let streams = streams.expect("the item carries streams");
|
||||
let deliverable = |index: i32| {
|
||||
streams
|
||||
@@ -3554,7 +3612,7 @@ mod tests {
|
||||
}"#;
|
||||
|
||||
let item: JellyfinItem = serde_json::from_str(json).expect("item should deserialize");
|
||||
let media = item.to_media_item("server1".to_string());
|
||||
let media = item.into_media_item("server1".to_string());
|
||||
|
||||
let user_data = media.user_data.expect("user data should be mapped");
|
||||
assert_eq!(user_data.is_favorite, Some(true));
|
||||
@@ -3574,7 +3632,7 @@ mod tests {
|
||||
let json = r#"{"Id": "x", "Name": "No User Data", "Type": "Movie"}"#;
|
||||
|
||||
let item: JellyfinItem = serde_json::from_str(json).expect("item should deserialize");
|
||||
let media = item.to_media_item("server1".to_string());
|
||||
let media = item.into_media_item("server1".to_string());
|
||||
|
||||
assert!(media.user_data.is_none());
|
||||
}
|
||||
@@ -3618,7 +3676,7 @@ mod tests {
|
||||
}"#;
|
||||
|
||||
let jellyfin_item: JellyfinItem = serde_json::from_str(json).expect("Failed to parse");
|
||||
let media_item = jellyfin_item.to_media_item("test-server-id".to_string());
|
||||
let media_item = jellyfin_item.into_media_item("test-server-id".to_string());
|
||||
|
||||
assert_eq!(media_item.id, "album456");
|
||||
assert_eq!(media_item.name, "Love and Theft");
|
||||
|
||||
@@ -155,6 +155,10 @@ impl ThumbnailCache {
|
||||
}
|
||||
|
||||
/// Save thumbnail to cache
|
||||
// The arguments are the cache key (item/type/tag) plus the payload and its
|
||||
// dimensions — all independent scalars borrowed from the caller. A parameter
|
||||
// struct would only move the same list one level down.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn save_thumbnail(
|
||||
&self,
|
||||
db: Arc<RusqliteService>,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "jellytau",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.2",
|
||||
"identifier": "com.dtourolle.jellytau",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
@@ -18,10 +18,11 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null,
|
||||
"csp": "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'",
|
||||
"devCsp": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; 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: ws: wss:; worker-src 'self' blob:; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'",
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": ["$APPDATA/**"]
|
||||
"scope": ["$APPDATA/thumbnails/**"]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+35
-2
@@ -118,16 +118,41 @@ async playerSetVolume(volume: number) : Promise<PlayerStatus> {
|
||||
async playerToggleMute() : Promise<PlayerStatus> {
|
||||
return await TAURI_INVOKE("player_toggle_mute");
|
||||
},
|
||||
/**
|
||||
* Set the active audio track on a native backend directly.
|
||||
*
|
||||
* TRACES: UR-021 | IR-019, DR-024
|
||||
*/
|
||||
async playerSetAudioTrack(streamIndex: number) : Promise<PlayerStatus> {
|
||||
return await TAURI_INVOKE("player_set_audio_track", { streamIndex });
|
||||
},
|
||||
/**
|
||||
* Switch audio track - handles both HTML5 (stream reload) and native (direct switch)
|
||||
* Note: Frontend should handle saving series preferences after this command succeeds
|
||||
*
|
||||
* The split is the requirement: an HTML5 `<video>` element cannot be told to
|
||||
* change audio track, so the stream is re-opened at the chosen
|
||||
* `AudioStreamIndex` and the frontend seeks the reloaded element back to
|
||||
* `position`; a native backend (ExoPlayer) switches in place by track-group
|
||||
* index. libmpv implements neither — it is the audio-only backend here and
|
||||
* leaves `PlayerBackend::set_audio_track` at its `not_implemented()` default,
|
||||
* which is why IR-019 is met by these two paths rather than by MPV.
|
||||
*
|
||||
* TRACES: UR-021 | IR-019, DR-024
|
||||
*/
|
||||
async playerSwitchAudioTrack(repositoryHandle: string, streamIndex: number, arrayIndex: number, useHtml5: boolean, currentPosition: number | null, mediaSourceId: string | null) : Promise<AudioTrackSwitchResponse> {
|
||||
return await TAURI_INVOKE("player_switch_audio_track", { repositoryHandle, streamIndex, arrayIndex, useHtml5, currentPosition, mediaSourceId });
|
||||
},
|
||||
/**
|
||||
* Set (or clear, with `None`) the active subtitle track on a native backend.
|
||||
*
|
||||
* On Android this indexes ExoPlayer's *text track groups* — i.e. the position
|
||||
* of the sideloaded `MediaItem.SubtitleConfiguration`, not the Jellyfin stream
|
||||
* index. The HTML5 path never reaches here; it toggles its own `<track>`
|
||||
* children. libmpv implements neither, leaving the trait default in place.
|
||||
*
|
||||
* TRACES: UR-020 | IR-018, DR-023
|
||||
*/
|
||||
async playerSetSubtitleTrack(streamIndex: number | null) : Promise<PlayerStatus> {
|
||||
return await TAURI_INVOKE("player_set_subtitle_track", { streamIndex });
|
||||
},
|
||||
@@ -1421,13 +1446,21 @@ async repositoryGetLatestItems(handle: string, parentId: string, limit: number |
|
||||
return await TAURI_INVOKE("repository_get_latest_items", { handle, parentId, limit });
|
||||
},
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
async repositoryGetResumeItems(handle: string, parentId: string | null, limit: number | null) : Promise<MediaItem[]> {
|
||||
return await TAURI_INVOKE("repository_get_resume_items", { handle, parentId, limit });
|
||||
},
|
||||
/**
|
||||
* Get next up episodes
|
||||
* Get next up episodes.
|
||||
*
|
||||
* TRACES: UR-023, UR-034 | IR-024, JA-014 | DR-026
|
||||
*/
|
||||
async repositoryGetNextUpEpisodes(handle: string, seriesId: string | null, limit: number | null) : Promise<MediaItem[]> {
|
||||
return await TAURI_INVOKE("repository_get_next_up_episodes", { handle, seriesId, limit });
|
||||
|
||||
@@ -19,6 +19,9 @@ import type {
|
||||
PlaylistEntry,
|
||||
PlaylistCreatedResult,
|
||||
} from "./types";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("RepositoryClient");
|
||||
|
||||
/**
|
||||
* Repository client - thin wrapper over Rust HybridRepository
|
||||
@@ -39,14 +42,14 @@ export class RepositoryClient {
|
||||
accessToken: string,
|
||||
serverId: string
|
||||
): Promise<string> {
|
||||
console.log("[RepositoryClient] Creating Rust repository...");
|
||||
log.debug("Creating Rust repository...");
|
||||
this.handle = await commands.repositoryCreate(serverUrl, userId, accessToken, serverId);
|
||||
|
||||
// Store for URL construction
|
||||
this._serverUrl = serverUrl;
|
||||
this._accessToken = accessToken;
|
||||
|
||||
console.log("[RepositoryClient] Repository created with handle:", this.handle);
|
||||
log.debug("Repository created with handle:", this.handle);
|
||||
return this.handle;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
import { haptics } from "$lib/utils/haptics";
|
||||
import { toast } from "$lib/stores/toast";
|
||||
import { favoriteOverrides } from "$lib/stores/favorites";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("FavoriteButton");
|
||||
|
||||
interface Props {
|
||||
itemId: string;
|
||||
@@ -78,7 +81,7 @@
|
||||
isAnimating = false;
|
||||
}, 600);
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle favorite:", error);
|
||||
log.error("Failed to toggle favorite:", error);
|
||||
toast.show("Failed to update favorites", "error");
|
||||
isAnimating = false;
|
||||
} finally {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { downloads, type DownloadInfo } from "$lib/stores/downloads";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("DownloadItem");
|
||||
|
||||
interface Props {
|
||||
download: DownloadInfo;
|
||||
@@ -69,7 +72,7 @@
|
||||
// Refresh to update UI
|
||||
await downloads.refresh(download.userId);
|
||||
} catch (error) {
|
||||
console.error("Failed to pause download:", error);
|
||||
log.error("Failed to pause download:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +82,7 @@
|
||||
// Refresh to update UI
|
||||
await downloads.refresh(download.userId);
|
||||
} catch (error) {
|
||||
console.error("Failed to resume download:", error);
|
||||
log.error("Failed to resume download:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +92,7 @@
|
||||
// Refresh to update UI
|
||||
await downloads.refresh(download.userId);
|
||||
} catch (error) {
|
||||
console.error("Failed to cancel download:", error);
|
||||
log.error("Failed to cancel download:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +102,7 @@
|
||||
// Refresh to update UI
|
||||
await downloads.refresh(download.userId);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete download:", error);
|
||||
log.error("Failed to delete download:", error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("AlbumDownloadButton");
|
||||
|
||||
interface Props {
|
||||
albumId: string;
|
||||
@@ -60,7 +63,7 @@
|
||||
try {
|
||||
const userId = $auth.user?.id;
|
||||
if (!userId) {
|
||||
console.error("No user ID found");
|
||||
log.error("No user ID found");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -95,7 +98,7 @@
|
||||
await downloads.refresh(userId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Album download operation failed:", error);
|
||||
log.error("Album download operation failed:", error);
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
||||
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("ArtistDetailView");
|
||||
|
||||
interface Props {
|
||||
artist: MediaItem;
|
||||
@@ -47,7 +50,7 @@
|
||||
});
|
||||
albums = albumsResult.items.filter(item => item.kind === "album");
|
||||
} catch (e) {
|
||||
console.warn("Failed to load albums:", e);
|
||||
log.warn("Failed to load albums:", e);
|
||||
} finally {
|
||||
albumsLoading = false;
|
||||
}
|
||||
@@ -62,7 +65,7 @@
|
||||
});
|
||||
topTracks = tracksResult.items.filter(item => item.kind === "track");
|
||||
} catch (e) {
|
||||
console.warn("Failed to load tracks:", e);
|
||||
log.warn("Failed to load tracks:", e);
|
||||
} finally {
|
||||
tracksLoading = false;
|
||||
}
|
||||
@@ -82,14 +85,14 @@
|
||||
.slice(0, 6);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to load related artists:", e);
|
||||
log.warn("Failed to load related artists:", e);
|
||||
} finally {
|
||||
artistsLoading = false;
|
||||
}
|
||||
|
||||
singlesLoading = false;
|
||||
} catch (e) {
|
||||
console.error("Error loading artist content:", e);
|
||||
log.error("Error loading artist content:", e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
<script lang="ts">
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { isServerReachable } from "$lib/stores/connectivity";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("ClearHistoryButton");
|
||||
|
||||
interface Props {
|
||||
/** Series or season id to clear. */
|
||||
@@ -51,7 +54,7 @@
|
||||
await auth.getRepository().clearWatchHistory(itemId);
|
||||
onCleared?.();
|
||||
} catch (e) {
|
||||
console.error("Failed to clear watch history:", e);
|
||||
log.error("Failed to clear watch history:", e);
|
||||
alert(
|
||||
`Could not clear watch history: ${e instanceof Error ? e.message : String(e)}`
|
||||
);
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import DownloadButtonCore from "./DownloadButtonCore.svelte";
|
||||
import type { DownloadState } from "./DownloadButtonCore.svelte";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("DownloadButton");
|
||||
|
||||
/**
|
||||
* Single audio track download button
|
||||
@@ -39,7 +42,7 @@
|
||||
});
|
||||
|
||||
async function handleClick() {
|
||||
console.log("🖱️ Download button clicked! Current status:", status);
|
||||
log.debug("🖱️ Download button clicked! Current status:", status);
|
||||
if (isProcessing) return;
|
||||
|
||||
isProcessing = true;
|
||||
@@ -63,25 +66,25 @@
|
||||
// Start download
|
||||
const userId = $auth.user?.id;
|
||||
if (!userId) {
|
||||
console.error("No user ID found");
|
||||
log.error("No user ID found");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
|
||||
console.log("🎯 Starting download for item:", itemId);
|
||||
log.debug("🎯 Starting download for item:", itemId);
|
||||
|
||||
// Get stream URL
|
||||
const streamUrl = await repo.getAudioStreamUrl(itemId);
|
||||
console.log(" Stream URL obtained:", streamUrl?.substring(0, 50) + "...");
|
||||
log.debug(" Stream URL obtained:", streamUrl?.substring(0, 50) + "...");
|
||||
if (!streamUrl) {
|
||||
throw new Error("Failed to get stream URL");
|
||||
}
|
||||
|
||||
// Get target directory
|
||||
const targetDir = await commands.storageGetPath();
|
||||
console.log(" Target directory:", targetDir);
|
||||
log.debug(" Target directory:", targetDir);
|
||||
|
||||
// Queue and start download in single atomic operation
|
||||
const downloadId = await commands.downloadItemAndStart({
|
||||
@@ -93,16 +96,16 @@
|
||||
artistName: artistName || null,
|
||||
albumName: albumName || null,
|
||||
});
|
||||
console.log(" Download queued and started with ID:", downloadId);
|
||||
log.debug(" Download queued and started with ID:", downloadId);
|
||||
|
||||
// Refresh downloads list
|
||||
await downloads.refresh(userId);
|
||||
} catch (e) {
|
||||
console.error("❌ Failed to start download:", e);
|
||||
log.error("❌ Failed to start download:", e);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Download operation failed:", error);
|
||||
log.error("Download operation failed:", error);
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
||||
import { useOfflineFilterReload } from "$lib/composables/useOfflineFilterReload";
|
||||
import type { Genre, MediaItem, ItemType } from "$lib/api/types";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("GenericGenreBrowser");
|
||||
|
||||
/**
|
||||
* Generic genre browser supporting Movies, Music Albums, and TV Series
|
||||
@@ -92,7 +95,7 @@
|
||||
genres = result.sort((a, b) => a.name.localeCompare(b.name));
|
||||
applyFilter();
|
||||
} catch (e) {
|
||||
console.error("Failed to load genres:", e);
|
||||
log.error("Failed to load genres:", e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
@@ -115,7 +118,7 @@
|
||||
});
|
||||
genreItems = result.items;
|
||||
} catch (e) {
|
||||
console.error("Failed to load genre items:", e);
|
||||
log.error("Failed to load genre items:", e);
|
||||
} finally {
|
||||
loadingItems = false;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
import TrackList from "./TrackList.svelte";
|
||||
import AlphabetScrollBar from "./AlphabetScrollBar.svelte";
|
||||
import { excludePodcasts } from "$lib/utils/podcastFilter";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("GenericMediaListPage");
|
||||
|
||||
/**
|
||||
* Generic media list page supporting Albums, Artists, Playlists, and Tracks
|
||||
@@ -154,7 +157,7 @@
|
||||
items = excludePodcasts(result.items);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to load ${config.itemType}:`, e);
|
||||
log.error(`Failed to load ${config.itemType}:`, e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- TRACES: UR-029, UR-051 | DR-069, DR-070 -->
|
||||
<!-- TRACES: UR-029, UR-037, UR-051 | DR-042, DR-069, DR-070 -->
|
||||
<script lang="ts">
|
||||
import type { MediaItem, Library } from "$lib/api/types";
|
||||
import MediaCard from "./MediaCard.svelte";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- TRACES: UR-051, UR-052, UR-068 | DR-068, DR-078, DR-119 -->
|
||||
<!-- TRACES: UR-037, UR-051, UR-052, UR-068 | DR-042, DR-068, DR-078, DR-119 -->
|
||||
<script lang="ts">
|
||||
import type { MediaItem, Library } from "$lib/api/types";
|
||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||
@@ -9,6 +9,9 @@
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
||||
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("MediaCard");
|
||||
|
||||
interface Props {
|
||||
item: MediaItem | Library;
|
||||
@@ -171,7 +174,7 @@
|
||||
media.albumName ?? undefined
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[MediaCard] Failed to queue download:", err);
|
||||
log.error("Failed to queue download:", err);
|
||||
queueError = "Failed to queue";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
import LibraryGrid from "./LibraryGrid.svelte";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("PersonDetailView");
|
||||
|
||||
interface Props {
|
||||
person: MediaItem;
|
||||
@@ -34,7 +37,7 @@
|
||||
movies = result.items.filter(item => item.kind === "movie");
|
||||
series = result.items.filter(item => item.kind === "series");
|
||||
} catch (e) {
|
||||
console.error("Failed to load filmography:", e);
|
||||
log.error("Failed to load filmography:", e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
||||
import { favoriteOverrides } from "$lib/stores/favorites";
|
||||
import { formatDuration } from "$lib/utils/duration";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("PlaylistDetail");
|
||||
|
||||
interface Props {
|
||||
playlist: MediaItem;
|
||||
@@ -40,7 +43,7 @@
|
||||
const repo = auth.getRepository();
|
||||
entries = await repo.getPlaylistItems(playlist.id);
|
||||
} catch (e) {
|
||||
console.error("[PlaylistDetail] Failed to load items:", e);
|
||||
log.error("Failed to load items:", e);
|
||||
toast.error("Failed to load playlist items");
|
||||
} finally {
|
||||
loading = false;
|
||||
@@ -62,7 +65,7 @@
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[PlaylistDetail] Failed to play all:", e);
|
||||
log.error("Failed to play all:", e);
|
||||
toast.error("Failed to play playlist");
|
||||
}
|
||||
}
|
||||
@@ -82,7 +85,7 @@
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[PlaylistDetail] Failed to shuffle play:", e);
|
||||
log.error("Failed to shuffle play:", e);
|
||||
toast.error("Failed to shuffle playlist");
|
||||
}
|
||||
}
|
||||
@@ -100,7 +103,7 @@
|
||||
playlist.name = trimmed;
|
||||
toast.success("Playlist renamed");
|
||||
} catch (e) {
|
||||
console.error("[PlaylistDetail] Failed to rename:", e);
|
||||
log.error("Failed to rename:", e);
|
||||
toast.error("Failed to rename playlist");
|
||||
editName = playlist.name;
|
||||
} finally {
|
||||
@@ -115,7 +118,7 @@
|
||||
toast.success("Playlist deleted");
|
||||
goto("/library");
|
||||
} catch (e) {
|
||||
console.error("[PlaylistDetail] Failed to delete:", e);
|
||||
log.error("Failed to delete:", e);
|
||||
toast.error("Failed to delete playlist");
|
||||
} finally {
|
||||
showDeleteConfirm = false;
|
||||
@@ -129,7 +132,7 @@
|
||||
entries = entries.filter(e => e.playlistItemId !== entry.playlistItemId);
|
||||
toast.success("Track removed");
|
||||
} catch (e) {
|
||||
console.error("[PlaylistDetail] Failed to remove track:", e);
|
||||
log.error("Failed to remove track:", e);
|
||||
toast.error("Failed to remove track");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import type { MediaItem, MediaKind, Person } from "$lib/api/types";
|
||||
import MediaCard from "./MediaCard.svelte";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("RelatedItemsSection");
|
||||
|
||||
interface Props {
|
||||
currentItemId: string;
|
||||
@@ -57,7 +60,7 @@
|
||||
return; // Success - return early
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to load similar items from API:", e);
|
||||
log.warn("Failed to load similar items from API:", e);
|
||||
// Fall through to genre-based loading
|
||||
}
|
||||
}
|
||||
@@ -78,7 +81,7 @@
|
||||
|
||||
items = result.items.filter(item => item.id !== currentItemId);
|
||||
} catch (e) {
|
||||
console.warn("Failed to load related items by genre:", e);
|
||||
log.warn("Failed to load related items by genre:", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +97,7 @@
|
||||
const artistAlbums = result.items.filter(item => item.id !== currentItemId);
|
||||
items = [...items, ...artistAlbums];
|
||||
} catch (e) {
|
||||
console.warn("Failed to load albums by artist:", e);
|
||||
log.warn("Failed to load albums by artist:", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +109,7 @@
|
||||
relatedItems = uniqueItems;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Failed to load related items";
|
||||
console.error("Error loading related items:", e);
|
||||
log.error("Error loading related items:", e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import { QUALITY_PRESETS, type QualityPreset } from "$lib/api/quality-presets";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("SeasonDownloadButton");
|
||||
|
||||
interface Props {
|
||||
seasonId: string;
|
||||
@@ -46,11 +49,11 @@
|
||||
try {
|
||||
const userId = $auth.user?.id;
|
||||
if (!userId) {
|
||||
console.error("No user ID found");
|
||||
log.error("No user ID found");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("📺 Starting season download for:", seasonName, "quality:", quality);
|
||||
log.debug("📺 Starting season download for:", seasonName, "quality:", quality);
|
||||
|
||||
// Get target directory
|
||||
const targetDir = await commands.storageGetPath();
|
||||
@@ -67,7 +70,7 @@
|
||||
quality
|
||||
);
|
||||
|
||||
console.log(`✅ Queued ${downloadIds.length} episodes for download`);
|
||||
log.debug(`✅ Queued ${downloadIds.length} episodes for download`);
|
||||
|
||||
// Pin the season item
|
||||
await downloads.pinItem(seasonId);
|
||||
@@ -77,9 +80,9 @@
|
||||
// rest as slots free up.
|
||||
const handle = auth.getRepository().getHandle();
|
||||
await commands.enqueueVideoDownloads(handle, downloadIds, targetDir);
|
||||
console.log(" Episodes enqueued; backend pump will start them");
|
||||
log.debug(" Episodes enqueued; backend pump will start them");
|
||||
} catch (error) {
|
||||
console.error("Failed to start season download:", error);
|
||||
log.error("Failed to start season download:", error);
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import { QUALITY_PRESETS, type QualityPreset } from "$lib/api/quality-presets";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("SeriesDownloadButton");
|
||||
|
||||
interface Props {
|
||||
seriesId: string;
|
||||
@@ -40,11 +43,11 @@
|
||||
try {
|
||||
const userId = $auth.user?.id;
|
||||
if (!userId) {
|
||||
console.error("No user ID found");
|
||||
log.error("No user ID found");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("📺 Starting series download for:", seriesName, "quality:", quality);
|
||||
log.debug("📺 Starting series download for:", seriesName, "quality:", quality);
|
||||
|
||||
// Get target directory
|
||||
const targetDir = await commands.storageGetPath();
|
||||
@@ -59,7 +62,7 @@
|
||||
quality
|
||||
);
|
||||
|
||||
console.log(` Queued ${downloadIds.length} episodes for download`);
|
||||
log.debug(` Queued ${downloadIds.length} episodes for download`);
|
||||
|
||||
// Pin the series item
|
||||
await downloads.pinItem(seriesId);
|
||||
@@ -69,9 +72,9 @@
|
||||
// rest as slots free up.
|
||||
const handle = auth.getRepository().getHandle();
|
||||
await commands.enqueueVideoDownloads(handle, downloadIds, targetDir);
|
||||
console.log(" Episodes enqueued; backend pump will start them");
|
||||
log.debug(" Episodes enqueued; backend pump will start them");
|
||||
} catch (error) {
|
||||
console.error("Failed to start series download:", error);
|
||||
log.error("Failed to start series download:", error);
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
import AddToPlaylistModal from "$lib/components/playlist/AddToPlaylistModal.svelte";
|
||||
import { calculateMenuPosition, type MenuPosition } from "$lib/utils/menuPosition";
|
||||
import { formatDuration } from "$lib/utils/duration";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("TrackList");
|
||||
|
||||
/** Queue context for remote transfer - what type of queue is this? */
|
||||
export type QueueContext =
|
||||
@@ -55,7 +58,7 @@
|
||||
|
||||
// If this is an album, use the backend album command (more efficient)
|
||||
if (context && context.type === "album") {
|
||||
console.log(`[TrackList] Playing track: "${track.name}" (ID: ${track.id}, index in list: ${index})`);
|
||||
log.debug(`Playing track: "${track.name}" (ID: ${track.id}, index in list: ${index})`);
|
||||
await playerController.playAlbumTrack({
|
||||
albumId: context.albumId,
|
||||
albumName: context.albumName,
|
||||
@@ -91,7 +94,7 @@
|
||||
// Queue will auto-update from Rust backend event
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : 'Unknown error';
|
||||
console.error("Failed to play track:", errorMessage);
|
||||
log.error("Failed to play track:", errorMessage);
|
||||
toast.error(`Failed to play track: ${errorMessage}`, 5000);
|
||||
} finally {
|
||||
isPlayingTrack = null;
|
||||
@@ -145,9 +148,9 @@
|
||||
try {
|
||||
// Queue store now handles everything in Rust - just pass the track
|
||||
await queue.addToQueue(track, position);
|
||||
console.log(`Added "${track.name}" to queue (${position})`);
|
||||
log.debug(`Added "${track.name}" to queue (${position})`);
|
||||
} catch (e) {
|
||||
console.error("Failed to add to queue:", e);
|
||||
log.error("Failed to add to queue:", e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import { QUALITY_PRESETS, type QualityPreset } from "$lib/api/quality-presets";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("VideoDownloadButton");
|
||||
|
||||
interface Props {
|
||||
itemId: string;
|
||||
@@ -57,17 +60,17 @@
|
||||
try {
|
||||
const userId = $auth.user?.id;
|
||||
if (!userId) {
|
||||
console.error("No user ID found");
|
||||
log.error("No user ID found");
|
||||
return;
|
||||
}
|
||||
|
||||
const repo = auth.getRepository();
|
||||
|
||||
console.log("🎬 Starting video download for item:", itemId, "quality:", quality);
|
||||
log.debug("🎬 Starting video download for item:", itemId, "quality:", quality);
|
||||
|
||||
// Get stream URL based on quality
|
||||
const streamUrl = await repo.getVideoDownloadUrl(itemId, quality);
|
||||
console.log(" Stream URL obtained");
|
||||
log.debug(" Stream URL obtained");
|
||||
|
||||
// Get target directory
|
||||
const targetDir = await commands.storageGetPath();
|
||||
@@ -85,7 +88,7 @@
|
||||
filePath = `videos/${safeName}.mp4`;
|
||||
}
|
||||
|
||||
console.log(" File path:", filePath);
|
||||
log.debug(" File path:", filePath);
|
||||
|
||||
// Queue download with video metadata
|
||||
const downloadId = await downloads.downloadVideo(
|
||||
@@ -101,16 +104,16 @@
|
||||
episodeNumber,
|
||||
seasonNumber
|
||||
);
|
||||
console.log(" Download queued with ID:", downloadId);
|
||||
log.debug(" Download queued with ID:", downloadId);
|
||||
|
||||
// Pin the item metadata
|
||||
await downloads.pinItem(itemId);
|
||||
|
||||
// Actually start the download
|
||||
await commands.startDownload(downloadId, streamUrl, targetDir);
|
||||
console.log(" Download started");
|
||||
log.debug(" Download started");
|
||||
} catch (error) {
|
||||
console.error("Failed to start video download:", error);
|
||||
log.error("Failed to start video download:", error);
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { syncService } from "$lib/services/syncService";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("WatchedToggleButton");
|
||||
|
||||
interface Props {
|
||||
/** Episode, season or series id. */
|
||||
@@ -81,7 +84,7 @@
|
||||
} catch (e) {
|
||||
// Put the button back where it was — the change did not happen.
|
||||
optimistic = null;
|
||||
console.error("Failed to change watched state:", e);
|
||||
log.error("Failed to change watched state:", e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
import VolumeControl from "./VolumeControl.svelte";
|
||||
import CachedImage from "../common/CachedImage.svelte";
|
||||
import { currentQueueItem } from "$lib/stores/queue";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("AudioPlayer");
|
||||
|
||||
interface Props {
|
||||
media: MediaItem | null;
|
||||
@@ -130,7 +133,7 @@
|
||||
queue.skipTo(index);
|
||||
await playerController.skipTo(index);
|
||||
} catch (e) {
|
||||
console.error("Failed to skip to queue item:", e);
|
||||
log.error("Failed to skip to queue item:", e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -38,6 +38,9 @@
|
||||
import CastButton from "$lib/components/sessions/CastButton.svelte";
|
||||
import VolumeControl from "./VolumeControl.svelte";
|
||||
import CachedImage from "../common/CachedImage.svelte";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("MiniPlayer");
|
||||
|
||||
interface Props {
|
||||
media: MediaItem | null;
|
||||
@@ -159,7 +162,7 @@
|
||||
await playerController.seek(newPosition);
|
||||
haptics.tap();
|
||||
} catch (err) {
|
||||
console.error("Failed to seek:", err);
|
||||
log.error("Failed to seek:", err);
|
||||
toast.show("Failed to seek", "error");
|
||||
}
|
||||
}
|
||||
@@ -230,7 +233,7 @@
|
||||
// Vertical swipe
|
||||
if (Math.abs(diffY) > swipeThreshold && diffY > 0) {
|
||||
// Swiped up - Open full player
|
||||
console.log("[MiniPlayer] Swipe-up detected, expanding player");
|
||||
log.debug("Swipe-up detected, expanding player");
|
||||
haptics.tap();
|
||||
onExpand?.();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { queue } from "$lib/stores/queue";
|
||||
import CachedImage from "../common/CachedImage.svelte";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("QueueView");
|
||||
|
||||
interface Props {
|
||||
items: MediaItem[];
|
||||
@@ -82,7 +85,7 @@
|
||||
// Sync with backend
|
||||
await playerController.moveInQueue(fromIndex, toIndex);
|
||||
} catch (e) {
|
||||
console.error("Failed to move queue item:", e);
|
||||
log.error("Failed to move queue item:", e);
|
||||
// The store already updated optimistically, refresh if needed
|
||||
}
|
||||
}
|
||||
@@ -109,7 +112,7 @@
|
||||
queue.removeFromQueue(index);
|
||||
await playerController.removeFromQueue(index);
|
||||
} catch (err) {
|
||||
console.error("Failed to remove from queue:", err);
|
||||
log.error("Failed to remove from queue:", err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -75,6 +75,9 @@
|
||||
planHandoffReturn,
|
||||
type BackgroundAudioState,
|
||||
} from "./backgroundAudioHandoff";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("VideoPlayer");
|
||||
|
||||
interface Props {
|
||||
media: MediaItem | null;
|
||||
@@ -276,14 +279,22 @@
|
||||
});
|
||||
|
||||
|
||||
// Get available audio tracks from media streams
|
||||
// The audio tracks available for this item, as the server described them.
|
||||
//
|
||||
// Jellyfin has no separate "audio tracks" endpoint: the tracks arrive on the
|
||||
// item itself, in `MediaStreams`, which the backend asks for by name in
|
||||
// `get_item`'s `Fields=` list and tags with an opaque `kind`. Selecting by
|
||||
// that tag rather than by Jellyfin's `Type` string keeps the taxonomy on the
|
||||
// Rust side of the boundary.
|
||||
//
|
||||
// TRACES: UR-021 | IR-016, JA-009 | DR-024
|
||||
const audioTracks = $derived(() => {
|
||||
if (!media || !media.mediaStreams) {
|
||||
console.log("[VideoPlayer] No media or mediaStreams available");
|
||||
log.debug("No media or mediaStreams available");
|
||||
return [];
|
||||
}
|
||||
const tracks = media.mediaStreams.filter(stream => stream.kind === "audio");
|
||||
console.log("[VideoPlayer] Found audio tracks:", tracks.length, tracks);
|
||||
log.debug("Found audio tracks:", tracks.length, tracks);
|
||||
return tracks;
|
||||
});
|
||||
|
||||
@@ -296,7 +307,7 @@
|
||||
if (preference.audioTrackDisplayTitle) {
|
||||
const match = tracks.find(t => t.displayTitle === preference.audioTrackDisplayTitle);
|
||||
if (match) {
|
||||
console.log("[VideoPlayer] Matched audio track by display title:", match.displayTitle);
|
||||
log.debug("Matched audio track by display title:", match.displayTitle);
|
||||
return match.index;
|
||||
}
|
||||
}
|
||||
@@ -305,14 +316,14 @@
|
||||
if (preference.audioTrackLanguage) {
|
||||
const match = tracks.find(t => t.language === preference.audioTrackLanguage);
|
||||
if (match) {
|
||||
console.log("[VideoPlayer] Matched audio track by language:", match.language);
|
||||
log.debug("Matched audio track by language:", match.language);
|
||||
return match.index;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to default track
|
||||
const defaultTrack = tracks.find(t => t.isDefault) || tracks[0];
|
||||
console.log("[VideoPlayer] Using default/first audio track:", defaultTrack.displayTitle || defaultTrack.language);
|
||||
log.debug("Using default/first audio track:", defaultTrack.displayTitle || defaultTrack.language);
|
||||
return defaultTrack.index;
|
||||
}
|
||||
|
||||
@@ -327,15 +338,15 @@
|
||||
const preference = await commands.storageGetSeriesAudioPreference(userId, media.seriesId);
|
||||
|
||||
if (preference) {
|
||||
console.log("[VideoPlayer] Loaded series audio preference:", preference);
|
||||
log.debug("Loaded series audio preference:", preference);
|
||||
const matchedIndex = findBestAudioTrack(preference);
|
||||
if (matchedIndex !== null) {
|
||||
selectedAudioTrackIndex = matchedIndex;
|
||||
console.log("[VideoPlayer] Applied series audio preference, track index:", matchedIndex);
|
||||
log.debug("Applied series audio preference, track index:", matchedIndex);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[VideoPlayer] Failed to load series audio preference:", err);
|
||||
log.warn("Failed to load series audio preference:", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,11 +358,11 @@
|
||||
// TRACES: UR-020 | DR-176 | UT-168
|
||||
const subtitleTracks = $derived(() => {
|
||||
if (!media || !media.mediaStreams) {
|
||||
console.log("[VideoPlayer] No media or mediaStreams available for subtitles");
|
||||
log.debug("No media or mediaStreams available for subtitles");
|
||||
return [];
|
||||
}
|
||||
const tracks = subtitleStreamsOf(media.mediaStreams);
|
||||
console.log("[VideoPlayer] Found subtitle tracks:", tracks.length, tracks);
|
||||
log.debug("Found subtitle tracks:", tracks.length, tracks);
|
||||
return tracks;
|
||||
});
|
||||
|
||||
@@ -539,7 +550,7 @@
|
||||
if (isHlsStream && Hls.isSupported()) {
|
||||
// Clean up existing HLS instance if any - CRITICAL for preventing dual audio
|
||||
if (hls) {
|
||||
console.log('[VideoPlayer] Cleaning up existing HLS instance');
|
||||
log.debug('Cleaning up existing HLS instance');
|
||||
// Detach from media element first to stop all audio/video
|
||||
hls.detachMedia();
|
||||
// Stop loading and flush buffers
|
||||
@@ -563,7 +574,7 @@
|
||||
setTimeout(() => {
|
||||
if (!videoElement) return;
|
||||
|
||||
console.log('[VideoPlayer] Creating new HLS instance for:', currentStreamUrl);
|
||||
log.debug('Creating new HLS instance for:', currentStreamUrl);
|
||||
|
||||
// Create new HLS instance
|
||||
hls = new Hls({
|
||||
@@ -591,14 +602,14 @@
|
||||
|
||||
// Listen for media attached event
|
||||
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||
console.log('[VideoPlayer] HLS.js attached to video element');
|
||||
log.debug('HLS.js attached to video element');
|
||||
// Load the HLS stream
|
||||
hls!.loadSource(currentStreamUrl);
|
||||
});
|
||||
|
||||
// Listen for manifest parsed event
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
console.log('[VideoPlayer] HLS manifest parsed, ready to play');
|
||||
log.debug('HLS manifest parsed, ready to play');
|
||||
});
|
||||
|
||||
// On the Android WebView the element's own `canplay` may not fire for
|
||||
@@ -615,7 +626,7 @@
|
||||
if (canplayFallbackTimeout) clearTimeout(canplayFallbackTimeout);
|
||||
canplayFallbackTimeout = setTimeout(() => {
|
||||
if (!isMediaReady && videoElement && videoElement.readyState >= 2) {
|
||||
console.warn('[VideoPlayer] HLS canplay fallback - revealing video (readyState:', videoElement.readyState, ')');
|
||||
log.warn('HLS canplay fallback - revealing video (readyState:', videoElement.readyState, ')');
|
||||
markMediaReady();
|
||||
}
|
||||
}, 5000);
|
||||
@@ -625,7 +636,7 @@
|
||||
|
||||
// Handle errors
|
||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
console.error('[VideoPlayer] HLS error:', data);
|
||||
log.error('HLS error:', data);
|
||||
if (data.fatal) {
|
||||
// Is this the stream ending or the stream breaking? Jellyfin's
|
||||
// transcoded HLS doesn't always emit #EXT-X-ENDLIST, so both arrive
|
||||
@@ -642,25 +653,25 @@
|
||||
attempts: hlsFatalRecoveryAttempts,
|
||||
})) {
|
||||
case 'ended':
|
||||
console.log('[VideoPlayer] Fatal network error near end of stream - treating as ended');
|
||||
log.debug('Fatal network error near end of stream - treating as ended');
|
||||
notifyEnded();
|
||||
break;
|
||||
case 'retry':
|
||||
console.error('[VideoPlayer] Fatal network error, trying to recover (attempt', hlsFatalRecoveryAttempts, ')');
|
||||
log.error('Fatal network error, trying to recover (attempt', hlsFatalRecoveryAttempts, ')');
|
||||
hls!.startLoad();
|
||||
break;
|
||||
case 'giveUp':
|
||||
console.error('[VideoPlayer] Fatal network error, max recovery attempts reached');
|
||||
log.error('Fatal network error, max recovery attempts reached');
|
||||
hls!.destroy();
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
console.error('[VideoPlayer] Fatal media error, trying to recover');
|
||||
log.error('Fatal media error, trying to recover');
|
||||
hls!.recoverMediaError();
|
||||
break;
|
||||
default:
|
||||
console.error('[VideoPlayer] Unrecoverable HLS error');
|
||||
log.error('Unrecoverable HLS error');
|
||||
hls!.destroy();
|
||||
break;
|
||||
}
|
||||
@@ -670,7 +681,7 @@
|
||||
|
||||
// Cleanup on effect re-run
|
||||
return () => {
|
||||
console.log('[VideoPlayer] Effect cleanup: destroying HLS instance');
|
||||
log.debug('Effect cleanup: destroying HLS instance');
|
||||
if (hls) {
|
||||
hls.detachMedia();
|
||||
hls.stopLoad();
|
||||
@@ -683,11 +694,11 @@
|
||||
};
|
||||
} else if (isHlsStream && videoElement.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
// Native HLS support (Safari)
|
||||
console.log('[VideoPlayer] Using native HLS support');
|
||||
log.debug('Using native HLS support');
|
||||
videoElement.src = currentStreamUrl;
|
||||
} else {
|
||||
// Not an HLS stream, use regular video element
|
||||
console.log('[VideoPlayer] Using regular video element for non-HLS stream');
|
||||
log.debug('Using regular video element for non-HLS stream');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -696,24 +707,24 @@
|
||||
if (videoElement) {
|
||||
videoElement.muted = false;
|
||||
videoElement.volume = 1.0;
|
||||
console.log("[VideoPlayer] Video element configured: muted=", videoElement.muted, "volume=", videoElement.volume);
|
||||
log.debug("Video element configured: muted=", videoElement.muted, "volume=", videoElement.volume);
|
||||
|
||||
// DIAGNOSTIC: Check if video has audio tracks
|
||||
if ((videoElement as any).audioTracks) {
|
||||
console.log("[VideoPlayer] Audio tracks count:", (videoElement as any).audioTracks.length);
|
||||
log.debug("Audio tracks count:", (videoElement as any).audioTracks.length);
|
||||
|
||||
// Set initial audio track (prefer default track)
|
||||
if (selectedAudioTrackIndex === null && audioTracks().length > 0) {
|
||||
const defaultTrack = audioTracks().find(t => t.isDefault);
|
||||
selectedAudioTrackIndex = defaultTrack ? defaultTrack.index : audioTracks()[0].index;
|
||||
console.log("[VideoPlayer] Selected default audio track:", selectedAudioTrackIndex);
|
||||
log.debug("Selected default audio track:", selectedAudioTrackIndex);
|
||||
}
|
||||
}
|
||||
if ((videoElement as any).mozHasAudio !== undefined) {
|
||||
console.log("[VideoPlayer] mozHasAudio:", (videoElement as any).mozHasAudio);
|
||||
log.debug("mozHasAudio:", (videoElement as any).mozHasAudio);
|
||||
}
|
||||
if ((videoElement as any).webkitAudioDecodedByteCount !== undefined) {
|
||||
console.log("[VideoPlayer] webkitAudioDecodedByteCount:", (videoElement as any).webkitAudioDecodedByteCount);
|
||||
log.debug("webkitAudioDecodedByteCount:", (videoElement as any).webkitAudioDecodedByteCount);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -741,7 +752,7 @@
|
||||
return;
|
||||
}
|
||||
untrack(() => {
|
||||
console.log("[VideoPlayer] Initial position changed, seeking to:", pos);
|
||||
log.debug("Initial position changed, seeking to:", pos);
|
||||
lastAppliedInitialPosition = pos;
|
||||
if (videoElement) {
|
||||
videoElement.currentTime = pos;
|
||||
@@ -767,7 +778,7 @@
|
||||
selectedQuality = settings.streamingQuality ?? "original";
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("[VideoPlayer] Failed to load streaming qualities:", err);
|
||||
log.warn("Failed to load streaming qualities:", err);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -800,8 +811,8 @@
|
||||
// Initialize player via Rust - Rust will decide which backend to use based on platform
|
||||
if (media && currentStreamUrl) {
|
||||
try {
|
||||
console.log("[VideoPlayer] Initializing player for:", media.name);
|
||||
console.log("[VideoPlayer] Stream URL:", currentStreamUrl);
|
||||
log.debug("Initializing player for:", media.name);
|
||||
log.debug("Stream URL:", currentStreamUrl);
|
||||
|
||||
// Resolve subtitle URLs for the native (ExoPlayer) path. These must be
|
||||
// in hand *before* the play request: ExoPlayer sideloads subtitles as
|
||||
@@ -819,7 +830,7 @@
|
||||
sentSubtitleTracks = mediaSourceId
|
||||
? await resolveSubtitleTracks(media.mediaStreams, (index) => getSubtitleUrl(index))
|
||||
: [];
|
||||
console.log(`[VideoPlayer] Sending ${sentSubtitleTracks.length} subtitle tracks to the backend`);
|
||||
log.debug(`Sending ${sentSubtitleTracks.length} subtitle tracks to the backend`);
|
||||
|
||||
// Call Rust backend to start playback
|
||||
// Rust will choose ExoPlayer (Android), libmpv (Linux), or tell us to use HTML5
|
||||
@@ -839,7 +850,7 @@
|
||||
// Rust tells us which backend it's using
|
||||
useHtml5Element = response.useHtml5Element;
|
||||
backendChosen = true;
|
||||
console.log(`[VideoPlayer] Backend: ${response.backend}, useHtml5Element: ${useHtml5Element}`);
|
||||
log.debug(`Backend: ${response.backend}, useHtml5Element: ${useHtml5Element}`);
|
||||
|
||||
// Rust reported a native backend (Android/ExoPlayer). Honour it only if
|
||||
// the user opted into the experimental native path; otherwise fall back
|
||||
@@ -850,13 +861,13 @@
|
||||
// just started, or ExoPlayer and the <video> element both decode the
|
||||
// same stream and the audio doubles.
|
||||
if (!useHtml5Element && !$experimentalNativeVideo) {
|
||||
console.log("[VideoPlayer] Native backend available but experimentalNativeVideo is off - using HTML5");
|
||||
log.debug("Native backend available but experimentalNativeVideo is off - using HTML5");
|
||||
useHtml5Element = true;
|
||||
try {
|
||||
await commands.playerStop();
|
||||
didStopBackendEarly = true;
|
||||
} catch (err) {
|
||||
console.warn("[VideoPlayer] Failed to stop native backend:", err);
|
||||
log.warn("Failed to stop native backend:", err);
|
||||
}
|
||||
} else if (!useHtml5Element) {
|
||||
// Native path: clear the opaque layers between the viewport and the
|
||||
@@ -864,7 +875,7 @@
|
||||
// Paired with disableNativeVideoCompositing() in the teardown path —
|
||||
// leaving this on renders the rest of the app over a transparent
|
||||
// window.
|
||||
console.log("[VideoPlayer] Using native ExoPlayer video surface");
|
||||
log.debug("Using native ExoPlayer video surface");
|
||||
enableNativeVideoCompositing();
|
||||
}
|
||||
|
||||
@@ -872,14 +883,14 @@
|
||||
// For transcoded content, we need to keep the backend running to handle seeking/audio track switching
|
||||
if (useHtml5Element && !needsTranscoding && !didStopBackendEarly) {
|
||||
try {
|
||||
console.log("[VideoPlayer] Using HTML5 for direct stream - stopping backend player to prevent dual audio");
|
||||
log.debug("Using HTML5 for direct stream - stopping backend player to prevent dual audio");
|
||||
await commands.playerStop();
|
||||
didStopBackendEarly = true; // Track that we stopped the backend
|
||||
} catch (err) {
|
||||
console.warn("[VideoPlayer] Failed to stop backend player:", err);
|
||||
log.warn("Failed to stop backend player:", err);
|
||||
}
|
||||
} else if (useHtml5Element && needsTranscoding) {
|
||||
console.log("[VideoPlayer] Using HTML5 for transcoded stream - keeping backend for seeking/transcoding decisions");
|
||||
log.debug("Using HTML5 for transcoded stream - keeping backend for seeking/transcoding decisions");
|
||||
// Backend is kept running but should not play audio since HTML5 element handles playback
|
||||
didStartNativePlayback = true; // Track that we need to stop backend on unmount
|
||||
}
|
||||
@@ -964,12 +975,12 @@
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[VideoPlayer] Failed to initialize player:", err);
|
||||
log.error("Failed to initialize player:", err);
|
||||
if (backendChosen) {
|
||||
// The backend already accepted the item; a later error (e.g. event
|
||||
// subscription) must not silently switch the seek/controls path to
|
||||
// HTML5 while the native backend keeps playing.
|
||||
console.warn("[VideoPlayer] Backend already initialized - keeping native mode despite error");
|
||||
log.warn("Backend already initialized - keeping native mode despite error");
|
||||
} else {
|
||||
// Fallback to HTML5 on error
|
||||
useHtml5Element = true;
|
||||
@@ -1034,8 +1045,8 @@
|
||||
// Flattened to a single string on purpose: the Android WebView console
|
||||
// bridge stringifies objects as "[object Object]" in logcat, which made
|
||||
// this whole payload useless when diagnosing over adb.
|
||||
console.log(
|
||||
`[VideoPlayer Debug] t=${videoElement.currentTime.toFixed(2)}` +
|
||||
log.debug(
|
||||
`Debug t=${videoElement.currentTime.toFixed(2)}` +
|
||||
` display=${currentTime.toFixed(2)}` +
|
||||
` readyState=${videoElement.readyState}` +
|
||||
` networkState=${videoElement.networkState}` +
|
||||
@@ -1103,7 +1114,7 @@
|
||||
|
||||
// Clean up HLS.js instance - prevent dual audio on unmount
|
||||
if (hls) {
|
||||
console.log("[VideoPlayer] Destroying HLS.js instance on unmount");
|
||||
log.debug("Destroying HLS.js instance on unmount");
|
||||
hls.detachMedia(); // Detach from video element first
|
||||
hls.stopLoad(); // Stop loading and flush buffers
|
||||
hls.destroy();
|
||||
@@ -1121,10 +1132,10 @@
|
||||
// Skip if we already stopped the backend early (non-transcoded + HTML5)
|
||||
if (didStartNativePlayback && !didStopBackendEarly) {
|
||||
try {
|
||||
console.log("[VideoPlayer] Stopping backend player on component unmount");
|
||||
log.debug("Stopping backend player on component unmount");
|
||||
await commands.playerStop();
|
||||
} catch (err) {
|
||||
console.error("[VideoPlayer] Failed to stop backend player:", err);
|
||||
log.error("Failed to stop backend player:", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1179,20 +1190,20 @@
|
||||
}
|
||||
|
||||
function handleLoadedMetadata() {
|
||||
console.log("[VideoPlayer] loadedmetadata event");
|
||||
log.debug("loadedmetadata event");
|
||||
// Intrinsic dimensions are known now, which is what PiP sizes its window
|
||||
// from — before this they are 0 and the ratio would be rejected. (DR-160)
|
||||
reportPipVideoState();
|
||||
console.log("[VideoPlayer] Video element duration:", videoElement?.duration);
|
||||
console.log("[VideoPlayer] Media item runTimeTicks:", media?.runTimeTicks);
|
||||
console.log("[VideoPlayer] Needs transcoding:", needsTranscoding);
|
||||
log.debug("Video element duration:", videoElement?.duration);
|
||||
log.debug("Media item runTimeTicks:", media?.runTimeTicks);
|
||||
log.debug("Needs transcoding:", needsTranscoding);
|
||||
|
||||
// For direct streams without runTimeTicks, use video element's duration
|
||||
if (videoElement && videoElement.duration && !isNaN(videoElement.duration) && videoElement.duration !== Infinity) {
|
||||
const newDuration = videoElement.duration;
|
||||
console.log("[VideoPlayer] Setting videoDuration to:", newDuration);
|
||||
log.debug("Setting videoDuration to:", newDuration);
|
||||
videoDuration = newDuration;
|
||||
console.log("[VideoPlayer] videoDuration state is now:", videoDuration);
|
||||
log.debug("videoDuration state is now:", videoDuration);
|
||||
}
|
||||
|
||||
// Tell the Rust controller the media is loaded and its duration (mirrors the
|
||||
@@ -1201,8 +1212,8 @@
|
||||
|
||||
// Use setTimeout to log the derived value after reactive updates
|
||||
setTimeout(() => {
|
||||
console.log("[VideoPlayer] Derived duration value:", duration);
|
||||
console.log("[VideoPlayer] Duration source:", media?.runTimeTicks ? "runTimeTicks" : "video element");
|
||||
log.debug("Derived duration value:", duration);
|
||||
log.debug("Duration source:", media?.runTimeTicks ? "runTimeTicks" : "video element");
|
||||
}, 0);
|
||||
}
|
||||
|
||||
@@ -1243,15 +1254,15 @@
|
||||
el.volume = 1.0;
|
||||
if (shouldPlay) await el.play();
|
||||
} catch (err) {
|
||||
console.error("[VideoPlayer] Failed to resume after background audio:", err);
|
||||
log.error("Failed to resume after background audio:", err);
|
||||
}
|
||||
};
|
||||
|
||||
if (el.readyState >= 1 /* HAVE_METADATA */) {
|
||||
console.log("[VideoPlayer] Applying foreground seek to:", (seekOffset + seekTo).toFixed(1));
|
||||
log.debug("Applying foreground seek to:", (seekOffset + seekTo).toFixed(1));
|
||||
await doSeek();
|
||||
} else {
|
||||
console.log("[VideoPlayer] Deferring foreground seek until loadedmetadata:", (seekOffset + seekTo).toFixed(1));
|
||||
log.debug("Deferring foreground seek until loadedmetadata:", (seekOffset + seekTo).toFixed(1));
|
||||
el.addEventListener("loadedmetadata", () => { void doSeek(); }, { once: true });
|
||||
}
|
||||
return true;
|
||||
@@ -1259,7 +1270,7 @@
|
||||
|
||||
function markMediaReady() {
|
||||
if (isMediaReady) return;
|
||||
console.log("[VideoPlayer] Marking media ready");
|
||||
log.debug("Marking media ready");
|
||||
isMediaReady = true;
|
||||
// A handoff return can be revealed here (not via canplay) — apply its seek.
|
||||
void applyPendingForegroundSeek();
|
||||
@@ -1267,14 +1278,14 @@
|
||||
|
||||
async function handleCanPlay() {
|
||||
// Media is ready to play - transition from Loading to Playing state (DR-001)
|
||||
console.log("[VideoPlayer] canplay event fired - media is ready");
|
||||
log.debug("canplay event fired - media is ready");
|
||||
isMediaReady = true;
|
||||
|
||||
// Ensure video is unmuted and at max volume (critical for Android)
|
||||
if (videoElement) {
|
||||
videoElement.muted = false;
|
||||
videoElement.volume = 1.0;
|
||||
console.log("[VideoPlayer] Video unmuted on canplay, volume: 1.0");
|
||||
log.debug("Video unmuted on canplay, volume: 1.0");
|
||||
}
|
||||
|
||||
// Returning from background audio: resume the <video> at the position native
|
||||
@@ -1286,7 +1297,7 @@
|
||||
|
||||
// Seek to initial position if resuming playback
|
||||
if (initialPosition && initialPosition > 0 && !hasPerformedInitialSeek && videoElement) {
|
||||
console.log("[VideoPlayer] Seeking to initial position:", initialPosition);
|
||||
log.debug("Seeking to initial position:", initialPosition);
|
||||
hasPerformedInitialSeek = true;
|
||||
lastAppliedInitialPosition = initialPosition; // mark this value as applied so the change-effect ignores it
|
||||
|
||||
@@ -1317,7 +1328,7 @@
|
||||
await videoElement.play();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[VideoPlayer] Failed to seek to initial position:", err);
|
||||
log.error("Failed to seek to initial position:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1327,7 +1338,7 @@
|
||||
const error = video.error;
|
||||
|
||||
// Log comprehensive error details
|
||||
console.error("[VideoPlayer] Video error event:", {
|
||||
log.error("Video error event:", {
|
||||
code: error?.code,
|
||||
message: error?.message,
|
||||
networkState: video.networkState,
|
||||
@@ -1346,28 +1357,28 @@
|
||||
|
||||
const errorCode = error?.code || 0;
|
||||
const msg = errorMessages[errorCode] || `Unknown error (code ${errorCode})`;
|
||||
console.error("[VideoPlayer] Error interpretation:", msg);
|
||||
log.error("Error interpretation:", msg);
|
||||
|
||||
// Log additional debugging info
|
||||
console.error("[VideoPlayer] Stream URL:", currentStreamUrl);
|
||||
console.error("[VideoPlayer] Needs transcoding:", needsTranscoding);
|
||||
log.error("Stream URL:", currentStreamUrl);
|
||||
log.error("Needs transcoding:", needsTranscoding);
|
||||
|
||||
// Network state meanings: 0=EMPTY, 1=IDLE, 2=LOADING, 3=NO_SOURCE
|
||||
const networkStates = ["NETWORK_EMPTY", "NETWORK_IDLE", "NETWORK_LOADING", "NETWORK_NO_SOURCE"];
|
||||
console.error("[VideoPlayer] Network state:", networkStates[video.networkState] || video.networkState);
|
||||
log.error("Network state:", networkStates[video.networkState] || video.networkState);
|
||||
|
||||
// Ready state meanings: 0=NOTHING, 1=METADATA, 2=CURRENT_DATA, 3=FUTURE_DATA, 4=ENOUGH_DATA
|
||||
const readyStates = ["HAVE_NOTHING", "HAVE_METADATA", "HAVE_CURRENT_DATA", "HAVE_FUTURE_DATA", "HAVE_ENOUGH_DATA"];
|
||||
console.error("[VideoPlayer] Ready state:", readyStates[video.readyState] || video.readyState);
|
||||
log.error("Ready state:", readyStates[video.readyState] || video.readyState);
|
||||
}
|
||||
|
||||
function handleWaiting() {
|
||||
console.log("[VideoPlayer] waiting event - buffering");
|
||||
log.debug("waiting event - buffering");
|
||||
isBuffering = true;
|
||||
}
|
||||
|
||||
function handlePlaying() {
|
||||
console.log("[VideoPlayer] playing event - playback resumed");
|
||||
log.debug("playing event - playback resumed");
|
||||
isBuffering = false;
|
||||
// Safety net: if we reached `playing` we are definitely renderable, even if
|
||||
// `canplay`/hls FRAG_BUFFERED were missed on this WebView.
|
||||
@@ -1375,9 +1386,9 @@
|
||||
}
|
||||
|
||||
function handleLoadStart() {
|
||||
console.log("[VideoPlayer] loadstart event - starting to load:", currentStreamUrl);
|
||||
console.log("[VideoPlayer] Video element readyState:", videoElement?.readyState);
|
||||
console.log("[VideoPlayer] Video element networkState:", videoElement?.networkState);
|
||||
log.debug("loadstart event - starting to load:", currentStreamUrl);
|
||||
log.debug("Video element readyState:", videoElement?.readyState);
|
||||
log.debug("Video element networkState:", videoElement?.networkState);
|
||||
|
||||
// Clear any existing fallback timeout
|
||||
if (canplayFallbackTimeout) {
|
||||
@@ -1387,12 +1398,12 @@
|
||||
// Set up a fallback timeout in case canplay event never fires
|
||||
canplayFallbackTimeout = setTimeout(() => {
|
||||
if (!isMediaReady && videoElement) {
|
||||
console.warn("[VideoPlayer] canplay event did not fire within 5 seconds");
|
||||
console.log("[VideoPlayer] Fallback check - readyState:", videoElement.readyState, "networkState:", videoElement.networkState);
|
||||
log.warn("canplay event did not fire within 5 seconds");
|
||||
log.debug("Fallback check - readyState:", videoElement.readyState, "networkState:", videoElement.networkState);
|
||||
|
||||
// Check if video is actually ready despite event not firing
|
||||
if (videoElement.readyState >= 3) { // HAVE_FUTURE_DATA or HAVE_ENOUGH_DATA
|
||||
console.log("[VideoPlayer] Video appears ready (readyState >= 3), forcing media ready state");
|
||||
log.debug("Video appears ready (readyState >= 3), forcing media ready state");
|
||||
markMediaReady();
|
||||
}
|
||||
}
|
||||
@@ -1422,7 +1433,7 @@
|
||||
jrayActors = actors;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[VideoPlayer] JRay lookup failed:", err);
|
||||
log.warn("JRay lookup failed:", err);
|
||||
if (token === jrayRequestId) jrayActors = [];
|
||||
}
|
||||
}
|
||||
@@ -1500,8 +1511,8 @@
|
||||
// reason. Log the element state so an unexplained pause/resume loop can be
|
||||
// attributed from an adb capture instead of guessed at.
|
||||
const el = videoElement;
|
||||
console.log(
|
||||
`[VideoPlayer] pause event — t=${el ? el.currentTime.toFixed(2) : "?"}` +
|
||||
log.debug(
|
||||
`pause event — t=${el ? el.currentTime.toFixed(2) : "?"}` +
|
||||
` readyState=${el?.readyState}` +
|
||||
` networkState=${el?.networkState}` +
|
||||
` seeking=${el?.seeking}` +
|
||||
@@ -1545,7 +1556,7 @@
|
||||
try {
|
||||
await playerController.toggle();
|
||||
} catch (err) {
|
||||
console.error("[VideoPlayer] Failed to toggle playback:", err);
|
||||
log.error("Failed to toggle playback:", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1587,7 +1598,7 @@
|
||||
isDraggingSeekBar = false;
|
||||
|
||||
try {
|
||||
console.log("[VideoPlayer] Seeking to:", targetTime.toFixed(2));
|
||||
log.debug("Seeking to:", targetTime.toFixed(2));
|
||||
|
||||
// Optimistic display; the primitive updates currentTime/seekOffset as it
|
||||
// completes (reloadSource drives the stream URL via the adapter bridge).
|
||||
@@ -1609,9 +1620,9 @@
|
||||
startTimeUpdates();
|
||||
}
|
||||
|
||||
console.log("[VideoPlayer] Seek completed at:", currentTime.toFixed(2), "offset:", seekOffset);
|
||||
log.debug("Seek completed at:", currentTime.toFixed(2), "offset:", seekOffset);
|
||||
} catch (err) {
|
||||
console.error("[VideoPlayer] Seek failed:", err);
|
||||
log.error("Seek failed:", err);
|
||||
} finally {
|
||||
isSeeking = false;
|
||||
isDraggingSeekBar = false;
|
||||
@@ -1646,12 +1657,12 @@
|
||||
|
||||
function toggleBackgroundAudio() {
|
||||
backgroundAudioOn = !backgroundAudioOn;
|
||||
console.log("[VideoPlayer] Background-audio toggle ->", backgroundAudioOn);
|
||||
log.debug("Background-audio toggle ->", backgroundAudioOn);
|
||||
// Arm/disarm native background-audio mode AND flip auto-PiP the other way,
|
||||
// so exactly one background behavior is active.
|
||||
const armed = setBackgroundAudioEnabled(backgroundAudioOn);
|
||||
if (!armed) {
|
||||
console.warn("[VideoPlayer] Background audio NOT armed natively (no bridge)");
|
||||
log.warn("Background audio NOT armed natively (no bridge)");
|
||||
}
|
||||
setAutoEnterEnabled(!backgroundAudioOn);
|
||||
}
|
||||
@@ -1667,7 +1678,7 @@
|
||||
// if the element is mid-teardown — which shipped audio starting from 0:00.
|
||||
const pos = computeHandoffPosition(currentTime, 0);
|
||||
const wasPlaying = isPlaying;
|
||||
console.log("[VideoPlayer] Background-audio handoff at position:", pos.toFixed(1));
|
||||
log.debug("Background-audio handoff at position:", pos.toFixed(1));
|
||||
handoffState = { active: true, wasPlaying };
|
||||
try {
|
||||
if (!media) return;
|
||||
@@ -1708,7 +1719,7 @@
|
||||
videoElement.load();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[VideoPlayer] Background-audio handoff failed:", err);
|
||||
log.error("Background-audio handoff failed:", err);
|
||||
handoffState = { ...initialHandoffState };
|
||||
}
|
||||
}
|
||||
@@ -1726,7 +1737,7 @@
|
||||
try {
|
||||
// Absolute position the native audio reached (base offset applied in Rust).
|
||||
const pos = await commands.playerExitBackgroundAudio();
|
||||
console.log("[VideoPlayer] Returning from background audio at:", pos.toFixed(1));
|
||||
log.debug("Returning from background audio at:", pos.toFixed(1));
|
||||
|
||||
isMediaReady = false;
|
||||
// The foreground seek below (pendingForegroundSeek/handleCanPlay) OWNS the
|
||||
@@ -1829,7 +1840,7 @@
|
||||
await Promise.resolve();
|
||||
currentStreamUrl = targetUrl;
|
||||
} catch (err) {
|
||||
console.error("[VideoPlayer] Background-audio return failed:", err);
|
||||
log.error("Background-audio return failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1848,7 +1859,7 @@
|
||||
// WebKitGTK rejects when the gesture isn't recognised as user-activated;
|
||||
// the immersive call below is what matters on Android, so don't let a
|
||||
// rejection here abort it.
|
||||
console.warn("[VideoPlayer] requestFullscreen rejected:", err);
|
||||
log.warn("requestFullscreen rejected:", err);
|
||||
});
|
||||
enterImmersive();
|
||||
isFullscreen = true;
|
||||
@@ -1898,7 +1909,7 @@
|
||||
});
|
||||
pendingSeekTarget = newTime;
|
||||
|
||||
console.log("[VideoPlayer] Relative seek:", {
|
||||
log.debug("Relative seek:", {
|
||||
offset: `${seconds > 0 ? "+" : ""}${seconds}s`,
|
||||
from: currentTime.toFixed(2),
|
||||
to: newTime.toFixed(2),
|
||||
@@ -2084,7 +2095,7 @@
|
||||
}
|
||||
|
||||
async function selectAudioTrack(streamIndex: number, arrayIndex: number) {
|
||||
console.log("[VideoPlayer] Selecting audio track - streamIndex:", streamIndex, "arrayIndex:", arrayIndex);
|
||||
log.debug("Selecting audio track - streamIndex:", streamIndex, "arrayIndex:", arrayIndex);
|
||||
const previousTrackIndex = selectedAudioTrackIndex;
|
||||
selectedAudioTrackIndex = streamIndex;
|
||||
showAudioTrackMenu = false;
|
||||
@@ -2105,7 +2116,7 @@
|
||||
startTimeUpdates();
|
||||
}
|
||||
|
||||
console.log("[VideoPlayer] Successfully changed audio track");
|
||||
log.debug("Successfully changed audio track");
|
||||
|
||||
// Save series audio preference for future episodes
|
||||
if (media && media.seriesId) {
|
||||
@@ -2124,14 +2135,14 @@
|
||||
selectedTrack.language || null,
|
||||
streamIndex
|
||||
);
|
||||
console.log("[VideoPlayer] Saved series audio preference:", selectedTrack.displayTitle || selectedTrack.language);
|
||||
log.debug("Saved series audio preference:", selectedTrack.displayTitle || selectedTrack.language);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[VideoPlayer] Failed to save series audio preference:", err);
|
||||
log.warn("Failed to save series audio preference:", err);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[VideoPlayer] Failed to change audio track:", err);
|
||||
log.error("Failed to change audio track:", err);
|
||||
// Revert to previous track on error
|
||||
selectedAudioTrackIndex = previousTrackIndex;
|
||||
}
|
||||
@@ -2169,9 +2180,9 @@
|
||||
if (videoElement && !videoElement.paused) {
|
||||
startTimeUpdates();
|
||||
}
|
||||
console.log("[VideoPlayer] Streaming quality changed:", quality);
|
||||
log.debug("Streaming quality changed:", quality);
|
||||
} catch (err) {
|
||||
console.error("[VideoPlayer] Failed to change streaming quality:", err);
|
||||
log.error("Failed to change streaming quality:", err);
|
||||
selectedQuality = previous;
|
||||
} finally {
|
||||
changingQuality = false;
|
||||
@@ -2202,7 +2213,7 @@
|
||||
const trackStreamIndex = parseInt(track.getAttribute("data-stream-index") || "-1");
|
||||
if (trackStreamIndex === streamIndex && track.track) {
|
||||
track.track.mode = "showing";
|
||||
console.log("[VideoPlayer] Enabled subtitle track:", streamIndex);
|
||||
log.debug("Enabled subtitle track:", streamIndex);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2222,7 +2233,7 @@
|
||||
* TRACES: UR-020 | DR-023, IR-016 | UT-147
|
||||
*/
|
||||
async function selectSubtitle(streamIndex: number | null) {
|
||||
console.log("[VideoPlayer] Selecting subtitle - streamIndex:", streamIndex);
|
||||
log.debug("Selecting subtitle - streamIndex:", streamIndex);
|
||||
selectedSubtitleIndex = streamIndex;
|
||||
showSubtitleMenu = false;
|
||||
|
||||
@@ -2234,9 +2245,9 @@
|
||||
try {
|
||||
const indexToUse = nativeSubtitleArrayIndex(sentSubtitleTracks, streamIndex);
|
||||
await commands.playerSetSubtitleTrack(indexToUse);
|
||||
console.log("[VideoPlayer] Native backend subtitle track changed - streamIndex:", streamIndex, "position:", indexToUse);
|
||||
log.debug("Native backend subtitle track changed - streamIndex:", streamIndex, "position:", indexToUse);
|
||||
} catch (error) {
|
||||
console.error("[VideoPlayer] Failed to set subtitle track:", error);
|
||||
log.error("Failed to set subtitle track:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
import { toast } from "$lib/stores/toast";
|
||||
import CreatePlaylistModal from "./CreatePlaylistModal.svelte";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("AddToPlaylist");
|
||||
|
||||
interface Props {
|
||||
isOpen?: boolean;
|
||||
@@ -39,7 +42,7 @@
|
||||
playlists = result.items;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[AddToPlaylist] Failed to load playlists:", e);
|
||||
log.error("Failed to load playlists:", e);
|
||||
toast.error("Failed to load playlists");
|
||||
} finally {
|
||||
loading = false;
|
||||
@@ -54,7 +57,7 @@
|
||||
toast.success(`Added to "${playlist.name}"`);
|
||||
onClose?.();
|
||||
} catch (e) {
|
||||
console.error("[AddToPlaylist] Failed to add:", e);
|
||||
log.error("Failed to add:", e);
|
||||
toast.error("Failed to add to playlist");
|
||||
} finally {
|
||||
adding = null;
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
import { goto } from "$app/navigation";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { toast } from "$lib/stores/toast";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("CreatePlaylist");
|
||||
|
||||
interface Props {
|
||||
isOpen?: boolean;
|
||||
@@ -27,7 +30,7 @@
|
||||
onClose?.();
|
||||
goto(`/library/${result.id}`);
|
||||
} catch (e) {
|
||||
console.error("[CreatePlaylist] Failed:", e);
|
||||
log.error("Failed:", e);
|
||||
toast.error("Failed to create playlist");
|
||||
} finally {
|
||||
creating = false;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user