Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d095e1f410 | ||
|
|
a7365b9511 | ||
|
|
16658889a2 | ||
|
|
98b2ede8bd | ||
|
|
38d56e6c89 | ||
|
|
4f4741cee5 | ||
|
|
20e2331560 | ||
|
|
bb140a8734 | ||
|
|
28b600304f | ||
|
|
8fbf4d92cb | ||
|
|
d32ca13d00 | ||
|
|
2a3f08f8a4 | ||
|
|
68ca1d585d | ||
|
|
0815445aa7 | ||
|
|
048c99ebcc | ||
|
|
34026d22b4 | ||
|
|
aeb29f916b | ||
|
|
f83c7ed1f0 | ||
|
|
b313b61717 | ||
|
|
fb6bd5cae1 | ||
|
|
da6b039b29 | ||
|
|
080cdbf383 | ||
|
|
6b7ce512ed | ||
|
|
55b37ba2f4 | ||
|
|
d52470e0cd | ||
|
|
e12f0065a6 | ||
|
|
63d4df0cde | ||
|
|
6b90582e3e | ||
|
|
ea3c765561 | ||
|
|
ac3cd67164 | ||
|
|
f5bee069c0 | ||
|
|
adcdadfcaf | ||
|
|
6406ca3fad | ||
|
|
4af6ed0f98 | ||
|
|
164157f98e | ||
|
|
95eb16d5ef | ||
|
|
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 | ||
|
|
73641e192c | ||
|
|
be907b4945 | ||
|
|
3b9a8ad695 | ||
|
|
ab95f5013d | ||
|
|
5e8efa252e | ||
|
|
1285908733 | ||
|
|
8e98e1c37a | ||
|
|
440d7a01a9 | ||
|
|
dccb5f53dd | ||
|
|
c142568230 | ||
|
|
95129d04a3 | ||
|
|
f0f98feae8 | ||
|
|
d9e1e256e9 | ||
|
|
42868fc2e6 | ||
|
|
c0c6c5023e | ||
|
|
521acc75fd | ||
|
|
886cbcb29a | ||
|
|
2cc39cd7fd | ||
|
|
e457a9884c | ||
|
|
de1c13e72f | ||
|
|
5096c01960 | ||
|
|
2d67b0e4f5 | ||
|
|
13264e225b | ||
|
|
041969f446 | ||
|
|
1a9805f0f3 | ||
|
|
82b6982d68 | ||
|
|
3363ff7f08 | ||
|
|
9858b7cb92 | ||
|
|
f46d7bf676 | ||
|
|
74bffea650 | ||
|
|
7e1f0e0547 | ||
|
|
e015c4c9b1 | ||
|
|
0861523015 | ||
|
|
1e599627b5 | ||
|
|
fa7cb6e908 |
@@ -0,0 +1,23 @@
|
|||||||
|
# Local Android release signing.
|
||||||
|
#
|
||||||
|
# Copy to `.env` and fill in. `.env` is gitignored and is the single source of
|
||||||
|
# truth for local release signing — scripts/write-keystore-properties.sh reads
|
||||||
|
# it and regenerates src-tauri/gen/android/keystore.properties before every
|
||||||
|
# release build, because `tauri android init` overwrites that file.
|
||||||
|
#
|
||||||
|
# Only needed for `bun run android:build:release`. Debug builds sign with the
|
||||||
|
# local debug keystore and need nothing here.
|
||||||
|
#
|
||||||
|
# CI does not use this file: build-release.yml reconstructs the keystore from
|
||||||
|
# the ANDROID_KEYSTORE_BASE64 secret and writes the same properties itself.
|
||||||
|
|
||||||
|
# Key alias inside the keystore.
|
||||||
|
ANDROID_KEY_ALIAS=jellytau
|
||||||
|
|
||||||
|
# Absolute path to the .jks. Keep it outside the repo, or in the gitignored
|
||||||
|
# android-keystore/ directory.
|
||||||
|
ANDROID_KEYSTORE_FILE=/absolute/path/to/jellytau-release.jks
|
||||||
|
|
||||||
|
# Keystore and key passwords. These are secrets — never commit the filled-in .env.
|
||||||
|
ANDROID_KEYSTORE_PASSWORD=
|
||||||
|
ANDROID_KEY_PASSWORD=
|
||||||
@@ -13,9 +13,19 @@ on:
|
|||||||
- '**/*.md'
|
- '**/*.md'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Incremental state is never reused between CI runs -- pure disk cost.
|
||||||
|
CARGO_INCREMENTAL: 0
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Run Tests
|
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
|
runs-on: linux/amd64
|
||||||
container:
|
container:
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||||
@@ -27,13 +37,22 @@ jobs:
|
|||||||
- name: Cache Rust dependencies
|
- name: Cache Rust dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
|
# Registry only -- never src-tauri/target. That directory is ~16 GB and
|
||||||
|
# was cached under five separate keys, which filled the runner's 74 GB
|
||||||
|
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
|
||||||
|
# registry/src is omitted too: cargo re-extracts it for free from
|
||||||
|
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
|
||||||
path: |
|
path: |
|
||||||
~/.cargo/registry
|
~/.cargo/registry/index
|
||||||
~/.cargo/git
|
~/.cargo/registry/cache
|
||||||
src-tauri/target
|
~/.cargo/git/db
|
||||||
key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
|
# One shared key across every job. The old per-job keys existed to stop
|
||||||
|
# debug/release target artifacts clobbering each other; with target no
|
||||||
|
# longer cached, registry contents are target-independent, so all jobs
|
||||||
|
# want the same crates. First job to finish saves; the rest restore.
|
||||||
|
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-cargo-host-
|
${{ runner.os }}-cargo-registry-
|
||||||
|
|
||||||
- name: Cache Node dependencies
|
- name: Cache Node dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
@@ -56,11 +75,41 @@ jobs:
|
|||||||
- name: Check frontend/backend boundary
|
- name: Check frontend/backend boundary
|
||||||
run: bash scripts/check-frontend-boundary.sh
|
run: bash scripts/check-frontend-boundary.sh
|
||||||
|
|
||||||
|
# The docs are the maintained source of truth for architecture and
|
||||||
|
# process, and they cross-reference each other heavily. A rename that
|
||||||
|
# misses a link turns a doc into a dead end silently. Pure shell + git —
|
||||||
|
# no tool is installed at job time.
|
||||||
|
- name: Check documentation links
|
||||||
|
run: bash scripts/check-doc-links.sh
|
||||||
|
|
||||||
- name: Run frontend tests
|
- name: Run frontend tests
|
||||||
run: |
|
run: |
|
||||||
bunx svelte-kit sync
|
bunx svelte-kit sync
|
||||||
bun run test
|
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
|
||||||
|
|
||||||
|
# Clippy is a hard gate. It was advisory while the tree carried a warning
|
||||||
|
# backlog; that backlog is gone (0 warnings on 1.97.1, the pinned
|
||||||
|
# toolchain), so a warning here is now new breakage rather than old noise.
|
||||||
|
#
|
||||||
|
# This only means anything because src-tauri/rust-toolchain.toml pins the
|
||||||
|
# compiler: clippy's lint set moves between releases, so an unpinned gate
|
||||||
|
# would fail on whatever the runner happened to install. The pin and this
|
||||||
|
# flag stand or fall together — if you unpin, drop this back to advisory.
|
||||||
|
- name: Run clippy
|
||||||
|
run: |
|
||||||
|
cd src-tauri
|
||||||
|
cargo clippy --all-targets -- -D warnings
|
||||||
|
|
||||||
- name: Run Rust tests
|
- name: Run Rust tests
|
||||||
run: |
|
run: |
|
||||||
cd src-tauri
|
cd src-tauri
|
||||||
@@ -93,13 +142,22 @@ jobs:
|
|||||||
- name: Cache Rust dependencies
|
- name: Cache Rust dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
|
# Registry only -- never src-tauri/target. That directory is ~16 GB and
|
||||||
|
# was cached under five separate keys, which filled the runner's 74 GB
|
||||||
|
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
|
||||||
|
# registry/src is omitted too: cargo re-extracts it for free from
|
||||||
|
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
|
||||||
path: |
|
path: |
|
||||||
~/.cargo/registry
|
~/.cargo/registry/index
|
||||||
~/.cargo/git
|
~/.cargo/registry/cache
|
||||||
src-tauri/target
|
~/.cargo/git/db
|
||||||
key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}
|
# One shared key across every job. The old per-job keys existed to stop
|
||||||
|
# debug/release target artifacts clobbering each other; with target no
|
||||||
|
# longer cached, registry contents are target-independent, so all jobs
|
||||||
|
# want the same crates. First job to finish saves; the rest restore.
|
||||||
|
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-cargo-android-
|
${{ runner.os }}-cargo-registry-
|
||||||
|
|
||||||
- name: Cache Node dependencies
|
- name: Cache Node dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ on:
|
|||||||
env:
|
env:
|
||||||
RUST_BACKTRACE: 1
|
RUST_BACKTRACE: 1
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
|
# Incremental state is never reused between CI runs -- pure disk cost.
|
||||||
|
CARGO_INCREMENTAL: 0
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
@@ -27,13 +29,22 @@ jobs:
|
|||||||
- name: Cache Rust dependencies
|
- name: Cache Rust dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
|
# Registry only -- never src-tauri/target. That directory is ~16 GB and
|
||||||
|
# was cached under five separate keys, which filled the runner's 74 GB
|
||||||
|
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
|
||||||
|
# registry/src is omitted too: cargo re-extracts it for free from
|
||||||
|
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
|
||||||
path: |
|
path: |
|
||||||
~/.cargo/registry
|
~/.cargo/registry/index
|
||||||
~/.cargo/git
|
~/.cargo/registry/cache
|
||||||
src-tauri/target
|
~/.cargo/git/db
|
||||||
key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
|
# One shared key across every job. The old per-job keys existed to stop
|
||||||
|
# debug/release target artifacts clobbering each other; with target no
|
||||||
|
# longer cached, registry contents are target-independent, so all jobs
|
||||||
|
# want the same crates. First job to finish saves; the rest restore.
|
||||||
|
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-cargo-host-
|
${{ runner.os }}-cargo-registry-
|
||||||
|
|
||||||
- name: Cache Node dependencies
|
- name: Cache Node dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
@@ -54,6 +65,22 @@ jobs:
|
|||||||
bun run test --run
|
bun run test --run
|
||||||
continue-on-error: false
|
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
|
- name: Run Rust tests
|
||||||
run: bun run test:rust
|
run: bun run test:rust
|
||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
@@ -75,13 +102,22 @@ jobs:
|
|||||||
- name: Cache Rust dependencies
|
- name: Cache Rust dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
|
# Registry only -- never src-tauri/target. That directory is ~16 GB and
|
||||||
|
# was cached under five separate keys, which filled the runner's 74 GB
|
||||||
|
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
|
||||||
|
# registry/src is omitted too: cargo re-extracts it for free from
|
||||||
|
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
|
||||||
path: |
|
path: |
|
||||||
~/.cargo/registry
|
~/.cargo/registry/index
|
||||||
~/.cargo/git
|
~/.cargo/registry/cache
|
||||||
src-tauri/target
|
~/.cargo/git/db
|
||||||
key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
|
# One shared key across every job. The old per-job keys existed to stop
|
||||||
|
# debug/release target artifacts clobbering each other; with target no
|
||||||
|
# longer cached, registry contents are target-independent, so all jobs
|
||||||
|
# want the same crates. First job to finish saves; the rest restore.
|
||||||
|
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-cargo-host-
|
${{ runner.os }}-cargo-registry-
|
||||||
|
|
||||||
- name: Cache Node dependencies
|
- name: Cache Node dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
@@ -110,13 +146,32 @@ jobs:
|
|||||||
- name: Prepare Linux artifacts
|
- name: Prepare Linux artifacts
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist/linux
|
mkdir -p dist/linux
|
||||||
# Copy AppImage
|
# Match by extension, not by product name. Bundle filenames follow
|
||||||
if [ -f "src-tauri/target/release/bundle/appimage/jellytau_"*.AppImage ]; then
|
# `productName`, so renaming the app (jellytau -> JellyTau) made the
|
||||||
cp src-tauri/target/release/bundle/appimage/jellytau_*.AppImage dist/linux/
|
# old `jellytau_*.deb` glob match nothing — and because the copy was
|
||||||
fi
|
# wrapped in `if [ -f ... ]`, the artifact simply vanished from the
|
||||||
# Copy .deb if built
|
# release with no error. Each bundle directory holds one file.
|
||||||
if [ -f "src-tauri/target/release/bundle/deb/jellytau_"*.deb ]; then
|
#
|
||||||
cp src-tauri/target/release/bundle/deb/jellytau_*.deb dist/linux/
|
# `if [ -f "dir/"*.ext ]` was also wrong on its own terms: with more
|
||||||
|
# than one match `test` gets extra arguments and fails.
|
||||||
|
#
|
||||||
|
# No `shopt -s nullglob` here: the runner executes `run:` blocks with
|
||||||
|
# POSIX sh, where shopt does not exist -- it exited 127 and killed the
|
||||||
|
# step (which is why v0.9.0 and v0.9.1 built but never published).
|
||||||
|
# Without nullglob an unmatched pattern stays literal, so test each
|
||||||
|
# candidate instead. Same POSIX-only rule as traceability-check.yml.
|
||||||
|
for bundle in \
|
||||||
|
src-tauri/target/release/bundle/appimage/*.AppImage \
|
||||||
|
src-tauri/target/release/bundle/deb/*.deb \
|
||||||
|
src-tauri/target/release/bundle/rpm/*.rpm; do
|
||||||
|
[ -e "$bundle" ] || continue
|
||||||
|
cp -v "$bundle" dist/linux/
|
||||||
|
done
|
||||||
|
|
||||||
|
# A release with no Linux package is a failure, not a quiet success.
|
||||||
|
if [ -z "$(ls -A dist/linux/)" ]; then
|
||||||
|
echo "::error::No Linux bundles found under src-tauri/target/release/bundle/"
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
ls -lah dist/linux/
|
ls -lah dist/linux/
|
||||||
|
|
||||||
@@ -125,7 +180,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: jellytau-linux
|
name: jellytau-linux
|
||||||
path: dist/linux/
|
path: dist/linux/
|
||||||
retention-days: 30
|
retention-days: 7
|
||||||
|
|
||||||
build-windows:
|
build-windows:
|
||||||
name: Build Windows
|
name: Build Windows
|
||||||
@@ -143,14 +198,31 @@ jobs:
|
|||||||
- name: Cache Rust dependencies
|
- name: Cache Rust dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
|
# Registry only -- never src-tauri/target. That directory is ~16 GB and
|
||||||
|
# was cached under five separate keys, which filled the runner's 74 GB
|
||||||
|
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
|
||||||
|
# registry/src is omitted too: cargo re-extracts it for free from
|
||||||
|
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
|
||||||
path: |
|
path: |
|
||||||
~/.cargo/registry
|
~/.cargo/registry/index
|
||||||
~/.cargo/git
|
~/.cargo/registry/cache
|
||||||
~/.cache/cargo-xwin
|
~/.cargo/git/db
|
||||||
src-tauri/target
|
# One shared key across every job. The old per-job keys existed to stop
|
||||||
key: ${{ runner.os }}-cargo-windows-${{ hashFiles('**/Cargo.lock') }}
|
# debug/release target artifacts clobbering each other; with target no
|
||||||
|
# longer cached, registry contents are target-independent, so all jobs
|
||||||
|
# want the same crates. First job to finish saves; the rest restore.
|
||||||
|
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-cargo-windows-
|
${{ runner.os }}-cargo-registry-
|
||||||
|
|
||||||
|
- name: Cache Windows CRT/SDK (cargo-xwin)
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: ~/.cache/cargo-xwin
|
||||||
|
# Contents track the xwin version baked into the builder image, not our
|
||||||
|
# lockfile -- keying this on Cargo.lock re-downloaded the whole SDK on
|
||||||
|
# every release bump. Bump the suffix by hand if the image's xwin moves.
|
||||||
|
key: ${{ runner.os }}-cargo-xwin-v1
|
||||||
|
|
||||||
- name: Cache Node dependencies
|
- name: Cache Node dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
@@ -181,7 +253,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: jellytau-windows
|
name: jellytau-windows
|
||||||
path: dist/windows/
|
path: dist/windows/
|
||||||
retention-days: 30
|
retention-days: 7
|
||||||
|
|
||||||
build-android:
|
build-android:
|
||||||
name: Build Android
|
name: Build Android
|
||||||
@@ -200,13 +272,22 @@ jobs:
|
|||||||
- name: Cache Rust dependencies
|
- name: Cache Rust dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
|
# Registry only -- never src-tauri/target. That directory is ~16 GB and
|
||||||
|
# was cached under five separate keys, which filled the runner's 74 GB
|
||||||
|
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
|
||||||
|
# registry/src is omitted too: cargo re-extracts it for free from
|
||||||
|
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
|
||||||
path: |
|
path: |
|
||||||
~/.cargo/registry
|
~/.cargo/registry/index
|
||||||
~/.cargo/git
|
~/.cargo/registry/cache
|
||||||
src-tauri/target
|
~/.cargo/git/db
|
||||||
key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}
|
# One shared key across every job. The old per-job keys existed to stop
|
||||||
|
# debug/release target artifacts clobbering each other; with target no
|
||||||
|
# longer cached, registry contents are target-independent, so all jobs
|
||||||
|
# want the same crates. First job to finish saves; the rest restore.
|
||||||
|
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-cargo-android-
|
${{ runner.os }}-cargo-registry-
|
||||||
|
|
||||||
- name: Cache Node dependencies
|
- name: Cache Node dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
@@ -270,7 +351,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: jellytau-android
|
name: jellytau-android
|
||||||
path: dist/android/
|
path: dist/android/
|
||||||
retention-days: 30
|
retention-days: 7
|
||||||
|
|
||||||
create-release:
|
create-release:
|
||||||
name: Create Release
|
name: Create Release
|
||||||
@@ -317,10 +398,11 @@ jobs:
|
|||||||
echo "" >> release_notes.md
|
echo "" >> release_notes.md
|
||||||
echo "#### Linux" >> release_notes.md
|
echo "#### Linux" >> release_notes.md
|
||||||
echo "- **AppImage** - Run directly on most Linux distributions" >> release_notes.md
|
echo "- **AppImage** - Run directly on most Linux distributions" >> release_notes.md
|
||||||
echo "- **DEB** - Install via \`sudo dpkg -i jellytau_*.deb\` (Ubuntu/Debian)" >> release_notes.md
|
echo "- **DEB** - Install via \`sudo dpkg -i JellyTau_*.deb\` (Ubuntu/Debian)" >> release_notes.md
|
||||||
|
echo "- **RPM** - Install via \`sudo rpm -i JellyTau-*.rpm\` (Fedora/openSUSE)" >> release_notes.md
|
||||||
echo "" >> release_notes.md
|
echo "" >> release_notes.md
|
||||||
echo "#### Windows" >> release_notes.md
|
echo "#### Windows" >> release_notes.md
|
||||||
echo "- **Installer (.exe)** - Run \`jellytau_*-setup.exe\` (NSIS). Unsigned — SmartScreen may warn on first run." >> release_notes.md
|
echo "- **Installer (.exe)** - Run \`JellyTau_*-setup.exe\` (NSIS). Unsigned — SmartScreen may warn on first run." >> release_notes.md
|
||||||
echo "" >> release_notes.md
|
echo "" >> release_notes.md
|
||||||
echo "#### Android" >> release_notes.md
|
echo "#### Android" >> release_notes.md
|
||||||
echo "- **APK** - Install via \`adb install jellytau-release.apk\` or sideload via file manager" >> release_notes.md
|
echo "- **APK** - Install via \`adb install jellytau-release.apk\` or sideload via file manager" >> release_notes.md
|
||||||
@@ -334,13 +416,13 @@ jobs:
|
|||||||
echo "" >> release_notes.md
|
echo "" >> release_notes.md
|
||||||
echo "#### Linux (AppImage)" >> release_notes.md
|
echo "#### Linux (AppImage)" >> release_notes.md
|
||||||
echo "\`\`\`bash" >> release_notes.md
|
echo "\`\`\`bash" >> release_notes.md
|
||||||
echo "chmod +x jellytau_*.AppImage" >> release_notes.md
|
echo "chmod +x JellyTau_*.AppImage" >> release_notes.md
|
||||||
echo "./jellytau_*.AppImage" >> release_notes.md
|
echo "./JellyTau_*.AppImage" >> release_notes.md
|
||||||
echo "\`\`\`" >> release_notes.md
|
echo "\`\`\`" >> release_notes.md
|
||||||
echo "" >> release_notes.md
|
echo "" >> release_notes.md
|
||||||
echo "#### Linux (DEB)" >> release_notes.md
|
echo "#### Linux (DEB)" >> release_notes.md
|
||||||
echo "\`\`\`bash" >> release_notes.md
|
echo "\`\`\`bash" >> release_notes.md
|
||||||
echo "sudo dpkg -i jellytau_*.deb" >> release_notes.md
|
echo "sudo dpkg -i JellyTau_*.deb" >> release_notes.md
|
||||||
echo "jellytau" >> release_notes.md
|
echo "jellytau" >> release_notes.md
|
||||||
echo "\`\`\`" >> release_notes.md
|
echo "\`\`\`" >> release_notes.md
|
||||||
echo "" >> release_notes.md
|
echo "" >> release_notes.md
|
||||||
|
|||||||
@@ -81,8 +81,20 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check minimum threshold
|
# Minimum coverage. RATCHET POLICY: this number only ever goes UP.
|
||||||
MIN_THRESHOLD=50
|
#
|
||||||
|
# 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=88
|
||||||
if [ "$COVERAGE" -lt "$MIN_THRESHOLD" ]; then
|
if [ "$COVERAGE" -lt "$MIN_THRESHOLD" ]; then
|
||||||
echo "❌ ERROR: Coverage ($COVERAGE%) is below minimum threshold ($MIN_THRESHOLD%)"
|
echo "❌ ERROR: Coverage ($COVERAGE%) is below minimum threshold ($MIN_THRESHOLD%)"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -90,6 +102,15 @@ jobs:
|
|||||||
|
|
||||||
echo "✅ Coverage is acceptable ($COVERAGE% >= $MIN_THRESHOLD%)"
|
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
|
- name: Check modified files
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -30,11 +30,6 @@ coverage
|
|||||||
.nyc_output
|
.nyc_output
|
||||||
*.lcov
|
*.lcov
|
||||||
|
|
||||||
# WebdriverIO E2E tests
|
|
||||||
e2e/logs/
|
|
||||||
e2e/screenshots/
|
|
||||||
wdio-*.log
|
|
||||||
|
|
||||||
# Vitest
|
# Vitest
|
||||||
.vitest
|
.vitest
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Dependencies & build output
|
||||||
|
node_modules/
|
||||||
|
.svelte-kit/
|
||||||
|
|
||||||
|
# Scratch worktrees (git-ignored) — full checkouts of this repo
|
||||||
|
.claude/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
/package/
|
||||||
|
|
||||||
|
# Rust backend (rustfmt owns this tree)
|
||||||
|
src-tauri/
|
||||||
|
|
||||||
|
# Generated by tauri-specta — regenerated on every Rust build, never hand-edited
|
||||||
|
src/lib/api/bindings.ts
|
||||||
|
|
||||||
|
# Lockfiles and generated data
|
||||||
|
bun.lock
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# Generated docs (built by the publish-docs CI job)
|
||||||
|
docs/SUMMARY.md
|
||||||
|
docs/README.md
|
||||||
|
docs/api-redirect.md
|
||||||
|
docs-site/book/
|
||||||
|
|
||||||
|
# Hand-maintained Markdown (docs/, CHANGELOG.md, README.md, ...). Prettier
|
||||||
|
# reflows tables and wrapped prose, which would swamp real doc diffs and fight
|
||||||
|
# the hand-tuned layout of docs/requirements.md and docs/traceability.md
|
||||||
|
# (the latter is generated by scripts/extract-traces.ts).
|
||||||
|
**/*.md
|
||||||
|
|
||||||
|
# CI workflow YAML — formatting churn here would obscure real pipeline diffs.
|
||||||
|
.gitea/
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/prettierrc",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"quoteProps": "as-needed",
|
||||||
|
"trailingComma": "all",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"plugins": ["prettier-plugin-svelte"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.svelte",
|
||||||
|
"options": { "parser": "svelte" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+1074
-7
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,16 @@ bun run android:dev # build + deploy
|
|||||||
bun run android:logs # logcat
|
bun run android:logs # logcat
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The **debug** build type carries `applicationIdSuffix ".debug"`, so
|
||||||
|
`com.dtourolle.jellytau.debug` ("JellyTau Debug") installs *alongside* a release
|
||||||
|
build with its own data dir — never uninstall the release app to test a debug
|
||||||
|
one. `./scripts/build-and-deploy.sh release --device --debug` puts an
|
||||||
|
R8-minified *release* build in that same slot, signed with the local debug
|
||||||
|
keystore, for validating minification without the real key. Only the
|
||||||
|
applicationId is suffixed; Kotlin classes stay in the `namespace` package
|
||||||
|
`com.dtourolle.jellytau`, so JNI lookups and R8 keep rules are unaffected. See
|
||||||
|
[README_ANDROID_BUILD.md](src-tauri/android/README_ANDROID_BUILD.md).
|
||||||
|
|
||||||
CI runs on **Gitea Actions** (`.gitea/workflows/`), not GitHub. Use the `gh` CLI
|
CI runs on **Gitea Actions** (`.gitea/workflows/`), not GitHub. Use the `gh` CLI
|
||||||
only against the mirror if one exists; the canonical remote is
|
only against the mirror if one exists; the canonical remote is
|
||||||
`gitea.tourolle.paris`.
|
`gitea.tourolle.paris`.
|
||||||
@@ -91,14 +101,22 @@ Tooling:
|
|||||||
bun run traces # extract traces (default format)
|
bun run traces # extract traces (default format)
|
||||||
bun run traces:json # JSON — e.g. | jq '.byType' or '.requirements."UR-005"'
|
bun run traces:json # JSON — e.g. | jq '.byType' or '.requirements."UR-005"'
|
||||||
bun run traces:markdown # regenerate docs/traceability.md
|
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
|
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
|
**CI is Gitea Actions** (`.gitea/workflows/`, remote `gitea.tourolle.paris`), not
|
||||||
GitHub. `traceability-check.yml` fails the build if coverage drops below
|
GitHub. `traceability-check.yml` fails the build if coverage drops below
|
||||||
**50%** (`MIN_THRESHOLD`); `build-and-test.yml` runs frontend + Rust tests and an
|
**82%** (`MIN_THRESHOLD`, a *ratchet* — raise it as coverage climbs, never lower
|
||||||
Android `cargo check`. See [docs/traceability-ci.md](docs/traceability-ci.md) and
|
it to make a build pass) or if any traced ID is undefined; `build-and-test.yml`
|
||||||
[docs/traces-quick-ref.md](docs/traces-quick-ref.md).
|
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
|
### Traces drive release notes
|
||||||
|
|
||||||
@@ -152,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 |
|
| [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)
|
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)
|
### Core principles (from the architecture docs)
|
||||||
|
|
||||||
|
|||||||
+24
-3
@@ -52,13 +52,34 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
|
|||||||
RUN curl -fsSL https://bun.sh/install | bash && \
|
RUN curl -fsSL https://bun.sh/install | bash && \
|
||||||
ln -s /root/.bun/bin/bun /usr/local/bin/bun
|
ln -s /root/.bun/bin/bun /usr/local/bin/bun
|
||||||
|
|
||||||
# Install Rust using rustup
|
# Install Rust using rustup, pinned to an exact release.
|
||||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && \
|
#
|
||||||
|
# 🔴 RUST_VERSION must equal `channel` in src-tauri/rust-toolchain.toml.
|
||||||
|
#
|
||||||
|
# The two are a pair. rust-toolchain.toml is what makes a developer's `cargo
|
||||||
|
# clippy` agree with CI's; this line is what makes the image already contain that
|
||||||
|
# toolchain. If they drift, rustup silently downloads the pinned version the
|
||||||
|
# first time cargo runs inside a job — a toolchain install at job time, which
|
||||||
|
# CLAUDE.md's "🔴 CI installs no system tools" rule forbids (and which costs
|
||||||
|
# ~1min plus a network dependency on every build).
|
||||||
|
#
|
||||||
|
# 🔴 Changing this line does NOT change CI on its own: the image must be
|
||||||
|
# rebuilt and pushed (`scripts/build-builder-image.sh`) before the new pin is
|
||||||
|
# authoritative. Bump rust-toolchain.toml and this line together, rebuild, push,
|
||||||
|
# then merge.
|
||||||
|
#
|
||||||
|
# Was: `sh -s -- -y` (latest stable, whatever it happened to be on rebuild day).
|
||||||
|
ENV RUST_VERSION=1.97.1
|
||||||
|
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
||||||
|
sh -s -- -y --profile minimal --default-toolchain "$RUST_VERSION" && \
|
||||||
. $HOME/.cargo/env && \
|
. $HOME/.cargo/env && \
|
||||||
|
rustup default "$RUST_VERSION" && \
|
||||||
rustup target add aarch64-linux-android && \
|
rustup target add aarch64-linux-android && \
|
||||||
rustup target add armv7-linux-androideabi && \
|
rustup target add armv7-linux-androideabi && \
|
||||||
rustup target add x86_64-linux-android && \
|
rustup target add x86_64-linux-android && \
|
||||||
rustup component add rustfmt clippy
|
rustup component add rustfmt clippy && \
|
||||||
|
rustc --version && \
|
||||||
|
cargo clippy --version
|
||||||
|
|
||||||
# Setup Android SDK
|
# Setup Android SDK
|
||||||
RUN mkdir -p $ANDROID_HOME && \
|
RUN mkdir -p $ANDROID_HOME && \
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Duncan Tourolle
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -42,7 +42,7 @@ For the full set of build, test, and Android helper scripts, see
|
|||||||
|-------|----------|
|
|-------|----------|
|
||||||
| Architecture overview & subsystem docs | [docs/architecture/](docs/architecture/) |
|
| Architecture overview & subsystem docs | [docs/architecture/](docs/architecture/) |
|
||||||
| Requirements, traceability & technical debt | [docs/requirements.md](docs/requirements.md) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| Release checklist | [docs/release-checklist.md](docs/release-checklist.md) |
|
||||||
|
|||||||
+47
-2
@@ -22,15 +22,60 @@
|
|||||||
- [Database Design](architecture/08-database-design.md)
|
- [Database Design](architecture/08-database-design.md)
|
||||||
- [Security](architecture/09-security.md)
|
- [Security](architecture/09-security.md)
|
||||||
|
|
||||||
# UX & Specs
|
# UX
|
||||||
|
|
||||||
- [UX Flows](ux-flows.md)
|
- [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)
|
- [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](build-release.md)
|
- [Build & Release](build/build-release.md)
|
||||||
- [Release Checklist](release-checklist.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)
|
- [Docker](build/docker.md)
|
||||||
- [Builder Image](build/build-builder-image.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) |
|
| Certificate Validation | System CA store (configurable for self-signed) |
|
||||||
| Token Transmission | Bearer token in `Authorization` header only |
|
| Token Transmission | Bearer token in `Authorization` header only |
|
||||||
| Token Refresh | Handled by Jellyfin server (long-lived tokens) |
|
| 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
|
## Local Data Protection
|
||||||
|
|
||||||
@@ -67,3 +129,4 @@ pub struct EncryptedFileStorage; // AES-256-GCM fallback
|
|||||||
3. **Logout Cleanup**: Token deletion from secure storage on logout
|
3. **Logout Cleanup**: Token deletion from secure storage on logout
|
||||||
4. **No Token Logging**: Tokens are never written to logs or debug output
|
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
|
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)
|
## One builder image (shared with CI)
|
||||||
|
|
||||||
The deb/rpm and Windows-cross flows build on the **unified registry builder**
|
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
|
`gitea.tourolle.paris/dtourolle/jellytau-builder`), the same image CI uses. It
|
||||||
carries every packaging tool: Android SDK/NDK, `rpm`/`file` (Linux bundler),
|
carries every packaging tool: Android SDK/NDK, `rpm`/`file` (Linux bundler),
|
||||||
`cargo-xwin` + `lld` + `llvm` + `nsis` + the `x86_64-pc-windows-msvc` rust target
|
`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
|
(Windows). There is **one** dependency source of truth — no per-stage tool
|
||||||
installs.
|
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
|
${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
|
your bind-mounted source (like the `dev` service), so source edits need no image
|
||||||
rebuild.
|
rebuild.
|
||||||
@@ -28,7 +28,7 @@ docker build -f Dockerfile.builder -t jellytau-builder:latest .
|
|||||||
BUILDER_IMAGE=jellytau-builder:latest bun run docker:build:windows
|
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.
|
because `makepkg` is Arch-specific — it is not part of the unified builder.
|
||||||
|
|
||||||
| Target | Format | Docker command | Functional? |
|
| 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
|
## Linux: deb + rpm
|
||||||
|
|
||||||
Tauri's bundler produces these natively. The build runs on the existing Ubuntu
|
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
|
```bash
|
||||||
bun run docker:build:linux # deb + rpm -> ./dist
|
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
|
**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
|
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
|
[../packaging/arch/PKGBUILD](../../packaging/arch/PKGBUILD) and build it with
|
||||||
`makepkg` on an Arch base image ([../Dockerfile.arch](../Dockerfile.arch)):
|
`makepkg` on an Arch base image ([../Dockerfile.arch](../../Dockerfile.arch)):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run docker:build:arch # .pkg.tar.zst -> ./dist
|
bun run docker:build:arch # .pkg.tar.zst -> ./dist
|
||||||
+19
-6
@@ -116,17 +116,30 @@ Runs after both builds succeed (only on version tags):
|
|||||||
- **Use:** Run directly on any Linux distro
|
- **Use:** Run directly on any Linux distro
|
||||||
- **Installation:**
|
- **Installation:**
|
||||||
```bash
|
```bash
|
||||||
chmod +x jellytau_*.AppImage
|
chmod +x JellyTau_*.AppImage
|
||||||
./jellytau_*.AppImage
|
./JellyTau_*.AppImage
|
||||||
```
|
```
|
||||||
|
|
||||||
#### DEB Package
|
#### DEB Package
|
||||||
- **File:** `jellytau_*.deb`
|
- **File:** `JellyTau_*.deb`
|
||||||
- **Size:** ~80-120 MB
|
- **Size:** ~80-120 MB
|
||||||
- **Use:** Install on Debian/Ubuntu/similar
|
- **Use:** Install on Debian/Ubuntu/similar
|
||||||
- **Installation:**
|
- **Installation:**
|
||||||
```bash
|
```bash
|
||||||
sudo dpkg -i jellytau_*.deb
|
sudo dpkg -i JellyTau_*.deb
|
||||||
|
jellytau
|
||||||
|
```
|
||||||
|
- **Note:** the Debian package is named `jelly-tau` (Tauri kebab-cases
|
||||||
|
`productName`), while the command stays `jellytau`. The package declares
|
||||||
|
`Replaces`/`Conflicts`/`Provides: jellytau`, so upgrading from a release built
|
||||||
|
before the rename replaces it rather than installing a second copy.
|
||||||
|
|
||||||
|
#### RPM Package
|
||||||
|
- **File:** `JellyTau-*.rpm`
|
||||||
|
- **Use:** Install on Fedora/openSUSE/similar
|
||||||
|
- **Installation:**
|
||||||
|
```bash
|
||||||
|
sudo rpm -i JellyTau-*.rpm
|
||||||
jellytau
|
jellytau
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -294,8 +307,8 @@ bun run tauri build # Local build test
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Documentation
|
### Documentation
|
||||||
1. Update [CHANGELOG.md](../CHANGELOG.md) with changes
|
1. Update [CHANGELOG.md](../../CHANGELOG.md) with changes
|
||||||
2. Update [README.md](../README.md) with new features
|
2. Update [README.md](../../README.md) with new features
|
||||||
3. Document breaking changes
|
3. Document breaking changes
|
||||||
4. Add migration guide if needed
|
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.
|
h264 fine. No Windows-specific code.
|
||||||
- **Audio-only (music)** — the native audio backends are libmpv (Linux) and
|
- **Audio-only (music)** — the native audio backends are libmpv (Linux) and
|
||||||
ExoPlayer (Android); neither exists on Windows. Instead
|
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
|
uses `WebviewAudioBackend` on non-Linux/non-Android targets: it hands the stream
|
||||||
URL to a webview `<audio>` element (see
|
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
|
which reports state back through the same `player_report_*` round-trip the video
|
||||||
path uses. Pure Rust + Tauri events.
|
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
|
> `--runner cargo-xwin --target x86_64-pc-windows-msvc` is what flips it into
|
||||||
> Windows mode and enables the `nsis`/`msi` bundlers on Linux.
|
> 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`,
|
whole toolchain: the `x86_64-pc-windows-msvc` rust target, `cargo-xwin`, `lld`,
|
||||||
`llvm`, and `nsis`.
|
`llvm`, and `nsis`.
|
||||||
|
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# Defect windows — which bugs were present when
|
||||||
|
|
||||||
|
For each fixed defect, the releases it was actually present in. Companion to
|
||||||
|
[CHANGELOG.md](../CHANGELOG.md), which says what changed; this says how long each
|
||||||
|
fault had been shipping before it did.
|
||||||
|
|
||||||
|
**"Present since"** is the first *release* containing the defective code, not the
|
||||||
|
first release where a user could hit it — those differ, sometimes by months, and
|
||||||
|
the gap is called out where it matters. **"How dated"** records the evidence, so a
|
||||||
|
row can be re-checked or disputed:
|
||||||
|
|
||||||
|
| Method | Meaning |
|
||||||
|
|--------|---------|
|
||||||
|
| `pickaxe` | `git log -S<token>` on the defective token — the commit that introduced the exact string, then the earliest tag containing it. Strongest evidence. |
|
||||||
|
| `feature` | The defect is inseparable from a feature that landed whole (bad rung in a new algorithm, missing caller in new plumbing), dated to that feature's release. |
|
||||||
|
| `absence` | The fix *adds* something that was never there. Dated to when the surrounding code was built, since there is no introducing commit to find. Weakest — treat as "no later than". |
|
||||||
|
|
||||||
|
## Present since the first release
|
||||||
|
|
||||||
|
Nine defects date to the initial proof of concept (v0.0.1, 2026-06-23) and shipped
|
||||||
|
for between two weeks and seven weeks short of two months before anyone hit them.
|
||||||
|
That is the dominant pattern here: not regressions, but original assumptions that
|
||||||
|
went unexercised until a later feature leaned on them.
|
||||||
|
|
||||||
|
| Defect | Present since | Fixed in | Shipped broken for | How dated |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `AudioStreamIndex=0` pinned the video stream as the audio track (DR-140) | v0.0.1 | **v0.4.6** | ~7 weeks | pickaxe |
|
||||||
|
| Download URL spelled `videoBitrate`, which Jellyfin does not bind (DR-123) | v0.0.1 | **v0.5.1** | ~7 weeks | pickaxe |
|
||||||
|
| `pause_download` / `resume_download` were no-ops (DR-168) | v0.0.1 | **v0.5.3** | ~7.5 weeks | pickaxe |
|
||||||
|
| `.part` sidecar named by `with_extension`, so no cleanup path matched it (DR-169) | v0.0.1 | **v0.5.3** | ~7.5 weeks | pickaxe |
|
||||||
|
| `Range` sent on every retry regardless of the response (DR-170) | v0.0.1 | **v0.5.3** | ~7.5 weeks | pickaxe |
|
||||||
|
| `/Items/Latest` requested with the default `GroupItems=false` | v0.0.1 | **v0.5.1** | ~7 weeks | pickaxe |
|
||||||
|
| `SubtitleStreamIndex` omitted from PlaybackInfo, letting the server burn in (DR-176) | v0.0.1 | **v0.5.5** | ~8 weeks | pickaxe |
|
||||||
|
| No `PlaySessionId`, and one hardcoded `DeviceId`, on every stream URL (DR-177) | v0.0.1 | **v0.5.5** | ~8 weeks | pickaxe |
|
||||||
|
| `download_item` never recorded `media_type`; NULL read as `'audio'` (DR-135) | v0.0.1 | **v0.4.6** | ~7 weeks | pickaxe |
|
||||||
|
| `download_album` read its track list from the local cache (DR-173) | v0.0.1 | **v0.5.5** | ~8 weeks | pickaxe |
|
||||||
|
| Device profile carried no `MaxAudioChannels` (DR-141) | v0.0.1 | **v0.4.6** | ~7 weeks | absence |
|
||||||
|
| Streaming ceiling fixed at 20 Mbps with no way to lower it (UR-074) | v0.0.1 | **v0.5.3** (as a feature) | ~7.5 weeks | pickaxe |
|
||||||
|
| Hero banner auto-rotation never restarted after a manual swipe (DR-038) | v0.0.1 | **v0.9.1** | ~8.5 weeks | pickaxe |
|
||||||
|
|
||||||
|
### Why they took so long to surface
|
||||||
|
|
||||||
|
Four of these were **latent until a later feature exercised them**, which is why
|
||||||
|
the fix lands so far from the cause:
|
||||||
|
|
||||||
|
- The `videoBitrate` casing was harmless while every download was `original`. It
|
||||||
|
became visible only once a quality picker existed to select against — and then
|
||||||
|
produced no error, just a full-size file, because Jellyfin discards an unbound
|
||||||
|
query key silently.
|
||||||
|
- The unconditional `Range` header was inert for the same reason: `original` is
|
||||||
|
the one rung served with a `Content-Length` and real byte-range support. It
|
||||||
|
started corrupting files in **v0.5.1**, the moment the casing fix made
|
||||||
|
transcoded downloads actually transcode. So the *code* dates to v0.0.1 and the
|
||||||
|
*corruption* to v0.5.1 — a one-release window for the visible symptom.
|
||||||
|
- The missing `PlaySessionId` only bites when a stream is re-opened for the same
|
||||||
|
item. Nothing re-opened one until quality switching, transcoded seek and
|
||||||
|
audio-track switching existed.
|
||||||
|
- The omitted `SubtitleStreamIndex` only bites on sources whose own default
|
||||||
|
subtitle track is image-based, since that is what forces the server from
|
||||||
|
sidecar to burn-in.
|
||||||
|
|
||||||
|
Two were **masked by soft failure**: the asset protocol being disabled (DR-134)
|
||||||
|
was hidden by the thumbnail cache falling back to the server copy whenever the
|
||||||
|
server was reachable, and `AudioStreamIndex=0` was hidden by servers that
|
||||||
|
silently correct an out-of-range index — which is exactly why it was reported as
|
||||||
|
"*some* videos have no audio" rather than as a bug in the client.
|
||||||
|
|
||||||
|
## Introduced by a feature, fixed later
|
||||||
|
|
||||||
|
| Defect | Present since | Fixed in | How dated |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Native-path resume position never applied (both layers assumed the other seeked) | v0.0.9/v0.0.10 | **v0.5.1** | feature (`PlayerAdapter` contract) |
|
||||||
|
| `get_downloaded_items` matched "this library exists" rather than constraining the item to it (DR-167) | v0.0.17 | **v0.5.3** | feature (browsable downloaded library) |
|
||||||
|
| `SCOPE_ITEM_TYPES` — the frontend/backend boundary leak (DR-063) | v0.0.17 | **v0.2.1** | pickaxe |
|
||||||
|
| `check:boundary` anchored to the query site, blind to a named const (DR-094) | v0.0.17 | **v0.2.1** | feature (tripwire landed with the leak it missed) |
|
||||||
|
| Coverage gate divided by hardcoded denominators, reporting 158% (DR-093) | v0.0.1 | **v0.2.1** | pickaxe |
|
||||||
|
| Tap deferral raced the WebView's synthesized click (DR-092 → DR-098) | v0.1.5 | **v0.2.7** | feature (the deferral itself) |
|
||||||
|
| Transport for webview media decided from `el.paused` in the DOM (DR-097) | v0.0.9/v0.0.10 | **v0.2.7** | feature (`Html5PlayerAdapter`) |
|
||||||
|
| `pick_current_episode` rung 3 returned the first *gap*, not the furthest watched | v0.3.0 | **v0.5.1** | feature |
|
||||||
|
| `mirror_user_data` mirrored `is_favorite` alone and returned early (DR-155) | v0.4.0 | **v0.5.1** | pickaxe |
|
||||||
|
| Stop-report path never fed the sync queue that existed for it (DR-154) | v0.4.6 | **v0.5.1** | feature (queue + drain landed with no producer) |
|
||||||
|
| Background-audio base applied in two display-only places (DR-159) | v0.2.9 | **v0.5.3** | pickaxe |
|
||||||
|
| Positions reported as 0 before the first tick, and always 0 for webview media (DR-178/179/180) | v0.5.3 | **v0.5.5** | feature (DR-159's tick boundary) |
|
||||||
|
| Length-less handoff transcode left to the player's own load-error retry, which can only restart it (DR-203) | v0.0.16 | **v0.8.2** | feature (the handoff's progressive-mp3 choice) |
|
||||||
|
|
||||||
|
Three of these are worth separating out, because the defect is not a mistake in
|
||||||
|
the code so much as **plumbing that was built and never connected**:
|
||||||
|
|
||||||
|
- `repository_get_next_up_episodes` accepted a `series_id` from the day it was
|
||||||
|
written, and no caller passed one until v0.3.0.
|
||||||
|
- The sync queue and its drain were built, tested and running in v0.4.6 with
|
||||||
|
neither of its two would-be producers ever called.
|
||||||
|
- Both halves of the watched-state backend existed with no caller before v0.5.3.
|
||||||
|
|
||||||
|
An automated check cannot see any of these — the code is present, tested and
|
||||||
|
reachable in principle. Only tracing a requirement to a *call site* catches it.
|
||||||
|
|
||||||
|
## Short windows (one release or less)
|
||||||
|
|
||||||
|
| Defect | Present since | Fixed in | Note |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `experimentalNativeVideo` defaulted on, shipping audio with a blank screen (DR-161 → DR-172) | v0.5.3 | **v0.5.4** | One release. The decode path was fine; the compositing step never ran. |
|
||||||
|
| Webview-shaped audio profile insufficient — server ignores a profile's audio codec (DR-149) | v0.4.7 | **v0.4.8** | The v0.4.7 fix for DR-148 was necessary and not sufficient. |
|
||||||
|
| Android `versionCode` floor went stale (`minor*100` yielding less than the 5002 already in the field) | v0.5.0 | **v0.5.3** | Caught before a broken APK shipped; no released build was un-installable. |
|
||||||
|
| Subtitle sidecar work reverted by a commit assembled from a stale tree | v0.5.5 | **v0.5.5** | Never released broken — both commits are in v0.5.5. |
|
||||||
|
|
||||||
|
## Fixed twice / never actually broken
|
||||||
|
|
||||||
|
- **Autoplay time reset (v0.0.2).** Two commit objects carry this identical
|
||||||
|
change: `dcf08f30` (merged via Gitea PR #3, tagged v0.0.2) and `fa7cb6e9` (the
|
||||||
|
local original). Both have the same parent `674c8e5c` and the same diff. A merge
|
||||||
|
chain pulled `fa7cb6e9` and its follow-up `1e599627` into master's history
|
||||||
|
during v0.5.5, so `git log v0.5.4..v0.5.5` lists an autoplay fix that changed no
|
||||||
|
file in that release — `nextEpisodeService.ts` is byte-identical across the tag
|
||||||
|
boundary. The fix shipped in **v0.0.2** and has not regressed.
|
||||||
|
|
||||||
|
This is the one case where reading the changelog off `git log` subjects would
|
||||||
|
have produced a false entry, and it is a good argument for the project's
|
||||||
|
practice of deriving release notes from TRACES rather than commit subjects.
|
||||||
|
|
||||||
|
## Recurring shapes
|
||||||
|
|
||||||
|
Four causes account for most of the table:
|
||||||
|
|
||||||
|
1. **An omitted parameter is not a neutral default.** `SubtitleStreamIndex`,
|
||||||
|
`AudioStreamIndex`, `GroupItems` and `MaxAudioChannels` all had a server-side
|
||||||
|
default that was actively wrong, and in three of the four the server's choice
|
||||||
|
was more expensive than the one intended — burn-in forcing a full re-encode
|
||||||
|
being the extreme case.
|
||||||
|
2. **Silent binding failures.** `videoBitRate` produced no error, no warning and a
|
||||||
|
plausible-looking file. So did an unbound `Range`, and so did the coverage gate
|
||||||
|
dividing by a stale denominator.
|
||||||
|
3. **Two layers each assuming the other acts.** Native resume (adapter recorded
|
||||||
|
the position, backend never seeked), end-of-playback dispatch (two paths, one
|
||||||
|
unreachable), and the surface/attach split in v0.5.0's native video.
|
||||||
|
4. **A guard keyed on state that moves.** The tap deferral keyed suppression on a
|
||||||
|
timer handle the callback had already cleared; the HTML5 toggle keyed
|
||||||
|
play-vs-pause on `el.paused`, which flips while buffering.
|
||||||
|
|
||||||
|
## Reproducing this
|
||||||
|
|
||||||
|
The pickaxe rows can be re-derived directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git log --oneline --reverse -S'<defective token>' -- src-tauri/src # introducing commit
|
||||||
|
git tag --contains <sha> | sort -V | head -1 # first release with it
|
||||||
|
```
|
||||||
|
|
||||||
|
Blaming the lines a fix removed (`git blame` at the fix's parent) is faster to run
|
||||||
|
across many commits but was **not** used for the rows above: it reliably lands on
|
||||||
|
whichever commit last touched the adjacent lines, which is usually not the commit
|
||||||
|
that introduced the defect. It was used only to shortlist candidates.
|
||||||
@@ -109,8 +109,9 @@ git push origin v1.2.0
|
|||||||
## After Release (Workflow Complete)
|
## After Release (Workflow Complete)
|
||||||
|
|
||||||
- [ ] Download artifacts from release page:
|
- [ ] Download artifacts from release page:
|
||||||
- [ ] `jellytau_*.AppImage` (Linux)
|
- [ ] `JellyTau_*.AppImage` (Linux)
|
||||||
- [ ] `jellytau_*.deb` (Linux)
|
- [ ] `JellyTau_*.deb` (Linux)
|
||||||
|
- [ ] `JellyTau-*.rpm` (Linux)
|
||||||
- [ ] `jellytau-release.apk` (Android)
|
- [ ] `jellytau-release.apk` (Android)
|
||||||
- [ ] `jellytau-release.aab` (Android)
|
- [ ] `jellytau-release.aab` (Android)
|
||||||
|
|
||||||
@@ -255,9 +256,8 @@ First build takes longer (cache warming). Subsequent releases are faster due to
|
|||||||
**Android:** 8.0+
|
**Android:** 8.0+
|
||||||
|
|
||||||
### 🔗 Links
|
### 🔗 Links
|
||||||
- [Changelog](../../CHANGELOG.md)
|
- [Changelog](https://gitea.tourolle.paris/dtourolle/jellytau/src/branch/master/CHANGELOG.md)
|
||||||
- [Issues](../../issues)
|
- [Issues](https://gitea.tourolle.paris/dtourolle/jellytau/issues)
|
||||||
- [Discussion](../../discussions)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
Built with Tauri, SvelteKit, and Rust 🦀
|
Built with Tauri, SvelteKit, and Rust 🦀
|
||||||
|
|||||||
+179
-23
@@ -16,7 +16,7 @@ For a narrative overview of the system design, see
|
|||||||
| UR-003 | Play videos | High | Done |
|
| UR-003 | Play videos | High | Done |
|
||||||
| UR-004 | Play audio uninterrupted | High | Done |
|
| UR-004 | Play audio uninterrupted | High | Done |
|
||||||
| UR-005 | Control media playback (pause, play, skip, scrub) | 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-007 | Navigate media in library | High | Done |
|
||||||
| UR-008 | Search media across libraries | High | Done |
|
| UR-008 | Search media across libraries | High | Done |
|
||||||
| UR-009 | Connect to Jellyfin to access media | High | Done |
|
| UR-009 | Connect to Jellyfin to access media | High | Done |
|
||||||
@@ -84,6 +84,8 @@ For a narrative overview of the system design, see
|
|||||||
| UR-071 | Media the viewer is watching can be **kept**, by a whole-file download that runs in the background independently of playback and at its own quality, so it is unaffected by bitrate changes. Where the streamed bytes already are that file (direct play), they are kept rather than fetched twice. A completed download is then played from disk rather than streamed again | Medium | Proposed |
|
| UR-071 | Media the viewer is watching can be **kept**, by a whole-file download that runs in the background independently of playback and at its own quality, so it is unaffected by bitrate changes. Where the streamed bytes already are that file (direct play), they are kept rather than fetched twice. A completed download is then played from disk rather than streamed again | Medium | Proposed |
|
||||||
| UR-073 | Watched state is something the viewer can **set**, not only something playback records. Any episode, season, series or movie can be marked watched — or unwatched again — from where it is shown, without sitting through it or erasing its history wholesale. Marking a season or series covers the episodes inside it, and works with the server unreachable | Medium | Done |
|
| UR-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-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 |
|
| 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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -100,7 +102,7 @@ External system integrations and platform-specific implementations.
|
|||||||
| IR-002 | Build scripts for Android and Linux | Build | UR-001 | Done |
|
| 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-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-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-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-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 |
|
| IR-008 | Android audio focus handling (pause on call) | Platform | UR-004, UR-006 | Done |
|
||||||
@@ -114,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-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-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-017 | Jellyfin API client for transcoding parameters | API | UR-022 | Planned |
|
||||||
| IR-018 | libmpv subtitle rendering and selection | Playback | UR-020 | 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 | libmpv audio track selection | Playback | UR-021 | Planned |
|
| 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-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-022 | Jellyfin API client for person/cast data | API | UR-035, UR-036 | Done |
|
||||||
| IR-023 | Database schema for person/cast caching | Storage | UR-035, UR-036 | Done |
|
| IR-023 | Database schema for person/cast caching | Storage | UR-035, UR-036 | Done |
|
||||||
@@ -129,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-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 |
|
| 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
|
### 2.2 Jellyfin API Requirements
|
||||||
|
|
||||||
API endpoints and data contracts required for Jellyfin integration.
|
API endpoints and data contracts required for Jellyfin integration.
|
||||||
@@ -170,6 +192,7 @@ API endpoints and data contracts required for Jellyfin integration.
|
|||||||
| JA-033 | Query favourite items (`Filters=IsFavorite`, recursive, scoped by item type) | Items | UR-067 | Done |
|
| JA-033 | Query favourite items (`Filters=IsFavorite`, recursive, scoped by item type) | Items | UR-067 | Done |
|
||||||
| JA-034 | Read `UserData` (favourite, played, resume position) from item responses | UserData | UR-069 | Done |
|
| JA-034 | Read `UserData` (favourite, played, resume position) from item responses | UserData | UR-069 | Done |
|
||||||
| JA-035 | Mark item played (`POST /Users/{userId}/PlayedItems/{itemId}`) | UserData | UR-025 | Done |
|
| JA-035 | Mark item played (`POST /Users/{userId}/PlayedItems/{itemId}`) | UserData | UR-025 | Done |
|
||||||
|
| JA-036 | Query next-up episodes excluding in-progress ones (`/Shows/NextUp` with `EnableResumable=false`) | Shows | UR-059 | Done |
|
||||||
|
|
||||||
### 2.3 Development Requirements
|
### 2.3 Development Requirements
|
||||||
|
|
||||||
@@ -306,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-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-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-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-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-147 | One search input per screen, and the URL is the search's single source of truth. The header bar rendered only under `/library/**` and merely *navigated* to `/search` (DR-063), so a desktop search handed the user to a screen whose input was a different element — the header box cleared itself and vanished, and the page's own box took over mid-word. That page then re-derived its input from `?q=` against `library.searchQuery` on every store write, so the next keystroke re-ran the effect and snapped the text back to the query the header had sent (and a scope chip back to the URL's scope); entering from the bottom-nav Search tab skipped it only because the effect early-returned on an empty query. The bar now renders on `/search` too (`showHeaderSearch`) and is the sole md+ input — the page's own input is `md:hidden` — and on that route it republishes the query into the URL with `replaceState`, so a whole session of typing costs one history entry. The page *consumes* that URL once per distinct value (`seedFromSearchUrl` against a non-reactive `applied` marker) instead of continuously reconciling it, and the scope chips publish through the same URL so the bar and the chips cannot disagree. Landing on `/search` with a seeded query focuses the bar and puts the caret at the end, because the box the user was typing in belonged to the unmounted route | UI | UR-049, UR-054 | Done |
|
||||||
| DR-142 | An episode has exactly **one** surface, and it is complete. Two divergent renderings existed: `EpisodeFocusView` (reached from Continue Watching, the series episode list, the TV landing page and Downloads — i.e. every real entry point) offered only Play and Favourite, while the bare `/library/<episodeId>` page nobody routed to carried the download button, the series/season breadcrumbs and the cast section. Opening an episode the normal way therefore silently lost the ability to download it. The Focus View is now the single surface and carries the full §5B.2 composition — hero action row `Play / Download / Favourite`, series name and `SxEy` badge as links back to the series and to that season's anchor, then genres → cast → similar shows *below* the episode strip, never above it (DR-062). `/library/<episodeId>` redirects into it (`episodeRedirectTarget`, the same rule seasons follow under DR-103), and an episode with no `seriesId` renders the same component series-less rather than falling back to a second, lesser page. The focused episode is fetched in full rather than reused from the season fan-out, because that is a *list* query and carries neither cast nor genres — the sections would have rendered empty. The strip hides itself when the episode has no siblings, a card that only shows the episode you are already on being noise | UI | UR-048, UR-058 | Done |
|
| DR-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 |
|
||||||
@@ -326,9 +349,40 @@ Internal architecture, components, and application logic.
|
|||||||
| DR-167 | Each downloaded library shows only its own media. Cached items carry no link back to their library — `library_id` and `parent_id` are NULL on every row ([[offline-libraries-never-cached]]) — so `get_downloaded_items` matched the library branch with `EXISTS (SELECT 1 FROM libraries l WHERE l.id = ?)`, which asserts only that the requested library *exists* and never constrains the item to it. Opening any downloaded library therefore listed every downloaded top-level item on the server: films under Music, albums under TV. The sibling query that decides which libraries *appear* already carried the right rule — a `collection_type` ↔ `item_type` mapping — so the two disagreed about the same question. That mapping is now the named constant `LIBRARY_HOLDS_ITEM`, used by both, and a library of unknown collection type still keeps everything rather than being emptied by a rule that cannot classify it. The taxonomy stays in Rust, never the frontend | Downloads | UR-055 | Done |
|
| DR-167 | Each downloaded library shows only its own media. Cached items carry no link back to their library — `library_id` and `parent_id` are NULL on every row ([[offline-libraries-never-cached]]) — so `get_downloaded_items` matched the library branch with `EXISTS (SELECT 1 FROM libraries l WHERE l.id = ?)`, which asserts only that the requested library *exists* and never constrains the item to it. Opening any downloaded library therefore listed every downloaded top-level item on the server: films under Music, albums under TV. The sibling query that decides which libraries *appear* already carried the right rule — a `collection_type` ↔ `item_type` mapping — so the two disagreed about the same question. That mapping is now the named constant `LIBRARY_HOLDS_ITEM`, used by both, and a library of unknown collection type still keeps everything rather than being emptied by a rule that cannot classify it. The taxonomy stays in Rust, never the frontend | Downloads | UR-055 | Done |
|
||||||
| DR-168 | Pause and resume actually stop and restart the bytes. `pause_download` wrote `status = 'paused'` and did nothing else, and no cancellation existed anywhere in the download stack — no token, no flag, no abort — so the streaming task ran on, kept writing, and overwrote the row with `completed`/`failed` when it finished: the row flicked to "paused" and undid itself. `resume_download` had the mirror defect, flipping the row to `pending` without calling `pump_download_queue`; the pump runs when something calls it rather than polling, so a resumed download sat untouched until an unrelated event happened to pump the queue. A per-download stop flag (`download::stop`) is the missing half — a module-level registry because the two sides never meet, the command holding Tauri state and the worker running detached in `async_runtime::spawn`. The worker reads it between chunks and on retry (so a pause is not swallowed by a 45-second backoff), flushes, and returns `Stopped`, which is deliberately **not** retryable and **not** recorded as a failure: the `.part` file is left intact because that is exactly what the resume's Range request continues from. Registering returns a *fresh* flag, or a resumed download would inherit the pause that stopped it and halt instantly. Cancel and `clear_stale_downloads` signal it too, so neither deletes a file still being written | Downloads | UR-055 | Done |
|
| DR-168 | Pause and resume actually stop and restart the bytes. `pause_download` wrote `status = 'paused'` and did nothing else, and no cancellation existed anywhere in the download stack — no token, no flag, no abort — so the streaming task ran on, kept writing, and overwrote the row with `completed`/`failed` when it finished: the row flicked to "paused" and undid itself. `resume_download` had the mirror defect, flipping the row to `pending` without calling `pump_download_queue`; the pump runs when something calls it rather than polling, so a resumed download sat untouched until an unrelated event happened to pump the queue. A per-download stop flag (`download::stop`) is the missing half — a module-level registry because the two sides never meet, the command holding Tauri state and the worker running detached in `async_runtime::spawn`. The worker reads it between chunks and on retry (so a pause is not swallowed by a 45-second backoff), flushes, and returns `Stopped`, which is deliberately **not** retryable and **not** recorded as a failure: the `.part` file is left intact because that is exactly what the resume's Range request continues from. Registering returns a *fresh* flag, or a resumed download would inherit the pause that stopped it and halt instantly. Cancel and `clear_stale_downloads` signal it too, so neither deletes a file still being written | Downloads | UR-055 | Done |
|
||||||
| DR-169 | Partial files are actually reaped. The worker named its sidecar with `Path::with_extension("part")`, which *replaces* the extension — `movie.mp4` became `movie.part` — while every cleanup path deleted `"{file_path}.part"`, i.e. `movie.mp4.part`. The two never matched, so the partial file of every cancelled or failed download stayed on disk indefinitely, invisible to the disk-usage totals because no `downloads` row pointed at it. `partial_path` appends instead, is the single definition both the writer and the cleaners use, and incidentally removes a collision the old form had, where `movie.mp4` and `movie.mkv` mapped to one `movie.part` | Downloads | UR-055 | Done |
|
| DR-169 | Partial files are actually reaped. The worker named its sidecar with `Path::with_extension("part")`, which *replaces* the extension — `movie.mp4` became `movie.part` — while every cleanup path deleted `"{file_path}.part"`, i.e. `movie.mp4.part`. The two never matched, so the partial file of every cancelled or failed download stayed on disk indefinitely, invisible to the disk-usage totals because no `downloads` row pointed at it. `partial_path` appends instead, is the single definition both the writer and the cleaners use, and incidentally removes a collision the old form had, where `movie.mp4` and `movie.mkv` mapped to one `movie.part` | Downloads | UR-055 | Done |
|
||||||
|
| DR-173 | Downloading an album queues the **whole** album, and every track it queued is findable offline afterwards. Two independent gaps left an album with a handful of its tracks on the device while the button reported the album as downloaded. First, `download_album` took its track list from `items WHERE album_id = ?` — the local catalog cache. Jellyfin does not return `AlbumId` on every listing endpoint, so tracks cached by one of those endpoints sit in `items` with a NULL `album_id` and are invisible to that query; on the reporter's database three whole albums (18, 12 and 9 tracks) had it NULL on *every* track, so "download album" would have queued nothing for them, and a partially-linked album queued only the linked subset. Second, the frontend then resolved one stream URL per track from its own list and paired it with the returned row ids **by position** — a pairing with no basis, since the ids came back in the backend's `index_number` order over a different set of rows, so a row could be handed another track's URL and any track past the end of the shorter list was never started at all; on Android that loop also stopped wherever the webview was suspended. The same `album_id` is what `OfflineRepository::get_items` joins a track to its album on, so a track that did download stayed invisible under its album offline — the two halves of the same missing link. The operation now belongs to Rust end to end: `HybridRepository::get_album_tracks` asks the **server** what the album contains (cache-first `get_items` is right for browsing and wrong for deciding what to download) and errors offline so the caller falls back to the ungated local catalog, keeping the queue-while-offline flow; `queue_album_tracks` writes the album link onto every track it queues — queuing a track *is* the statement that it belongs to the album, rather than something to hope a listing endpoint recorded — and the stream URLs are resolved here through the existing reconnect resolver, now scoped to the rows just queued so one album cannot start every unrelated pending row. Nothing crosses the IPC boundary but the album id. Re-queuing a broken album heals it: the missing tracks are added and the tracks already on disk get their link. `download_series`/`download_season` still derive their episode lists from the cache the same way and want the same treatment | Downloads | UR-018, UR-055 | Done |
|
||||||
| DR-170 | Downloads at a chosen bitrate are no longer corrupted by their own retries. Only the `original` preset asks for `Static=true`; every other rung requests a **transcode**, which Jellyfin serves chunked, with no `Content-Length`, and cannot byte-seek — so it ignores `Range` and answers `200` with the whole stream from the beginning rather than `206` with the requested tail. The worker sent the Range header whenever a `.part` existed and appended the body unconditionally, so each retry and each resume concatenated a fresh copy of the entire transcode onto the bytes already on disk: the file grew past its real size and would not play, which is why "downloads for different bitrates" stayed broken after the `videoBitRate` casing fix (DR-adc460f3) corrected the *request*. `resume_offset` makes the response decide — append only on a `206`, otherwise truncate and take the stream from the top — and the total size is computed from that offset rather than from a partial length the server never agreed to | Downloads | UR-071 | Done |
|
| DR-170 | Downloads at a chosen bitrate are no longer corrupted by their own retries. Only the `original` preset asks for `Static=true`; every other rung requests a **transcode**, which Jellyfin serves chunked, with no `Content-Length`, and cannot byte-seek — so it ignores `Range` and answers `200` with the whole stream from the beginning rather than `206` with the requested tail. The worker sent the Range header whenever a `.part` existed and appended the body unconditionally, so each retry and each resume concatenated a fresh copy of the entire transcode onto the bytes already on disk: the file grew past its real size and would not play, which is why "downloads for different bitrates" stayed broken after the `videoBitRate` casing fix (DR-adc460f3) corrected the *request*. `resume_offset` makes the response decide — append only on a `206`, otherwise truncate and take the stream from the top — and the total size is computed from that offset rather than from a partial length the server never agreed to | Downloads | UR-071 | Done |
|
||||||
|
| DR-172 | Native Android video is opt-in again, because as a default it shipped as **audio with no picture**. DR-161 flipped `experimentalNativeVideo` on so picture-in-picture could shrink a real video surface; on a device that produced sound and a blank screen. The decode path was never the problem — logcat showed ExoPlayer running (`Position update` ticks) and feeding a live `SurfaceView` with an active BufferQueue. The compositing was: the SurfaceView sits *behind* the WebView, and the step that clears the opaque layers above it never took effect, with `WebView transparent = false` logged and `= true` never appearing. So the video rendered correctly the whole time, behind an opaque page. This is exactly the defect the flag existed to contain — `VideoPlayer.scrubRegression.test.ts` had recorded that "the native SurfaceView has never been visible through the webview" — and enabling it by default shipped a verified decode path on top of an unverified display path. Reverting costs nothing that matters: PiP does not depend on it (DR-160 drives PiP from the WebView `<video>`), and working video outranks PiP showing a native surface. The flag stays available in Settings, now described as incomplete rather than as a performance win, and the scrub-regression mocks that were made explicit under DR-161 are kept explicit so those tests state which path they guard rather than inheriting a default that has now moved twice. Fixing the compositing is the prerequisite for trying this default again | UI | UR-003, UR-004, UR-041 | Done |
|
||||||
| DR-171 | A downloaded video keeps audio the device can actually decode. `original` quality asked for `Static=true`, which hands back the source file byte-for-byte — E-AC-3/AC-3/DTS/TrueHD track included — and video is rendered on both platforms by the webview `<video>` element, which decodes none of them. Streaming already knew this: DR-149 judges the track the server would serve against `WEBVIEW_AUDIO_CODECS` and forces a transcode over Jellyfin's own direct-play offer, because 10.11.5 honours a `DirectPlayProfile`'s container and video codec but ignores its audio codec. The download path never consulted that policy, so the *same film* had sound when streamed and played as picture in silence once downloaded — and offline a download is the only source a video has, so there was no working path left to fall back to. The rule is now one rule: `served_audio_codec` picks the track the server will serve (the default, or the first when none is marked) and both callers judge it, the streaming verdict staying a bool and the download path needing the codec itself so it can say what to re-encode. Only the audio is re-encoded — `allowVideoStreamCopy=true` keeps an h264 source's picture byte-for-byte and no bitrate or resolution cap is added, so `original` still means original quality; a source the webview could not have rendered anyway (HEVC) becomes h264 as a side effect, which is the only form of it that would have played. The decision is per item rather than blanket because the transcode costs the byte-range resumability `Static=true` gives the download worker (see DR-170 for what a chunked, length-less response does to a resume), so a file whose audio already plays keeps the direct copy. An unknown codec — item not fetchable, or the server named none — changes nothing: the policy only ever *adds* a transcode, so it cannot make a working download worse. The codec set judged against is the **webview's**, not the platform's, even though DR-161 made ExoPlayer the Android default: `experimentalNativeVideo` is a user setting, a downloaded file outlives whatever it was set to when the file arrived, and the narrow list is the only one that holds on both sides of it — at the cost of a Dolby-licensed device re-encoding a track its ExoPlayer could have played. `resolve_video_download_url` is the single entrance for all three resolution sites (the frontend's per-item command, the bulk series/season enqueue, and the offline-queued resume), since the pure builder cannot look a codec up and a caller that forgets to is exactly how the silent downloads shipped. **Files already downloaded stay silent** — the bytes on disk are the wrong bytes and only a re-download replaces them | Downloads | UR-071, UR-004 | Done |
|
| DR-171 | A downloaded video keeps audio the device can actually decode. `original` quality asked for `Static=true`, which hands back the source file byte-for-byte — E-AC-3/AC-3/DTS/TrueHD track included — and video is rendered on both platforms by the webview `<video>` element, which decodes none of them. Streaming already knew this: DR-149 judges the track the server would serve against `WEBVIEW_AUDIO_CODECS` and forces a transcode over Jellyfin's own direct-play offer, because 10.11.5 honours a `DirectPlayProfile`'s container and video codec but ignores its audio codec. The download path never consulted that policy, so the *same film* had sound when streamed and played as picture in silence once downloaded — and offline a download is the only source a video has, so there was no working path left to fall back to. The rule is now one rule: `served_audio_codec` picks the track the server will serve (the default, or the first when none is marked) and both callers judge it, the streaming verdict staying a bool and the download path needing the codec itself so it can say what to re-encode. Only the audio is re-encoded — `allowVideoStreamCopy=true` keeps an h264 source's picture byte-for-byte and no bitrate or resolution cap is added, so `original` still means original quality; a source the webview could not have rendered anyway (HEVC) becomes h264 as a side effect, which is the only form of it that would have played. The decision is per item rather than blanket because the transcode costs the byte-range resumability `Static=true` gives the download worker (see DR-170 for what a chunked, length-less response does to a resume), so a file whose audio already plays keeps the direct copy. An unknown codec — item not fetchable, or the server named none — changes nothing: the policy only ever *adds* a transcode, so it cannot make a working download worse. The codec set judged against is the **webview's**, not the platform's, even though DR-161 made ExoPlayer the Android default: `experimentalNativeVideo` is a user setting, a downloaded file outlives whatever it was set to when the file arrived, and the narrow list is the only one that holds on both sides of it — at the cost of a Dolby-licensed device re-encoding a track its ExoPlayer could have played. `resolve_video_download_url` is the single entrance for all three resolution sites (the frontend's per-item command, the bulk series/season enqueue, and the offline-queued resume), since the pure builder cannot look a codec up and a caller that forgets to is exactly how the silent downloads shipped. **Files already downloaded stay silent** — the bytes on disk are the wrong bytes and only a re-download replaces them | Downloads | UR-071, UR-004 | Done |
|
||||||
| DR-162 | Video streams are opened against a **bandwidth ceiling the user chose**, instead of a fixed allowance nobody could change. Every video URL carried `MaxStreamingBitrate=20000000`/`VideoBitrate=18000000`, `PlaybackInfo` negotiated at 20 Mbps, and the device profile advertised `999999999` — so on a metered or slow connection the only lever was not watching. `StreamingQuality` is a ladder of ceilings (Original, 20/10/8/4/2/1 Mbps, 720 kbps) in which a step is not a label but a bundle of transcode parameters: the total ceiling, the audio share of it, and the resolution that budget can carry. It lives in Rust because those numbers are Jellyfin encoding vocabulary — the frontend names a variant and reads labels back over `player_get_streaming_qualities`, the same arrangement as the EQ preset curves. The video bitrate is the total *minus* the audio share, so the two together honour the cap rather than overshooting it by the size of the audio track, and `MaxHeight` falls with the ladder so a small budget is not spent on pixels it cannot afford. The cap has to reach the **negotiation**, not only the transcode URL: `max_static_bitrate` in the device profile is what makes the server refuse to direct-play a source fatter than the ceiling, and without it a 30 Mbps remux is handed over untouched and every URL parameter downstream is moot — which is why it is applied at all four places that decide bandwidth (the HLS builder, `PlaybackInfo`, `open_live_stream`, and the background-audio handoff, which takes the lower of the cap and its own 384 kbps). The ceiling is process-wide rather than a field on `OnlineRepository`, mirroring `INCLUDE_CATALOG_BROWSE`: it is a preference about *this device's connection*, it must survive a repository rebuilt on re-login, and every builder plus the negotiation have to agree on it or the cap leaks. Settings owns the durable default and is the only writer to `app_settings` — persisted unlike the rest of `VideoSettings`, because a limit set for a metered connection that silently reverts to uncapped on the next launch spends the user's data with no changed setting to show for it — and it is restored at startup from the async runtime, defaulting to uncapped if the read fails so a database problem degrades to the old behaviour rather than to an arbitrary limit. The in-player menu is the per-video override: a cap is a property of the stream the server is producing, so it cannot be applied to one already in flight — `player_set_stream_quality` re-opens the stream at the new quality and resumes at the current position, reloading a native backend itself and handing HTML5 a URL for the same `reloadSource` primitive the audio-track switch uses, so no strategy branch lives in the UI. It deliberately does not persist. This gives UR-070 its resume-at-the-same-point mechanism; the server-offered per-item rendition list that requirement also asks for remains proposed | Playback | UR-074, UR-070 | Done |
|
| DR-162 | Video streams are opened against a **bandwidth ceiling the user chose**, instead of a fixed allowance nobody could change. Every video URL carried `MaxStreamingBitrate=20000000`/`VideoBitrate=18000000`, `PlaybackInfo` negotiated at 20 Mbps, and the device profile advertised `999999999` — so on a metered or slow connection the only lever was not watching. `StreamingQuality` is a ladder of ceilings (Original, 20/10/8/4/2/1 Mbps, 720 kbps) in which a step is not a label but a bundle of transcode parameters: the total ceiling, the audio share of it, and the resolution that budget can carry. It lives in Rust because those numbers are Jellyfin encoding vocabulary — the frontend names a variant and reads labels back over `player_get_streaming_qualities`, the same arrangement as the EQ preset curves. The video bitrate is the total *minus* the audio share, so the two together honour the cap rather than overshooting it by the size of the audio track, and `MaxHeight` falls with the ladder so a small budget is not spent on pixels it cannot afford. The cap has to reach the **negotiation**, not only the transcode URL: `max_static_bitrate` in the device profile is what makes the server refuse to direct-play a source fatter than the ceiling, and without it a 30 Mbps remux is handed over untouched and every URL parameter downstream is moot — which is why it is applied at all four places that decide bandwidth (the HLS builder, `PlaybackInfo`, `open_live_stream`, and the background-audio handoff, which takes the lower of the cap and its own 384 kbps). The ceiling is process-wide rather than a field on `OnlineRepository`, mirroring `INCLUDE_CATALOG_BROWSE`: it is a preference about *this device's connection*, it must survive a repository rebuilt on re-login, and every builder plus the negotiation have to agree on it or the cap leaks. Settings owns the durable default and is the only writer to `app_settings` — persisted unlike the rest of `VideoSettings`, because a limit set for a metered connection that silently reverts to uncapped on the next launch spends the user's data with no changed setting to show for it — and it is restored at startup from the async runtime, defaulting to uncapped if the read fails so a database problem degrades to the old behaviour rather than to an arbitrary limit. The in-player menu is the per-video override: a cap is a property of the stream the server is producing, so it cannot be applied to one already in flight — `player_set_stream_quality` re-opens the stream at the new quality and resumes at the current position, reloading a native backend itself and handing HTML5 a URL for the same `reloadSource` primitive the audio-track switch uses, so no strategy branch lives in the UI. It deliberately does not persist. This gives UR-070 its resume-at-the-same-point mechanism; the server-offered per-item rendition list that requirement also asks for remains proposed | Playback | UR-074, UR-070 | Done |
|
||||||
|
| DR-174 | Tiles of mixed shapes are laid out **justified** rather than gridded. A CSS grid gives every cell one box, so on a page holding square music covers, 16:9 library backdrops and 2:3 posters at once, everything that is not the chosen shape is cropped to it — the home shortcut strip was explicitly forcing `aspect="video"` on music libraries for exactly this reason, which lined the row up by cutting the covers down. `layoutMosaic` packs tiles into rows of a **shared height** and gives each its own width from its own aspect ratio: it adds tiles to a row until the height needed to fill the container has fallen to the target, closes the row there (so rows land at or below the target, never above), and justifies the row to the container width by absorbing the rounding remainder into its widest tile, where a pixel is least visible. The last row is deliberately *not* justified — with one tile left over, filling the width would inflate it to a banner — so it sits at the target height, left-aligned. Ratios are clamped to a band, which costs a crop on genuine outliers and stops one panorama owning a row or one very tall image shrinking to a sliver. It is a pure module with no DOM: the component supplies only the two things the DOM knows — the measured container width, and the artwork's *decoded* aspect ratio, reported by `CachedImage` so the layout uses the shape an image actually has rather than the one its item type implies. Those measurements are committed in one debounced batch rather than per image, because artwork arrives over several hundred milliseconds and re-packing on each arrival would shuffle the grid under the pointer repeatedly. Labels are drawn *over* the bottom of each tile rather than beneath it: a caption below sits outside the computed box, and one that wraps to two lines would break the row alignment the layout exists to provide | UI | UR-075 | Done |
|
||||||
|
| DR-175 | A library knows which favourites category it belongs to, and the frontend does not work it out. The mosaic offers a favourites tile per category beside its library, which needs a collection-type → category answer; deriving it in Svelte would have re-created the exact leak `SearchScope::item_types` was extracted to close (docs/specs/scoped-search-boundary.md) — one table of Jellyfin vocabulary, differing only in which vocabulary. `SearchScope::for_collection_type` maps `movies`/`tvshows`/`music` and returns `None` for everything else, so a Live TV or books library gets no tile at all rather than one opening an unfiltered list; `All` is never derived from a library, being the cross-library entry offered beside them rather than a property of one. `Library::new` stamps the result onto every library at construction — a constructor rather than a struct literal precisely so a derived field cannot be forgotten at one of the four sites — and it rides to the frontend as an optional `favoritesScope`, absent rather than null when there is none. The UI's remaining share is presentation only: what to call the tile, where to put it, and showing a category's tile **once** however many libraries share it, since two movie libraries have one favourites list between them | UI | UR-075, UR-067 | Done |
|
||||||
|
| DR-176 | The server is never asked to burn a subtitle into the picture. `PlaybackInfo` omitted `SubtitleStreamIndex`, which does not mean "none" — the server then honours the source's default/forced flag and picks a track itself. On a source whose default subtitle is image-based (PGS/DVD/DVB) that track cannot go out as a sidecar, so the server falls back to `SubtitleMethod=Encode` and composites it into the video. The cost lands on the *video*, not the subtitle: burn-in rules out remuxing, so an HEVC stream the device could have taken untouched is re-encoded frame by frame. Observed on an HEVC + E-AC-3 + PGSSUB episode, where only the audio actually needed transcoding: the server could not sustain the re-encode in real time, the buffer never grew past a single segment, and playback stalled every few seconds — taking seeking with it, since each seek restarted the encoder and cost seconds before the first frame. The fix is to request `SubtitleStreamIndex=-1` explicitly and to advertise every *text* format we can render (`srt`/`subrip`/`ass`/`ssa`/`vtt`) as `External`, so a subtitle can only ever arrive as a sidecar. Nothing is lost, because the app already fetches subtitle tracks itself and draws them over the video (UR-020) — the server's composited copy was always redundant. Image-based tracks are consequently not offered, which is honest rather than a regression: the renderer cannot composite a bitmap, and the previous behaviour paid for them by making the whole stream unwatchable. Both halves of that hold at the layer that can enforce them. The sentinel travels on the stream URL as well as in the negotiation, because the negotiation is not what opens most streams — a quality switch, a transcoded seek and an audio-track switch each rebuild the URL on their own, and an omitted index there lets the server pick the default track back up out of whatever session state it still holds. And "not offered" is enforced where the offer is made: each subtitle stream crosses the boundary carrying the backend's verdict on whether it can arrive as a sidecar, so the picker lists only tracks the app can draw instead of showing an entry that ticks and displays nothing. Only an explicit "no" hides a track, so a stream carrying no verdict behaves as before | Playback | UR-020, UR-004 | Done |
|
||||||
|
| DR-177 | Each video transcode this device opens is its own server-side job, and the one it replaces is stopped. Jellyfin keys a transcode job by device **and** play session, and every stream URL the app built carried the same hardcoded `DeviceId` with no `PlaySessionId` at all — so the second stream for an item was indistinguishable from the first. Re-opening a stream is not rare: a mid-playback quality switch (UR-074), a transcoded seek and an audio-track switch all do it, each leaving the previous ffmpeg running. Observed on-device when switching bitrate mid-film: the server served the new playlist, then rejected the new job's segments with `400 hls1/main/0.ts` while the two jobs contended for one transcode path, and playback stalled — reproducible against the server, where a second stream for a live job's item alternates between serving bytes and 400ing per attempt, which is what made it read as flaky rather than broken. `begin_video_play_session` mints a session id per open and reports the one it supersedes; the URL builder stops that job (`DELETE /Videos/ActiveEncodings`, un-retried and best-effort — a slow stop must not delay playback, and the new stream no longer collides either way) before returning. Placing it in the URL builder rather than in each caller means every re-open path is covered by construction. Two client faults made the same incident worse and are fixed with it: the fatal-HLS-error handler added the transcode seek offset to a position that already included it, so past roughly the halfway mark of a film any transient network error cleared the "near end" threshold and was reported as end-of-stream — turning a recoverable stall into a skip to the next item, exactly when a quality switch had just made the offset large; and the HTML5 reload primitive resolved on its own `canplay` timeout, so a reload the server never served reported success, leaving the picker showing a quality that was not playing and the caller with nothing to revert | Playback | UR-074, UR-004 | Done |
|
||||||
|
| DR-178 | Every position that leaves the app is read from the controller, not from a backend that may not be playing anything. `PlayerController::position()` forwards to the native backend, which is authoritative for exactly one of the three ways this app renders media. On the **webview** path — the shipping default for video on both platforms — nothing is loaded into that backend at all: the `<video>` element is the player, its ticks were re-emitted to the frontend and then dropped, and the backend answered 0 forever. During a **background-audio handoff** the base that converts the stream's relative timeline to the episode's is applied once at the native tick boundary (DR-159), so before ExoPlayer's first tick nothing has applied it and the reading is 0 there too. Both holes surfaced as the same user-visible bug through different doors: returning to the foreground while the audio-only transcode was still opening handed the frontend `0.0`, and the video reloaded at `StartTimeTicks=0` — the episode restarting from the beginning — while the `Stopped` report that followed wrote that zero to Jellyfin as the resume point. `absolute_position()` answers for all three paths: the maximum of the backend's reading, the last position webview-rendered media reported, and the handoff base. The maximum is exact rather than a heuristic, because at most one term is ever meaningful at a time and the base is a floor the stream cannot physically be behind. `duration()` gains the same fallback for the same reason. The element's reading is cleared wherever it stops being the player — teardown, a handoff taking over, a different item loading — so it can never be attributed to what plays next | Player | UR-005, UR-025, UR-040 | Done (pending device verification) |
|
||||||
|
| DR-179 | Jellyfin is told what was played: progress while it plays, and a stop when it ends. A device trace of 35 minutes' playback requested `/Sessions/Playing/Progress` **zero** times and sent 14 `Stopped` reports, every one of them at position 0. Three faults, one subject. *Progress never left the device*: the frontend service writes it to the local DB by design, and nothing on the Rust side reported it for webview-rendered media — so the server learned a position only when the player was closed, and a crash or a swipe-away cost the session. It is now reported from the controller's own position ticks, through the 30s throttler it already owned and shares with the native audio path, which covers all three rendering paths in one place instead of adding a second frequent IPC caller. *Zero-position stops were sent*: Jellyfin stores the reported position as the resume point, so a zero does not merely fail to inform, it instructs the server to forget — and no zero was ever real, each one coming from asking a player that was not rendering the media (see DR-178). They are withheld; one landed 40s after the frontend had correctly reported 15:22 for the same episode, overwriting it. *A finished episode reported nothing at all*: Jellyfin decides "watched" from the stop report and its percentage, and in background audio-only mode nobody sends one — the webview is suspended and its element was torn down at the handoff, while the backend advances to the next episode without a word about the one that ended, so an episode listened to end-to-end on the lockscreen never counted as watched. `on_playback_ended` now reports it stopped at its **runtime** (not the last tick, which can be seconds short or, on a handoff whose ticks stopped early, nowhere near the end) before any advance, since after one the queue's current item is the next episode. Scoped to the audio-only handoff, the case the frontend provably cannot cover, so foreground playback keeps its single existing report; music ending natively remains unreported and wants its own change. The reporting seam is a `PlaybackReportSink` the controller sends to, which also collapses three copies of the spawn-a-task-and-hope block into one and is what let all of this be written as failing tests rather than found on a device a second time | Player | UR-025, UR-005, UR-040 | Done (pending device verification) |
|
||||||
|
| DR-180 | A background-audio handoff of a **downloaded** episode starts where the video left off. The handoff prefers a local file over the audio-only stream (DR-128), but the two begin in different places and were treated alike: a stream is built with `StartTimeTicks`, so the server makes the handoff point that stream's zero and the base is the handoff position with no seek — while a file has no such parameter and begins at the episode's own zero, so basing it at the handoff position claimed minutes of audio that were about to play from the beginning. Backgrounding a downloaded episode therefore restarted it while the lockscreen scrubber, dutifully adding the base, showed the position it should have been at. `background_audio_plan` splits the two: a file gets no base and a real seek, a stream keeps the base and no seek (seeking one would skip *past* the content by the handoff position again). The same distinction settles an inbound seek — `seek_absolute` re-opens a *streamed* handoff at the requested position because a chunked length-less transcode cannot honour a seek, which is not true of local media, and `resume_stream_at` refuses a non-remote source outright, so routing a lockscreen scrub of a downloaded episode through it failed the seek rather than performing it | Player | UR-040, UR-071 | Done (pending device verification) |
|
||||||
|
| DR-181 | A resumed transcode plays. Every video stream URL carried the resume position as `StartTimeTicks`, which is correct for a progressive response and fatal for an HLS one: Jellyfin builds each segment URI by echoing the **master playlist's** query string into it, and its segment handler opens by rejecting any request carrying `StartTimeTicks > 0` (`ArgumentException` → `400`). One position on the playlist therefore 400s every `hls1/main/N.ts` behind it, so hls.js exhausted its retries and gave up — presenting as an episode that will not resume while the same episode from the beginning is fine, the `> 0` being exactly why the beginning survived. The parameter is also unnecessary there: a playlist spans the whole item and asking for segment N *is* the seek, which the server transcodes from. So it is removed from the URL builder entirely rather than conditionalised — the builder has one caller shape and no way to know whether the response will be segmented — and the position becomes what it always was for HLS, a seek issued once the player has loaded: the seek path reloads at zero and seeks the element, and the resume path lets the player seek itself. The progressive `/Audio/universal` builder used by the background-audio handoff is a different endpoint with no segments and keeps its `StartTimeTicks`, which is why an audio-only handoff resumes correctly and a video one did not | Playback | UR-004, UR-074 | Done |
|
||||||
|
| DR-182 | Native video shows a picture. The poster/title card is an opaque `bg-black` overlay drawn over the whole video area while `isMediaReady` is false, and **every** signal that clears it is emitted by the HTML5 `<video>` element — `canplay`, `loadedmetadata`, hls.js `FRAG_BUFFERED`, the `playing` event, and two `readyState` timeouts. The native path renders no such element (`{#if !!useHtml5Element}`), so on Android nothing could ever clear it: ExoPlayer decoded to a live SurfaceView behind a black div for the entire session. That is DR-172's "audio with no picture" report, and it is indistinguishable on screen from the compositing failure DR-172 attributed it to — which is why the flag was reverted rather than fixed. Both the overlay and the native branch date from the original POC commit, so the native path has never been able to reveal itself; the 2026-08-11 device verification predates neither and does not contradict this, since a spike run that never reached a steady state would not have shown it. The backend's own events are the equivalent signals and `nativeSignalRevealsVideo` is the rule for reading them: `state === "playing"` mirrors the element's `playing` event, and a position tick carrying a real position or duration mirrors the `readyState` backstops, covering a first state event that is dropped or arrives before the listener is attached. `buffering`/`paused`/`stopped`/`error` deliberately do not qualify — revealing on `error` would replace the title card with a transparent hole showing the launcher through the app. The rule is a pure module rather than a branch inside the component because the decision that was missing is exactly the part worth guarding, and the component needs a DOM and a mounted player to exercise | UI | UR-003, UR-004, UR-041 | Done |
|
||||||
|
| DR-183 | The JavaScript bridges are installed before the page that uses them loads. WebView binds an injected object into JS at **page-load time**: an `addJavascriptInterface` call landing after the page has loaded does not appear to that page. They were installed from `configureWebViewForMedia`, which finds the WebView by walking the view tree 500 ms after `onCreate` — a race against Tauri's own page load, and one that is *permanent* when lost, because the identity guard added for DR-097's stale-proxy bug then declines to re-inject on every later resume pass. The whole set (`AndroidVideoSurface`, `AndroidPictureInPicture`, `AndroidBackgroundAudio`, `AndroidNetworkType`, `AndroidImmersive`, `AndroidInsets`) would simply be absent from `window`, and silently: every call site optional-chains the bridge, so a missing one is a no-op rather than an error. This is a candidate explanation for DR-172's other piece of evidence — `WebView transparent = false` logged, `= true` never appearing, i.e. the enable call never reaching Kotlin at all. `WryActivity.setWebView()` calls the `onWebViewCreate` hook immediately before wry issues the first `loadUrl` (confirmed in wry 0.55's `main_pipe.rs`, where the `setWebView` JNI call precedes `load_url`), so a bridge installed there is bound by the time any page runs. The hook can fire during `super.onCreate()`, before the rest of our own `onCreate`, so only work needing nothing but the WebView moves into it — insets stay in `configureWebViewForMedia`, which runs later and on every resume. The tree-walk path is kept as a fallback, and `enableNativeVideoCompositing` now logs an explicit error when the bridge is missing, so the ambiguity that left DR-172 unresolved cannot recur silently | Android | UR-003, UR-004, UR-040, UR-041 | Done |
|
||||||
|
| DR-184 | The video SurfaceView leaves the view hierarchy when the video does. `VideoOverlayManager.detachVideoSurface` had **no callers anywhere in the tree** — the mirror of the DR-151 defect, where `setActivity` had none — so `attachVideoSurface` was one-way: `JellyTauPlayer.clearVideoSurface()` dropped its `surfaceView` reference and cleared ExoPlayer's without removing the view, leaving it parented to the content view for the life of the process, with the next native video adding another SurfaceView beneath it. The stack was invisible while the WebView was opaque, which is why it went unnoticed. Two consequences outlive the leak: `isVideoSurfaceAttached()` gates `PictureInPictureManager.canEnterPip` through `isNativeVideoPath()`, so it reported an attached surface forever after the first native video (saved from offering PiP over nothing only by the `isPlayingVideo()` check beside it), and every abandoned surface held its `OnLayoutChangeListener` on the content view. Detach is called from `clearVideoSurface`, which covers stop, the switch to audio, and the background-audio handoff, and always runs on the main thread because every caller is already inside a `mainHandler.post`. It removes the view from its *own* parent rather than looking the content view up from an Activity reference, so an Activity recreated underneath it cannot strand the view | Android | UR-003, UR-041 | Done |
|
||||||
|
| DR-185 | The app shell stops painting over the video surface. `app.css` clears the page's opaque layers for native video through three selectors, and one of them — `html[data-native-video="active"] [data-app-shell]` — was written against an attribute **no component has ever set, in any commit**. The shell is `+layout.svelte`'s root `div`, which paints `--color-background` across the entire viewport; VideoPlayer is `fixed inset-0 z-50` and correctly makes *itself* transparent on the native path, but it stacks *above* the shell, so the WebView still composited the shell's opaque background over the whole screen and the SurfaceView behind it could never be seen. This is the missing half of the compositing DR-172 went looking for: the spec's own layer table lists this layer as "cleared by `data-native-video` → app.css", which was written but never wired, and `html`/`body` being genuinely transparent made the CSS look correct in isolation. The failure is invisible three ways over — the CSS is valid, the selector is plausible, and a rule matching nothing looks exactly like a rule matching something already transparent — while the symptom (black screen, audio fine) is identical to a real compositing failure, which is how it survived DR-150 through DR-172. Fixed by setting the attribute the rule was written for, and guarded by asserting the *relationship* rather than the rule: every attribute the compositing block targets must be set somewhere in the app, so a selector aimed at nothing fails the suite instead of failing silently on a device | UI | UR-003, UR-004, UR-041 | Done |
|
||||||
|
| DR-186 | The play overlay comes down when the backend plays. `isPlaying` was assigned once from the `player_play_item` response and thereafter only by the `player://state-changed` listener — a channel the backend never emits, the same dead wire that DR-182's first fix was mistakenly hung on. On the native path the flag therefore froze at whatever the initial response said: with ExoPlayer playing, the UI still believed it was paused, so the `bg-black/30` play-button overlay stayed raised across the whole video area and the transport button kept showing ▶. The video was simultaneously dimmed and covered while it played, which reads as "the overlay never goes away" and is easily mistaken for a second compositing fault. The mirror reads the same `player` store `playerEvents.ts` feeds, which is what the architecture already says is authoritative — the player reports state, the UI consumes it — and is gated to the native path so HTML5 keeps its element-event wiring, which is authoritative there | UI | UR-003, UR-005 | Done |
|
||||||
|
| DR-187 | The system bars go away with the player, not only with the fullscreen button. `enterImmersive()` had exactly one caller, `toggleFullscreen()`, so opening the player left the status and navigation bars painted over it until the user pressed a button most never press. On the native path this is worse than cosmetic: the SurfaceView fills the content view, so the bars sit directly on top of the video. The player is a full-screen surface by construction — `fixed inset-0 z-50` over a `MATCH_PARENT` surface — so entry is the right moment. Called synchronously in `onMount` before any `await`, per the native-mode pitfall, and paired with the `exitImmersive()` already unconditional in `onDestroy`, so a player torn down while immersive cannot leave the rest of the app without bars | UI | UR-066, UR-003 | Done |
|
||||||
|
| DR-188 | Native Android video is **ready to be the default except for the background-audio handoff**, and the flip therefore waits. The picture defects behind DR-172 are all found, fixed and device-verified — DR-185 (the app shell painted over the surface through a CSS rule targeting an attribute nothing set), DR-182 (nothing could lift the poster card on a path with no `<video>` element), DR-183 (the JS bridges raced the page load, so `setTransparent(true)` could never arrive), DR-184 (the SurfaceView was never detached), plus DR-186 and DR-187, the two UI defects only this path could reveal. On a device logcat now carries `WebView transparent = true` and `Marking media ready` with video on screen, which is the pair DR-172 went looking for and could not find, and skip, seek and rotation were exercised by hand. Turning the default on then surfaced a *different* unverified sub-path: the background-audio handoff could only *return* through the HTML5 element, so coming back from the lockscreen left playback dead, and the flip waited for that rather than shipping a verified sub-path over an unverified one as DR-161 had. **The default is now on.** The two defects holding it back are fixed and device-verified — DR-196 (the handoff return restarts the renderer that is actually on screen) and DR-194 (the letterbox bars are painted rather than retaining stale framebuffer content) — with the evidence this default has been held to since DR-161: an audio handoff at 69:54 returning to video playing at 70:18, and clean bars across playback, the control bar and a rotation round-trip. An explicit stored choice still wins in both directions, so an opt-out survives the flip (the stored value is null-checked rather than compared to "true", which would have silently re-enabled it for everyone who turned it off) | Android | UR-003, UR-004 | Done |
|
||||||
|
| DR-189 | The control bar comes down on a touchscreen. Its hide timer was armed from exactly one place — the player container's `onmousemove` — and a touchscreen never fires `mousemove`, so on Android the bar was never scheduled to hide and sat over the video for the whole film. It went unnoticed for as long as the native video surface was itself invisible (DR-172/DR-185): with nothing behind it to obscure, a permanent control bar reads as the UI rather than as a defect. Two changes, because there were two faults. `revealControls()` replaces `handleMouseMove` and is called on entry and on every touch interaction as well as on mouse movement, so touch arms the countdown. And the countdown became an `$effect` over the state rather than a one-shot timer armed by the input event: the first attempt armed a timer on entry, three seconds later playback had not started, `shouldHideControls` correctly declined, and nothing ever re-armed it — the timer has to follow the conditions that *permit* hiding, which arrive on their own schedule. The decision itself is `shouldHideControls` in `controlsVisibility.ts`, pure and separated from the clock and the DOM, because what was wrong here was the conditions and not the `setTimeout`: the bar stays up while paused (a user who paused by tapping the surface has no other way back), mid-seek (the position readout is the point of the bar then), and while any track/subtitle/quality menu is open (the menus are anchored to the bar, so hiding it would take the open menu with it) | UI | UR-003, UR-066 | Done |
|
||||||
|
| DR-191 | Forcing the WebView overlay to redraw from the Activity, because with the ExoPlayer **SurfaceView** beneath it the overlay's ordinary damage stopped reaching the screen: the page kept mutating — the clock text every second, the control bar's opacity going to 0 — while the display held whatever frame it last presented, over video that animated perfectly. Not a state defect; the live DOM showed the slider advancing 476 → 479 across three seconds behind a screen showing neither. Only **structural** changes got through, which is why the play overlay always appeared to work (an `{#if}` block, added and removed) while the progress bar never did, and why rotation lost the transport UI. A CSS animation cannot help, since opacity animates on the compositor without repainting the layer. **Superseded by DR-192**: this drove `postInvalidateOnAnimation` in a loop, which treats the symptom — the cause is the SurfaceView's separate layer, and removing that removes the need. Kept as the record of how the mechanism was identified | Android | UR-003, UR-004 | Superseded by DR-192 |
|
||||||
|
| DR-195 | Play/pause works on the native path, because the frontend stops claiming a webview element is playing when there is none. `html5_playing` is Rust's record of "a webview `<video>` is active and in this state", and `toggle_playback`, `play` and `pause` all route transport to that element whenever it is set. The player route mirrored element state into it **unconditionally** — from `handleReportStart` and, fatally, from `handleReportProgress`, which VideoPlayer calls on a 10-second interval — so on the native path the frontend re-declared every ten seconds that an element was playing when none existed, and every transport intent was emitted into the void. The pause button was dead from the on-screen tap, from the control bar, and from a direct `player_toggle` invocation, while seek and skip kept working because `player_seek_video` decides elsewhere; that asymmetry is the signature. It also explains the flashing, since the control bar and the JRay overlay both key off `isPlaying`, which was being contradicted on every interval tick. DR-193 clearing the flag at load was necessary but insufficient on its own — the interval put it straight back. The mirror now lives in `mirrorElementStateToRust` in VideoPlayer, gated on `useHtml5Element`, which is the only place that knows whether an element renders at all; the route cannot tell the two paths apart, which is precisely how it came to lie. Confirmed on device by ADB: surface tap and control bar each pause (position frozen across repeated samples, transport label flipped) and resume | Playback | UR-005, UR-003 | Done |
|
||||||
|
| DR-196 | Returning from background audio brings the picture back on the **native** path, because the return now restarts the renderer that is actually on screen. The two paths resume by different means: the webview `<video>` reloads off its stream URL, watched by an `$effect` that reinitialises HLS and lets `canplay` drive the seek — while ExoPlayer owns no element and nothing watches the URL on its behalf, so its playback is only ever started by an explicit `player_play_item` + adapter load, issued once from `onMount`. `exitBackgroundAudioHandoff` did only the URL assignment, for both paths, so on the native path it restarted nothing: `player_exit_background_audio` had already stopped the handoff's audio player, leaving the backend holding no item at all. The symptom is a black screen with a play overlay pinned at 0:00, a seek bar at zero, and a play button that does nothing — the process alive and the frontend still logging, since nothing crashed; the transition was simply dropped. The branch is decided by `planHandoffReturn` (pure, in `backgroundAudioHandoff.ts`), which also folds in `shouldResumeOnForeground` so a lockscreen pause during the handoff still wins over the snapshot taken on the way out. Subtitle configurations are reused from the ones resolved at mount, since ExoPlayer sideloads them as `MediaItem.SubtitleConfiguration`s and cannot accept one after `prepare()`. Verified on device: handoff to audio at 69:54, return restored video playing at 70:18 | Playback | UR-040, UR-003 | Done |
|
||||||
|
| DR-197 | Continue Watching and Next Up stop showing the same episode. Jellyfin's `/Shows/NextUp` defaults `EnableResumable=true`, which returns a partially-watched episode as its own series' next up — precisely the episode `/Items/Resume` already returns — so the Home "Next Episode" row and the TV landing's Next Up row duplicated Continue Watching card for card. `build_next_up_endpoint` sends `EnableResumable=false`, and because servers predating that parameter ignore it, `filterInProgressNextUpItems` also drops any next-up entry whose id appears in the resume list. It is the mirror of DR-089 and lives beside it: same presentation-layer de-duplication over two lists the frontend already holds, no Jellyfin taxonomy involved. The resume filter still reads its frontier from the *unfiltered* Next Up list, so removing in-progress entries cannot resurrect a stale resume card. The division is then exact: Continue Watching offers episodes the viewer has started and not finished, Next Up offers the episode after the ones they finished | Repository | UR-059 | Done |
|
||||||
|
| DR-200 | The lockscreen notification is exempt from `POST_NOTIFICATIONS`, because of the **session token**, not because it belongs to a foreground service — and the difference is what the code now records. `POST_NOTIFICATIONS` was declared in the manifest and requested nowhere, so on Android 13+ it sat permanently denied; an audit read that as a threat to UR-006, since the media notification is what carries the lockscreen transport controls. It is not. Android's own wording is that the permission covers "non-exempt (including Foreground Services (FGS)) notifications", with denied users seeing FGS notices "in the Task Manager but [not] in the notification drawer" — so an FGS notification is explicitly *not* exempt — while separately "Notifications related to media sessions are exempt from this behavior change". The platform predicate is `Notification.isMediaNotification()`, which requires `MediaStyle` **and** a non-null `EXTRA_MEDIA_SESSION`, and it is byte-identical across API 33–36. `NotificationManagerService` uses it to decide whether to drop the post, and SystemUI's media carousel (`MediaDataProcessor.onNotificationAdded`) is gated on the *same* predicate — so a token-less notification is not merely absent from the shade, it never reaches the notification listener and the lockscreen/Quick-Settings controls do not exist at all. Confirmed on device (HONOR ROD2-W09, Android 16 / SDK 36): appops `POST_NOTIFICATION: ignore`, `granted=false`, and the service simultaneously `isForeground=true` with `foregroundNoti=Notification(category=transport actions=3 vis=PUBLIC)`. So **no runtime permission request is added** — a prompt the app does not need is a prompt that can be permanently denied for nothing — and no `checkSelfPermission` gate is placed on `startForeground`, which would trade a cosmetic problem for the "did not then call Service.startForeground()" kill. What is added is the guard that matches the real precondition: `mediaSessionCompat?.sessionToken` is a null-safe call, and the exemption hangs entirely on it, so both builders now bind the token once and log an error if it is ever null while the permission is denied — converting a failure that is invisible unless the tester happened to deny the permission (most grant it reflexively) into a logcat line. The manifest declaration is *kept*, unrequested, and documented: media3 does not need it (media3-session declares no permissions and the `MediaSessionService` guide asks only for the two `FOREGROUND_SERVICE` ones), but the exemption covers media and self-managed-call notifications only, so a download-completion notice (UR-011) would be an ordinary notification and silently dropped — keeping the declaration is what makes adding one a one-file change | Android | UR-006 | Done |
|
||||||
|
| DR-201 | A lockscreen skip means different things depending on what is playing, and the backend decides which. `onSkipToNext`/`onSkipToPrevious` forwarded a bare `"next"`/`"previous"` to Rust, which always advanced the queue — correct for music, wrong for a video whose audio is running through a background-audio handoff (UR-040), where the buttons should scrub. Pressing skip to re-hear a line jumped to the next *episode* instead. `resolve_skip_action` in `player/seek.rs` maps the command to either `Advance` or `SeekTo`, and `is_background_audio_active()` is the whole test: the handoff exists only for video, and an episode played through it reports `MediaType::Audio`, so media type cannot distinguish the case. Forward jumps 30s, back 10s — asymmetric because the back button replays dialogue just missed rather than travels — and both clamp to `[0, duration]`, since a negative offset is rejected by backends and a seek past the end reads as EOF and would advance, the very outcome being prevented. Routed through the same spawn-then-`seek_absolute` path as the scrubber, because a handoff seek re-opens the stream and must not run under the blocking lock (DR-159). The Kotlin keeps sending the same opaque command; only the `PlaybackStateCompat` gains `ACTION_FAST_FORWARD`/`ACTION_REWIND` so the system draws seek affordances rather than skip arrows that lie about what they do | Playback | UR-040, UR-006 | Done |
|
||||||
|
| DR-202 | Video keeps the display awake. Android counts its display timeout from the last *user input*, and watching something is exactly the case where there is none, so the screen dimmed and slept mid-film unless the user kept tapping it. Nothing held it: `FLAG_KEEP_SCREEN_ON` appeared nowhere in the app, and neither renderer supplies a hold for free — ExoPlayer's `setWakeMode` is a CPU/wifi wake lock that says nothing about the display, and it draws into the `TextureView` this app owns (DR-192) rather than media3's `PlayerView`, which is the widget that would otherwise set `keepScreenOn` itself; the WebView `<video>` path is no better, because the display wake lock Chrome takes for video lives in the browser layer and not in an embedded WebView. `ScreenWakeManager` toggles `FLAG_KEEP_SCREEN_ON` on the Activity window — window-scoped, so it stops applying the moment the app is not visible and cannot outlive a crash the way an explicitly acquired `PowerManager.WakeLock` can, and it needs no permission (the manifest's `WAKE_LOCK` is the media service's). The two rendering paths are independent holders OR-ed in the pure `ScreenWakeState`: the native path follows `onIsPlayingChanged` plus surface teardown, so the hold tracks what ExoPlayer *reports* rather than what the UI intends, and the webview path reuses the `setHtml5VideoState` report the frontend already sends for PiP (DR-160) rather than adding a bridge. Audio is deliberately not a holder — playing music with the screen off is the point of that path — so the hold is gated on the media type being video, and it is dropped on pause, on stop, on surface teardown, and on a new WebView, since a page that goes away never sends its own final `active = false`. Also the repo's first Kotlin JVM unit tests: `ScreenWakeState` is framework-free so the decision is testable off-device with `./gradlew :app:testUniversalDebugUnitTest`. Verified on device (FP5, native path): `IS PLAYING CHANGED: true` → `keepScreenOn = true` 17 ms later and `fl=KEEP_SCREEN_ON` on the window in `dumpsys`, a pause releasing it and the resume re-taking it. The webview path is unverified | Android | UR-003, UR-004 | Done |
|
||||||
|
| DR-203 | The background-audio handoff stops silently rewinding to the point it started. A player retry is only a *retry* if it can resume where the load failed, and ExoPlayer decides that in `ProgressiveMediaPeriod.configureRetry`: it keeps the load position when the content length is known or the extractor produced a seek map with a duration, and otherwise assumes the source is live — the data at the URL is taken to have changed, so every sample queue is reset and the URL is re-requested from offset 0. The handoff transcode (`/Audio/{id}/universal?Container=mp3&TranscodingProtocol=http`, DR-129) satisfies neither condition: chunked, so no `Content-Length`, and a live mp3 encode carries no `Xing` header, so the duration is unset — on device every position tick reads `<position> / 0.0`. Its URL carries `StartTimeTicks` = the handoff point, so "from offset 0" is the handoff point, and after any transient load error playback resumed there and ran on normally. Nothing was reported: a successful retry raises no error and no `STATE_ENDED`, so neither arm of DR-129 was ever consulted, no `onPositionDiscontinuity` handler existed, and the app's only trace of it was a position that went backwards — which is why it read as random, since it needs a network blip to land while a load is in flight rather than while the ~50s buffer covers it, and why it survived the two earlier fixes for the same *symptom* (DR-129's phantom end, DR-159's relative-timeline leak). The decision is Rust's: `player_retry_restarts_stream` marks a `Remote` audio-only video item, and `loadWithMetadata` carries the answer to Kotlin, where the pure `StreamRetryDecision` holds it for a `DefaultLoadErrorHandlingPolicy` subclass that returns `C.TIME_UNSET` — which makes `onLoadError` answer `DONT_RETRY_FATAL` *before* reaching `configureRetry`. The rewind therefore becomes a recoverable error, and `recoverable_error_resume` already knows what to do with one: re-open at the position playback actually reached, `StartTimeTicks` rewritten, with backoff and the shared attempt budget. Every other source keeps the player's retry, because a static file and an HLS playlist both declare their timeline and are resumed in place. A `onPositionDiscontinuity` handler is added for the log line alone, so a recurrence is visible rather than invisible — loud for `DISCONTINUITY_REASON_INTERNAL`, which is the rewind's own signature, and quiet for the backwards jump a resume's re-prepare legitimately makes. Reproduced and verified on device (FP5), same procedure both times: background-audio handoff, 60s to fill the buffer, a 45s radio outage, then watch. **Before** — the outage passed unnoticed and 3.5 minutes later, with nothing logged in between, `BUFFERING` → `READY` → position `1165.4s` → `840.3s`, exactly the handoff base, no error and no `STATE_ENDED`; the same log line reports `Media ready! Duration: -9.223372036854776E15`, which is `C.TIME_UNSET` and the precondition itself. **After** — `Load error on a stream that cannot be resumed in place — declining the player's retry` at the outage, playback continuing undisturbed off the buffer for 69s (a fatal load error is only raised when the renderer next needs data), then `ERROR_CODE_IO_NETWORK_CONNECTION_FAILED` → `re-opening at 785.6s in 2s` → `READY`, playing on from 785.6s with no rewind in the following 7 minutes | Playback | UR-040, UR-004 | Done |
|
||||||
|
| DR-199 | The webview stops undoing the network security config. `MainActivity.configureWebViewSettings` set `mixedContentMode = MIXED_CONTENT_ALWAYS_ALLOW` together with `allowFileAccess = true` and `allowContentAccess = true`, which is a blanket cleartext opt-in reached by hand — exactly the thing `network_security_config.xml` exists to prevent and its own comment warns against (DR-138). Nothing needed any of the three. `file://` is never loaded: cached thumbnails go through `convertFileSrc`, which on Android resolves to `http://asset.localhost/…` and is answered by wry's request interceptor rather than the filesystem, and downloaded media goes over the loopback HTTP server (DR-137), which exists precisely because the asset/file route cannot stream a large file. `content://` is never loaded either — the manifest's `FileProvider` is for outbound share intents, not webview navigation. And mixed content never arises: Tauri serves the UI from `http://tauri.localhost` (`use_https_scheme` defaults false and is not set in `tauri.conf.json`), while both `127.0.0.1` and `asset.localhost` are loopback/`.localhost` origins that Chromium treats as potentially trustworthy, so they are not mixed content to begin with. A plain-HTTP *remote* Jellyfin server would be, but the network security config already rejects it before any mixed-content check runs — so `ALWAYS_ALLOW` bought nothing and only widened the hole. `COMPATIBILITY_MODE` rather than `NEVER_ALLOW` is a deliberate hedge and not the default — the platform default at targetSdk 21+ *is* `NEVER_ALLOW` — because none of this can be verified anywhere but a device, and compatibility mode keeps passive content (images) working if the analysis missed a path. `allowFileAccess = false` restores the targetSdk-30+ default; `allowContentAccess = false` is a genuine tightening (its default is true) and is the first thing to look at if something that used to render stops. The two files now cross-reference each other so the pair cannot drift apart again | Security | UR-071 | Done (pending device verification) |
|
||||||
|
| DR-194 | Stale pixels in the letterbox bars — the rotation "flash of the previous frame", a ghost control bar stranded in the top bar, each new clock digit drawn over the last (`35:42` with the `1` still showing through the `2`), and menus (sleep timer, quality) leaving their imprint behind. One cause for all of it: **nothing painted the bars.** The window surface is opaque (the theme is not translucent), and for an opaque surface HWUI deliberately does not clear the damaged region before replaying a frame — it assumes the view hierarchy covers every pixel. That hierarchy is window background → video `TextureView` → transparent WebView, and `fitSurfaceToScreen` sizes the TextureView to the *letterboxed* video rect, so the bars were the window background's alone to paint. `setTransparent(true)` cleared that background to `TRANSPARENT`, leaving the bars painted by nobody and whatever was last in the framebuffer surviving in them. Fixed by keeping the window background opaque black while compositing; the WebView's own background is what lets the video through, and the TextureView is drawn on top of the window background, so an opaque one cannot hide it. Three earlier fixes aimed at the window's rotation animation and at TextureView frame-retention (two `postOnAnimation` hops, an `onSurfaceTextureUpdated` reveal, then `ROTATION_ANIMATION_JUMPCUT` + `FLAG_FULLSCREEN`) all missed, because the pixels were never the animation's; the alpha-hiding among them made it worse by blanking the one view that reliably paints its own rect. Those are removed, `FLAG_FULLSCREEN` included — it fought edge-to-edge insets for no gain. Verified on device: ghosting reproduced with native video on, then absent after the fix, across playback, the control bar and a rotation round-trip | Android | UR-003, UR-066 | Done |
|
||||||
|
| DR-193 | Play/pause reaches the player that is actually rendering. `toggle_playback`, `play` and `pause` all route to the webview element when `is_html5_active()`, which is `html5_playing.is_some()` — a flag written **only** by the element's own state reports and cleared only when it reports "stopped"/"idle" (or on a background-audio handoff). An element that went away without that final report, or webview-rendered music earlier in the same process, therefore left the flag set, and on Android's native video path every transport intent was emitted as a `ControlCommand` at an element that no longer existed: the pause button did nothing, from the on-screen tap and from the control bar alike, while seek and skip kept working because `player_seek_video` decides elsewhere. Whether it happened at all depended on what had played before, which is exactly what made it read as flaky rather than broken. `load_and_play` — the native load path, and the one the HTML5 video path deliberately avoids via `set_current_item` — now clears the flag, because loading into the native backend *is* the statement that native renders this item. Nothing is lost on the webview path: an element re-establishes its own authority the moment it reports again, so this is the existing "element is gone" semantics applied where it can be known directly rather than inferred from a report that may never arrive | Playback | UR-005, UR-003 | Done |
|
||||||
|
| DR-192 | Native video presents through a **TextureView**, not a SurfaceView. A SurfaceView renders on its own layer *outside* the app window and punches a transparent region through it; everything drawn above that hole — for us the entire Svelte UI in a transparent WebView — depends on that composition path, and Android's own graphics documentation states that "overlays do not currently work correctly with SurfaceView or TextureView". The consequences were four symptoms of one cause (DR-191): a frozen progress bar, controls that would not fade, rotation losing the transport UI, and overlays that lingered after the DOM removed them. A TextureView is an ordinary view whose frames are drawn as a texture in the window's normal rendering pass, so there is no second layer and no transparent region, and the WebView above composites like it would over any other view — which is why media3 offers `surface_type="texture_view"` and why it is the standard remedy for ExoPlayer overlay problems. The trade is accepted rather than hidden: TextureView costs more power and memory than SurfaceView and adds a frame of latency, but hardware decode through MediaCodec is untouched, so the reason native video exists survives it. `setVideoTextureView` installs ExoPlayer's own `SurfaceTextureListener`, so the old `SurfaceHolder.Callback` wiring is deleted rather than ported — adding a listener of ours would displace it and the video would never appear. PiP needs no change, since a TextureView is a View and the aspect-ratio probe reads its measured bounds | Android | UR-003, UR-004, UR-041 | Done |
|
||||||
|
| DR-190 | The background-audio handoff can return to the native path. Everything that restores playback on the way back is written around the WebView `<video>`: `applyPendingForegroundSeek` returns early on `!videoElement`, the HLS re-init `$effect` returns early on `!useHtml5Element`, and `pendingForegroundSeek`/`pendingForegroundPlay` — which own the post-handoff position and play/pause — are consumed only by `handleCanPlay` and `markMediaReady`, an element event and a path that reaches the same guard. On the native path there is no element, so `exitBackgroundAudioHandoff` completes, clears `handoffState`, blanks and reassigns `currentStreamUrl` to force an effect that will not run, and nothing ever restarts ExoPlayer: the user returns from the lockscreen to a dead player. This never showed while the path was opt-in and its picture was invisible anyway. The return needs the native equivalent of the element reload — re-issue the item to the backend, seek to the position `player_exit_background_audio` reports, then honour `wasPlaying` — routed through the adapter rather than the element, so both paths restore through one contract | Playback | UR-040, UR-003 | Proposed |
|
||||||
| DR-161 | Native video is the default, so picture-in-picture has a real surface. DR-160 makes PiP work on the HTML5 path, but that path can only ever shrink the *UI* into the PiP window; showing the video itself needs the SurfaceView behind the WebView, which is what `experimentalNativeVideo` gates. The flag now defaults to on when the user has never chosen, with an explicit stored choice still winning in both directions so anyone who turned it off keeps it off. This is a deliberate acceptance of risk: the flag existed because the native path was an unfinished spike, and `VideoPlayer.scrubRegression.test.ts` documents its history — a native init that flipped to HTML5 mid-lifecycle and left seeks going down one path while ExoPlayer played on another. Those tests pin the **flag-off** interim override (native response overridden to HTML5, backend stopped once), which the default no longer selects, so they now mock the flag off rather than inherit it: they still guard that path, but they no longer describe what ships. The native scrub/seek path is consequently not covered by the suite and needs device verification | UI | UR-041, UR-003 | Needs device verification |
|
| DR-161 | Native video is the default, so picture-in-picture has a real surface. DR-160 makes PiP work on the HTML5 path, but that path can only ever shrink the *UI* into the PiP window; showing the video itself needs the SurfaceView behind the WebView, which is what `experimentalNativeVideo` gates. The flag now defaults to on when the user has never chosen, with an explicit stored choice still winning in both directions so anyone who turned it off keeps it off. This is a deliberate acceptance of risk: the flag existed because the native path was an unfinished spike, and `VideoPlayer.scrubRegression.test.ts` documents its history — a native init that flipped to HTML5 mid-lifecycle and left seeks going down one path while ExoPlayer played on another. Those tests pin the **flag-off** interim override (native response overridden to HTML5, backend stopped once), which the default no longer selects, so they now mock the flag off rather than inherit it: they still guard that path, but they no longer describe what ships. The native scrub/seek path is consequently not covered by the suite and needs device verification | UI | UR-041, UR-003 | Needs device verification |
|
||||||
| DR-159 | The background-audio handoff stops leaking its relative timeline. The handoff plays the episode as a *relative* stream — the audio-only URL is built with `StartTimeTicks` = the position the screen was locked at, so ExoPlayer's zero is the handoff point — and `background_audio_base` holds the offset that turns one back into a real position. The base was a **display-only** correction, applied in exactly two places (the lockscreen scrubber and the internal truncation maths) while every other consumer worked in the relative timeline treating the number as absolute. Each crossing threw away exactly `base` seconds, which is why the jump-back distance varied with where the screen was locked and read as random. Three crossings were live: progress reporting to Jellyfin sent the relative position every 30s, so the server was told `real − base` — and since DR-155 now mirrors the server's position back and refreshes on a cache hit, that regressed value returned as the resume point (lock at 40 min, listen to 90, reopen at 50); lockscreen seeks went out absolute and came back relative, against a chunked length-less transcode that cannot honour a seek at all, so a clamped seek landed at stream zero; and media3's own `seekToDefaultPosition`/`seekBack`/`seekForward` bypassed the `ForwardingPlayer` wrapper entirely, reaching the real ExoPlayer — `Util.handlePlayButtonAction` seeking an ended player to the relative zero being the same mechanism as DR-129's truncation bug through a different door. The fix converts **once, at the boundary**: `JellyTauPlayer`'s position tick adds the base (and shifts the duration with it, since the stream's own length is only what remains) before either `nativeOnPositionUpdate` or the lockscreen sees it, so position updates, progress reports, the frontend and the truncation check all speak the episode's timeline and none needs to know a handoff happened. The base is consequently *removed* from `claim_stream_resume`, `truncated_stream_resume_position` and `player_exit_background_audio`, where adding it now double-counts, and the lockscreen's `positionOffsetMs` addition goes with it (the field remains, read-only, as the tick's input). Inbound seeks go the other way: `seek_absolute` is the new boundary for every outside seek, re-opening the stream at the requested position via `resume_stream_at` when a handoff is active — which is what `onSeekTo` had claimed for months in a comment describing code that did not exist — and an ordinary seek otherwise. `seekToDefaultPosition` is swallowed rather than forwarded, since Rust already owns what "play after the stream ended" means and the `play()` that follows reaches it. Exit reads the position *before* clearing either base, or a tick landing in between hands back a relative one | Player | UR-040, UR-005, UR-025 | Done (pending device verification) |
|
| DR-159 | The background-audio handoff stops leaking its relative timeline. The handoff plays the episode as a *relative* stream — the audio-only URL is built with `StartTimeTicks` = the position the screen was locked at, so ExoPlayer's zero is the handoff point — and `background_audio_base` holds the offset that turns one back into a real position. The base was a **display-only** correction, applied in exactly two places (the lockscreen scrubber and the internal truncation maths) while every other consumer worked in the relative timeline treating the number as absolute. Each crossing threw away exactly `base` seconds, which is why the jump-back distance varied with where the screen was locked and read as random. Three crossings were live: progress reporting to Jellyfin sent the relative position every 30s, so the server was told `real − base` — and since DR-155 now mirrors the server's position back and refreshes on a cache hit, that regressed value returned as the resume point (lock at 40 min, listen to 90, reopen at 50); lockscreen seeks went out absolute and came back relative, against a chunked length-less transcode that cannot honour a seek at all, so a clamped seek landed at stream zero; and media3's own `seekToDefaultPosition`/`seekBack`/`seekForward` bypassed the `ForwardingPlayer` wrapper entirely, reaching the real ExoPlayer — `Util.handlePlayButtonAction` seeking an ended player to the relative zero being the same mechanism as DR-129's truncation bug through a different door. The fix converts **once, at the boundary**: `JellyTauPlayer`'s position tick adds the base (and shifts the duration with it, since the stream's own length is only what remains) before either `nativeOnPositionUpdate` or the lockscreen sees it, so position updates, progress reports, the frontend and the truncation check all speak the episode's timeline and none needs to know a handoff happened. The base is consequently *removed* from `claim_stream_resume`, `truncated_stream_resume_position` and `player_exit_background_audio`, where adding it now double-counts, and the lockscreen's `positionOffsetMs` addition goes with it (the field remains, read-only, as the tick's input). Inbound seeks go the other way: `seek_absolute` is the new boundary for every outside seek, re-opening the stream at the requested position via `resume_stream_at` when a handoff is active — which is what `onSeekTo` had claimed for months in a comment describing code that did not exist — and an ordinary seek otherwise. `seekToDefaultPosition` is swallowed rather than forwarded, since Rust already owns what "play after the stream ended" means and the `play()` that follows reaches it. Exit reads the position *before* clearing either base, or a tick landing in between hands back a relative one | Player | UR-040, UR-005, UR-025 | Done (pending device verification) |
|
||||||
| DR-158 | A watched toggle, on the episode row, the season header, the series and movie hero, and the Episode Focus View. Both halves of the backend already existed and neither had a caller: `mark_played` (`POST /PlayedItems`) was reachable only from the sync drain replaying rows the *reporter* had queued, and `clear_watch_history` (`DELETE /PlayedItems`) only from the destructive "erase this series' history" button — so the sole way to mark something watched was to play it. Jellyfin applies both recursively over a season or series, so the container case needs no client-side fan-out *online*. Offline it does: `storage_set_watched` writes the item **and its descendants** (drawn from `items` by `parent_id`/`album_id`/`season_id`/`series_id`, so an uncached id selects nothing and the statement no-ops instead of raising a foreign-key error), because otherwise marking a season watched with no server would tick the season and leave every episode inside it unwatched. It is deliberately separate from `storage_mark_played`, which stays the single-item "this finished playing" path that increments `play_count`. Un-marking clears the resume position as well as the flag, matching the server. `QueuedOp::MarkUnplayed` gives the queue the missing direction — pushing as `clear_watch_history` — so the toggle works offline both ways rather than only one; without it un-marking would have been the half that needed a connection. The button is an everyday toggle, so unlike `ClearHistoryButton` it does not confirm, and it holds an optimistic state because the caller's `watched` prop only catches up after a reload (a season means a round trip, during which the button would otherwise appear to ignore the tap) | UI | UR-073 | Done |
|
| DR-158 | A watched toggle, on the episode row, the season header, the series and movie hero, and the Episode Focus View. Both halves of the backend already existed and neither had a caller: `mark_played` (`POST /PlayedItems`) was reachable only from the sync drain replaying rows the *reporter* had queued, and `clear_watch_history` (`DELETE /PlayedItems`) only from the destructive "erase this series' history" button — so the sole way to mark something watched was to play it. Jellyfin applies both recursively over a season or series, so the container case needs no client-side fan-out *online*. Offline it does: `storage_set_watched` writes the item **and its descendants** (drawn from `items` by `parent_id`/`album_id`/`season_id`/`series_id`, so an uncached id selects nothing and the statement no-ops instead of raising a foreign-key error), because otherwise marking a season watched with no server would tick the season and leave every episode inside it unwatched. It is deliberately separate from `storage_mark_played`, which stays the single-item "this finished playing" path that increments `play_count`. Un-marking clears the resume position as well as the flag, matching the server. `QueuedOp::MarkUnplayed` gives the queue the missing direction — pushing as `clear_watch_history` — so the toggle works offline both ways rather than only one; without it un-marking would have been the half that needed a connection. The button is an everyday toggle, so unlike `ClearHistoryButton` it does not confirm, and it holds an optimistic state because the caller's `watched` prop only catches up after a reload (a season means a round trip, during which the button would otherwise appear to ignore the tap) | UI | UR-073 | Done |
|
||||||
@@ -339,6 +393,18 @@ 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-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-138 | Loopback is exempted from Android's cleartext ban, and nothing else is. Release builds set `usesCleartextTraffic="false"`, so the webview's request to the local media server (DR-137) was rejected by network security policy before any I/O — `<video>` failed in the same millisecond as `loadstart`, with `NETWORK_NO_SOURCE` and no server-side log at all, which is why it looked identical to a missing file. A `network-security-config` resource permits cleartext for `127.0.0.1` only and keeps `base-config cleartextTrafficPermitted="false"`, so a remote server must still be HTTPS; this is deliberately not a blanket opt-in. The manifest attribute is ignored once the config is present, so the config is the single authority. `sync-android-sources.sh` also had to learn to copy `res/xml`, which it skipped — the manifest references the resource, so a missed copy fails the resource link rather than degrading quietly | Security | UR-071 | Done |
|
||||||
| DR-093 | Traceability coverage gate derives its requirement denominators from `requirements.md` at run time rather than hardcoded literals: `countDefinedRequirements` counts an ID only where it leads a markdown table row (ignoring the "Traces To" column and prose) and deduplicates IDs listed both in the definition tables and in the §3 traceability matrix; `computeCoverage` reports the *intersection* of traced and defined IDs so an ID traced in code but absent from `requirements.md` is surfaced as `orphaned` instead of inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. CI and `bun run traces:coverage` share this computation and fail on both a sub-threshold and an impossible >100% result | Tooling | - | Done |
|
| DR-093 | Traceability coverage gate derives its requirement denominators from `requirements.md` at run time rather than hardcoded literals: `countDefinedRequirements` counts an ID only where it leads a markdown table row (ignoring the "Traces To" column and prose) and deduplicates IDs listed both in the definition tables and in the §3 traceability matrix; `computeCoverage` reports the *intersection* of traced and defined IDs so an ID traced in code but absent from `requirements.md` is surfaced as `orphaned` instead of inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. CI and `bun run traces:coverage` share this computation and fail on both a sub-threshold and an impossible >100% result | Tooling | - | Done |
|
||||||
|
| 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-210 | Thumbnail cache writes are confined to the cache directory. The filename was built from `item_id`, `image_type` and `tag`, but only `tag` was sanitised — and `Path::join` neither folds `..` nor keeps the base when handed an absolute path, so a value arriving verbatim from server JSON decided where a file landed. The tag's existing rule (non-alphanumerics become `_`) now applies to all three parts, and the resolved path is checked with `starts_with(cache_dir)` at the point of use. The database keeps the raw key and the resolved path, so lookups still match and pre-existing rows still resolve. Not exploitable as shipped — server URLs must be HTTPS and Android blocks cleartext, so the id comes from a server the user chose to trust — the value is making the write path consistent with how caller-supplied paths are handled elsewhere | Storage | UR-012 | Done |
|
||||||
|
| DR-211 | Download paths are confined to the download root. `file_path` and `target_dir` reached `PathBuf::join` unchecked from the frontend, and `mark_download_completed` persisted a caller-supplied path later passed to `remove_file`. A correct sanitiser already existed and `download_item_and_start` used it, but `download_item` is itself a command accepting `file_path` raw, so the guard was bypassable rather than absent — the fix moves it inside instead of adding a second one. Sanitising is **per path component**: whole-string sanitising would rewrite `downloads/x.mp3` to `downloads_x.mp3` and relocate every existing download. Confinement happens after the join, since a join with an absolute second half discards the root | Downloads | UR-011 | Done |
|
||||||
|
| DR-212 | Query and URL construction bind or encode their inputs. Three sites interpolated caller-supplied values directly: the offline `get_items` item-type filter built `IN ('a','b')` by string formatting, `build_get_items_endpoint` wrote `ParentId`/`IncludeItemTypes`/`SortBy`/`SortOrder` into a URL unencoded, and `player_set_volume` accepted NaN and out-of-range floats. Each is a *consistency* defect rather than a novel one — the same file already did it correctly a few lines away (parameter placeholders in `search`, `urlencoding::encode` for genres, `clamp` in every player backend). List separators stay unencoded and encoding is per element, because Jellyfin splits these parameters on the comma | Repository | UR-007, UR-065 | Done |
|
||||||
|
| DR-213 | Containerised builds hand their artifacts back to the host user. The compose services bind-mount the repo and run as root — their caches live at `/root/.cargo` and `/root/.bun`, so a non-root container user cannot write them — which leaves root-owned files accumulating in the developer's working tree: 11,124 of them when this was found, enough that `cargo clean` and `scripts/clean.sh` failed with EACCES and a plain `cargo build` died part-way, since build scripts compile for the host and land in `target/debug` even during a cross-build. Ownership is restored at the end of each containerised build, reading the intended owner from the checkout so no uid needs plumbing through. Running the containers as the host uid is the tidier fix and remains open; it needs the cache volumes relocated off `/root` first | Tooling | - | Done |
|
||||||
|
| DR-214 | The app identifies itself correctly everywhere a user or a package manager reads its name. `productName` was the scaffold's lowercase `jellytau`, which is what the Android release build showed under its icon and what the deb/rpm/NSIS bundles carried as their display name — invisible in development because `build.gradle.kts` overrides the label to "JellyTau Debug" for the debug build type, so the install a developer looks at daily was the only correctly-cased one. `mainBinaryName` pins the executable filename so nothing that resolves a path by name has to change. `strings.xml` moves into the canonical android tree, where `sync-android-sources.sh` already copies `res/values/*.xml`, so the fix survives regenerating `gen/`. Bundle metadata (publisher, copyright, category, descriptions, licence) was entirely absent, which is why the packages shipped with no maintainer or description — the hand-written Arch PKGBUILD and `.desktop` had all of it, so only the *generated* packaging was wrong | Packaging | - | Done |
|
||||||
|
| DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -350,29 +416,29 @@ Internal architecture, components, and application logic.
|
|||||||
|----------|-------------------------|-------------------------|
|
|----------|-------------------------|-------------------------|
|
||||||
| UR-001 | IR-001, IR-002 | - |
|
| UR-001 | IR-001, IR-002 | - |
|
||||||
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
|
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
|
||||||
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010 |
|
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010, 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 |
|
| 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 |
|
| 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-007 | IR-010 | DR-007, DR-008, DR-016 |
|
||||||
| UR-008 | IR-010 | DR-007, DR-011 |
|
| UR-008 | IR-010 | DR-007, DR-011 |
|
||||||
| UR-009 | IR-009, IR-010, IR-011 | - |
|
| UR-009 | IR-009, IR-010, IR-011 | - |
|
||||||
| UR-010 | IR-012, IR-021 | DR-037, DR-059 |
|
| UR-010 | IR-012, IR-021 | DR-037, DR-059 |
|
||||||
| UR-011 | IR-013 | DR-003, DR-015, DR-018 |
|
| 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-013 | IR-013 | DR-017 |
|
||||||
| UR-014 | IR-010 | DR-014, DR-019 |
|
| UR-014 | IR-010 | DR-014, DR-019 |
|
||||||
| UR-015 | - | DR-005, DR-020 |
|
| UR-015 | - | DR-005, DR-020 |
|
||||||
| UR-016 | - | - |
|
| UR-016 | - | - |
|
||||||
| UR-017 | - | DR-014, DR-021 |
|
| UR-017 | - | DR-014, DR-021 |
|
||||||
| UR-018 | IR-013 | DR-015, DR-018 |
|
| UR-018 | IR-013 | DR-015, DR-018, DR-173 |
|
||||||
| UR-019 | IR-015 | DR-022 |
|
| UR-019 | IR-015 | DR-022 |
|
||||||
| UR-020 | IR-016, IR-018 | DR-023 |
|
| 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 |
|
| UR-021 | IR-016, IR-019 | DR-024 | <!-- IR-019 delivered by ExoPlayer + HLS stream re-open, not libmpv -->
|
||||||
| UR-022 | IR-017 | DR-025 |
|
| UR-022 | IR-017 | DR-025 |
|
||||||
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049 |
|
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049 |
|
||||||
| UR-024 | IR-010 | DR-027 |
|
| UR-024 | IR-010 | DR-027 |
|
||||||
| UR-025 | IR-015 | DR-028, DR-131, DR-132 |
|
| UR-025 | IR-015 | DR-028, DR-131, DR-132, DR-178, DR-179 |
|
||||||
| UR-026 | - | DR-029, DR-048, DR-050 |
|
| UR-026 | - | DR-029, DR-048, DR-050 |
|
||||||
| UR-027 | IR-020 | DR-030 |
|
| UR-027 | IR-020 | DR-030 |
|
||||||
| UR-028 | - | DR-031 |
|
| UR-028 | - | DR-031 |
|
||||||
@@ -387,8 +453,8 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-037 | IR-010 | DR-042 |
|
| UR-037 | IR-010 | DR-042 |
|
||||||
| UR-038 | IR-010 | DR-043 |
|
| UR-038 | IR-010 | DR-043 |
|
||||||
| UR-039 | - | DR-045, DR-046 |
|
| UR-039 | - | DR-045, DR-046 |
|
||||||
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159 |
|
| 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 |
|
| 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-042 | IR-009, IR-014 | DR-054 |
|
||||||
| UR-043 | IR-027 | DR-055 |
|
| UR-043 | IR-027 | DR-055 |
|
||||||
| UR-044 | - | DR-056 |
|
| UR-044 | - | DR-056 |
|
||||||
@@ -402,7 +468,7 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-052 | IR-027 | DR-078, DR-079, DR-080, DR-143 |
|
| UR-052 | IR-027 | DR-078, DR-079, DR-080, DR-143 |
|
||||||
| UR-053 | IR-029 | DR-074 |
|
| UR-053 | IR-029 | DR-074 |
|
||||||
| UR-054 | - | DR-075, DR-076, DR-077, DR-147 |
|
| UR-054 | - | DR-075, DR-076, DR-077, DR-147 |
|
||||||
| UR-055 | - | DR-081, DR-082, DR-083, DR-084, DR-167, DR-168, DR-169 |
|
| UR-055 | - | DR-081, DR-082, DR-083, DR-084, DR-167, DR-168, DR-169, DR-173 |
|
||||||
| UR-056 | - | DR-085 |
|
| UR-056 | - | DR-085 |
|
||||||
| UR-057 | - | DR-086 |
|
| UR-057 | - | DR-086 |
|
||||||
| UR-058 | - | DR-087, DR-142 |
|
| UR-058 | - | DR-087, DR-142 |
|
||||||
@@ -412,15 +478,17 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-063 | - | DR-105 |
|
| UR-063 | - | DR-105 |
|
||||||
| UR-064 | - | DR-106 |
|
| UR-064 | - | DR-106 |
|
||||||
| UR-065 | IR-030 | DR-108, DR-109, DR-110, DR-111 |
|
| UR-065 | IR-030 | DR-108, DR-109, DR-110, DR-111 |
|
||||||
| UR-066 | IR-031 | DR-112, DR-157 |
|
| UR-066 | IR-031 | DR-112, DR-157, DR-187, DR-194 |
|
||||||
| UR-067 | - | DR-115, DR-116, DR-117, DR-118 |
|
| UR-067 | - | DR-115, DR-116, DR-117, DR-118 |
|
||||||
| UR-068 | - | DR-119 |
|
| UR-068 | - | DR-119 |
|
||||||
| UR-069 | - | DR-113, DR-114, DR-120 |
|
| UR-069 | - | DR-113, DR-114, DR-120 |
|
||||||
| UR-070 | - | DR-121, DR-122 |
|
| UR-070 | - | DR-121, DR-122 |
|
||||||
| UR-071 | IR-032 | DR-123, DR-124, DR-125, DR-126, DR-127, DR-128, DR-133, DR-134, DR-135, DR-136, DR-137, DR-138, DR-170, DR-171 |
|
| 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-072 | - | DR-156 |
|
||||||
| UR-073 | - | DR-158 |
|
| UR-073 | - | DR-158 |
|
||||||
| UR-074 | - | DR-162 |
|
| UR-074 | - | DR-162, DR-177, DR-181 |
|
||||||
|
| UR-075 | - | DR-174, DR-175 |
|
||||||
|
| UR-076 | - | DR-209 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -573,6 +641,9 @@ Internal architecture, components, and application logic.
|
|||||||
| UT-152 | Caching a server result mirrors its watch position locally — including for an item carrying a position but no favourite flag — without inventing a row for an item the server reported no user data for, and without pulling a still-unsynced local position backwards | DR-155 | Done |
|
| UT-152 | Caching a server result mirrors its watch position locally — including for an item carrying a position but no favourite flag — without inventing a row for an item the server reported no user data for, and without pulling a still-unsynced local position backwards | DR-155 | Done |
|
||||||
| UT-162 | Each downloaded library lists only its own media: the music library shows the album and neither the film nor the series, the movie library only the film, the TV library only the series | DR-163 | Done |
|
| UT-162 | Each downloaded library lists only its own media: the music library shows the album and neither the film nor the series, the movie library only the film, the TV library only the series | DR-163 | Done |
|
||||||
| UT-163 | `partial_path` appends rather than replacing the extension, so it matches what the cleanup paths delete, keeps two sources for one title apart, and still produces a sidecar for an extension-less target | DR-165 | Done |
|
| UT-163 | `partial_path` appends rather than replacing the extension, so it matches what the cleanup paths delete, keeps two sources for one title apart, and still produces a sidecar for an extension-less target | DR-165 | Done |
|
||||||
|
| UT-170 | `queue_album_tracks` queues a row for every track of the album — including tracks the cache holds without an `album_id` and tracks it has never seen at all — links each one to its album so offline browsing can find it, returns the row ids in track order, and is idempotent: re-queuing fills the gaps without duplicating rows or resetting a completed track. `cached_album_tracks` (the offline fallback) finds tracks by either album link and does not sweep in another album's | DR-173 | Done |
|
||||||
|
| UT-171 | `resolve_pending_download_urls` restricted to a set of row ids resolves only those rows and leaves other pending rows untouched, and an empty id set resolves nothing rather than sweeping everything | DR-173 | Done |
|
||||||
|
| UT-172 | `album_file_names` gives every track of an album its own file: a title repeated within the album (deluxe edition, two discs) is disambiguated by track number and item id instead of the second download overwriting the first, an unambiguous title keeps its own name, and path separators in a title are sanitised so a track cannot escape the album directory | DR-173 | Done |
|
||||||
| UT-164 | `resume_offset` appends only when the server answered `206`; a `200` after a Range request restarts the file, because that body is the whole stream | DR-166 | Done |
|
| UT-164 | `resume_offset` appends only when the server answered `206`; a `200` after a Range request restarts the file, because that body is the whole stream | DR-166 | Done |
|
||||||
| UT-165 | A registered download starts unflagged, `signal` sets the flag its worker reads, signalling an unregistered id reports not-in-flight, `clear` forgets it, and re-registering drops a previous stop so a resumed download does not halt instantly | DR-164 | Done |
|
| UT-165 | A registered download starts unflagged, `signal` sets the flag its worker reads, signalling an unregistered id reports not-in-flight, `clear` forgets it, and re-registering drops a previous stop so a resumed download does not halt instantly | DR-164 | Done |
|
||||||
| UT-166 | `original` quality re-encodes audio the webview cannot decode (E-AC-3/AC-3/DTS/TrueHD) to AAC without capping bitrate or resolution, keeps the `Static=true` direct copy for audio that plays here (AAC/MP3/Opus/Vorbis/FLAC) and for an unknown codec, leaves the explicit quality presets untouched, and picks the served track by the same default-or-first rule the streaming verdict uses | DR-171 | Done |
|
| UT-166 | `original` quality re-encodes audio the webview cannot decode (E-AC-3/AC-3/DTS/TrueHD) to AAC without capping bitrate or resolution, keeps the `Static=true` direct copy for audio that plays here (AAC/MP3/Opus/Vorbis/FLAC) and for an unknown codec, leaves the explicit quality presets untouched, and picks the served track by the same default-or-first rule the streaming verdict uses | DR-171 | Done |
|
||||||
@@ -580,6 +651,21 @@ Internal architecture, components, and application logic.
|
|||||||
| UT-154 | `mark_unplayed` parses to `QueuedOp::MarkUnplayed` and is rejected without an item id, and a queued un-mark drains to the server as `clear_watch_history` | DR-158 | Done |
|
| UT-154 | `mark_unplayed` parses to `QueuedOp::MarkUnplayed` and is rejected without an item id, and a queued un-mark drains to the server as `clear_watch_history` | DR-158 | Done |
|
||||||
| UT-156 | A capped step reaches the transcode URL as all four of its parts (total ceiling, the video/audio split summing to the cap, and a `MaxHeight`), the uncapped default keeps the historical 20/18 Mbps allowance and constrains no resolution, and the background-audio handoff takes the lower of the cap and its own 384 kbps | DR-162 | Done |
|
| UT-156 | A capped step reaches the transcode URL as all four of its parts (total ceiling, the video/audio split summing to the cap, and a `MaxHeight`), the uncapped default keeps the historical 20/18 Mbps allowance and constrains no resolution, and the background-audio handoff takes the lower of the cap and its own 384 kbps | DR-162 | Done |
|
||||||
| UT-157 | The quality ladder is internally consistent — video + audio equals the cap at every step, audio never consumes the budget, only `Original` is uncapped — descends in bitrate, resolution and audio share together, and round-trips through the serde token it is persisted as | DR-162 | Done |
|
| UT-157 | The quality ladder is internally consistent — video + audio equals the cap at every step, audio never consumes the budget, only `Original` is uncapped — descends in bitrate, resolution and audio share together, and round-trips through the serde token it is persisted as | DR-162 | Done |
|
||||||
|
| UT-158 | Justified rows fill the container width exactly and never overflow it, every tile in a row shares one height, and each tile's width follows its own aspect ratio — a 16:9 tile coming out more than twice the width of a 2:3 tile at the same height | DR-174 | Done |
|
||||||
|
| UT-159 | The awkward cases of the packing: a short last row is left at the target height rather than stretched across the container, a last row that would overflow is brought down, an extreme ratio is clamped instead of taking a row to itself, a missing or nonsensical ratio falls back to square instead of collapsing the tile, an unmeasured container renders nothing rather than 1px tiles, and every tile is placed exactly once in order | DR-174 | Done |
|
||||||
|
| UT-160 | The default row height suits its container: it grows with the width, stays inside its bounds, and at phone width still fits two 16:9 tiles side by side | DR-174 | Done |
|
||||||
|
| UT-161 | A collection type maps to its favourites scope (`movies`/`tvshows`/`music`), every other kind — Live TV, channels, box sets, books, unknown — maps to none rather than to `All`, and a constructed library carries the scope across the wire as `favoritesScope`, omitted entirely when it has none | DR-175 | Done |
|
||||||
|
| UT-167 | The mosaic's composition: the cross-library favourites entry leads, each library is followed by its own category tile pointing at that category's tab, a category shared by two libraries still yields one tile, a library kind favourites do not carve up yields none, a scope the page offers no tab for is ignored, and every tile is uniquely keyed | DR-174, DR-175 | Done |
|
||||||
|
| UT-168 | Subtitles are negotiated as sidecars, never burned in: the requested `SubtitleStreamIndex` is the explicit "none" sentinel (`-1`) rather than omitted, every text format we can render (`srt`/`subrip`/`ass`/`ssa`/`vtt`) is advertised as `External`, and the burn-in verdict is by format — text never forces it, image formats (PGSSUB, dvdsub) always do, case-insensitively. The same sentinel rides the stream URL itself, so a stream re-opened without a fresh negotiation cannot inherit a subtitle. And the verdict reaches the picker: a subtitle stream carries `supportsExternalDelivery` — set only for subtitles, `false` for a bitmap format and for one the server left unnamed — which drops the tracks the app could never draw from the menu, the `<track>` children and the native play request alike, without even fetching their URLs, while a stream carrying no verdict at all is still offered | DR-176 | Done |
|
||||||
|
| UT-173 | Every video stream URL carries a `PlaySessionId`, each open mints a fresh one, and the open reports the session it superseded so that job can be stopped | DR-177 | Done |
|
||||||
|
| UT-174 | A fatal HLS network error is read against the *absolute* position: mid-film — including after a quality switch, where the seek offset carries the whole resume position — it is retried rather than reported as the end of the stream, the last tenth of a known runtime is treated as the end, an unknown runtime retries, and retries stop once the budget is spent | DR-177 | Done |
|
||||||
|
| UT-175 | A stream reload that never becomes playable is reported as a failure instead of resolving as success, so the caller can revert its selection rather than leave the UI claiming a stream that is not playing | DR-177 | Done |
|
||||||
|
| UT-176 | A handoff's position is floored at its base: with no tick yet landed the exit position is the point the screen was locked at rather than 0, and once ticks are flowing (the base already applied natively) it is not added twice | DR-178 | Done |
|
||||||
|
| UT-177 | Webview-rendered media's reported position and duration are the controller's, and are dropped the moment that element stops being the player — on teardown, and when a handoff takes over | DR-178 | Done |
|
||||||
|
| UT-178 | A stop report at position 0 is withheld rather than sent (it would clear the resume point), while a real position is still reported from either rendering path — the element's on the webview path, the backend's on the native one | DR-179 | Done |
|
||||||
|
| UT-179 | An audio-only episode that ends naturally is reported stopped at its runtime, so Jellyfin marks it played; a truncated stream, which is about to be re-opened, reports nothing | DR-179 | Done |
|
||||||
|
| UT-180 | Position ticks report progress to the server, throttled to one report per item per window rather than one per tick | DR-179 | Done |
|
||||||
|
| UT-181 | The handoff plan matches its source: a downloaded file takes no base and a seek, a stream takes the base and no seek, and a handoff at 0:00 takes neither; a downloaded handoff's absolute seek stays an ordinary seek instead of a stream rebuild | DR-180 | Done |
|
||||||
| UT-153 | Scroll handling per navigation kind: a forward move always lands at the top even when the previous page was scrolled and even when the target was visited before, Back restores that route's own saved offset (and the top when it has none), offsets are kept per route rather than shared, a repeated Back still restores, and the initial load leaves the container alone | DR-156 | Done |
|
| UT-153 | Scroll handling per navigation kind: a forward move always lands at the top even when the previous page was scrolled and even when the target was visited before, Back restores that route's own saved offset (and the top when it has none), offsets are kept per route rather than shared, a repeated Back still restores, and the initial load leaves the container alone | DR-156 | Done |
|
||||||
| UT-142 | The audio codecs offered for video direct play: a Dolby device's real `MediaCodecList` output drops `ac3`/`eac3`, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims `aac`, and stray spacing or casing does not decide whether the user gets sound | DR-148 | Done |
|
| UT-142 | The audio codecs offered for video direct play: a Dolby device's real `MediaCodecList` output drops `ac3`/`eac3`, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims `aac`, and stray spacing or casing does not decide whether the user gets sound | DR-148 | Done |
|
||||||
| UT-143 | Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to "Off", and a server-default track is never auto-selected | UR-020, DR-023 | Done |
|
| UT-143 | Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to "Off", and a server-default track is never auto-selected | UR-020, DR-023 | Done |
|
||||||
@@ -587,6 +673,32 @@ Internal architecture, components, and application logic.
|
|||||||
| UT-145 | The frontend's subtitle payload survives the IPC hop: a camelCase `PlayItemRequest` carrying `subtitles` deserializes, `create_media_item` lands them on `MediaItem.subtitles` in the order sent, and a request without the field still defaults to empty | UR-020, IR-016 | Done |
|
| UT-145 | The frontend's subtitle payload survives the IPC hop: a camelCase `PlayItemRequest` carrying `subtitles` deserializes, `create_media_item` lands them on `MediaItem.subtitles` in the order sent, and a request without the field still defaults to empty | UR-020, IR-016 | Done |
|
||||||
| UT-146 | The subtitle JSON serialized across the JNI boundary uses the keys `JellyTauPlayer.load()` reads — `url`, `language`, `label` and `mime_type`, never `mimeType` | UR-020, IR-016, JA-008 | Done |
|
| UT-146 | The subtitle JSON serialized across the JNI boundary uses the keys `JellyTauPlayer.load()` reads — `url`, `language`, `label` and `mime_type`, never `mimeType` | UR-020, IR-016, JA-008 | Done |
|
||||||
| UT-147 | The native subtitle payload and the track-selection index come from the same resolved list: the wire shape keeps `mime_type` and stream order, `playerPlayItem` actually sends it, and the index is a position in the sent list (so a track whose URL failed to resolve cannot shift the others) rather than the menu's row number | UR-020, IR-016 | Done |
|
| UT-147 | The native subtitle payload and the track-selection index come from the same resolved list: the wire shape keeps `mime_type` and stream order, `playerPlayItem` actually sends it, and the index is a position in the sent list (so a track whose URL failed to resolve cannot shift the others) rather than the menu's row number | UR-020, IR-016 | Done |
|
||||||
|
| UT-182 | An HLS video URL never carries `StartTimeTicks` — with a position supplied or not — while the master playlist, codec, media source and chosen audio track still ride on it | DR-181 | Done |
|
||||||
|
| UT-183 | A reloaded stream is resumed by seeking the element to the absolute position with the transcode offset cleared to zero — never by carrying the position as an offset base, which since DR-181 would display the position while playing the item from its start — and a reload to 0:00 waits for no seek | DR-181 | Done |
|
||||||
|
| UT-184 | The native reveal rule fires on `state === "playing"` and on a position tick carrying a position or a duration, and on nothing else — not `buffering`, `paused`, `stopped`, `ended` or `error`, not an empty tick, and not a negative position | DR-182 | Done |
|
||||||
|
| UT-188 | The control-bar auto-hide rule permits hiding only during uninterrupted playback: it declines while paused, while a seek is in flight, and while a track/subtitle/quality menu is open — asserted against the pure `shouldHideControls` rule rather than a clock or a DOM | DR-189 | Done |
|
||||||
|
| UT-189 | On the native path the player never calls `player_report_state` — driven through the real 10-second progress interval under fake timers, which is the call site that mattered; asserting on a freshly mounted player passes with the guard deleted and guards nothing | DR-195 | Done |
|
||||||
|
| UT-187 | On the native path the play overlay follows the backend: it clears when the backend resumes after a pause and is raised again when the backend pauses, and the system bars are hidden on player entry rather than only by the fullscreen button | DR-186, DR-187 | Done |
|
||||||
|
| UT-186 | Every attribute the native-video compositing block in app.css targets is set somewhere in the app — `[data-app-shell]` in particular — so a selector aimed at nothing fails the suite instead of failing silently on a device | DR-185 | Done |
|
||||||
|
| UT-185 | Mounted on the native path (backend reports native, opt-in flag on, no `<video>` element rendered and the backend not stopped), VideoPlayer keeps the poster card up until the backend reports something, drops it on a playing state or a position tick with a duration, and keeps it up through `error` and `stopped` | DR-182 | Done |
|
||||||
|
| UT-190 | `build_next_up_endpoint` sends `EnableResumable=false` with the user and limit, and no `SeriesId` filter when none was requested | DR-197, JA-036 | Done |
|
||||||
|
| UT-191 | A per-series next-up query keeps `SeriesId` and the resumable exclusion, and defaults the limit | DR-197 | Done |
|
||||||
|
| UT-192 | `filterInProgressNextUpItems` drops an episode present in the resume list, keeps the genuinely unstarted next episode, leaves the rest of the row intact, and is a no-op when nothing is in progress | DR-197 | Done |
|
||||||
|
| UT-193 | The shipped Tauri security config stays restrictive: `csp` is set, `script-src` carries no `'unsafe-inline'`/`'unsafe-eval'`/wildcard, `object-src`/`frame-src` are `'none'`, the directives playback needs (asset scheme, loopback, `blob:`, `ipc:`) are present, and the asset-protocol scope covers only the thumbnail cache — never the storage root that holds the database | DR-198 | Done |
|
||||||
|
| UT-194 | Normal audio (no background-audio handoff) keeps queue advance on both skip buttons | DR-201 | Done |
|
||||||
|
| UT-195 | In background-audio mode a skip scrubs +30s/-10s instead of advancing the queue — the reported defect | DR-201 | Done |
|
||||||
|
| UT-196 | Skipping back near the start clamps to zero rather than seeking negative | DR-201 | Done |
|
||||||
|
| UT-197 | Skipping forward near the end clamps to the duration rather than running past it into an EOF-driven advance | DR-201 | Done |
|
||||||
|
| UT-198 | An unknown duration still scrubs and still refuses to go negative | DR-201 | Done |
|
||||||
|
| UT-199 | The screen-wake decision: video playing holds the display, pausing releases it, audio playing never holds it, a webview element going inactive releases even without a pause report, either renderer alone is enough to hold, and teardown drops both | DR-202 | Done |
|
||||||
|
| UT-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-204 | Thumbnail cache writes stay inside the cache directory: a traversal-style and an absolute `item_id` both fail to produce a file outside it, a filename made only of already-safe characters is byte-identical to the one the previous code produced, and an odd id still round-trips through `get_cached_path` | DR-210 | Done |
|
||||||
|
| UT-205 | Queued download paths cannot escape the download root — traversal, absolute and `..` forms are refused — while the four real path shapes the app builds, including the absolute one `download_series` produces, come back unchanged; and a completed download cannot register a file outside the root | DR-211 | Done |
|
||||||
|
| UT-206 | The offline item-type filter is bound rather than interpolated (a value containing a quote and `OR 1=1` matches nothing instead of disabling the `WHERE`), `build_get_items_endpoint` percent-encodes its values while preserving the commas Jellyfin splits on, and volume normalisation clamps out-of-range input and maps NaN to a finite value | DR-212 | Done |
|
||||||
|
| UT-200 | The stream a player could only restart is refused its retry: the handoff transcode answers yes to `player_retry_restarts_stream` while music, video and a downloaded episode answer no, and the Kotlin decision starts permissive, flips on a non-resumable load, and is restored by the next ordinary one | DR-203 | Done |
|
||||||
|
| UT-207 | The hero banner's rotation timer restarts from the moment of a manual change: a swipe 5.5s into a 6s interval waits a further 6s instead of firing the leftover 500ms, repeated restarts never stack timers, and `stop()` ends rotation | DR-038 | Done |
|
||||||
|
|
||||||
### Integration Tests
|
### Integration Tests
|
||||||
|
|
||||||
@@ -599,8 +711,8 @@ Internal architecture, components, and application logic.
|
|||||||
| IT-005 | MPRIS lockscreen controls on Linux | IR-005, UR-006 | Pending |
|
| 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-006 | Offline mode with local database | IR-013, UR-002 | Pending |
|
||||||
| IT-007 | Media download and local playback | DR-015, UR-011 | 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-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 via libmpv | IR-019, UR-021 | 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-010 | Playback progress sync to Jellyfin | IR-015, UR-025 | Pending |
|
||||||
| IT-011 | Resume playback from server position | IR-015, UR-019 | Pending |
|
| IT-011 | Resume playback from server position | IR-015, UR-019 | Pending |
|
||||||
| IT-012 | Equalizer bands via libmpv | IR-020, UR-027 | Pending |
|
| IT-012 | Equalizer bands via libmpv | IR-020, UR-027 | Pending |
|
||||||
@@ -612,6 +724,50 @@ Internal architecture, components, and application logic.
|
|||||||
|
|
||||||
## 5. Technical Debt
|
## 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
|
### 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.
|
**Issue**: The `keyring-rs` crate (v3.x) has issues with retrieving credentials from the Linux Secret Service API, despite successfully saving them.
|
||||||
@@ -723,7 +879,7 @@ deprecated in current Media3.)
|
|||||||
**Affected Files**:
|
**Affected Files**:
|
||||||
- [src/lib/components/player/AudioPlayer.svelte](../src/lib/components/player/AudioPlayer.svelte) - Duplicate handlers
|
- [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/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/stores/playbackMode.ts](../src/lib/stores/playbackMode.ts) - Position conversion
|
||||||
- [src/lib/services/playbackReporting.ts](../src/lib/services/playbackReporting.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
|
- [ ] Linked to existing URs, or new URs/DRs are allocated in
|
||||||
[requirements.md](../requirements.md).
|
[requirements.md](../requirements.md).
|
||||||
- [ ] Requirement-implementing code will carry `// TRACES:` comments (CLAUDE.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
|
## Conflicts & hygiene
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
# Spec: Android native video — transparent-webview spike
|
# Spec: Android native video — transparent-webview spike
|
||||||
|
|
||||||
**Status:** Spike succeeded — native video confirmed working on a physical
|
**Status:** Spike succeeded (2026-08-11); shipped behind `experimentalNativeVideo`,
|
||||||
device (2026-08-11) with `experimentalNativeVideo` on. Shipped behind that flag,
|
default off. Flipping that default shipped **audio with no picture** and was
|
||||||
default off. Branch `feat/android-native-video`.
|
reverted (DR-172). Three defects behind that have since been fixed — DR-182
|
||||||
|
(nothing on the native path could lift the poster overlay), DR-183 (the JS
|
||||||
|
bridges raced the page load), DR-184 (the SurfaceView was never detached).
|
||||||
|
Branch `fix/android-native-video-visible`. **The default stays off until the
|
||||||
|
device criteria below are green.**
|
||||||
|
|
||||||
**The spike's central question is answered: yes.** A `SurfaceView` *can* be
|
**The spike's central question is answered: yes.** A `SurfaceView` *can* be
|
||||||
composited behind a transparent Tauri WebView on Android. Nothing upstream
|
composited behind a transparent Tauri WebView on Android. Nothing upstream
|
||||||
@@ -227,6 +231,9 @@ The spike is **complete** when one of these is true:
|
|||||||
- [x] `experimentalNativeVideo` off → behaviour byte-identical to today. Guarded by `adapterSelection.test.ts`, which asserts the flag-off case forces HTML5 even when Rust reports native.
|
- [x] `experimentalNativeVideo` off → behaviour byte-identical to today. Guarded by `adapterSelection.test.ts`, which asserts the flag-off case forces HTML5 even when Rust reports native.
|
||||||
- [x] `webviewAudio.ts` no longer inspects `navigator.userAgent`; the platform's audio backend is read from Rust (`player_get_capabilities` → `usesWebviewAudio`).
|
- [x] `webviewAudio.ts` no longer inspects `navigator.userAgent`; the platform's audio backend is read from Rust (`player_get_capabilities` → `usesWebviewAudio`).
|
||||||
- [x] `experimentalNativeVideo` on → video plays via ExoPlayer, correctly positioned, on a physical device (2026-08-11). The surface reaches the hierarchy and is visible through the transparent WebView — the whole point of the spike.
|
- [x] `experimentalNativeVideo` on → video plays via ExoPlayer, correctly positioned, on a physical device (2026-08-11). The surface reaches the hierarchy and is visible through the transparent WebView — the whole point of the spike.
|
||||||
|
- [x] The poster/title card comes down on the native path. It never could: every `markMediaReady()` call site is a `<video>` element event and the native branch renders no element, so an opaque `bg-black` overlay covered the ExoPlayer surface for the whole session. See DR-182; guarded by `mediaReady.test.ts` (UT-184) and `VideoPlayer.nativeReveal.test.ts` (UT-185), the latter written failing first.
|
||||||
|
- [x] The `AndroidVideoSurface` bridge is installed before the page that calls it loads, via `WryActivity.onWebViewCreate` instead of a 500 ms tree walk, and a missing bridge now logs an error instead of no-oping. See DR-183.
|
||||||
|
- [x] The SurfaceView is detached when video stops, instead of accumulating one leaked view per native video. See DR-184.
|
||||||
- [ ] Seek, audio-track switch and subtitle selection exercised through `NativePlayerAdapter`. Playback is confirmed; these individual controls are not yet each verified on the native path.
|
- [ ] Seek, audio-track switch and subtitle selection exercised through `NativePlayerAdapter`. Playback is confirmed; these individual controls are not yet each verified on the native path.
|
||||||
- [ ] No artefacts on rotation, background/foreground, or **mini-player transition** — the last is the one case the fullscreen assumption does not cover, so it is the likeliest place to find a problem.
|
- [ ] No artefacts on rotation, background/foreground, or **mini-player transition** — the last is the one case the fullscreen assumption does not cover, so it is the likeliest place to find a problem.
|
||||||
- [ ] `adb shell dumpsys media.metrics` (or logcat) confirms a hardware decoder is in use. Plausible but unmeasured — do not claim the MediaCodec win until this is read.
|
- [ ] `adb shell dumpsys media.metrics` (or logcat) confirms a hardware decoder is in use. Plausible but unmeasured — do not claim the MediaCodec win until this is read.
|
||||||
@@ -238,9 +245,26 @@ The spike is **complete** when one of these is true:
|
|||||||
- [ ] `nativeAdapter.ts:11-14` no longer cites tauri#10152.
|
- [ ] `nativeAdapter.ts:11-14` no longer cites tauri#10152.
|
||||||
|
|
||||||
Either way:
|
Either way:
|
||||||
- [x] `bun run check` (0 errors), `bun run test` (892 passed), `bun run check:boundary` pass.
|
- [x] `bun run check` (0 errors), `bun run test` (997 passed), `bun run check:boundary` pass.
|
||||||
- [x] `cargo fmt` / `cargo clippy` clean (no new warnings); `cargo test` passes (603 lib + 7 doc).
|
- [x] `cargo fmt` / `cargo clippy` clean (no new warnings); `cargo test` passes (603 lib + 7 doc).
|
||||||
|
|
||||||
|
### Why the 2026-08-11 verification and DR-172 do not contradict each other
|
||||||
|
|
||||||
|
The spike was reported working on device; the same path then shipped as audio
|
||||||
|
with no picture. Both are consistent with DR-182: the poster overlay is drawn
|
||||||
|
only while `isMediaReady` is false, and the native path has no way to set it, so
|
||||||
|
what the surface shows depends entirely on **whether that overlay is on screen**
|
||||||
|
— not on whether compositing works. Any run that reached the player through a
|
||||||
|
path leaving `isMediaReady` already true (a handoff return, a re-render, a
|
||||||
|
session that had previously played on the HTML5 path) shows video; a cold start
|
||||||
|
into the native path never does. That is also why DR-172 read the symptom as a
|
||||||
|
compositing failure: on screen the two are identical, and the one piece of
|
||||||
|
evidence separating them — `WebView transparent = true` never being logged —
|
||||||
|
points at DR-183 rather than at the compositing itself.
|
||||||
|
|
||||||
|
**This reasoning is not yet device-confirmed.** It explains the reports and is
|
||||||
|
backed by the code, but the criteria above are what settle it.
|
||||||
|
|
||||||
> Note: this environment has no host WebKitGTK dev packages, no Android SDK and
|
> Note: this environment has no host WebKitGTK dev packages, no Android SDK and
|
||||||
> no `bun`, so all of the above were run inside the CI builder image
|
> no `bun`, so all of the above were run inside the CI builder image
|
||||||
> (`gitea.tourolle.paris/dtourolle/jellytau-builder:latest`). On Fedora the bind
|
> (`gitea.tourolle.paris/dtourolle/jellytau-builder:latest`). On Fedora the bind
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ Frontend (`bun run test`):
|
|||||||
|------|--------|
|
|------|--------|
|
||||||
| UT-105 | `favorites` store override precedence: store value beats `userData.isFavorite` beats `false` |
|
| 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) |
|
| 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
|
Any component logic worth testing gets extracted into a plain `.ts` module first
|
||||||
(`favoritesView.ts`), rather than tested through the component.
|
(`favoritesView.ts`), rather than tested through the component.
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# Spec: Library mosaic (library overview + home shortcuts)
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
**Requirements:** UR-075 → DR-174, DR-175 (with UR-067 → DR-117 extended)
|
||||||
|
**UX spec:** [ux-flows.md](../ux-flows.md) §5C.2 (Favourites)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The library overview and the home "Your Libraries" strip stop being fixed-shape
|
||||||
|
grids and become a **mosaic**: rows share one height, and each tile is as wide as
|
||||||
|
its own artwork is. A square music cover, a 16:9 library backdrop and a 2:3
|
||||||
|
poster sit in the same row at their own proportions instead of all three being
|
||||||
|
cropped into whichever box the grid picked. Favourites gain a tile per category,
|
||||||
|
placed beside the library that category belongs to, alongside the existing
|
||||||
|
cross-library entry.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
Every surface here shows artwork of more than one shape. The grid resolved that
|
||||||
|
by choosing one shape and cropping to it — and the home strip said so out loud:
|
||||||
|
|
||||||
|
> Uniform 16:9 artwork so music (square) and video libraries line up at the same
|
||||||
|
> height in this mixed row.
|
||||||
|
|
||||||
|
Lining them up is right; cropping the covers to do it is not. Holding the
|
||||||
|
**height** fixed and letting the **width** vary achieves the same alignment with
|
||||||
|
no crop at all, which is the whole idea of a justified layout.
|
||||||
|
|
||||||
|
Favourites had one entry for everything. With per-category tiles, "my favourite
|
||||||
|
albums" is one tap from the library page rather than a tap plus a tab.
|
||||||
|
|
||||||
|
## Layer assignment
|
||||||
|
|
||||||
|
| Logic / responsibility | Layer | Why it belongs there |
|
||||||
|
|------------------------|-------|----------------------|
|
||||||
|
| Collection type → favourites category (`movies` → Movies, `livetv` → none) | **Rust** | Jellyfin vocabulary. It changes when Jellyfin renames a collection type, never when this page is redesigned — the same test that put `SearchScope::item_types` in Rust. Shipping it in Svelte would have re-created the leak [scoped-search-boundary.md](scoped-search-boundary.md) exists to document. |
|
||||||
|
| Which scopes exist at all (`SearchScope`) | **Rust** | Already there; unchanged. |
|
||||||
|
| Row packing: heights, widths, justification, clamping | Frontend | Geometry of a rendered page. It changes when the layout is redesigned and never when the API does. |
|
||||||
|
| Assumed artwork shape before the image loads (music = square, else wide) | Frontend | The shape of a *picture*, not a taxonomy — and it is only a starting guess, overruled by the decoded image. |
|
||||||
|
| Tile labels, order, and showing a category's tile once | Frontend | Pure presentation: wording and placement. |
|
||||||
|
|
||||||
|
Borderline row: the "assumed artwork shape" is a per-collection-type default, and
|
||||||
|
any per-collection-type table deserves suspicion. The tie-breaker: it does not
|
||||||
|
decide *what a category means* or what is fetched — it seeds a pixel dimension
|
||||||
|
that the loaded bitmap immediately corrects. Getting it wrong costs one re-pack,
|
||||||
|
not a wrong result. The scope mapping, which does decide what is fetched, went to
|
||||||
|
Rust.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Wire
|
||||||
|
|
||||||
|
`Library` gains one optional field, derived at construction:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct Library {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub collection_type: String,
|
||||||
|
pub image_tag: Option<String>,
|
||||||
|
pub favorites_scope: Option<SearchScope>, // ← new
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SearchScope {
|
||||||
|
pub fn for_collection_type(collection_type: &str) -> Option<SearchScope>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type Library = { …; favoritesScope?: SearchScope | null }
|
||||||
|
```
|
||||||
|
|
||||||
|
`Library::new` derives it, so the four construction sites (online views, two
|
||||||
|
offline cache reads, tests) cannot forget it. `None` is *omitted* from the JSON,
|
||||||
|
not sent as null. No new command, no new event.
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
`src/lib/components/library/mosaic.ts` — pure, no DOM:
|
||||||
|
|
||||||
|
- `layoutMosaic(items, { containerWidth, targetHeight, gap })` → rows of tiles
|
||||||
|
with pixel boxes. Tiles join a row until the height needed to fill the width
|
||||||
|
drops to the target; the row closes there and is justified to the container
|
||||||
|
width, the rounding remainder absorbed by its widest tile. The **last row is
|
||||||
|
not justified** (one leftover tile would inflate into a banner) — it sits at
|
||||||
|
the target height, left-aligned.
|
||||||
|
- `layoutMosaicStrip(items, height)` → the same rule as one fixed-height row, for
|
||||||
|
a horizontally scrolling shelf.
|
||||||
|
- `mosaicTargetHeight(containerWidth)` → the row height chosen when the caller
|
||||||
|
doesn't pick one. Bounded so a phone still fits two tiles across and a desktop
|
||||||
|
doesn't turn each library into a billboard.
|
||||||
|
- Ratios are clamped to a band (0.5–2.5) so one panorama can't own a row.
|
||||||
|
|
||||||
|
`MosaicGrid.svelte` supplies the two things only the DOM knows — the measured
|
||||||
|
container width (`bind:clientWidth`) and the artwork's decoded ratio — and
|
||||||
|
renders the caller's `tile` snippet. `CachedImage` gained an `onNaturalSize`
|
||||||
|
callback for the second. Measured ratios are committed in one debounced batch
|
||||||
|
(120 ms): artwork arrives over several hundred milliseconds and re-packing per
|
||||||
|
image would shuffle the grid under the pointer.
|
||||||
|
|
||||||
|
`MosaicTile.svelte` draws one tile at an exact pixel box, with its label written
|
||||||
|
**over** the bottom of the artwork. A caption below the box would add height the
|
||||||
|
layout didn't compute, and a caption that wrapped to two lines would break the
|
||||||
|
row alignment the mosaic exists to provide.
|
||||||
|
|
||||||
|
### Composition
|
||||||
|
|
||||||
|
`libraryMosaic.ts` (pure, tested) builds the tile list: the cross-library
|
||||||
|
favourites entry first, then each library followed by its own category tile. A
|
||||||
|
category appears **once** — two movie libraries share one favourites list, so a
|
||||||
|
tile each would be two tiles to the same place. A library whose `favoritesScope`
|
||||||
|
is absent (Live TV, channels, books) gets no tile rather than one opening an
|
||||||
|
unfiltered list.
|
||||||
|
|
||||||
|
Home uses the same tiles in `layout="strip"` but **without** the favourites tiles:
|
||||||
|
home already carries Favourite Movies / Shows / Music rows of its own, and a
|
||||||
|
second entry point in the strip above them would be redundant.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- The item grids inside a library (`/library/movies`, `/library/music/albums`, …).
|
||||||
|
Those show one item type each, so a uniform grid crops nothing; the mosaic buys
|
||||||
|
them nothing but reflow.
|
||||||
|
- Backdrop/collage artwork for libraries with no image of their own.
|
||||||
|
- Reordering or pinning libraries.
|
||||||
+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`:
|
Traceability validation lives in `.gitea/workflows/traceability-check.yml`:
|
||||||
|
|
||||||
- ✅ Automatic trace extraction
|
- ✅ Automatic trace extraction
|
||||||
- ✅ Coverage validation against minimum threshold (50%)
|
- ✅ Coverage validation against minimum threshold (88%, ratcheted)
|
||||||
- ✅ Modified file checking
|
- ✅ Modified file checking
|
||||||
- ✅ Artifact preservation
|
- ✅ Artifact preservation
|
||||||
- ✅ Summary reports
|
- ✅ Summary reports
|
||||||
@@ -43,7 +43,7 @@ Extracts all TRACES comments from:
|
|||||||
|
|
||||||
### 2. Coverage Thresholds
|
### 2. Coverage Thresholds
|
||||||
The workflow checks:
|
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
|
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
|
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
|
**orphaned** and does not count toward coverage. UT/IT test identifiers are a
|
||||||
separate taxonomy and are excluded entirely.
|
separate taxonomy and are excluded entirely.
|
||||||
|
|
||||||
The workflow **fails** and blocks merge if coverage drops below 50% — or if it
|
The workflow **fails** and blocks merge if coverage drops below the threshold —
|
||||||
computes above 100%, which can only mean the gate is miscounting.
|
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
|
### 3. Modified File Checking
|
||||||
On pull requests, the workflow:
|
On pull requests, the workflow:
|
||||||
@@ -120,13 +152,13 @@ TRACES: [UR-###, ...] | [IR-###, ...] | [DR-###, ...] | [JA-###, ...]
|
|||||||
|
|
||||||
### On Push to Main Branch
|
### On Push to Main Branch
|
||||||
1. ✅ Extracts all traces from code
|
1. ✅ Extracts all traces from code
|
||||||
2. ✅ Validates coverage is >= 50%
|
2. ✅ Validates coverage is >= 88%
|
||||||
3. ✅ Generates full traceability report
|
3. ✅ Generates full traceability report
|
||||||
4. ✅ Saves report as artifact
|
4. ✅ Saves report as artifact
|
||||||
|
|
||||||
### On Pull Request
|
### On Pull Request
|
||||||
1. ✅ Extracts all traces
|
1. ✅ Extracts all traces
|
||||||
2. ✅ Validates coverage >= 50%
|
2. ✅ Validates coverage >= 88%
|
||||||
3. ✅ Checks modified files for TRACES
|
3. ✅ Checks modified files for TRACES
|
||||||
4. ✅ Warns if new code lacks TRACES
|
4. ✅ Warns if new code lacks TRACES
|
||||||
5. ✅ Suggests proper format
|
5. ✅ Suggests proper format
|
||||||
@@ -134,7 +166,8 @@ TRACES: [UR-###, ...] | [IR-###, ...] | [DR-###, ...] | [JA-###, ...]
|
|||||||
|
|
||||||
### Failure Scenarios
|
### Failure Scenarios
|
||||||
The workflow **fails** (blocks merge) if:
|
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
|
- JSON extraction fails
|
||||||
- Invalid trace format
|
- 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
|
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.
|
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
|
### Targets
|
||||||
- **Short term** (Sprint): Maintain ≥50% overall
|
- **Short term** (Sprint): Maintain ≥88% overall (the current ratchet)
|
||||||
- **Medium term** (Month): Reach 70% overall coverage
|
- **Medium term** (Month): Hold above 90% and ratchet the gate to match
|
||||||
- **Long term** (Release): Reach 90% coverage with focus on:
|
- **Long term** (Release): Reach 95% coverage with focus on:
|
||||||
- IR requirements (API clients)
|
- IR requirements (API clients)
|
||||||
- JA requirements (Jellyfin API endpoints)
|
- JA requirements (Jellyfin API endpoints)
|
||||||
- Remaining UR/DR requirements
|
- Remaining UR/DR requirements
|
||||||
@@ -209,14 +242,14 @@ When submitting a pull request:
|
|||||||
|
|
||||||
- [ ] All new code has TRACES comments linking to requirements
|
- [ ] All new code has TRACES comments linking to requirements
|
||||||
- [ ] TRACES format is correct: `// TRACES: UR-001 | DR-002`
|
- [ ] TRACES format is correct: `// TRACES: UR-001 | DR-002`
|
||||||
- [ ] Workflow passes (coverage ≥ 50%)
|
- [ ] Workflow passes (coverage ≥ 88%)
|
||||||
- [ ] No coverage regressions
|
- [ ] No coverage regressions
|
||||||
- [ ] Artifact traceability report was generated
|
- [ ] Artifact traceability report was generated
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### "Coverage below minimum threshold"
|
### "Coverage below minimum threshold"
|
||||||
**Problem:** Workflow fails with coverage < 50%
|
**Problem:** Workflow fails with coverage < 88%
|
||||||
|
|
||||||
**Solution:**
|
**Solution:**
|
||||||
1. Run `bun run traces:json` locally
|
1. Run `bun run traces:json` locally
|
||||||
|
|||||||
+8388
-5604
File diff suppressed because it is too large
Load Diff
+13
-12
@@ -52,10 +52,10 @@ fn test_queue_next() {
|
|||||||
|
|
||||||
## Where to Find Requirements
|
## Where to Find Requirements
|
||||||
|
|
||||||
1. **User Requirements (UR):** [README.md](README.md#1-user-requirements)
|
1. **User Requirements (UR):** [requirements.md](requirements.md#1-user-requirements)
|
||||||
2. **Integration Requirements (IR):** [README.md](README.md#21-integration-requirements)
|
2. **Integration Requirements (IR):** [requirements.md](requirements.md#21-integration-requirements)
|
||||||
3. **Development Requirements (DR):** [README.md](README.md#23-development-requirements)
|
3. **Development Requirements (DR):** [requirements.md](requirements.md#23-development-requirements)
|
||||||
4. **Jellyfin API (JA):** [README.md](README.md#22-jellyfin-api-requirements)
|
4. **Jellyfin API (JA):** [requirements.md](requirements.md#22-jellyfin-api-requirements)
|
||||||
|
|
||||||
## How to Add TRACES
|
## How to Add TRACES
|
||||||
|
|
||||||
@@ -133,18 +133,19 @@ bun run traces:json | jq '.requirements."UR-005"'
|
|||||||
### Before Committing
|
### Before Committing
|
||||||
1. Ensure all new code has TRACES
|
1. Ensure all new code has TRACES
|
||||||
2. Format is correct: `// TRACES: ...`
|
2. Format is correct: `// TRACES: ...`
|
||||||
3. Requirements exist in README.md
|
3. Requirements exist in `docs/requirements.md` — `bun run traces:validate`
|
||||||
4. No typos in requirement IDs
|
4. No typos in requirement IDs (same command catches them)
|
||||||
|
|
||||||
## CI/CD Validation
|
## CI/CD Validation
|
||||||
|
|
||||||
The workflow automatically checks:
|
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
|
- ✅ New files have TRACES
|
||||||
- ✅ JSON format is valid
|
- ✅ JSON format is valid
|
||||||
- ✅ Reports are generated
|
- ✅ Reports are generated
|
||||||
|
|
||||||
See [traceability-ci.md](docs/traceability-ci.md) for details.
|
See [traceability-ci.md](traceability-ci.md) for details.
|
||||||
|
|
||||||
## Tips & Tricks
|
## Tips & Tricks
|
||||||
|
|
||||||
@@ -198,10 +199,10 @@ A: Yes! TRACES show your implementation plan.
|
|||||||
|
|
||||||
## See Also
|
## See Also
|
||||||
|
|
||||||
- [Full Traceability Matrix](docs/traceability.md)
|
- [Full Traceability Matrix](traceability.md)
|
||||||
- [CI/CD Pipeline Guide](docs/traceability-ci.md)
|
- [CI/CD Pipeline Guide](traceability-ci.md)
|
||||||
- [Requirements Specification](README.md)
|
- [Requirements Specification](requirements.md)
|
||||||
- [Extraction Script](scripts/README.md#extract-tracests)
|
- [Extraction Script](../scripts/README.md#extract-tracests)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
# E2E Test Configuration
|
|
||||||
# Copy this file to .env and fill in your test credentials
|
|
||||||
|
|
||||||
# Jellyfin Server Configuration
|
|
||||||
TEST_SERVER_URL=https://demo.jellyfin.org/stable
|
|
||||||
TEST_SERVER_NAME=Demo Server
|
|
||||||
|
|
||||||
# Test User Credentials
|
|
||||||
TEST_USERNAME=demo
|
|
||||||
TEST_PASSWORD=
|
|
||||||
|
|
||||||
# Optional: Specific test data IDs (for testing playback, etc.)
|
|
||||||
# You can find these IDs in your Jellyfin server
|
|
||||||
TEST_MUSIC_LIBRARY_ID=
|
|
||||||
TEST_MOVIE_LIBRARY_ID=
|
|
||||||
TEST_ARTIST_ID=
|
|
||||||
TEST_ALBUM_ID=
|
|
||||||
TEST_TRACK_ID=
|
|
||||||
TEST_MOVIE_ID=
|
|
||||||
TEST_EPISODE_ID=
|
|
||||||
|
|
||||||
# Test Timeouts (milliseconds)
|
|
||||||
TEST_TIMEOUT=60000
|
|
||||||
TEST_WAIT_TIMEOUT=15000
|
|
||||||
-376
@@ -1,376 +0,0 @@
|
|||||||
# E2E Testing with WebdriverIO
|
|
||||||
|
|
||||||
End-to-end tests for JellyTau using WebdriverIO and tauri-driver. These tests run against a real Tauri app instance with an **isolated test database**.
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Configure test credentials (first time only)
|
|
||||||
cp e2e/.env.example e2e/.env
|
|
||||||
# Edit e2e/.env with your Jellyfin server details
|
|
||||||
|
|
||||||
# 2. Build the frontend
|
|
||||||
bun run build
|
|
||||||
|
|
||||||
# 3. Run E2E tests
|
|
||||||
bun run test:e2e
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Test Credentials
|
|
||||||
|
|
||||||
E2E tests use credentials from `e2e/.env` (gitignored). Copy the example file to get started:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp e2e/.env.example e2e/.env
|
|
||||||
```
|
|
||||||
|
|
||||||
**e2e/.env** (your private file):
|
|
||||||
```bash
|
|
||||||
# Your Jellyfin test server
|
|
||||||
TEST_SERVER_URL=https://your-jellyfin.example.com
|
|
||||||
TEST_SERVER_NAME=My Test Server
|
|
||||||
|
|
||||||
# Test user credentials
|
|
||||||
TEST_USERNAME=testuser
|
|
||||||
TEST_PASSWORD=yourpassword
|
|
||||||
|
|
||||||
# Optional: Specific test data IDs
|
|
||||||
TEST_MUSIC_LIBRARY_ID=abc123
|
|
||||||
TEST_ALBUM_ID=xyz789
|
|
||||||
# ... etc
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important:**
|
|
||||||
- ✅ `.env` is gitignored - your credentials stay private
|
|
||||||
- ✅ Tests fall back to Jellyfin demo server if `.env` doesn't exist
|
|
||||||
- ✅ Share `.env.example` with your team so they can set up their own
|
|
||||||
|
|
||||||
### Isolated Test Database
|
|
||||||
|
|
||||||
**Your production data is safe!** E2E tests use a completely separate database:
|
|
||||||
|
|
||||||
- **Production:** `~/.local/share/com.dtourolle.jellytau/` - Your real data ✅
|
|
||||||
- **E2E Tests:** `/tmp/jellytau-test-data/` - Isolated test data ✅
|
|
||||||
|
|
||||||
This is configured via the `JELLYTAU_DATA_DIR` environment variable in `wdio.conf.ts`.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Test Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
e2e/
|
|
||||||
├── .env.example # Template for test credentials
|
|
||||||
├── .env # Your credentials (gitignored)
|
|
||||||
├── specs/ # Test specifications
|
|
||||||
│ ├── app-launch.e2e.ts # App initialization tests
|
|
||||||
│ ├── auth.e2e.ts # Authentication flow
|
|
||||||
│ └── navigation.e2e.ts # Navigation and routing
|
|
||||||
├── pageobjects/ # Page Object Model (POM)
|
|
||||||
│ ├── BasePage.ts # Base class with common methods
|
|
||||||
│ ├── LoginPage.ts # Login page interactions
|
|
||||||
│ └── HomePage.ts # Home page interactions
|
|
||||||
└── helpers/ # Test utilities
|
|
||||||
├── testConfig.ts # Load .env configuration
|
|
||||||
└── testSetup.ts # Setup helpers
|
|
||||||
```
|
|
||||||
|
|
||||||
### Page Object Model
|
|
||||||
|
|
||||||
Tests use the Page Object Model pattern for maintainability:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Good: Using page objects
|
|
||||||
import LoginPage from "../pageobjects/LoginPage";
|
|
||||||
|
|
||||||
await LoginPage.waitForLoginPage();
|
|
||||||
await LoginPage.connectToServer(testConfig.serverUrl);
|
|
||||||
await LoginPage.login(testConfig.username, testConfig.password);
|
|
||||||
|
|
||||||
// Bad: Direct selectors in tests
|
|
||||||
await $("#server-url").setValue("https://...");
|
|
||||||
await $("button").click();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Writing Tests
|
|
||||||
|
|
||||||
### Using Test Configuration
|
|
||||||
|
|
||||||
Always use `testConfig` for credentials and server details:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { testConfig } from "../helpers/testConfig";
|
|
||||||
|
|
||||||
describe("My Feature", () => {
|
|
||||||
it("should test something", async () => {
|
|
||||||
// Use testConfig instead of hardcoded values
|
|
||||||
await LoginPage.connectToServer(testConfig.serverUrl);
|
|
||||||
await LoginPage.login(testConfig.username, testConfig.password);
|
|
||||||
|
|
||||||
// Access optional test data
|
|
||||||
if (testConfig.albumId) {
|
|
||||||
// Test with specific album
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Data IDs
|
|
||||||
|
|
||||||
For tests that need specific content (albums, tracks, etc.):
|
|
||||||
|
|
||||||
1. Find the ID in your Jellyfin server (check the URL when viewing an item)
|
|
||||||
2. Add it to your `e2e/.env`:
|
|
||||||
```bash
|
|
||||||
TEST_ALBUM_ID=abc123def456
|
|
||||||
```
|
|
||||||
3. Use it in tests:
|
|
||||||
```typescript
|
|
||||||
if (testConfig.albumId) {
|
|
||||||
await browser.url(`/album/${testConfig.albumId}`);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example Test
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { expect } from "@wdio/globals";
|
|
||||||
import LoginPage from "../pageobjects/LoginPage";
|
|
||||||
import { testConfig } from "../helpers/testConfig";
|
|
||||||
|
|
||||||
describe("Album Playback", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
// Login before each test
|
|
||||||
await LoginPage.waitForLoginPage();
|
|
||||||
await LoginPage.fullLoginFlow(
|
|
||||||
testConfig.serverUrl,
|
|
||||||
testConfig.username,
|
|
||||||
testConfig.password
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should play an album", async () => {
|
|
||||||
// Skip if no test album configured
|
|
||||||
if (!testConfig.albumId) {
|
|
||||||
console.log("Skipping - no TEST_ALBUM_ID configured");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigate to album
|
|
||||||
await browser.url(`/album/${testConfig.albumId}`);
|
|
||||||
|
|
||||||
// Click play
|
|
||||||
const playButton = await $('[aria-label="Play"]');
|
|
||||||
await playButton.click();
|
|
||||||
|
|
||||||
// Verify playback started
|
|
||||||
const miniPlayer = await $(".mini-player");
|
|
||||||
expect(await miniPlayer.isDisplayed()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running Tests
|
|
||||||
|
|
||||||
### Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run all E2E tests
|
|
||||||
bun run test:e2e
|
|
||||||
|
|
||||||
# Run in watch mode (development)
|
|
||||||
bun run test:e2e:dev
|
|
||||||
|
|
||||||
# Run specific test file
|
|
||||||
bun run test:e2e -- e2e/specs/auth.e2e.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
### Before Running
|
|
||||||
|
|
||||||
**Always build the frontend first:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run build
|
|
||||||
cd src-tauri && cargo build
|
|
||||||
```
|
|
||||||
|
|
||||||
The debug binary expects built frontend files in the `build/` directory.
|
|
||||||
|
|
||||||
## Test Files
|
|
||||||
|
|
||||||
### app-launch.e2e.ts
|
|
||||||
Basic app initialization tests:
|
|
||||||
- App launches successfully
|
|
||||||
- UI renders correctly
|
|
||||||
- Unauthenticated users redirect to login
|
|
||||||
|
|
||||||
**Status:** ✅ Working (no credentials needed)
|
|
||||||
|
|
||||||
### auth.e2e.ts
|
|
||||||
Full authentication flow:
|
|
||||||
- Server connection (2-step process)
|
|
||||||
- Login form validation
|
|
||||||
- Error handling
|
|
||||||
- Complete auth flow
|
|
||||||
|
|
||||||
**Status:** ✅ Working with any Jellyfin server
|
|
||||||
|
|
||||||
### navigation.e2e.ts
|
|
||||||
Routing and navigation:
|
|
||||||
- Protected routes
|
|
||||||
- Redirects
|
|
||||||
- Navigation after login
|
|
||||||
|
|
||||||
**Status:** ⚠️ Needs valid credentials (configure `.env`)
|
|
||||||
|
|
||||||
## Configuration Reference
|
|
||||||
|
|
||||||
### wdio.conf.ts
|
|
||||||
|
|
||||||
Main WebdriverIO configuration:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
port: 4444, // tauri-driver port
|
|
||||||
maxInstances: 1, // Run tests sequentially
|
|
||||||
logLevel: "warn", // Reduce noise
|
|
||||||
framework: "mocha",
|
|
||||||
timeout: 60000, // 60s test timeout
|
|
||||||
|
|
||||||
capabilities: [{
|
|
||||||
"tauri:options": {
|
|
||||||
application: "path/to/app",
|
|
||||||
env: {
|
|
||||||
JELLYTAU_DATA_DIR: "/tmp/jellytau-test-data" // Isolated DB
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
| Variable | Description | Default |
|
|
||||||
|----------|-------------|---------|
|
|
||||||
| `TEST_SERVER_URL` | Jellyfin server URL | `https://demo.jellyfin.org/stable` |
|
|
||||||
| `TEST_SERVER_NAME` | Server display name | `Demo Server` |
|
|
||||||
| `TEST_USERNAME` | Test user username | `demo` |
|
|
||||||
| `TEST_PASSWORD` | Test user password | `` (empty) |
|
|
||||||
| `TEST_MUSIC_LIBRARY_ID` | Music library ID | undefined |
|
|
||||||
| `TEST_ALBUM_ID` | Album ID for playback tests | undefined |
|
|
||||||
| `TEST_TRACK_ID` | Track ID for tests | undefined |
|
|
||||||
| `TEST_TIMEOUT` | Mocha test timeout (ms) | `60000` |
|
|
||||||
| `TEST_WAIT_TIMEOUT` | Element wait timeout (ms) | `15000` |
|
|
||||||
|
|
||||||
## Debugging
|
|
||||||
|
|
||||||
### View Application During Tests
|
|
||||||
|
|
||||||
Tests run with a visible window. To pause and inspect:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
it("debug test", async () => {
|
|
||||||
await LoginPage.waitForLoginPage();
|
|
||||||
|
|
||||||
// Pause for 10 seconds to inspect
|
|
||||||
await browser.pause(10000);
|
|
||||||
|
|
||||||
await LoginPage.enterServerUrl(testConfig.serverUrl);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Check Logs
|
|
||||||
|
|
||||||
- **WebdriverIO logs:** Console output (set `logLevel: "info"` in config)
|
|
||||||
- **tauri-driver logs:** Stdout/stderr from driver process
|
|
||||||
- **App logs:** Check app console (if running with dev tools)
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
**"Connection refused" in browser body**
|
|
||||||
- Frontend not built: Run `bun run build`
|
|
||||||
- Solution: Always build before testing
|
|
||||||
|
|
||||||
**"Element not found" errors**
|
|
||||||
- Selector might be wrong
|
|
||||||
- Element not loaded yet - add wait: `await element.waitForDisplayed()`
|
|
||||||
|
|
||||||
**"Invalid session id"**
|
|
||||||
- Normal when app closes between tests
|
|
||||||
- Each test file gets a fresh app instance
|
|
||||||
|
|
||||||
**Tests fail with "no .env file"**
|
|
||||||
- Copy `e2e/.env.example` to `e2e/.env`
|
|
||||||
- Configure your Jellyfin server details
|
|
||||||
|
|
||||||
**Database still using production data**
|
|
||||||
- Check `wdio.conf.ts` has `JELLYTAU_DATA_DIR` env var
|
|
||||||
- Rebuild app: `cd src-tauri && cargo build`
|
|
||||||
|
|
||||||
## Platform Support
|
|
||||||
|
|
||||||
### Supported
|
|
||||||
|
|
||||||
- ✅ **Linux** - Primary development platform
|
|
||||||
- ✅ **Windows** - Supported (paths auto-detected)
|
|
||||||
- ✅ **macOS** - Supported (paths auto-detected)
|
|
||||||
|
|
||||||
### Not Supported
|
|
||||||
|
|
||||||
- ❌ **Android** - E2E testing requires Appium + emulators (out of scope)
|
|
||||||
- Desktop tests cover 90% of app logic anyway
|
|
||||||
|
|
||||||
## Team Collaboration
|
|
||||||
|
|
||||||
### Sharing Test Configuration
|
|
||||||
|
|
||||||
**DO:**
|
|
||||||
- ✅ Commit `e2e/.env.example` with template values
|
|
||||||
- ✅ Update README when adding new test data requirements
|
|
||||||
- ✅ Use descriptive variable names in `.env.example`
|
|
||||||
|
|
||||||
**DON'T:**
|
|
||||||
- ❌ Commit `e2e/.env` with real credentials
|
|
||||||
- ❌ Hardcode server URLs in test files
|
|
||||||
- ❌ Skip authentication in tests (always test full flows)
|
|
||||||
|
|
||||||
### Setting Up for a New Team Member
|
|
||||||
|
|
||||||
1. **Clone repo**
|
|
||||||
2. **Copy env template:** `cp e2e/.env.example e2e/.env`
|
|
||||||
3. **Configure credentials:** Edit `e2e/.env` with your Jellyfin server
|
|
||||||
4. **Build frontend:** `bun run build`
|
|
||||||
5. **Run tests:** `bun run test:e2e`
|
|
||||||
|
|
||||||
That's it! No shared credentials needed.
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. **Use testConfig:** Never hardcode credentials
|
|
||||||
2. **Use Page Objects:** Keep selectors out of test specs
|
|
||||||
3. **Wait for Elements:** Always use `.waitForDisplayed()`
|
|
||||||
4. **Independent Tests:** Each test should work standalone
|
|
||||||
5. **Skip Gracefully:** Check for optional test data before using
|
|
||||||
6. **Build First:** Always `bun run build` before running tests
|
|
||||||
7. **Clear Names:** Use descriptive `describe` and `it` blocks
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
- [ ] Add more page objects (Player, Library, Queue, Settings)
|
|
||||||
- [ ] Create test data fixtures
|
|
||||||
- [ ] Add visual regression testing
|
|
||||||
- [ ] Mock Jellyfin API for faster, more reliable tests
|
|
||||||
- [ ] CI/CD integration (GitHub Actions)
|
|
||||||
- [ ] Test report generation
|
|
||||||
- [ ] Screenshot capture on failure
|
|
||||||
- [ ] Video recording of test runs
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
- [WebdriverIO Documentation](https://webdriver.io/)
|
|
||||||
- [Tauri Testing Guide](https://v2.tauri.app/develop/tests/webdriver/)
|
|
||||||
- [tauri-driver GitHub](https://github.com/tauri-apps/tauri/tree/dev/tooling/webdriver)
|
|
||||||
- [Mocha Documentation](https://mochajs.org/)
|
|
||||||
- [Page Object Model Pattern](https://webdriver.io/docs/pageobjects/)
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test configuration loaded from .env file
|
|
||||||
*/
|
|
||||||
export interface TestConfig {
|
|
||||||
serverUrl: string;
|
|
||||||
serverName: string;
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
musicLibraryId?: string;
|
|
||||||
movieLibraryId?: string;
|
|
||||||
artistId?: string;
|
|
||||||
albumId?: string;
|
|
||||||
trackId?: string;
|
|
||||||
movieId?: string;
|
|
||||||
episodeId?: string;
|
|
||||||
timeout: number;
|
|
||||||
waitTimeout: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load test configuration from .env file
|
|
||||||
* Falls back to demo server if .env doesn't exist
|
|
||||||
*/
|
|
||||||
export function loadTestConfig(): TestConfig {
|
|
||||||
const envPath = path.join(__dirname, "..", ".env");
|
|
||||||
const config: TestConfig = {
|
|
||||||
serverUrl: "https://demo.jellyfin.org/stable",
|
|
||||||
serverName: "Demo Server",
|
|
||||||
username: "demo",
|
|
||||||
password: "",
|
|
||||||
timeout: 60000,
|
|
||||||
waitTimeout: 15000,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Try to load .env file
|
|
||||||
if (fs.existsSync(envPath)) {
|
|
||||||
const envContent = fs.readFileSync(envPath, "utf-8");
|
|
||||||
const lines = envContent.split("\n");
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
// Skip comments and empty lines
|
|
||||||
if (line.trim().startsWith("#") || !line.trim()) continue;
|
|
||||||
|
|
||||||
const [key, ...valueParts] = line.split("=");
|
|
||||||
const value = valueParts.join("=").trim();
|
|
||||||
|
|
||||||
switch (key.trim()) {
|
|
||||||
case "TEST_SERVER_URL":
|
|
||||||
if (value) config.serverUrl = value;
|
|
||||||
break;
|
|
||||||
case "TEST_SERVER_NAME":
|
|
||||||
if (value) config.serverName = value;
|
|
||||||
break;
|
|
||||||
case "TEST_USERNAME":
|
|
||||||
if (value) config.username = value;
|
|
||||||
break;
|
|
||||||
case "TEST_PASSWORD":
|
|
||||||
config.password = value; // Can be empty
|
|
||||||
break;
|
|
||||||
case "TEST_MUSIC_LIBRARY_ID":
|
|
||||||
if (value) config.musicLibraryId = value;
|
|
||||||
break;
|
|
||||||
case "TEST_MOVIE_LIBRARY_ID":
|
|
||||||
if (value) config.movieLibraryId = value;
|
|
||||||
break;
|
|
||||||
case "TEST_ARTIST_ID":
|
|
||||||
if (value) config.artistId = value;
|
|
||||||
break;
|
|
||||||
case "TEST_ALBUM_ID":
|
|
||||||
if (value) config.albumId = value;
|
|
||||||
break;
|
|
||||||
case "TEST_TRACK_ID":
|
|
||||||
if (value) config.trackId = value;
|
|
||||||
break;
|
|
||||||
case "TEST_MOVIE_ID":
|
|
||||||
if (value) config.movieId = value;
|
|
||||||
break;
|
|
||||||
case "TEST_EPISODE_ID":
|
|
||||||
if (value) config.episodeId = value;
|
|
||||||
break;
|
|
||||||
case "TEST_TIMEOUT":
|
|
||||||
if (value) config.timeout = parseInt(value, 10);
|
|
||||||
break;
|
|
||||||
case "TEST_WAIT_TIMEOUT":
|
|
||||||
if (value) config.waitTimeout = parseInt(value, 10);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn(
|
|
||||||
"⚠️ No e2e/.env file found. Using demo server credentials."
|
|
||||||
);
|
|
||||||
console.warn(
|
|
||||||
" Copy e2e/.env.example to e2e/.env and configure your test server."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export a singleton instance
|
|
||||||
export const testConfig = loadTestConfig();
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import os from "node:os";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clears the JellyTau database and cache before tests
|
|
||||||
* This ensures each test run starts with a fresh state
|
|
||||||
*/
|
|
||||||
export function clearAppData() {
|
|
||||||
const appDataDir = path.join(
|
|
||||||
os.homedir(),
|
|
||||||
".local/share/com.dtourolle.jellytau"
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(appDataDir)) {
|
|
||||||
// Remove database file
|
|
||||||
const dbPath = path.join(appDataDir, "jellytau.db");
|
|
||||||
if (fs.existsSync(dbPath)) {
|
|
||||||
fs.unlinkSync(dbPath);
|
|
||||||
console.log("Cleared test database");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear any cache files if needed
|
|
||||||
// Add more cleanup as needed
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Failed to clear app data:", error);
|
|
||||||
// Don't fail tests if cleanup fails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for element with retries
|
|
||||||
* Useful for elements that might take time to appear
|
|
||||||
*/
|
|
||||||
export async function waitForElement(
|
|
||||||
selector: string,
|
|
||||||
timeout: number = 15000,
|
|
||||||
retries: number = 3
|
|
||||||
): Promise<WebdriverIO.Element> {
|
|
||||||
for (let i = 0; i < retries; i++) {
|
|
||||||
try {
|
|
||||||
const element = await $(selector);
|
|
||||||
await element.waitForDisplayed({ timeout });
|
|
||||||
return element;
|
|
||||||
} catch (error) {
|
|
||||||
if (i === retries - 1) throw error;
|
|
||||||
await browser.pause(1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error(`Element ${selector} not found after ${retries} retries`);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
export default class BasePage {
|
|
||||||
async waitForElement(selector: string, timeout: number = 10000) {
|
|
||||||
const element = await $(selector);
|
|
||||||
await element.waitForDisplayed({ timeout });
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
|
|
||||||
async clickElement(selector: string) {
|
|
||||||
const element = await this.waitForElement(selector);
|
|
||||||
await element.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
async enterText(selector: string, text: string) {
|
|
||||||
const element = await this.waitForElement(selector);
|
|
||||||
await element.setValue(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getText(selector: string): Promise<string> {
|
|
||||||
const element = await this.waitForElement(selector);
|
|
||||||
return await element.getText();
|
|
||||||
}
|
|
||||||
|
|
||||||
async isElementDisplayed(selector: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const element = await $(selector);
|
|
||||||
return await element.isDisplayed();
|
|
||||||
} catch (error) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import BasePage from "./BasePage";
|
|
||||||
|
|
||||||
class HomePage extends BasePage {
|
|
||||||
// Selectors
|
|
||||||
get loadingSpinner() {
|
|
||||||
return $(".animate-spin");
|
|
||||||
}
|
|
||||||
|
|
||||||
get browseLibrariesButton() {
|
|
||||||
return $("button*=Browse all libraries");
|
|
||||||
}
|
|
||||||
|
|
||||||
get offlineBanner() {
|
|
||||||
return $(".bg-amber-600\\/90");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Carousel sections
|
|
||||||
get heroSection() {
|
|
||||||
return $("div"); // Hero banner would need specific selector
|
|
||||||
}
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
async waitForHomePageLoad(timeout: number = 15000) {
|
|
||||||
// Wait for loading spinner to disappear
|
|
||||||
try {
|
|
||||||
await this.loadingSpinner.waitForDisplayed({ timeout: 5000 });
|
|
||||||
await this.loadingSpinner.waitForDisplayed({ timeout, reverse: true });
|
|
||||||
} catch {
|
|
||||||
// Spinner might not appear if page loads quickly
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async isOffline(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
return await this.offlineBanner.isDisplayed();
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async clickBrowseLibraries() {
|
|
||||||
await this.browseLibrariesButton.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
async hasContent(): Promise<boolean> {
|
|
||||||
// Check if browse button exists (indicates loaded state)
|
|
||||||
try {
|
|
||||||
return await this.browseLibrariesButton.isExisting();
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default new HomePage();
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import BasePage from "./BasePage";
|
|
||||||
|
|
||||||
class LoginPage extends BasePage {
|
|
||||||
// Selectors
|
|
||||||
get pageTitle() {
|
|
||||||
return $("h1");
|
|
||||||
}
|
|
||||||
|
|
||||||
get serverUrlInput() {
|
|
||||||
return $("#server-url");
|
|
||||||
}
|
|
||||||
|
|
||||||
get connectButton() {
|
|
||||||
return $('button[type="submit"]');
|
|
||||||
}
|
|
||||||
|
|
||||||
get usernameInput() {
|
|
||||||
return $("#username");
|
|
||||||
}
|
|
||||||
|
|
||||||
get passwordInput() {
|
|
||||||
return $("#password");
|
|
||||||
}
|
|
||||||
|
|
||||||
get signInButton() {
|
|
||||||
return $('button[type="submit"]');
|
|
||||||
}
|
|
||||||
|
|
||||||
get errorMessage() {
|
|
||||||
return $(".bg-red-900\\/50");
|
|
||||||
}
|
|
||||||
|
|
||||||
get backButton() {
|
|
||||||
return $("button*=Back");
|
|
||||||
}
|
|
||||||
|
|
||||||
get serverNameDisplay() {
|
|
||||||
return $('p.text-\\[var\\(--color-jellyfin\\)\\]');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
async waitForLoginPage(timeout: number = 10000) {
|
|
||||||
await this.serverUrlInput.waitForDisplayed({ timeout });
|
|
||||||
}
|
|
||||||
|
|
||||||
async enterServerUrl(url: string) {
|
|
||||||
await this.serverUrlInput.setValue(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
async clickConnect() {
|
|
||||||
await this.connectButton.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
async connectToServer(url: string) {
|
|
||||||
await this.enterServerUrl(url);
|
|
||||||
await this.clickConnect();
|
|
||||||
|
|
||||||
// Wait for transition to login form
|
|
||||||
await this.usernameInput.waitForDisplayed({ timeout: 10000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async enterUsername(username: string) {
|
|
||||||
await this.usernameInput.setValue(username);
|
|
||||||
}
|
|
||||||
|
|
||||||
async enterPassword(password: string) {
|
|
||||||
await this.passwordInput.setValue(password);
|
|
||||||
}
|
|
||||||
|
|
||||||
async clickSignIn() {
|
|
||||||
await this.signInButton.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
async login(username: string, password: string) {
|
|
||||||
await this.enterUsername(username);
|
|
||||||
await this.enterPassword(password);
|
|
||||||
await this.clickSignIn();
|
|
||||||
}
|
|
||||||
|
|
||||||
async fullLoginFlow(serverUrl: string, username: string, password: string) {
|
|
||||||
await this.waitForLoginPage();
|
|
||||||
await this.connectToServer(serverUrl);
|
|
||||||
await this.login(username, password);
|
|
||||||
}
|
|
||||||
|
|
||||||
async isOnServerStep(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
return await this.serverUrlInput.isDisplayed();
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async isOnLoginStep(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
return await this.usernameInput.isDisplayed();
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getErrorMessage(): Promise<string> {
|
|
||||||
await this.errorMessage.waitForDisplayed({ timeout: 5000 });
|
|
||||||
return await this.errorMessage.getText();
|
|
||||||
}
|
|
||||||
|
|
||||||
async hasError(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
return await this.errorMessage.isDisplayed();
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default new LoginPage();
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { expect } from "@wdio/globals";
|
|
||||||
|
|
||||||
describe("Application Launch", () => {
|
|
||||||
it("should launch the application", async () => {
|
|
||||||
// Wait for body element to appear
|
|
||||||
const body = await $("body");
|
|
||||||
await body.waitForDisplayed({ timeout: 15000 });
|
|
||||||
|
|
||||||
// Verify app launched successfully
|
|
||||||
expect(await body.isDisplayed()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render the main app container", async () => {
|
|
||||||
// The app has a root div with specific classes
|
|
||||||
const appContainer = await $("div.h-screen.bg-\\[var\\(--color-background\\)\\]");
|
|
||||||
|
|
||||||
// Verify the main container exists
|
|
||||||
expect(await appContainer.isExisting()).toBe(true);
|
|
||||||
expect(await appContainer.isDisplayed()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show JellyTau branding", async () => {
|
|
||||||
// The app should show JellyTau title on login page (default state)
|
|
||||||
const title = await $("h1");
|
|
||||||
await title.waitForDisplayed({ timeout: 10000 });
|
|
||||||
|
|
||||||
const titleText = await title.getText();
|
|
||||||
expect(titleText).toContain("JellyTau");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should redirect unauthenticated users to login", async () => {
|
|
||||||
// Wait for login page elements to appear
|
|
||||||
const serverUrlInput = await $("#server-url");
|
|
||||||
await serverUrlInput.waitForDisplayed({ timeout: 10000 });
|
|
||||||
|
|
||||||
// Verify we're on the login page
|
|
||||||
expect(await serverUrlInput.isDisplayed()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
import { expect } from "@wdio/globals";
|
|
||||||
import LoginPage from "../pageobjects/LoginPage";
|
|
||||||
import { testConfig } from "../helpers/testConfig";
|
|
||||||
|
|
||||||
describe("Authentication Flow", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
// Each test starts fresh - app should redirect to login
|
|
||||||
await LoginPage.waitForLoginPage();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Server Connection", () => {
|
|
||||||
it("should display the server connection form", async () => {
|
|
||||||
expect(await LoginPage.isOnServerStep()).toBe(true);
|
|
||||||
expect(await LoginPage.pageTitle.getText()).toContain("JellyTau");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show server URL input field", async () => {
|
|
||||||
const serverInput = await LoginPage.serverUrlInput;
|
|
||||||
|
|
||||||
expect(await serverInput.isDisplayed()).toBe(true);
|
|
||||||
expect(await serverInput.getAttribute("placeholder")).toContain("jellyfin");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have a disabled connect button when URL is empty", async () => {
|
|
||||||
const connectButton = await LoginPage.connectButton;
|
|
||||||
|
|
||||||
// Button should be disabled when input is empty
|
|
||||||
expect(await connectButton.isEnabled()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should enable connect button when URL is entered", async () => {
|
|
||||||
await LoginPage.enterServerUrl(testConfig.serverUrl);
|
|
||||||
|
|
||||||
const connectButton = await LoginPage.connectButton;
|
|
||||||
expect(await connectButton.isEnabled()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show error for invalid server URL", async () => {
|
|
||||||
await LoginPage.enterServerUrl("not-a-valid-url");
|
|
||||||
await LoginPage.clickConnect();
|
|
||||||
|
|
||||||
// Wait for error to appear
|
|
||||||
await browser.pause(2000);
|
|
||||||
|
|
||||||
expect(await LoginPage.hasError()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should transition to login form on successful connection", async () => {
|
|
||||||
// Using configured test server
|
|
||||||
await LoginPage.connectToServer(testConfig.serverUrl);
|
|
||||||
|
|
||||||
// Should now be on login step
|
|
||||||
expect(await LoginPage.isOnLoginStep()).toBe(true);
|
|
||||||
expect(await LoginPage.isOnServerStep()).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("User Login", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
// Connect to configured test server before each login test
|
|
||||||
await LoginPage.connectToServer(testConfig.serverUrl);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should display login form after server connection", async () => {
|
|
||||||
expect(await LoginPage.usernameInput.isDisplayed()).toBe(true);
|
|
||||||
expect(await LoginPage.passwordInput.isDisplayed()).toBe(true);
|
|
||||||
expect(await LoginPage.signInButton.isDisplayed()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show server information", async () => {
|
|
||||||
// Server name and URL should be displayed
|
|
||||||
const serverName = await LoginPage.serverNameDisplay;
|
|
||||||
expect(await serverName.isDisplayed()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have back button to return to server selection", async () => {
|
|
||||||
expect(await LoginPage.backButton.isDisplayed()).toBe(true);
|
|
||||||
|
|
||||||
await LoginPage.backButton.click();
|
|
||||||
await browser.pause(500);
|
|
||||||
|
|
||||||
// Should be back on server step
|
|
||||||
expect(await LoginPage.isOnServerStep()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should disable sign in button when username is empty", async () => {
|
|
||||||
const signInButton = await LoginPage.signInButton;
|
|
||||||
expect(await signInButton.isEnabled()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should enable sign in button when username is entered", async () => {
|
|
||||||
await LoginPage.enterUsername("demo");
|
|
||||||
|
|
||||||
const signInButton = await LoginPage.signInButton;
|
|
||||||
expect(await signInButton.isEnabled()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show error for invalid credentials", async () => {
|
|
||||||
await LoginPage.login("invalid-user", "wrong-password");
|
|
||||||
|
|
||||||
// Wait for error
|
|
||||||
await browser.pause(2000);
|
|
||||||
|
|
||||||
expect(await LoginPage.hasError()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Enable this test by configuring e2e/.env with valid credentials
|
|
||||||
it.skip("should successfully login with valid credentials", async () => {
|
|
||||||
await LoginPage.login(testConfig.username, testConfig.password);
|
|
||||||
|
|
||||||
// Wait for redirect to home page
|
|
||||||
await browser.pause(3000);
|
|
||||||
|
|
||||||
// Should redirect away from login page
|
|
||||||
const currentUrl = await browser.getUrl();
|
|
||||||
expect(currentUrl).not.toContain("/login");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Full Authentication Flow", () => {
|
|
||||||
it("should complete full auth flow with test server", async () => {
|
|
||||||
// Test the complete flow
|
|
||||||
await LoginPage.waitForLoginPage();
|
|
||||||
|
|
||||||
// Step 1: Enter server URL
|
|
||||||
expect(await LoginPage.isOnServerStep()).toBe(true);
|
|
||||||
await LoginPage.enterServerUrl(testConfig.serverUrl);
|
|
||||||
await LoginPage.clickConnect();
|
|
||||||
|
|
||||||
// Wait for transition
|
|
||||||
await browser.pause(2000);
|
|
||||||
|
|
||||||
// Step 2: Should be on login form
|
|
||||||
expect(await LoginPage.isOnLoginStep()).toBe(true);
|
|
||||||
|
|
||||||
// Step 3: Enter credentials
|
|
||||||
await LoginPage.enterUsername(testConfig.username);
|
|
||||||
await LoginPage.enterPassword(testConfig.password);
|
|
||||||
|
|
||||||
// Verify form is filled
|
|
||||||
const username = await LoginPage.usernameInput.getValue();
|
|
||||||
expect(username).toBe(testConfig.username);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { expect } from "@wdio/globals";
|
|
||||||
import LoginPage from "../pageobjects/LoginPage";
|
|
||||||
import HomePage from "../pageobjects/HomePage";
|
|
||||||
import { testConfig } from "../helpers/testConfig";
|
|
||||||
|
|
||||||
describe("Navigation", () => {
|
|
||||||
it("should redirect unauthenticated users to login", async () => {
|
|
||||||
// App should automatically redirect to login when not authenticated
|
|
||||||
await LoginPage.waitForLoginPage();
|
|
||||||
|
|
||||||
expect(await LoginPage.isOnServerStep()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should prevent direct access to protected routes", async () => {
|
|
||||||
// Try to navigate to a protected route
|
|
||||||
await browser.url("http://localhost:4444/session/fake-session-id/url");
|
|
||||||
await browser.pause(1000);
|
|
||||||
|
|
||||||
// Should redirect back to login
|
|
||||||
await LoginPage.waitForLoginPage(5000);
|
|
||||||
expect(await LoginPage.isOnServerStep()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// This test requires valid authentication - configure e2e/.env to enable
|
|
||||||
it.skip("should allow navigation after login", async () => {
|
|
||||||
// Login first
|
|
||||||
await LoginPage.fullLoginFlow(
|
|
||||||
testConfig.serverUrl,
|
|
||||||
testConfig.username,
|
|
||||||
testConfig.password
|
|
||||||
);
|
|
||||||
|
|
||||||
// Wait for home page
|
|
||||||
await HomePage.waitForHomePageLoad();
|
|
||||||
|
|
||||||
// Should be able to navigate
|
|
||||||
expect(await HomePage.hasContent()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
// ESLint flat config for the JellyTau frontend (Svelte 5 + TypeScript strict).
|
||||||
|
//
|
||||||
|
// TRACES: | DR-205
|
||||||
|
//
|
||||||
|
// Scope: `src/` (the presentation layer), `scripts/` (build tooling), and the
|
||||||
|
// root config files. The Rust backend is linted by clippy, not by this config.
|
||||||
|
//
|
||||||
|
// Formatting is NOT ESLint's job here — `eslint-config-prettier` is applied last
|
||||||
|
// and switches off every stylistic rule that would fight `prettier`. Run
|
||||||
|
// `bun run format` / `bun run format:check` for layout.
|
||||||
|
import js from "@eslint/js";
|
||||||
|
import ts from "typescript-eslint";
|
||||||
|
import svelte from "eslint-plugin-svelte";
|
||||||
|
import globals from "globals";
|
||||||
|
import prettier from "eslint-config-prettier";
|
||||||
|
import svelteConfig from "./svelte.config.js";
|
||||||
|
|
||||||
|
export default ts.config(
|
||||||
|
{
|
||||||
|
// Kept in one place so `npx eslint .` and editor integrations agree.
|
||||||
|
ignores: [
|
||||||
|
"node_modules/",
|
||||||
|
".svelte-kit/",
|
||||||
|
// Scratch worktrees (git-ignored) hold full checkouts of this repo,
|
||||||
|
// including their own generated .svelte-kit trees. Without this, `eslint .`
|
||||||
|
// lints every in-flight branch and reports its generated code as ours.
|
||||||
|
".claude/",
|
||||||
|
"build/",
|
||||||
|
"dist/",
|
||||||
|
"coverage/",
|
||||||
|
"package/",
|
||||||
|
"src-tauri/",
|
||||||
|
// Generated by tauri-specta on every Rust build — never hand-edited, and
|
||||||
|
// its shape is dictated by the Rust command definitions.
|
||||||
|
"src/lib/api/bindings.ts",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
js.configs.recommended,
|
||||||
|
...ts.configs.recommended,
|
||||||
|
...svelte.configs.recommended,
|
||||||
|
prettier,
|
||||||
|
...svelte.configs.prettier,
|
||||||
|
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
...globals.es2021,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// 🔴 TEMPORARILY OFF. A parallel migration is moving all ~468 `console.*`
|
||||||
|
// calls in `src/` onto a logger facade. Turning this on before that lands
|
||||||
|
// would paint the tree red and collide with that work.
|
||||||
|
//
|
||||||
|
// 👉 Switch this to "error" (allowing nothing, or at most
|
||||||
|
// `{ allow: ["warn", "error"] }`) once the logger-facade migration is
|
||||||
|
// merged — that is the whole point of the rule being listed here.
|
||||||
|
"no-console": "off",
|
||||||
|
|
||||||
|
// Unused values are a real signal, but `_`-prefixed args are the
|
||||||
|
// established way to say "this parameter exists for the signature".
|
||||||
|
//
|
||||||
|
// ⚠️ warn, not error: the tree carries ~94 genuinely dead bindings (stale
|
||||||
|
// imports, `$state` left over from refactors, unused `catch (e)`). Every
|
||||||
|
// one is a real finding, but fixing them here would mean ~50 unrelated
|
||||||
|
// files in this tooling commit. Clear the backlog, then promote to
|
||||||
|
// "error".
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
argsIgnorePattern: "^_",
|
||||||
|
varsIgnorePattern: "^_",
|
||||||
|
caughtErrorsIgnorePattern: "^_",
|
||||||
|
destructuredArrayIgnorePattern: "^_",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// Warn-only rules: each flags something real, but the existing tree has
|
||||||
|
// more instances than can be fixed without swamping unrelated diffs.
|
||||||
|
// Drive these to zero and promote them to "error" — do not delete them.
|
||||||
|
//
|
||||||
|
// `any` at the Tauri IPC boundary, mostly in code predating the
|
||||||
|
// tauri-specta bindings (~25 sites outside tests).
|
||||||
|
"@typescript-eslint/no-explicit-any": "warn",
|
||||||
|
// Empty catch/if bodies that swallow an error.
|
||||||
|
"no-empty": ["warn", { allowEmptyCatch: true }],
|
||||||
|
|
||||||
|
// Prefer `import type` so type-only imports are erased cleanly by the
|
||||||
|
// bundler instead of pulling a module in at run time.
|
||||||
|
"@typescript-eslint/consistent-type-imports": "off",
|
||||||
|
|
||||||
|
// Not applicable to this app (~130 hits, all no-ops). SvelteKit's
|
||||||
|
// `resolve()` exists so hrefs keep working under a non-empty
|
||||||
|
// `kit.paths.base`; JellyTau is an adapter-static SPA served from the
|
||||||
|
// Tauri webview root and svelte.config.js sets no `base`. Re-enable this
|
||||||
|
// the day a base path is introduced — the rule is otherwise correct.
|
||||||
|
// (Declared here, not in the *.svelte block: `goto()` is also called from
|
||||||
|
// plain .ts modules such as src/lib/utils/navigation.ts.)
|
||||||
|
"svelte/no-navigation-without-resolve": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
// Svelte components: the parser needs the project's svelte.config.js so it
|
||||||
|
// resolves preprocessors and Svelte 5 runes the same way the build does.
|
||||||
|
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
parser: ts.parser,
|
||||||
|
svelteConfig,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// Warn-only — real findings, but each fix is a behavioural refactor that
|
||||||
|
// does not belong in a tooling commit:
|
||||||
|
// require-each-key keyed {#each} changes DOM reuse semantics
|
||||||
|
// prefer-svelte-reactivity Set/Map -> SvelteSet/SvelteMap changes
|
||||||
|
// reactivity, not just syntax
|
||||||
|
// prefer-writable-derived $state + $effect -> writable $derived
|
||||||
|
// no-at-html-tags {@html} sites need an XSS review each
|
||||||
|
"svelte/require-each-key": "warn",
|
||||||
|
"svelte/prefer-svelte-reactivity": "warn",
|
||||||
|
"svelte/prefer-writable-derived": "warn",
|
||||||
|
"svelte/no-at-html-tags": "warn",
|
||||||
|
|
||||||
|
// Warn-only: this rule cannot see the Svelte *compiler's* warning set, so
|
||||||
|
// it reports `<!-- svelte-ignore a11y_… -->` as unused when the compiler
|
||||||
|
// may still be emitting the warning it suppresses. Verify against a real
|
||||||
|
// `bun run check` before deleting any of them.
|
||||||
|
"svelte/no-unused-svelte-ignore": "warn",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
// Node-side tooling: build/test scripts and root config files run under
|
||||||
|
// Bun/Node, not in the webview.
|
||||||
|
files: [
|
||||||
|
"scripts/**/*.{ts,js}",
|
||||||
|
"*.config.{ts,js}",
|
||||||
|
"*.config.*.{ts,js}",
|
||||||
|
"svelte.config.js",
|
||||||
|
"eslint.config.js",
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
// Test files: vitest globals are enabled in vitest.config.ts.
|
||||||
|
files: ["**/*.{test,spec}.{ts,js}", "src/test/**/*.{ts,js}"],
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
...globals.vitest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// Test doubles legitimately use `any` for partial mocks.
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
// `vi.mock` factories are hoisted above the import graph, so a lazy
|
||||||
|
// `require()` inside one is the documented escape hatch.
|
||||||
|
"@typescript-eslint/no-require-imports": "off",
|
||||||
|
// Several tests deliberately replay a production assignment sequence
|
||||||
|
// (`currentStreamUrl = newStreamUrl; hasSeeked = false;`) to document the
|
||||||
|
// `$effect` they stand in for. The "useless" write is the subject under
|
||||||
|
// test, not dead code.
|
||||||
|
"no-useless-assignment": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
+28
-13
@@ -1,7 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "jellytau",
|
"name": "jellytau",
|
||||||
"version": "0.5.3",
|
"version": "0.9.1",
|
||||||
"description": "",
|
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
|
||||||
|
"author": "Duncan Tourolle <duncan@tourolle.paris>",
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://gitea.tourolle.paris/dtourolle/jellytau"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -10,14 +17,19 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"test": "vitest",
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"test:ui": "vitest --ui",
|
"test:ui": "vitest --ui",
|
||||||
"test:coverage": "vitest --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:e2e": "wdio run ./wdio.conf.ts",
|
|
||||||
"test:e2e:dev": "wdio run ./wdio.conf.ts --watch",
|
|
||||||
"test:all": "./scripts/test-all.sh",
|
"test:all": "./scripts/test-all.sh",
|
||||||
"test:rust": "./scripts/test-rust.sh",
|
"test:rust": "./scripts/test-rust.sh",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
"check:boundary": "bash scripts/check-frontend-boundary.sh",
|
"check:boundary": "bash scripts/check-frontend-boundary.sh",
|
||||||
|
"check:links": "bash scripts/check-doc-links.sh",
|
||||||
|
"hooks:install": "./scripts/install-hooks.sh",
|
||||||
"android:build": "./scripts/build-android.sh",
|
"android:build": "./scripts/build-android.sh",
|
||||||
"android:build:release": "./scripts/build-android.sh release",
|
"android:build:release": "./scripts/build-android.sh release",
|
||||||
"android:build:device": "./scripts/build-android.sh --device",
|
"android:build:device": "./scripts/build-android.sh --device",
|
||||||
@@ -39,9 +51,9 @@
|
|||||||
"traces:json": "bun run scripts/extract-traces.ts --format json",
|
"traces:json": "bun run scripts/extract-traces.ts --format json",
|
||||||
"traces:markdown": "bun run scripts/extract-traces.ts --format markdown > docs/traceability.md",
|
"traces:markdown": "bun run scripts/extract-traces.ts --format markdown > docs/traceability.md",
|
||||||
"traces:coverage": "bun run scripts/extract-traces.ts --format coverage",
|
"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"
|
"release:notes": "bun run scripts/release-notes.ts"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
@@ -50,6 +62,7 @@
|
|||||||
"svelte-dnd-action": "^0.9.69"
|
"svelte-dnd-action": "^0.9.69"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
"@sveltejs/adapter-static": "^3.0.6",
|
"@sveltejs/adapter-static": "^3.0.6",
|
||||||
"@sveltejs/kit": "^2.9.0",
|
"@sveltejs/kit": "^2.9.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
@@ -58,18 +71,20 @@
|
|||||||
"@testing-library/svelte": "^5.3.1",
|
"@testing-library/svelte": "^5.3.1",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"@vitest/ui": "^4.0.16",
|
"@vitest/ui": "^4.0.16",
|
||||||
"@wdio/cli": "^9.5.0",
|
"eslint": "^10.8.1",
|
||||||
"@wdio/local-runner": "^9.5.0",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"@wdio/mocha-framework": "^9.5.0",
|
"eslint-plugin-svelte": "^3.23.0",
|
||||||
"@wdio/spec-reporter": "^9.5.0",
|
"globals": "^17.11.0",
|
||||||
"happy-dom": "^20.0.11",
|
"happy-dom": "^20.0.11",
|
||||||
"jsdom": "^27.4.0",
|
"jsdom": "^27.4.0",
|
||||||
|
"prettier": "^3.9.6",
|
||||||
|
"prettier-plugin-svelte": "^4.1.1",
|
||||||
"svelte": "^5.47.1",
|
"svelte": "^5.47.1",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "~5.6.2",
|
"typescript": "~5.6.2",
|
||||||
|
"typescript-eslint": "^8.67.0",
|
||||||
"vite": "^6.0.3",
|
"vite": "^6.0.3",
|
||||||
"vitest": ">=1.0.0 <5.0.0",
|
"vitest": ">=1.0.0 <5.0.0"
|
||||||
"webdriverio": "^9.5.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-2
@@ -8,7 +8,7 @@
|
|||||||
# tarball/VCS URL and drop the local-copy prepare() step.
|
# tarball/VCS URL and drop the local-copy prepare() step.
|
||||||
|
|
||||||
pkgname=jellytau
|
pkgname=jellytau
|
||||||
pkgver=0.0.18
|
pkgver=0.9.1
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="A cross-platform Jellyfin client"
|
pkgdesc="A cross-platform Jellyfin client"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -29,7 +29,45 @@ build() {
|
|||||||
bun run build
|
bun run build
|
||||||
# Only the raw binary is needed; packaging is done in package() below so we
|
# Only the raw binary is needed; packaging is done in package() below so we
|
||||||
# control the Arch filesystem layout ourselves rather than via tauri-bundler.
|
# control the Arch filesystem layout ourselves rather than via tauri-bundler.
|
||||||
(cd src-tauri && cargo build --release --locked)
|
#
|
||||||
|
# 🔴 `tauri/custom-protocol` is not optional. `tauri build` passes it for you;
|
||||||
|
# a bare `cargo build` does not, and without it Tauri loads the frontend from
|
||||||
|
# `devUrl` rather than the assets embedded from `frontendDist`. The result
|
||||||
|
# builds and installs cleanly and then cannot load its own UI. check() guards
|
||||||
|
# this.
|
||||||
|
(cd src-tauri && cargo build --release --locked --features tauri/custom-protocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
check() {
|
||||||
|
cd "$_srcdir"
|
||||||
|
|
||||||
|
# A Tauri binary built without `custom-protocol` does not embed the frontend;
|
||||||
|
# it serves it from `devUrl` (http://localhost:1420) instead. It compiles,
|
||||||
|
# links and installs perfectly, then launches into "Could not connect to
|
||||||
|
# localhost: Connection refused" — which is what this package did for its
|
||||||
|
# entire existence, because `tauri build` adds that feature for you and a bare
|
||||||
|
# `cargo build` does not.
|
||||||
|
#
|
||||||
|
# Test for the *assets*, not for the dev URL: `devUrl` is part of the config
|
||||||
|
# blob that generate_context!() embeds either way, so its presence proves
|
||||||
|
# nothing. A content-hashed filename from the vite build can only be in the
|
||||||
|
# binary if the bundle was embedded — the with-feature binary is ~400 KB
|
||||||
|
# larger for exactly this reason.
|
||||||
|
local _binary="src-tauri/target/release/jellytau"
|
||||||
|
local _asset
|
||||||
|
_asset="$(basename "$(ls -1 build/_app/immutable/entry/*.js | head -n1)")"
|
||||||
|
|
||||||
|
if [ -z "$_asset" ]; then
|
||||||
|
echo "==> ERROR: no frontend build found — 'bun run build' did not produce build/_app." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -qa "$_asset" "$_binary"; then
|
||||||
|
echo "==> ERROR: the frontend bundle is not embedded in the binary." >&2
|
||||||
|
echo " Build with --features tauri/custom-protocol, or the packaged app" >&2
|
||||||
|
echo " will start up unable to load its own UI." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
package() {
|
package() {
|
||||||
@@ -42,6 +80,11 @@ package() {
|
|||||||
install -Dm644 "packaging/arch/jellytau.desktop" \
|
install -Dm644 "packaging/arch/jellytau.desktop" \
|
||||||
"$pkgdir/usr/share/applications/jellytau.desktop"
|
"$pkgdir/usr/share/applications/jellytau.desktop"
|
||||||
|
|
||||||
|
# MIT is not in /usr/share/licenses/common, so Arch packaging requires the
|
||||||
|
# licence text to ship with the package.
|
||||||
|
install -Dm644 "LICENSE" \
|
||||||
|
"$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
|
|
||||||
# Icons (hicolor)
|
# Icons (hicolor)
|
||||||
install -Dm644 "src-tauri/icons/32x32.png" \
|
install -Dm644 "src-tauri/icons/32x32.png" \
|
||||||
"$pkgdir/usr/share/icons/hicolor/32x32/apps/jellytau.png"
|
"$pkgdir/usr/share/icons/hicolor/32x32/apps/jellytau.png"
|
||||||
|
|||||||
+83
-3
@@ -13,11 +13,26 @@ Run all tests (frontend + Rust backend).
|
|||||||
### `test-frontend.sh`
|
### `test-frontend.sh`
|
||||||
Run frontend tests only.
|
Run frontend tests only.
|
||||||
```bash
|
```bash
|
||||||
./scripts/test-frontend.sh # Run all tests
|
./scripts/test-frontend.sh # Single pass (same as `bun run test`)
|
||||||
./scripts/test-frontend.sh --watch # Watch mode
|
./scripts/test-frontend.sh --watch # Watch mode
|
||||||
./scripts/test-frontend.sh --ui # Open UI
|
./scripts/test-frontend.sh --ui # Open UI
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`bun run test` is `vitest run` — one pass, exit code, done. It used to be bare
|
||||||
|
`vitest`, which parked in watch mode; CLAUDE.md's "Before Committing" list tells
|
||||||
|
people to run it, so it had to terminate. The interactive modes moved to their
|
||||||
|
own entry points:
|
||||||
|
|
||||||
|
| Command | Runs |
|
||||||
|
|---------|------|
|
||||||
|
| `bun run test` | `vitest run` — single pass |
|
||||||
|
| `bun run test:watch` | `vitest` — watch mode |
|
||||||
|
| `bun run test:ui` | `vitest --ui` |
|
||||||
|
| `bun run test:coverage` | `vitest run --coverage` |
|
||||||
|
|
||||||
|
`test-frontend.sh` forwards any extra arguments to vitest and switches to the
|
||||||
|
long-running form automatically when it sees `--watch`, `-w`, or `--ui`.
|
||||||
|
|
||||||
### `test-rust.sh`
|
### `test-rust.sh`
|
||||||
Run Rust tests only.
|
Run Rust tests only.
|
||||||
```bash
|
```bash
|
||||||
@@ -69,7 +84,8 @@ Extract requirement IDs (TRACES) from source code and generate a traceability ma
|
|||||||
bun run traces # Generate markdown report
|
bun run traces # Generate markdown report
|
||||||
bun run traces:json # Generate JSON report
|
bun run traces:json # Generate JSON report
|
||||||
bun run traces:markdown # Save to docs/traceability.md
|
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/`)
|
The script scans all TypeScript, Svelte, and Rust files (plus `scripts/`)
|
||||||
@@ -84,6 +100,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
|
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).
|
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
|
> **Removed:** `check-req-coverage.sh`, `check-test-coverage.sh`, and
|
||||||
> `find-req-implementations.sh` were deleted in July 2026. They read an
|
> `find-req-implementations.sh` were deleted in July 2026. They read an
|
||||||
> undocumented `@req:` tag convention parallel to `TRACES:`, grepped `src-tauri/`
|
> undocumented `@req:` tag convention parallel to `TRACES:`, grepped `src-tauri/`
|
||||||
@@ -104,7 +126,8 @@ See [docs/traceability.md](../docs/traceability.md) for the latest generated map
|
|||||||
|
|
||||||
The traceability system is integrated with Gitea Actions CI/CD:
|
The traceability system is integrated with Gitea Actions CI/CD:
|
||||||
- Automatically validates TRACES on every push and pull request
|
- 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
|
- Warns if new code lacks TRACES comments
|
||||||
- Generates traceability reports automatically
|
- Generates traceability reports automatically
|
||||||
|
|
||||||
@@ -112,6 +135,59 @@ For details, see:
|
|||||||
- [Traceability CI Guide](../docs/traceability-ci.md) - Full CI/CD documentation
|
- [Traceability CI Guide](../docs/traceability-ci.md) - Full CI/CD documentation
|
||||||
- [TRACES Quick Reference](../docs/traces-quick-ref.md) - Quick guide for adding TRACES
|
- [TRACES Quick Reference](../docs/traces-quick-ref.md) - Quick guide for adding TRACES
|
||||||
|
|
||||||
|
## Linting & Formatting
|
||||||
|
|
||||||
|
There is no script wrapper for these — they are plain package.json entries:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run lint # eslint .
|
||||||
|
bun run lint:fix # eslint . --fix
|
||||||
|
bun run format # prettier --write .
|
||||||
|
bun run format:check # prettier --check .
|
||||||
|
```
|
||||||
|
|
||||||
|
Config lives in `eslint.config.js` (flat config: typescript-eslint +
|
||||||
|
eslint-plugin-svelte, tuned for Svelte 5 and TS `strict`), `.prettierrc`, and
|
||||||
|
`.prettierignore`. `src/lib/api/bindings.ts` is excluded from both — it is
|
||||||
|
generated by tauri-specta on every Rust build.
|
||||||
|
|
||||||
|
`bun run lint` is currently **error-clean but not warning-clean**: several rules
|
||||||
|
are deliberately set to `warn` because the existing tree has more hits than a
|
||||||
|
tooling change should touch (unused bindings, `any` at the IPC boundary, unkeyed
|
||||||
|
`{#each}`). Each one is annotated in `eslint.config.js` with why, and the
|
||||||
|
intended end state is `error`. Drive them down; do not delete them.
|
||||||
|
|
||||||
|
`no-console` is switched **off** for now — see the note in `eslint.config.js`.
|
||||||
|
|
||||||
|
## Git Hooks
|
||||||
|
|
||||||
|
### `install-hooks.sh`
|
||||||
|
Point git at the repo's tracked hooks directory (`core.hooksPath`).
|
||||||
|
```bash
|
||||||
|
bun run hooks:install # or: ./scripts/install-hooks.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### `hooks/pre-commit`
|
||||||
|
Runs the fast half of CLAUDE.md's "Before Committing" list so it is enforced
|
||||||
|
rather than remembered:
|
||||||
|
|
||||||
|
- `bun run check` (svelte-check)
|
||||||
|
- `bun run test` (vitest, single pass)
|
||||||
|
- `scripts/check-frontend-boundary.sh`
|
||||||
|
- `cargo fmt --all -- --check`, **only when staged files touch `src-tauri/`**
|
||||||
|
|
||||||
|
`cargo clippy` and `cargo test` are deliberately *not* in the hook — minutes per
|
||||||
|
commit is how you teach people to reach for `--no-verify`. They run in CI, and
|
||||||
|
locally via `bun run test:all`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit --no-verify # skip the hook for one commit
|
||||||
|
git config --unset core.hooksPath # uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
The hook skips itself during a merge, rebase, or cherry-pick, and when nothing
|
||||||
|
is staged.
|
||||||
|
|
||||||
## Utility Scripts
|
## Utility Scripts
|
||||||
|
|
||||||
### `clean.sh`
|
### `clean.sh`
|
||||||
@@ -124,8 +200,12 @@ Clean all build artifacts.
|
|||||||
|
|
||||||
You can also run these via npm/bun:
|
You can also run these via npm/bun:
|
||||||
```bash
|
```bash
|
||||||
|
bun run test # Frontend tests (single pass)
|
||||||
bun run test:all # All tests
|
bun run test:all # All tests
|
||||||
bun run test:rust # Rust tests
|
bun run test:rust # Rust tests
|
||||||
|
bun run lint # ESLint
|
||||||
|
bun run format:check # Prettier (check only)
|
||||||
|
bun run hooks:install # Install the git hooks
|
||||||
bun run android:build # Build Android APK
|
bun run android:build # Build Android APK
|
||||||
bun run android:deploy # Deploy to device
|
bun run android:deploy # Deploy to device
|
||||||
bun run android:dev # Build + deploy debug
|
bun run android:dev # Build + deploy debug
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ echo ""
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Deploy APK — extract build type (default debug), ignoring flags like --clean.
|
# Deploy APK — forward the build type and the side-by-side flag (which decides
|
||||||
BUILD_TYPE="debug"
|
# which package to launch), ignoring build-only flags like --clean and --device.
|
||||||
|
DEPLOY_ARGS=("debug")
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
debug|release) BUILD_TYPE="$arg" ;;
|
debug|release) DEPLOY_ARGS[0]="$arg" ;;
|
||||||
|
--debug|--side-by-side) DEPLOY_ARGS+=("--side-by-side") ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
./scripts/deploy-android.sh "$BUILD_TYPE"
|
./scripts/deploy-android.sh "${DEPLOY_ARGS[@]}"
|
||||||
|
|||||||
@@ -23,9 +23,18 @@ echo ""
|
|||||||
# which is what a distributable universal APK needs — but for an on-device test
|
# which is what a distributable universal APK needs — but for an on-device test
|
||||||
# it means three wasted Rust compiles. Pass --device (or ABI=aarch64) to build
|
# it means three wasted Rust compiles. Pass --device (or ABI=aarch64) to build
|
||||||
# only the connected device's architecture; --abi <t> targets one explicitly.
|
# only the connected device's architecture; --abi <t> targets one explicitly.
|
||||||
|
#
|
||||||
|
# Side-by-side: the `debug` build type always installs as
|
||||||
|
# com.dtourolle.jellytau.debug ("JellyTau Debug"), so it never collides with a
|
||||||
|
# real install. `release --debug` puts a *release* build — R8-minified, exactly
|
||||||
|
# what ships — into that same slot, signed with the local debug keystore. That
|
||||||
|
# is how you validate minification (R8 stripping JNI-loaded classes has broken
|
||||||
|
# release APKs here before) without the real signing key and without
|
||||||
|
# uninstalling the app you actually use.
|
||||||
BUILD_TYPE="debug"
|
BUILD_TYPE="debug"
|
||||||
CLEAN="${CLEAN:-0}"
|
CLEAN="${CLEAN:-0}"
|
||||||
ABI="${ABI:-}"
|
ABI="${ABI:-}"
|
||||||
|
SIDE_BY_SIDE="${SIDE_BY_SIDE:-0}"
|
||||||
next_is_abi=0
|
next_is_abi=0
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
if [ "$next_is_abi" = "1" ]; then
|
if [ "$next_is_abi" = "1" ]; then
|
||||||
@@ -37,10 +46,17 @@ for arg in "$@"; do
|
|||||||
--clean) CLEAN=1 ;;
|
--clean) CLEAN=1 ;;
|
||||||
--abi) next_is_abi=1 ;;
|
--abi) next_is_abi=1 ;;
|
||||||
--device) ABI="device" ;;
|
--device) ABI="device" ;;
|
||||||
|
--debug|--side-by-side) SIDE_BY_SIDE=1 ;;
|
||||||
debug|release) BUILD_TYPE="$arg" ;;
|
debug|release) BUILD_TYPE="$arg" ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# The debug build type is side-by-side unconditionally; the flag only means
|
||||||
|
# something for a release build.
|
||||||
|
if [ "$BUILD_TYPE" = "debug" ]; then
|
||||||
|
SIDE_BY_SIDE=1
|
||||||
|
fi
|
||||||
|
|
||||||
# Resolve --device to the attached device's Rust target triple.
|
# Resolve --device to the attached device's Rust target triple.
|
||||||
if [ "$ABI" = "device" ]; then
|
if [ "$ABI" = "device" ]; then
|
||||||
device_abi="$(adb shell getprop ro.product.cpu.abi 2>/dev/null | tr -d '\r\n')"
|
device_abi="$(adb shell getprop ro.product.cpu.abi 2>/dev/null | tr -d '\r\n')"
|
||||||
@@ -78,7 +94,14 @@ echo "🎨 Building frontend..."
|
|||||||
bun run build
|
bun run build
|
||||||
|
|
||||||
# Step 2: Build Android APK
|
# Step 2: Build Android APK
|
||||||
if [ "$BUILD_TYPE" = "release" ]; then
|
if [ "$BUILD_TYPE" = "release" ] && [ "$SIDE_BY_SIDE" = "1" ]; then
|
||||||
|
# A release build in the debug slot: R8 still runs, but the applicationId is
|
||||||
|
# suffixed and the debug keystore signs it (read by build.gradle.kts from
|
||||||
|
# JT_SIDE_BY_SIDE), so the real key is not needed and it replaces any other
|
||||||
|
# .debug install cleanly. Deliberately does NOT write keystore.properties.
|
||||||
|
echo "📦 Building side-by-side release APK (com.dtourolle.jellytau.debug)..."
|
||||||
|
JT_SIDE_BY_SIDE=1 bun run tauri android build --apk true "${TARGET_ARGS[@]}"
|
||||||
|
elif [ "$BUILD_TYPE" = "release" ]; then
|
||||||
# Configure release signing from .env (single source of truth). Must run
|
# Configure release signing from .env (single source of truth). Must run
|
||||||
# after sync-android-sources.sh, since gen/android is (re)generated there.
|
# after sync-android-sources.sh, since gen/android is (re)generated there.
|
||||||
./scripts/write-keystore-properties.sh
|
./scripts/write-keystore-properties.sh
|
||||||
@@ -92,3 +115,7 @@ fi
|
|||||||
echo ""
|
echo ""
|
||||||
echo "✅ APK build complete!"
|
echo "✅ APK build complete!"
|
||||||
echo "📱 APK location: src-tauri/gen/android/app/build/outputs/apk/"
|
echo "📱 APK location: src-tauri/gen/android/app/build/outputs/apk/"
|
||||||
|
|
||||||
|
# Containerised builds run as root against a bind-mounted tree; hand the
|
||||||
|
# artifacts back to the host user. No-op when not root. See DR-213.
|
||||||
|
"$(dirname "$0")/restore-ownership.sh"
|
||||||
|
|||||||
@@ -26,10 +26,26 @@ echo "🏷️ Tagging for registry..."
|
|||||||
docker tag ${IMAGE_NAME}:${IMAGE_TAG} ${FULL_IMAGE_NAME}
|
docker tag ${IMAGE_NAME}:${IMAGE_TAG} ${FULL_IMAGE_NAME}
|
||||||
|
|
||||||
# Step 3: Login to registry (if not already logged in)
|
# Step 3: Login to registry (if not already logged in)
|
||||||
|
#
|
||||||
|
# `docker info | grep Username` only ever reports a Docker Hub session, so for a
|
||||||
|
# private registry it never matched — meaning this branch fired on every push and
|
||||||
|
# dropped into an interactive `docker login`, which hangs any non-interactive run
|
||||||
|
# (a scripted release, or CI). Check the credential store for this specific
|
||||||
|
# registry instead, and refuse rather than prompt when there is no TTY to
|
||||||
|
# prompt on.
|
||||||
echo "🔐 Checking registry authentication..."
|
echo "🔐 Checking registry authentication..."
|
||||||
if ! docker info | grep -q "Username"; then
|
DOCKER_CFG="${DOCKER_CONFIG:-$HOME/.docker}/config.json"
|
||||||
echo "Not authenticated to Docker. Logging in to ${REGISTRY_HOST}..."
|
if ! grep -q "\"${REGISTRY_HOST}\"" "$DOCKER_CFG" 2>/dev/null; then
|
||||||
docker login ${REGISTRY_HOST}
|
if [ -t 0 ]; then
|
||||||
|
echo "Not authenticated to ${REGISTRY_HOST}. Logging in..."
|
||||||
|
docker login "${REGISTRY_HOST}"
|
||||||
|
else
|
||||||
|
echo "❌ Not authenticated to ${REGISTRY_HOST}, and stdin is not a TTY."
|
||||||
|
echo " Run this first: docker login ${REGISTRY_HOST}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " Using stored credentials for ${REGISTRY_HOST}."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Step 4: Push to registry
|
# Step 4: Push to registry
|
||||||
|
|||||||
@@ -42,3 +42,7 @@ if [[ -n "${OUTPUT_DIR:-}" ]]; then
|
|||||||
echo ""
|
echo ""
|
||||||
echo "📦 Copied bundles to $OUTPUT_DIR"
|
echo "📦 Copied bundles to $OUTPUT_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Containerised builds run as root against a bind-mounted tree; hand the
|
||||||
|
# artifacts back to the host user. No-op when not root. See DR-213.
|
||||||
|
"$(dirname "$0")/restore-ownership.sh"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
# it can bundle the NSIS installer from a Linux host.
|
# it can bundle the NSIS installer from a Linux host.
|
||||||
#
|
#
|
||||||
# Playback on Windows: video renders via WebView2 and audio via the webview
|
# 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):
|
# Requirements (present in the Docker windows-cross target / unified builder):
|
||||||
# - rustup target x86_64-pc-windows-msvc
|
# - rustup target x86_64-pc-windows-msvc
|
||||||
@@ -67,3 +67,7 @@ if [[ -n "${OUTPUT_DIR:-}" ]]; then
|
|||||||
echo ""
|
echo ""
|
||||||
echo "📦 Copied Windows artifacts to $OUTPUT_DIR"
|
echo "📦 Copied Windows artifacts to $OUTPUT_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Containerised builds run as root against a bind-mounted tree; hand the
|
||||||
|
# artifacts back to the host user. No-op when not root. See DR-213.
|
||||||
|
"$(dirname "$0")/restore-ownership.sh"
|
||||||
|
|||||||
Executable
+198
@@ -0,0 +1,198 @@
|
|||||||
|
#!/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.
|
||||||
|
#
|
||||||
|
# Only consulted when this is NOT a git checkout — inside one, the tracked-file
|
||||||
|
# list does this job and does not need maintaining. Kept for the tarball case.
|
||||||
|
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…"
|
||||||
|
|
||||||
|
# Ask git which markdown files are ours, rather than walking the filesystem.
|
||||||
|
#
|
||||||
|
# This started as a find(1) with a hand-maintained prune list, and that list was
|
||||||
|
# wrong three times in a row: it walked the scratch worktrees under .claude/,
|
||||||
|
# then makepkg's vendored cargo registry under packaging/arch/src/ — each time
|
||||||
|
# reporting a dependency's broken README as if it were ours. Every one of those
|
||||||
|
# directories is already git-ignored, so the tracked-file list is the exclusion
|
||||||
|
# rule, and it cannot drift out of date the way EXCLUDES did. It also matches
|
||||||
|
# what this script always claimed to do.
|
||||||
|
#
|
||||||
|
# Untracked-but-not-ignored files are deliberately included: a new doc added in
|
||||||
|
# a working tree should be checked before it is committed, not after.
|
||||||
|
if git rev-parse --git-dir >/dev/null 2>&1; then
|
||||||
|
mapfile -t md_files < <(
|
||||||
|
{ git ls-files -z --cached --others --exclude-standard -- '*.md' | tr '\0' '\n'; } \
|
||||||
|
| sed 's|^|./|' | sort -u
|
||||||
|
)
|
||||||
|
else
|
||||||
|
# Not a git checkout (an exported tarball, say): fall back to walking, with
|
||||||
|
# the prune list below as the only defence.
|
||||||
|
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)
|
||||||
|
fi
|
||||||
|
|
||||||
|
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.)"
|
||||||
@@ -13,25 +13,60 @@ if ! adb devices | grep -q "device$"; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Build type: debug or release (default: debug)
|
# Build type: debug or release (default: debug). `--debug` alongside `release`
|
||||||
BUILD_TYPE="${1:-debug}"
|
# means the side-by-side release build — same APK path, but it was packaged
|
||||||
|
# under the .debug applicationId, so the package to launch differs.
|
||||||
|
BUILD_TYPE="debug"
|
||||||
|
SIDE_BY_SIDE=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--debug|--side-by-side) SIDE_BY_SIDE=1 ;;
|
||||||
|
debug|release) BUILD_TYPE="$arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[ "$BUILD_TYPE" = "debug" ] && SIDE_BY_SIDE=1
|
||||||
|
|
||||||
|
# The .debug applicationId (see src-tauri/android/app/build.gradle.kts) is a
|
||||||
|
# separate package, so it installs alongside a real release build — no
|
||||||
|
# uninstall dance needed.
|
||||||
if [ "$BUILD_TYPE" = "release" ]; then
|
if [ "$BUILD_TYPE" = "release" ]; then
|
||||||
APK_PATH="src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk"
|
APK_PATH="src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk"
|
||||||
else
|
else
|
||||||
APK_PATH="src-tauri/gen/android/app/build/outputs/apk/universal/debug/app-universal-debug.apk"
|
APK_PATH="src-tauri/gen/android/app/build/outputs/apk/universal/debug/app-universal-debug.apk"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$SIDE_BY_SIDE" = "1" ]; then
|
||||||
|
APP_PACKAGE="com.dtourolle.jellytau.debug"
|
||||||
|
else
|
||||||
|
APP_PACKAGE="com.dtourolle.jellytau"
|
||||||
|
fi
|
||||||
|
|
||||||
# Check if APK exists
|
# Check if APK exists
|
||||||
if [ ! -f "$APK_PATH" ]; then
|
if [ ! -f "$APK_PATH" ]; then
|
||||||
echo "❌ APK not found at: $APK_PATH"
|
echo "❌ APK not found at: $APK_PATH"
|
||||||
echo "Run './scripts/build-android.sh $BUILD_TYPE' first"
|
if [ "$BUILD_TYPE" = "release" ] && [ "$SIDE_BY_SIDE" = "1" ]; then
|
||||||
|
echo "Run './scripts/build-android.sh release --debug' first"
|
||||||
|
else
|
||||||
|
echo "Run './scripts/build-android.sh $BUILD_TYPE' first"
|
||||||
|
fi
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "📦 Installing APK: $APK_PATH"
|
echo "📦 Installing APK: $APK_PATH"
|
||||||
adb install -r "$APK_PATH"
|
echo "📛 Package: $APP_PACKAGE"
|
||||||
|
|
||||||
|
if ! adb install -r "$APK_PATH"; then
|
||||||
|
echo ""
|
||||||
|
echo "❌ Install failed."
|
||||||
|
echo " If it says INSTALL_FAILED_UPDATE_INCOMPATIBLE, an older build of"
|
||||||
|
echo " '$APP_PACKAGE' signed with a different key is still installed."
|
||||||
|
echo " Uninstall just that one and retry:"
|
||||||
|
echo " adb uninstall $APP_PACKAGE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "✅ Deployment complete!"
|
echo "✅ Deployment complete!"
|
||||||
echo "🚀 Launch the app on your device"
|
echo "🚀 Launching..."
|
||||||
|
adb shell monkey -p "$APP_PACKAGE" -c android.intent.category.LAUNCHER 1 > /dev/null 2>&1 \
|
||||||
|
|| echo " (auto-launch failed — start it from the launcher)"
|
||||||
|
|||||||
+193
-17
@@ -11,10 +11,24 @@
|
|||||||
*
|
*
|
||||||
* @req-test: UT-089 - Requirement definitions parsed from requirements.md
|
* @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-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 { 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", () => {
|
describe("countDefinedRequirements", () => {
|
||||||
it("counts a well-formed table row as a defined requirement", () => {
|
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("DR-050")).toBe(true);
|
||||||
expect(defined.ids.has("UR-999")).toBe(false);
|
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", () => {
|
describe("computeCoverage", () => {
|
||||||
@@ -92,6 +180,7 @@ describe("computeCoverage", () => {
|
|||||||
JA: 0,
|
JA: 0,
|
||||||
total: 4,
|
total: 4,
|
||||||
ids: new Set(["UR-001", "UR-002", "DR-001", "DR-002"]),
|
ids: new Set(["UR-001", "UR-002", "DR-001", "DR-002"]),
|
||||||
|
testIds: new Set<string>(),
|
||||||
};
|
};
|
||||||
|
|
||||||
it("computes coverage as traced ∩ defined over defined", () => {
|
it("computes coverage as traced ∩ defined over defined", () => {
|
||||||
@@ -138,7 +227,15 @@ describe("computeCoverage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("reports 0% rather than NaN when nothing is defined", () => {
|
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);
|
const cov = computeCoverage([], empty);
|
||||||
expect(cov.percent).toBe(0);
|
expect(cov.percent).toBe(0);
|
||||||
expect(Number.isNaN(cov.percent)).toBe(false);
|
expect(Number.isNaN(cov.percent)).toBe(false);
|
||||||
@@ -157,26 +254,105 @@ 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", () => {
|
describe("live requirements.md", () => {
|
||||||
it("parses the real file to the counts the CI gate must use", () => {
|
it("parses the real file into a self-consistent denominator", () => {
|
||||||
// Guards the specific regression: CI hardcoded UR/39, IR/24, DR/48, JA/3
|
// Guards the original regression: CI hardcoded UR/39, IR/24, DR/48, JA/3
|
||||||
// (total 114) while the real file had grown to 211. Update these numbers
|
// (total 114) while the real file had grown past 200, so the gate compared
|
||||||
// deliberately when requirements are added — that edit is the signal the
|
// live traces against a frozen denominator and reported 158% coverage.
|
||||||
// denominator is live rather than frozen.
|
//
|
||||||
const fs = require("fs");
|
// Deliberately asserts *invariants*, not exact totals. Pinning the counts
|
||||||
const path = require("path");
|
// was tried and turned this test into a merge-conflict magnet: every
|
||||||
// import.meta.dir is Bun-only; derive from import.meta.url under vitest.
|
// requirement added on any branch had to edit the numbers here too, and the
|
||||||
const here = path.dirname(new URL(import.meta.url).pathname);
|
// comment above them grew into a ledger of which branch contributed which
|
||||||
|
// row. Worse, the pins never guarded the actual defect — a stale denominator
|
||||||
|
// is caught by the sum-consistency check below, and the >100% ratio it
|
||||||
|
// produced is covered directly by the computeCoverage tests, on fixtures.
|
||||||
const md = fs.readFileSync(
|
const md = fs.readFileSync(
|
||||||
path.resolve(here, "../docs/requirements.md"),
|
path.resolve(HERE, "../docs/requirements.md"),
|
||||||
"utf-8"
|
"utf-8"
|
||||||
);
|
);
|
||||||
const defined = countDefinedRequirements(md);
|
const defined = countDefinedRequirements(md);
|
||||||
|
|
||||||
expect(defined.UR).toBe(74);
|
// The parser found real rows of every type: a section silently failing to
|
||||||
expect(defined.IR).toBe(32);
|
// parse would shrink the denominator and inflate coverage.
|
||||||
expect(defined.DR).toBe(162);
|
expect(defined.UR).toBeGreaterThan(0);
|
||||||
expect(defined.JA).toBe(35);
|
expect(defined.IR).toBeGreaterThan(0);
|
||||||
expect(defined.total).toBe(303);
|
expect(defined.DR).toBeGreaterThan(0);
|
||||||
|
expect(defined.JA).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// The denominator is the sum of its parts, and every counted id is unique —
|
||||||
|
// double-counting one section is the other way a ratio breaks.
|
||||||
|
expect(defined.total).toBe(defined.UR + defined.IR + defined.DR + defined.JA);
|
||||||
|
expect(defined.ids.size).toBe(defined.total);
|
||||||
|
|
||||||
|
// The file is live, not frozen: it is well past the 114 the stale gate used.
|
||||||
|
expect(defined.total).toBeGreaterThan(200);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+133
-6
@@ -23,7 +23,7 @@ interface RequirementMapping {
|
|||||||
[reqId: string]: TraceEntry[];
|
[reqId: string]: TraceEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TracesData {
|
export interface TracesData {
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
totalFiles: number;
|
totalFiles: number;
|
||||||
totalTraces: number;
|
totalTraces: number;
|
||||||
@@ -37,8 +37,27 @@ interface TracesData {
|
|||||||
/** Requirements *defined* in requirements.md — the coverage denominators. */
|
/** Requirements *defined* in requirements.md — the coverage denominators. */
|
||||||
defined?: { UR: number; IR: number; DR: number; JA: number; total: number };
|
defined?: { UR: number; IR: number; DR: number; JA: number; total: number };
|
||||||
coverage?: CoverageResult;
|
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 = 88;
|
||||||
|
|
||||||
// Repo root, derived from this script's location (scripts/ -> repo root).
|
// 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.
|
// Must NOT be hardcoded to a developer's machine, or CI checkouts see no files.
|
||||||
//
|
//
|
||||||
@@ -222,7 +241,10 @@ export interface DefinedRequirements {
|
|||||||
DR: number;
|
DR: number;
|
||||||
JA: number;
|
JA: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
/** Requirement IDs (UR/IR/DR/JA) — the coverage denominator. */
|
||||||
ids: Set<string>;
|
ids: Set<string>;
|
||||||
|
/** Test IDs (UT/IT) from §4. A separate taxonomy: never part of coverage. */
|
||||||
|
testIds: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CoverageResult {
|
export interface CoverageResult {
|
||||||
@@ -247,11 +269,18 @@ export interface CoverageResult {
|
|||||||
*/
|
*/
|
||||||
export function countDefinedRequirements(markdown: string): DefinedRequirements {
|
export function countDefinedRequirements(markdown: string): DefinedRequirements {
|
||||||
const ids = new Set<string>();
|
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")) {
|
for (const line of markdown.split("\n")) {
|
||||||
const match = line.match(ROW_ID);
|
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) =>
|
const countOf = (type: string) =>
|
||||||
@@ -264,9 +293,39 @@ export function countDefinedRequirements(markdown: string): DefinedRequirements
|
|||||||
JA: countOf("JA"),
|
JA: countOf("JA"),
|
||||||
total: ids.size,
|
total: ids.size,
|
||||||
ids,
|
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.
|
* 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"));
|
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
|
let md = `# Code Traceability Matrix
|
||||||
|
|
||||||
**Generated:** ${new Date(data.timestamp).toLocaleString()}
|
**Generated:** ${new Date(data.timestamp).toLocaleString()}
|
||||||
@@ -365,7 +451,7 @@ ${data.byType.JA.join(", ")}
|
|||||||
md += `**Locations:** ${entries.length} file(s)\n\n`;
|
md += `**Locations:** ${entries.length} file(s)\n\n`;
|
||||||
|
|
||||||
for (const entry of entries) {
|
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`;
|
md += ` - **Line:** ${entry.line}\n`;
|
||||||
const contextPreview = entry.context.substring(0, 70);
|
const contextPreview = entry.context.substring(0, 70);
|
||||||
md += ` - **Context:** \`${contextPreview}${entry.context.length > 70 ? "..." : ""}\`\n`;
|
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.");
|
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
|
// 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.
|
// the stale-denominator bug for so long). Fail loudly rather than report it.
|
||||||
if (cov.percent > 100) {
|
if (cov.percent > 100) {
|
||||||
@@ -427,6 +520,37 @@ function reportCoverage(data: TracesData, minThreshold: number): number {
|
|||||||
return 0;
|
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.
|
// Main — guarded so this module stays importable from extract-traces.test.ts.
|
||||||
if (import.meta.main) {
|
if (import.meta.main) {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
@@ -447,11 +571,14 @@ if (import.meta.main) {
|
|||||||
total: defined.total,
|
total: defined.total,
|
||||||
};
|
};
|
||||||
data.coverage = computeCoverage(allTraced, defined);
|
data.coverage = computeCoverage(allTraced, defined);
|
||||||
|
data.dangling = findDanglingIds(allTraced, defined);
|
||||||
|
|
||||||
if (format === "json") {
|
if (format === "json") {
|
||||||
console.log(generateJson(data));
|
console.log(generateJson(data));
|
||||||
} else if (format === "coverage") {
|
} 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 {
|
} else {
|
||||||
console.log(generateMarkdown(data));
|
console.log(generateMarkdown(data));
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+87
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# JellyTau pre-commit hook — the fast half of CLAUDE.md's "Before Committing"
|
||||||
|
# list, enforced instead of remembered.
|
||||||
|
#
|
||||||
|
# TRACES: | DR-207
|
||||||
|
#
|
||||||
|
# Install with: bun run hooks:install (sets core.hooksPath=scripts/hooks)
|
||||||
|
# Skip once with: git commit --no-verify
|
||||||
|
#
|
||||||
|
# What runs here is deliberately limited to gates that finish in seconds:
|
||||||
|
#
|
||||||
|
# bun run check svelte-check (types)
|
||||||
|
# bun run test vitest, single pass
|
||||||
|
# scripts/check-frontend-boundary.sh domain-taxonomy tripwire (DR-094)
|
||||||
|
# cargo fmt --all -- --check only when src-tauri/ is staged
|
||||||
|
#
|
||||||
|
# NOT here, on purpose: `cargo clippy` and `cargo test`. Both take minutes on a
|
||||||
|
# cold target dir, which turns every commit into a coffee break and trains
|
||||||
|
# people to reach for --no-verify. CI (.gitea/workflows/build-and-test.yml) is
|
||||||
|
# where those run; `bun run test:all` is the local equivalent.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
# Merge and rebase commits carry someone else's changes, and conflict resolution
|
||||||
|
# is exactly when a slow gate is least welcome. Let them through — CI still
|
||||||
|
# gates the merge result.
|
||||||
|
GIT_DIR_PATH="$(git rev-parse --git-dir 2>/dev/null)" || exit 0
|
||||||
|
if [ -e "$GIT_DIR_PATH/MERGE_HEAD" ] ||
|
||||||
|
[ -d "$GIT_DIR_PATH/rebase-merge" ] ||
|
||||||
|
[ -d "$GIT_DIR_PATH/rebase-apply" ] ||
|
||||||
|
[ -e "$GIT_DIR_PATH/CHERRY_PICK_HEAD" ]; then
|
||||||
|
echo "pre-commit: merge/rebase in progress — skipping checks (CI still gates the result)."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Nothing staged (e.g. `git commit --amend` that only edits the message): nothing
|
||||||
|
# to check.
|
||||||
|
STAGED="$(git diff --cached --name-only --diff-filter=ACMR)"
|
||||||
|
if [ -z "$STAGED" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||||
|
cd "$REPO_ROOT" || exit 1
|
||||||
|
|
||||||
|
FAILED=0
|
||||||
|
|
||||||
|
run_gate() {
|
||||||
|
label="$1"
|
||||||
|
shift
|
||||||
|
echo ""
|
||||||
|
echo "🔎 pre-commit: $label"
|
||||||
|
if ! "$@"; then
|
||||||
|
echo "❌ pre-commit: $label failed"
|
||||||
|
FAILED=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
run_gate "svelte-check (bun run check)" bun run check
|
||||||
|
run_gate "frontend tests (bun run test)" bun run test
|
||||||
|
run_gate "frontend/backend boundary" bash scripts/check-frontend-boundary.sh
|
||||||
|
|
||||||
|
# rustfmt only matters when Rust actually changed, and `cargo fmt --check` is
|
||||||
|
# cheap (no compilation) whenever it does.
|
||||||
|
if printf '%s\n' "$STAGED" | grep -q '^src-tauri/'; then
|
||||||
|
if command -v cargo >/dev/null 2>&1; then
|
||||||
|
echo ""
|
||||||
|
echo "🔎 pre-commit: rustfmt (src-tauri/ is staged)"
|
||||||
|
if ! (cd src-tauri && cargo fmt --all -- --check); then
|
||||||
|
echo "❌ pre-commit: cargo fmt --all -- --check failed"
|
||||||
|
echo " fix with: cd src-tauri && cargo fmt"
|
||||||
|
FAILED=1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "⚠️ pre-commit: src-tauri/ staged but cargo is not on PATH — skipping rustfmt."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$FAILED" -ne 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "🛑 pre-commit checks failed. Fix them, or bypass deliberately with:"
|
||||||
|
echo " git commit --no-verify"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ pre-commit checks passed."
|
||||||
Executable
+43
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Point git at the repo's tracked hooks directory.
|
||||||
|
#
|
||||||
|
# TRACES: | DR-207
|
||||||
|
#
|
||||||
|
# bun run hooks:install # or: ./scripts/install-hooks.sh
|
||||||
|
#
|
||||||
|
# `core.hooksPath` is used rather than copying files into .git/hooks so the
|
||||||
|
# hooks stay version-controlled: an update to scripts/hooks/pre-commit reaches
|
||||||
|
# everyone on their next pull instead of needing a re-install.
|
||||||
|
#
|
||||||
|
# The setting is local to this clone (git config, not committed). To undo:
|
||||||
|
# git config --unset core.hooksPath
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
HOOKS_DIR="scripts/hooks"
|
||||||
|
|
||||||
|
if [ ! -d "$HOOKS_DIR" ]; then
|
||||||
|
echo "❌ $HOOKS_DIR does not exist — are you in the JellyTau repo?" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Git refuses to run a hook that is not executable, and the bit is easy to lose
|
||||||
|
# on a fresh checkout on some filesystems.
|
||||||
|
chmod +x "$HOOKS_DIR"/* 2>/dev/null || true
|
||||||
|
|
||||||
|
git config core.hooksPath "$HOOKS_DIR"
|
||||||
|
|
||||||
|
echo "✅ core.hooksPath = $(git config core.hooksPath)"
|
||||||
|
echo ""
|
||||||
|
echo "Installed hooks:"
|
||||||
|
for hook in "$HOOKS_DIR"/*; do
|
||||||
|
[ -f "$hook" ] || continue
|
||||||
|
echo " - $(basename "$hook")"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo "pre-commit runs: bun run check, bun run test, check-frontend-boundary.sh,"
|
||||||
|
echo "and cargo fmt --check when src-tauri/ is staged."
|
||||||
|
echo "Bypass a single commit with: git commit --no-verify"
|
||||||
+25
-4
@@ -1,13 +1,34 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# View Android logcat output filtered for the app
|
# View Android logcat output filtered for the app.
|
||||||
|
#
|
||||||
|
# Usage: ./scripts/logcat.sh [debug|release] (default: debug)
|
||||||
|
#
|
||||||
|
# The debug build has applicationIdSuffix ".debug" so it can be installed
|
||||||
|
# alongside a release build; pick the package to follow accordingly.
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
APP_PACKAGE="com.jellytau.app"
|
BUILD_TYPE="${1:-debug}"
|
||||||
|
|
||||||
|
if [ "$BUILD_TYPE" = "release" ]; then
|
||||||
|
APP_PACKAGE="com.dtourolle.jellytau"
|
||||||
|
else
|
||||||
|
APP_PACKAGE="com.dtourolle.jellytau.debug"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "📱 Showing logcat for $APP_PACKAGE"
|
echo "📱 Showing logcat for $APP_PACKAGE"
|
||||||
echo "Press Ctrl+C to stop"
|
echo "Press Ctrl+C to stop"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Filter logcat for the app's package name
|
# Prefer PID-scoped output when the app is running — it drops the noise that a
|
||||||
adb logcat | grep -i "$APP_PACKAGE\|tauri\|rust"
|
# text grep can't. Fall back to the old keyword filter when it isn't (so you can
|
||||||
|
# start the script first and then launch the app).
|
||||||
|
PID="$(adb shell pidof "$APP_PACKAGE" 2>/dev/null | tr -d '\r\n' | awk '{print $1}')"
|
||||||
|
|
||||||
|
if [ -n "$PID" ]; then
|
||||||
|
echo " (attached to pid $PID)"
|
||||||
|
adb logcat --pid="$PID"
|
||||||
|
else
|
||||||
|
echo " (app not running — falling back to keyword filter)"
|
||||||
|
adb logcat | grep -i "$APP_PACKAGE\|jellytau\|tauri\|rust"
|
||||||
|
fi
|
||||||
|
|||||||
Executable
+53
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Give build artifacts back to the human who owns the working tree.
|
||||||
|
#
|
||||||
|
# TRACES: | DR-213
|
||||||
|
#
|
||||||
|
# The containerised builds (docker-compose.yml: desktop-linux-build,
|
||||||
|
# windows-cross, android-build, test, dev) bind-mount the repo at /app and run
|
||||||
|
# as root, because their caches live at /root/.cargo and /root/.bun. Everything
|
||||||
|
# they write into src-tauri/target and dist/ is therefore root-owned *on the
|
||||||
|
# host* — and it accumulates: one audit found 11,124 such files, which is enough
|
||||||
|
# to make `cargo clean` and scripts/clean.sh fail with EACCES for the developer.
|
||||||
|
# Worse, a plain `cargo build` then dies part-way through, because build scripts
|
||||||
|
# compile for the host and land in target/debug even during a cross-build.
|
||||||
|
#
|
||||||
|
# Running the containers as the host uid would be the tidier fix, but it needs
|
||||||
|
# the cache volumes relocated off /root first. Until that happens, this restores
|
||||||
|
# ownership at the end of each containerised build, which is self-healing and
|
||||||
|
# needs no uid plumbing on the host side.
|
||||||
|
#
|
||||||
|
# Outside a container this is a no-op: it exits immediately unless it is running
|
||||||
|
# as root, so the native build scripts can call it unconditionally.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
# Not root (a normal developer build) — nothing to fix, and nothing we may fix.
|
||||||
|
[ "$(id -u)" -eq 0 ] || exit 0
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
REPO_ROOT="$(pwd)"
|
||||||
|
|
||||||
|
# Whoever owns the checkout is who the artifacts should belong to. Reading it
|
||||||
|
# from the tree means this works for any uid/gid without being told, including
|
||||||
|
# CI runners whose uid we do not control.
|
||||||
|
OWNER="$(stat -c '%u:%g' "$REPO_ROOT")"
|
||||||
|
|
||||||
|
# uid 0 owning the tree means it is not a bind mount from a normal host account
|
||||||
|
# (a root-owned checkout, or a CI image that clones as root). Nothing to give back.
|
||||||
|
if [ "${OWNER%%:*}" = "0" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🔑 Restoring ownership of build artifacts to ${OWNER}…"
|
||||||
|
|
||||||
|
for target in src-tauri/target src-tauri/gen dist build node_modules .svelte-kit; do
|
||||||
|
[ -e "$REPO_ROOT/$target" ] || continue
|
||||||
|
chown -R "$OWNER" "$REPO_ROOT/$target" 2>/dev/null || {
|
||||||
|
echo "⚠️ Could not fully chown $target — you may need:"
|
||||||
|
echo " sudo chown -R $OWNER $REPO_ROOT/$target"
|
||||||
|
}
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "✅ Ownership restored."
|
||||||
@@ -72,6 +72,21 @@ if [ -f src-tauri/Cargo.lock ]; then
|
|||||||
perl -0pi -e 's/(name = "jellytau"\nversion = )"[^"]*"/$1"'"$VERSION"'"/' src-tauri/Cargo.lock
|
perl -0pi -e 's/(name = "jellytau"\nversion = )"[^"]*"/$1"'"$VERSION"'"/' src-tauri/Cargo.lock
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# PKGBUILD — the Arch package version. Easy to miss because Arch packaging is a
|
||||||
|
# separate path from the tauri bundler, and missing it is exactly the failure
|
||||||
|
# this script exists to prevent: pkgver sat at 0.0.18 while the rest of the tree
|
||||||
|
# had moved on, so `makepkg` produced a package whose version bore no relation
|
||||||
|
# to the source it was built from. `pkgrel` resets to 1 because a new upstream
|
||||||
|
# version starts its packaging revisions over.
|
||||||
|
if [ -f packaging/arch/PKGBUILD ]; then
|
||||||
|
# Arch pkgver may not contain a hyphen (it separates pkgver from pkgrel), so a
|
||||||
|
# dev version like 0.9.0-3-gabc1234 becomes 0.9.0.r3.gabc1234, per the VCS
|
||||||
|
# package guidelines.
|
||||||
|
ARCH_VERSION="$(echo "$VERSION" | sed 's/-\([0-9]*\)-g/.r\1.g/; s/-/_/g')"
|
||||||
|
perl -0pi -e 's/^pkgver=.*$/pkgver='"$ARCH_VERSION"'/m' packaging/arch/PKGBUILD
|
||||||
|
perl -0pi -e 's/^pkgrel=.*$/pkgrel=1/m' packaging/arch/PKGBUILD
|
||||||
|
fi
|
||||||
|
|
||||||
# --- Android versionCode ----------------------------------------------------
|
# --- Android versionCode ----------------------------------------------------
|
||||||
# Only when the generated Android project exists (i.e. after `tauri android
|
# Only when the generated Android project exists (i.e. after `tauri android
|
||||||
# init`); on Linux/Windows jobs there is nothing to stamp.
|
# init`); on Linux/Windows jobs there is nothing to stamp.
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ function seed(dir: string) {
|
|||||||
path.join(dir, "src-tauri", "gen", "android", "app", "tauri.properties"),
|
path.join(dir, "src-tauri", "gen", "android", "app", "tauri.properties"),
|
||||||
"tauri.android.versionCode=1\n"
|
"tauri.android.versionCode=1\n"
|
||||||
);
|
);
|
||||||
|
fs.mkdirSync(path.join(dir, "packaging", "arch"), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, "packaging", "arch", "PKGBUILD"),
|
||||||
|
['pkgname=jellytau', 'pkgver=0.0.1', 'pkgrel=3', 'pkgdesc="x"', ''].join("\n")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function run(version: string, dir = tmp) {
|
function run(version: string, dir = tmp) {
|
||||||
@@ -178,4 +183,29 @@ describe("set-version.sh", () => {
|
|||||||
expect(JSON.parse(read("package.json")).version).not.toBe("0.0.1");
|
expect(JSON.parse(read("package.json")).version).not.toBe("0.0.1");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The Arch package is built by makepkg, not the tauri bundler, so its version
|
||||||
|
// lives in a file the rest of the release path never touches. It sat at
|
||||||
|
// 0.0.18 while the tree was on 0.8.x — makepkg happily produced a package
|
||||||
|
// whose version bore no relation to the source it was built from, which is
|
||||||
|
// the exact failure this script was written to prevent.
|
||||||
|
describe("PKGBUILD", () => {
|
||||||
|
it("stamps pkgver and resets pkgrel", () => {
|
||||||
|
run("0.9.0");
|
||||||
|
const pkgbuild = read("packaging/arch/PKGBUILD");
|
||||||
|
expect(pkgbuild).toMatch(/^pkgver=0\.9\.0$/m);
|
||||||
|
// A new upstream version starts its packaging revisions over.
|
||||||
|
expect(pkgbuild).toMatch(/^pkgrel=1$/m);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts a dev version into a pkgver Arch accepts", () => {
|
||||||
|
// pkgver may not contain a hyphen — it is the pkgver/pkgrel separator.
|
||||||
|
run("0.9.0-3-gabc1234");
|
||||||
|
const pkgbuild = read("packaging/arch/PKGBUILD");
|
||||||
|
const match = pkgbuild.match(/^pkgver=(.*)$/m);
|
||||||
|
expect(match).not.toBeNull();
|
||||||
|
expect(match![1]).not.toContain("-");
|
||||||
|
expect(match![1]).toBe("0.9.0.r3.gabc1234");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,19 @@ rm -rf "$TARGET_DIR/player" "$TARGET_DIR/security"
|
|||||||
cp -r "$SOURCE_DIR/player" "$TARGET_DIR/"
|
cp -r "$SOURCE_DIR/player" "$TARGET_DIR/"
|
||||||
cp -r "$SOURCE_DIR/security" "$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)
|
# Copy individual Kotlin files (like VideoOverlayManager.kt)
|
||||||
for kt_file in "$SOURCE_DIR"/*.kt; do
|
for kt_file in "$SOURCE_DIR"/*.kt; do
|
||||||
if [ -f "$kt_file" ]; then
|
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/**");
|
||||||
|
});
|
||||||
|
});
|
||||||
+6
-2
@@ -7,7 +7,9 @@ echo "🧪 Running all tests..."
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
echo "📦 Running frontend tests..."
|
echo "📦 Running frontend tests..."
|
||||||
bun run test --run
|
# `bun run test` is `vitest run` (single pass). It used to be bare `vitest`,
|
||||||
|
# which needed an explicit `--run` here to avoid parking CI in watch mode.
|
||||||
|
bun run test
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "🦀 Running Rust tests..."
|
echo "🦀 Running Rust tests..."
|
||||||
@@ -19,7 +21,9 @@ echo ""
|
|||||||
echo "🚧 Checking architectural gates..."
|
echo "🚧 Checking architectural gates..."
|
||||||
# Boundary tripwire (DR-094): no Jellyfin taxonomy in the presentation layer.
|
# Boundary tripwire (DR-094): no Jellyfin taxonomy in the presentation layer.
|
||||||
bun run check:boundary
|
bun run check:boundary
|
||||||
# Traceability coverage (DR-093): fails below 50%, or above 100% (miscount).
|
# Traceability coverage (DR-093): fails below the ratchet in
|
||||||
|
# .gitea/workflows/traceability-check.yml (MIN_THRESHOLD, currently 88%), or
|
||||||
|
# above 100% (miscount).
|
||||||
bun run traces:coverage
|
bun run traces:coverage
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Run frontend tests only
|
# Run frontend tests only.
|
||||||
|
#
|
||||||
|
# `bun run test` is a single pass (`vitest run`), which is what CI and the
|
||||||
|
# pre-commit hook want. This wrapper keeps the interactive modes reachable:
|
||||||
|
# pass --watch or --ui and vitest is invoked in its long-running form instead.
|
||||||
|
# Any other arguments (test-name filters, path filters, --reporter, ...) are
|
||||||
|
# forwarded to the single-pass run.
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "📦 Running frontend tests..."
|
echo "📦 Running frontend tests..."
|
||||||
bun run test "$@"
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--watch | --ui | -w)
|
||||||
|
exec bunx vitest "$@"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
exec bunx vitest run "$@"
|
||||||
|
|||||||
Generated
+1
-1
@@ -2018,7 +2018,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jellytau"
|
name = "jellytau"
|
||||||
version = "0.5.3"
|
version = "0.9.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+14
-8
@@ -1,8 +1,10 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "jellytau"
|
name = "jellytau"
|
||||||
version = "0.5.3"
|
version = "0.9.1"
|
||||||
description = "A Tauri App"
|
description = "A cross-platform Jellyfin client"
|
||||||
authors = ["you"]
|
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
|
||||||
|
license = "MIT"
|
||||||
|
repository = "https://gitea.tourolle.paris/dtourolle/jellytau"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
@@ -23,11 +25,15 @@ debug = "line-tables-only"
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# protocol-asset serves downloaded media and cached thumbnails to the webview
|
# protocol-asset serves cached thumbnails to the webview (asset://localhost on
|
||||||
# over http://asset.localhost; without it convertFileSrc yields a URL nothing
|
# Linux/macOS, http://asset.localhost on Windows/Android); without it
|
||||||
# answers. Paired with app.security.assetProtocol in tauri.conf.json, which
|
# convertFileSrc yields a URL nothing answers. Paired with
|
||||||
# scopes it to $APPDATA/**.
|
# app.security.assetProtocol in tauri.conf.json, which scopes it to
|
||||||
# TRACES: UR-071 | DR-134
|
# $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 = { version = "2", features = ["protocol-asset"] }
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-os = "2"
|
tauri-plugin-os = "2"
|
||||||
|
|||||||
@@ -41,6 +41,57 @@ When you need to modify Android/Kotlin files:
|
|||||||
- If you only edit `src-tauri/android/`, your changes won't be in the build
|
- If you only edit `src-tauri/android/`, your changes won't be in the build
|
||||||
- **You must edit both** (or edit source and copy to generated)
|
- **You must edit both** (or edit source and copy to generated)
|
||||||
|
|
||||||
|
### Debug and release install side by side
|
||||||
|
|
||||||
|
The **debug** build type sets `applicationIdSuffix = ".debug"` in
|
||||||
|
`app/build.gradle.kts`, so a debug build is a genuinely separate Android app:
|
||||||
|
|
||||||
|
| build | applicationId | launcher name | versionName | signed with |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `release` | `com.dtourolle.jellytau` | jellytau | `0.5.5` | real key (`.env`) |
|
||||||
|
| `release --debug` | `com.dtourolle.jellytau.debug` | JellyTau Debug | `0.5.5-debug-release` | debug keystore |
|
||||||
|
| `debug` | `com.dtourolle.jellytau.debug` | JellyTau Debug | `0.5.5-debug` | debug keystore |
|
||||||
|
|
||||||
|
`release --debug` is the **side-by-side release**: fully R8-minified, exactly
|
||||||
|
what ships, but packaged into the debug slot and signed with the local debug
|
||||||
|
keystore. It exists because R8 has broken release APKs here before (stripping
|
||||||
|
JNI-loaded player/security classes), and reproducing that previously meant
|
||||||
|
building with the real key and clobbering your working install. It shares the
|
||||||
|
applicationId *and* signature with the plain debug build, so the two replace
|
||||||
|
each other cleanly; only the versionName suffix tells you which is installed.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/build-and-deploy.sh release --device --debug # build + install it
|
||||||
|
```
|
||||||
|
|
||||||
|
The flag is plumbed through as `JT_SIDE_BY_SIDE=1`, read by `build.gradle.kts`.
|
||||||
|
CI never sets it, so distributable release builds are untouched.
|
||||||
|
|
||||||
|
That means:
|
||||||
|
|
||||||
|
- **No uninstall step.** Debug builds are signed with the local auto-generated
|
||||||
|
`~/.android/debug.keystore`, release builds with the real key. Two different
|
||||||
|
keys on the *same* package is `INSTALL_FAILED_UPDATE_INCOMPATIBLE`; two
|
||||||
|
different packages is just two apps.
|
||||||
|
- Each has **its own data directory** — separate settings, credentials,
|
||||||
|
downloads and offline cache. A debug experiment cannot corrupt the state of
|
||||||
|
the build you actually use. This is not optional and cannot be shared:
|
||||||
|
Android gives each applicationId its own UID and enforces the boundary in the
|
||||||
|
kernel. (`sharedUserId` is deprecated since API 29 and cannot be added to an
|
||||||
|
already-installed app anyway.) You log in again in the debug app, once.
|
||||||
|
- Only the *application* id changes. Kotlin classes stay in the `namespace`
|
||||||
|
package `com.dtourolle.jellytau`, so the JNI class lookups in
|
||||||
|
`src-tauri/src/player/android/mod.rs`, the manifest `<service>` entry and the
|
||||||
|
R8 keep rules in `proguard-jellytau.pro` are all unaffected. The FileProvider
|
||||||
|
authority is `${applicationId}.fileprovider`, so it follows the suffix
|
||||||
|
automatically.
|
||||||
|
- The launcher labels come from the `appLabel` / `activityLabel`
|
||||||
|
manifestPlaceholders (`AndroidManifest.xml` uses `${appLabel}`), *not* from
|
||||||
|
`resValue`, which would collide with Tauri's generated `strings.xml`.
|
||||||
|
|
||||||
|
Follow the right log stream with `./scripts/logcat.sh [debug|release]`
|
||||||
|
(defaults to debug).
|
||||||
|
|
||||||
### Key Files
|
### Key Files
|
||||||
|
|
||||||
Player-related Kotlin files:
|
Player-related Kotlin files:
|
||||||
|
|||||||
@@ -22,11 +22,25 @@ val keystoreProperties = Properties().apply {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Side-by-side release: set by `scripts/build-android.sh release --debug`, which
|
||||||
|
// exports JT_SIDE_BY_SIDE=1. It puts a fully R8-minified release build into the
|
||||||
|
// debug applicationId slot, signed with the local debug keystore — so you can
|
||||||
|
// test what minification actually produces (R8 stripping JNI-loaded classes has
|
||||||
|
// broken release APKs here before) without the real signing key and without
|
||||||
|
// uninstalling your working install. Unset in CI, so distributable release
|
||||||
|
// builds are untouched.
|
||||||
|
val sideBySideRelease = System.getenv("JT_SIDE_BY_SIDE").let { it == "1" || it == "true" }
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdk = 36
|
compileSdk = 36
|
||||||
namespace = "com.dtourolle.jellytau"
|
namespace = "com.dtourolle.jellytau"
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||||
|
// Launcher/app names come from placeholders so the debug build can
|
||||||
|
// rename itself without touching the generated strings.xml (a
|
||||||
|
// resValue() override there would collide with Tauri's own entries).
|
||||||
|
manifestPlaceholders["appLabel"] = "@string/app_name"
|
||||||
|
manifestPlaceholders["activityLabel"] = "@string/main_activity_title"
|
||||||
applicationId = "com.dtourolle.jellytau"
|
applicationId = "com.dtourolle.jellytau"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
@@ -45,6 +59,21 @@ android {
|
|||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
getByName("debug") {
|
getByName("debug") {
|
||||||
|
// Distinct applicationId so the debug build installs SIDE BY SIDE
|
||||||
|
// with a release/store install instead of demanding an uninstall
|
||||||
|
// (different signing keys on the same package = INSTALL_FAILED_
|
||||||
|
// UPDATE_INCOMPATIBLE). It gets its own data dir, its own settings
|
||||||
|
// and its own offline cache — the two are fully independent apps.
|
||||||
|
//
|
||||||
|
// This changes only the *application* id. The Kotlin/JNI classes
|
||||||
|
// stay in the `namespace` package (com.dtourolle.jellytau), so the
|
||||||
|
// fully-qualified class names Rust looks up over JNI, the manifest
|
||||||
|
// <service> entry and the R8 keep rules are all unaffected. The
|
||||||
|
// FileProvider authority is already ${applicationId}-relative.
|
||||||
|
applicationIdSuffix = ".debug"
|
||||||
|
versionNameSuffix = "-debug"
|
||||||
|
manifestPlaceholders["appLabel"] = "JellyTau Debug"
|
||||||
|
manifestPlaceholders["activityLabel"] = "JellyTau Debug"
|
||||||
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
||||||
isDebuggable = true
|
isDebuggable = true
|
||||||
isJniDebuggable = true
|
isJniDebuggable = true
|
||||||
@@ -56,7 +85,18 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
getByName("release") {
|
getByName("release") {
|
||||||
if (keystoreProperties.getProperty("storeFile") != null) {
|
if (sideBySideRelease) {
|
||||||
|
// Same slot, name and version scheme as the debug build type,
|
||||||
|
// plus "-release" so you can tell from Settings > Apps which of
|
||||||
|
// the two is currently sitting there. Signed with the debug
|
||||||
|
// keystore: it shares a signature with the debug build, so the
|
||||||
|
// two replace each other cleanly instead of colliding.
|
||||||
|
applicationIdSuffix = ".debug"
|
||||||
|
versionNameSuffix = "-debug-release"
|
||||||
|
manifestPlaceholders["appLabel"] = "JellyTau Debug"
|
||||||
|
manifestPlaceholders["activityLabel"] = "JellyTau Debug"
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
} else if (keystoreProperties.getProperty("storeFile") != null) {
|
||||||
signingConfig = signingConfigs.getByName("release")
|
signingConfig = signingConfigs.getByName("release")
|
||||||
}
|
}
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
@@ -67,8 +107,17 @@ android {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Java 17 bytecode. AGP 8.11 already requires a JDK 17 toolchain to run
|
||||||
|
// (the builder image ships openjdk-17), so "1.8" was only capping the
|
||||||
|
// bytecode we emit, not the JDK in use. Kotlin's jvmTarget and javac's
|
||||||
|
// source/targetCompatibility must agree or AGP 8 fails the build, so all
|
||||||
|
// three move together.
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = "1.8"
|
jvmTarget = "17"
|
||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
buildConfig = true
|
buildConfig = true
|
||||||
|
|||||||
@@ -11,6 +11,12 @@
|
|||||||
(An earlier version of this file was a partial <application> fragment on the
|
(An earlier version of this file was a partial <application> fragment on the
|
||||||
assumption that Tauri merged it. It did not: the hardwareAccelerated flag it
|
assumption that Tauri merged it. It did not: the hardwareAccelerated flag it
|
||||||
declared never reached any built APK. It is folded in properly below.)
|
declared never reached any built APK. It is folded in properly below.)
|
||||||
|
|
||||||
|
${appLabel} / ${activityLabel} are manifestPlaceholders set in
|
||||||
|
app/build.gradle.kts: they resolve to @string/app_name and
|
||||||
|
@string/main_activity_title for release, and to "JellyTau Debug" for the
|
||||||
|
debug build type (which also carries applicationIdSuffix ".debug" so it
|
||||||
|
installs alongside a release build).
|
||||||
-->
|
-->
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
@@ -19,22 +25,85 @@
|
|||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<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" />
|
<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
|
<application
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="${appLabel}"
|
||||||
android:theme="@style/Theme.jellytau"
|
android:theme="@style/Theme.jellytau"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
android:usesCleartextTraffic="${usesCleartextTraffic}"
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules">
|
||||||
<activity
|
<activity
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|density"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|density"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:label="@string/main_activity_title"
|
android:label="${activityLabel}"
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:supportsPictureInPicture="true"
|
android:supportsPictureInPicture="true"
|
||||||
@@ -42,8 +111,7 @@
|
|||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
<!-- AndroidTV support -->
|
<!-- No LEANBACK_LAUNCHER: see the Android TV note above. -->
|
||||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import android.webkit.WebView
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
|
||||||
|
|
||||||
class MainActivity : TauriActivity() {
|
class MainActivity : TauriActivity() {
|
||||||
private val handler = Handler(Looper.getMainLooper())
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
private var configAttempts = 0
|
private var configAttempts = 0
|
||||||
@@ -52,6 +53,47 @@ class MainActivity : TauriActivity() {
|
|||||||
*/
|
*/
|
||||||
private var bridgesInstalledOn: WebView? = null
|
private var bridgesInstalledOn: WebView? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* wry hands us the WebView here, and this is the only point at which the
|
||||||
|
* bridges can be installed *deterministically*.
|
||||||
|
*
|
||||||
|
* WebView binds an injected object into JS at **page-load time**: an
|
||||||
|
* addJavascriptInterface call that lands after the page has loaded does not
|
||||||
|
* appear to that page at all. The bridges used to be installed from
|
||||||
|
* [configureWebViewForMedia], which finds the WebView by walking the view
|
||||||
|
* tree 500 ms after onCreate — a race against Tauri's own page load, and one
|
||||||
|
* that is *permanent* when lost, because the identity guard then declines to
|
||||||
|
* re-inject on the resume passes. The whole set (`AndroidVideoSurface`,
|
||||||
|
* `AndroidPictureInPicture`, `AndroidBackgroundAudio`, `AndroidNetworkType`,
|
||||||
|
* `AndroidImmersive`, `AndroidInsets`) simply would not exist in `window`,
|
||||||
|
* silently: every one of them is called through an optional chain, so a
|
||||||
|
* missing bridge is a no-op rather than an error. That is a candidate
|
||||||
|
* explanation for DR-172's central piece of evidence — native video shipped
|
||||||
|
* with `WebView transparent = false` logged and `= true` never appearing,
|
||||||
|
* i.e. the enable call never reaching Kotlin.
|
||||||
|
*
|
||||||
|
* `WryActivity.setWebView()` calls this immediately before wry issues the
|
||||||
|
* first `loadUrl`, so a bridge installed here is bound by the time any page
|
||||||
|
* runs. Note this can fire during `super.onCreate()`, i.e. *before* the rest
|
||||||
|
* of our own onCreate — so only work that needs nothing but the WebView
|
||||||
|
* belongs here. Insets are deliberately left to
|
||||||
|
* [configureWebViewForMedia], which runs later and on every resume.
|
||||||
|
*
|
||||||
|
* TRACES: UR-003, UR-004 | DR-183
|
||||||
|
*/
|
||||||
|
override fun onWebViewCreate(webView: WebView) {
|
||||||
|
super.onWebViewCreate(webView)
|
||||||
|
android.util.Log.d("MainActivity", "onWebViewCreate - installing bridges before first page load")
|
||||||
|
mediaWebView = webView
|
||||||
|
// A new WebView means a new page, which reports no video yet. Anything the
|
||||||
|
// previous one left held would otherwise pin the screen on for the life of
|
||||||
|
// the process, since a page that goes away never sends its final
|
||||||
|
// setHtml5VideoState(false, …). (DR-202)
|
||||||
|
ScreenWakeManager.releaseAll()
|
||||||
|
installJavascriptBridges(webView)
|
||||||
|
configureWebViewSettings(webView)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@@ -78,6 +120,11 @@ class MainActivity : TauriActivity() {
|
|||||||
// TRACES: UR-003, UR-041 | DR-151
|
// TRACES: UR-003, UR-041 | DR-151
|
||||||
com.dtourolle.jellytau.player.JellyTauPlayer.setActivity(this)
|
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
|
// Configure WebView for media playback after Tauri initialization
|
||||||
handler.postDelayed({
|
handler.postDelayed({
|
||||||
configureWebViewForMedia()
|
configureWebViewForMedia()
|
||||||
@@ -151,6 +198,7 @@ class MainActivity : TauriActivity() {
|
|||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
NetworkTypeMonitor.stopWatching(this)
|
NetworkTypeMonitor.stopWatching(this)
|
||||||
|
ScreenWakeManager.clearActivity(this)
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +213,9 @@ class MainActivity : TauriActivity() {
|
|||||||
|
|
||||||
private fun configureWebViewForMedia() {
|
private fun configureWebViewForMedia() {
|
||||||
try {
|
try {
|
||||||
val webView = findWebView(window.decorView)
|
// onWebViewCreate normally got here first; the tree walk is the fallback
|
||||||
|
// for a WebView we were never handed.
|
||||||
|
val webView = mediaWebView ?: findWebView(window.decorView)
|
||||||
|
|
||||||
if (webView == null) {
|
if (webView == null) {
|
||||||
android.util.Log.w("MainActivity", "WebView not found (attempt ${configAttempts + 1}/$maxConfigAttempts)")
|
android.util.Log.w("MainActivity", "WebView not found (attempt ${configAttempts + 1}/$maxConfigAttempts)")
|
||||||
@@ -183,33 +233,47 @@ class MainActivity : TauriActivity() {
|
|||||||
android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
|
android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
|
||||||
mediaWebView = webView
|
mediaWebView = webView
|
||||||
|
|
||||||
// Register the @JavascriptInterface bridges EXACTLY ONCE per WebView.
|
|
||||||
//
|
|
||||||
// configureWebViewForMedia() runs from onCreate's delayed post AND from
|
|
||||||
// every onResume (plus each WebView re-find), so this used to re-inject
|
|
||||||
// all four bridges repeatedly - 5 times in a 45s session. WebView binds
|
|
||||||
// injected objects at page-load time; re-injecting over a live page
|
|
||||||
// leaves JS holding a stale proxy. The object stays truthy while its
|
|
||||||
// methods vanish, which surfaced as a flood of
|
|
||||||
// "WebView: Unknown object" chromium errors and, in JS,
|
|
||||||
// "TypeError: setEnabled is not a function".
|
|
||||||
//
|
|
||||||
// The visible bug: the background-audio toggle turned blue but never
|
|
||||||
// reached native, so backgroundAudioEnabled stayed false, onStop never
|
|
||||||
// dispatched 'jellytau-background', and a locked screen killed audio
|
|
||||||
// instantly (UR-040). Audio focus and PiP broke the same way.
|
|
||||||
//
|
|
||||||
// The settings/WebChromeClient work below is idempotent and must keep
|
|
||||||
// running on resume; only the bridge injection is one-shot.
|
|
||||||
|
|
||||||
// Re-push the safe-area insets. Unlike addJavascriptInterface this is
|
// Re-push the safe-area insets. Unlike addJavascriptInterface this is
|
||||||
// idempotent and MUST re-run: a page load discards the inline style the
|
// idempotent and MUST re-run: a page load discards the inline style the
|
||||||
// last push set, so the WebView would otherwise be left with no insets.
|
// last push set, so the WebView would otherwise be left with no insets.
|
||||||
WindowInsetsBridge.attachWebView(webView)
|
WindowInsetsBridge.attachWebView(webView)
|
||||||
|
|
||||||
|
// Normally already done by onWebViewCreate; this is the fallback path.
|
||||||
|
installJavascriptBridges(webView)
|
||||||
|
configureWebViewSettings(webView)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("MainActivity", "Failed to configure WebView for media", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the @JavascriptInterface bridges EXACTLY ONCE per WebView.
|
||||||
|
*
|
||||||
|
* This runs from [onWebViewCreate] — the only point early enough to be bound
|
||||||
|
* before the first page load — and from [configureWebViewForMedia] as a
|
||||||
|
* fallback. The latter runs from onCreate's delayed post AND from every
|
||||||
|
* onResume (plus each WebView re-find), so without the identity guard this
|
||||||
|
* re-injected every bridge repeatedly — 5 times in a 45s session. WebView
|
||||||
|
* binds injected objects at page-load time; re-injecting over a live page
|
||||||
|
* leaves JS holding a stale proxy. The object stays truthy while its methods
|
||||||
|
* vanish, which surfaced as a flood of "WebView: Unknown object" chromium
|
||||||
|
* errors and, in JS, "TypeError: setEnabled is not a function".
|
||||||
|
*
|
||||||
|
* The visible bug: the background-audio toggle turned blue but never reached
|
||||||
|
* native, so backgroundAudioEnabled stayed false, onStop never dispatched
|
||||||
|
* 'jellytau-background', and a locked screen killed audio instantly (UR-040).
|
||||||
|
* Audio focus and PiP broke the same way.
|
||||||
|
*
|
||||||
|
* Settings/WebChromeClient work is idempotent and must keep running on
|
||||||
|
* resume, so it lives in [configureWebViewSettings], not here.
|
||||||
|
*
|
||||||
|
* TRACES: UR-003, UR-004, UR-040, UR-041 | DR-183
|
||||||
|
*/
|
||||||
|
private fun installJavascriptBridges(webView: WebView) {
|
||||||
|
try {
|
||||||
if (webView === bridgesInstalledOn) {
|
if (webView === bridgesInstalledOn) {
|
||||||
android.util.Log.d("MainActivity", "JS bridges already installed on this WebView - skipping re-injection")
|
android.util.Log.d("MainActivity", "JS bridges already installed on this WebView - skipping re-injection")
|
||||||
configureWebViewSettings(webView)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
bridgesInstalledOn = webView
|
bridgesInstalledOn = webView
|
||||||
@@ -258,6 +322,10 @@ class MainActivity : TauriActivity() {
|
|||||||
@JavascriptInterface
|
@JavascriptInterface
|
||||||
fun setHtml5VideoState(active: Boolean, width: Int, height: Int, playing: Boolean) {
|
fun setHtml5VideoState(active: Boolean, width: Int, height: Int, playing: Boolean) {
|
||||||
PictureInPictureManager.setHtml5VideoState(active, width, height, playing)
|
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")
|
}, "AndroidPictureInPicture")
|
||||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidPictureInPicture' added")
|
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidPictureInPicture' added")
|
||||||
@@ -317,16 +385,45 @@ class MainActivity : TauriActivity() {
|
|||||||
@JavascriptInterface
|
@JavascriptInterface
|
||||||
fun setTransparent(transparent: Boolean) {
|
fun setTransparent(transparent: Boolean) {
|
||||||
handler.post {
|
handler.post {
|
||||||
val color = if (transparent) {
|
mediaWebView?.setBackgroundColor(
|
||||||
android.graphics.Color.TRANSPARENT
|
if (transparent) {
|
||||||
} else {
|
android.graphics.Color.TRANSPARENT
|
||||||
android.graphics.Color.BLACK
|
} else {
|
||||||
}
|
android.graphics.Color.BLACK
|
||||||
mediaWebView?.setBackgroundColor(color)
|
}
|
||||||
// The WebView's window/surface must also stop painting opaque, or a
|
)
|
||||||
// hardware-accelerated WebView still composites its own background.
|
// The WINDOW background stays OPAQUE — including while compositing.
|
||||||
|
// It is the only thing that paints the pixels the video does not
|
||||||
|
// cover, and clearing it was the whole defect.
|
||||||
|
//
|
||||||
|
// This window's surface is opaque: the theme is not translucent and
|
||||||
|
// `dumpsys window` shows no translucency flag on it. For an opaque
|
||||||
|
// surface HWUI deliberately does NOT clear the damaged region before
|
||||||
|
// replaying a frame — it assumes the view hierarchy paints every
|
||||||
|
// pixel it owns. That hierarchy is: window background, then the video
|
||||||
|
// TextureView, then this transparent WebView. `fitSurfaceToScreen`
|
||||||
|
// sizes the TextureView to the *letterboxed* video rect, so the bars
|
||||||
|
// around the video are painted by the window background and nothing
|
||||||
|
// else.
|
||||||
|
//
|
||||||
|
// Setting that background TRANSPARENT therefore left the bars painted
|
||||||
|
// by nobody, and stale framebuffer content simply survived in them:
|
||||||
|
// a whole ghost copy of the control bar stranded in the top bar, and
|
||||||
|
// each new clock digit composited over the one before it ("35:42"
|
||||||
|
// with the 1 still showing through the 2). The rotation flash is the
|
||||||
|
// same bug at full-screen scale — the pre-rotation image persisting
|
||||||
|
// in what became the new bars — which is why neither
|
||||||
|
// ROTATION_ANIMATION_JUMPCUT nor revealing on frame arrival ever
|
||||||
|
// touched it. Both were aimed at the window animation; the pixels
|
||||||
|
// were never the animation's.
|
||||||
|
//
|
||||||
|
// The WebView's own background, set above, is what lets the video
|
||||||
|
// through. An opaque window background cannot hide it: the
|
||||||
|
// TextureView is drawn on top of it, not under it.
|
||||||
|
//
|
||||||
|
// TRACES: UR-003, UR-066 | DR-194
|
||||||
window.setBackgroundDrawable(
|
window.setBackgroundDrawable(
|
||||||
android.graphics.drawable.ColorDrawable(color)
|
android.graphics.drawable.ColorDrawable(android.graphics.Color.BLACK)
|
||||||
)
|
)
|
||||||
android.util.Log.d("MainActivity", "WebView transparent = $transparent")
|
android.util.Log.d("MainActivity", "WebView transparent = $transparent")
|
||||||
}
|
}
|
||||||
@@ -371,10 +468,8 @@ class MainActivity : TauriActivity() {
|
|||||||
dispatchWebEvent("jellytau-network-changed")
|
dispatchWebEvent("jellytau-network-changed")
|
||||||
}
|
}
|
||||||
|
|
||||||
configureWebViewSettings(webView)
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.e("MainActivity", "Failed to configure WebView for media", e)
|
android.util.Log.e("MainActivity", "Failed to install JavaScript bridges", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,9 +517,52 @@ class MainActivity : TauriActivity() {
|
|||||||
|
|
||||||
javaScriptEnabled = true
|
javaScriptEnabled = true
|
||||||
domStorageEnabled = true
|
domStorageEnabled = true
|
||||||
allowFileAccess = true
|
|
||||||
allowContentAccess = true
|
// The three settings below used to read
|
||||||
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
// 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")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.dtourolle.jellytau
|
package com.dtourolle.jellytau
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.view.SurfaceView
|
import android.view.TextureView
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import com.dtourolle.jellytau.player.JellyTauPlayer
|
import com.dtourolle.jellytau.player.JellyTauPlayer
|
||||||
@@ -14,15 +14,18 @@ import com.dtourolle.jellytau.player.JellyTauPlayer
|
|||||||
*/
|
*/
|
||||||
object VideoOverlayManager {
|
object VideoOverlayManager {
|
||||||
|
|
||||||
private var attachedSurfaceView: SurfaceView? = null
|
private var attachedSurfaceView: TextureView? = null
|
||||||
private var contentLayoutListener: android.view.View.OnLayoutChangeListener? = null
|
private var contentLayoutListener: android.view.View.OnLayoutChangeListener? = null
|
||||||
private var listenerContentView: ViewGroup? = null
|
private var listenerContentView: ViewGroup? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attach the video SurfaceView to the Activity's content view.
|
* Attach the video view to the Activity's content view.
|
||||||
*
|
*
|
||||||
* The SurfaceView is added at index 0 (bottom of z-order) so it renders
|
* Added at index 0 (bottom of the z-order) so it renders behind the Tauri
|
||||||
* behind the Tauri WebView, allowing Svelte controls to overlay on top.
|
* WebView, allowing the Svelte controls to overlay on top. Since DR-192 this
|
||||||
|
* is a TextureView, so "behind" is ordinary view z-order within one window
|
||||||
|
* rather than a separate surface punched through it — which is what makes
|
||||||
|
* the overlay above it repaint reliably.
|
||||||
*
|
*
|
||||||
* @param activity The Activity to attach the surface to
|
* @param activity The Activity to attach the surface to
|
||||||
*/
|
*/
|
||||||
@@ -77,16 +80,29 @@ object VideoOverlayManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detach the video SurfaceView from the Activity's view hierarchy.
|
* Detach the video SurfaceView from the view hierarchy.
|
||||||
*
|
*
|
||||||
* @param activity The Activity to detach the surface from
|
* Must be called on the main thread.
|
||||||
|
*
|
||||||
|
* This had **no callers at all**, which made [attachVideoSurface] one-way:
|
||||||
|
* `JellyTauPlayer.clearVideoSurface()` dropped its `surfaceView` reference
|
||||||
|
* without removing the view, so every native video left its SurfaceView
|
||||||
|
* parented to the content view for the life of the process and the next one
|
||||||
|
* added another beneath it. The stack was invisible while the WebView was
|
||||||
|
* opaque, and [isVideoSurfaceAttached] — which gates
|
||||||
|
* `PictureInPictureManager.canEnterPip` — stayed true forever afterwards.
|
||||||
|
*
|
||||||
|
* Removes from the view's *own* parent rather than looking the content view
|
||||||
|
* up from an Activity, so it cannot leave a view behind when the Activity
|
||||||
|
* has been recreated under it.
|
||||||
|
*
|
||||||
|
* TRACES: UR-003, UR-041 | DR-184
|
||||||
*/
|
*/
|
||||||
fun detachVideoSurface(activity: Activity) {
|
fun detachVideoSurface() {
|
||||||
try {
|
try {
|
||||||
removeLayoutListener()
|
removeLayoutListener()
|
||||||
attachedSurfaceView?.let { surfaceView ->
|
attachedSurfaceView?.let { surfaceView ->
|
||||||
val contentView = activity.window.decorView.findViewById<ViewGroup>(android.R.id.content)
|
(surfaceView.parent as? ViewGroup)?.removeView(surfaceView)
|
||||||
contentView.removeView(surfaceView)
|
|
||||||
attachedSurfaceView = null
|
attachedSurfaceView = null
|
||||||
android.util.Log.d("VideoOverlayManager", "Video surface detached from view hierarchy")
|
android.util.Log.d("VideoOverlayManager", "Video surface detached from view hierarchy")
|
||||||
}
|
}
|
||||||
|
|||||||
+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
|
* Media commands are routed back to Rust via JNI to ensure proper
|
||||||
* queue management for next/previous track operations.
|
* 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)
|
@OptIn(UnstableApi::class)
|
||||||
class JellyTauPlaybackService : MediaSessionService() {
|
class JellyTauPlaybackService : MediaSessionService() {
|
||||||
@@ -228,6 +239,21 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
nativeOnMediaCommand("previous")
|
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() {
|
override fun onStop() {
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Stop pressed")
|
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Stop pressed")
|
||||||
nativeOnMediaCommand("stop")
|
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 {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
// Start as foreground service immediately to avoid crash
|
// Start as foreground service immediately to avoid crash
|
||||||
// Media3 will replace this with its own notification
|
// 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()
|
val notification = createBasicNotification()
|
||||||
startForeground(NOTIFICATION_ID, notification)
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
return super.onStartCommand(intent, flags, startId)
|
return super.onStartCommand(intent, flags, startId)
|
||||||
@@ -263,6 +383,11 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
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)
|
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||||
.setContentTitle("JellyTau")
|
.setContentTitle("JellyTau")
|
||||||
.setContentText("Playing")
|
.setContentText("Playing")
|
||||||
@@ -270,7 +395,7 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
.setContentIntent(pendingIntent)
|
.setContentIntent(pendingIntent)
|
||||||
.setStyle(
|
.setStyle(
|
||||||
androidx.media.app.NotificationCompat.MediaStyle()
|
androidx.media.app.NotificationCompat.MediaStyle()
|
||||||
.setMediaSession(mediaSessionCompat?.sessionToken)
|
.setMediaSession(sessionToken)
|
||||||
.setShowActionsInCompactView(0, 1, 2) // Show all 3 buttons in compact view
|
.setShowActionsInCompactView(0, 1, 2) // Show all 3 buttons in compact view
|
||||||
)
|
)
|
||||||
.addAction(
|
.addAction(
|
||||||
@@ -433,6 +558,14 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
PlaybackStateCompat.ACTION_STOP or
|
PlaybackStateCompat.ACTION_STOP or
|
||||||
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
|
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
|
||||||
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS 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
|
PlaybackStateCompat.ACTION_SEEK_TO
|
||||||
)
|
)
|
||||||
.setState(
|
.setState(
|
||||||
@@ -446,6 +579,24 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
/**
|
/**
|
||||||
* Update the notification with current media metadata and playback state.
|
* Update the notification with current media metadata and playback state.
|
||||||
* This should be called whenever metadata or playback state changes.
|
* 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) {
|
private fun updateNotification(title: String, artist: String, isPlaying: Boolean) {
|
||||||
val intent = packageManager.getLaunchIntentForPackage(packageName)
|
val intent = packageManager.getLaunchIntentForPackage(packageName)
|
||||||
@@ -456,6 +607,12 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
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)
|
val notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setContentText(artist)
|
.setContentText(artist)
|
||||||
@@ -463,7 +620,7 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
.setContentIntent(pendingIntent)
|
.setContentIntent(pendingIntent)
|
||||||
.setStyle(
|
.setStyle(
|
||||||
androidx.media.app.NotificationCompat.MediaStyle()
|
androidx.media.app.NotificationCompat.MediaStyle()
|
||||||
.setMediaSession(mediaSessionCompat?.sessionToken)
|
.setMediaSession(sessionToken)
|
||||||
.setShowActionsInCompactView(0, 1, 2) // Show all 3 buttons in compact view
|
.setShowActionsInCompactView(0, 1, 2) // Show all 3 buttons in compact view
|
||||||
)
|
)
|
||||||
.addAction(
|
.addAction(
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import android.net.Uri
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.view.SurfaceHolder
|
import android.view.TextureView
|
||||||
import android.view.SurfaceView
|
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
@@ -21,6 +20,9 @@ import androidx.media3.common.PlaybackException
|
|||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
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.*
|
import kotlinx.coroutines.*
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -225,9 +227,12 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
/** Media type enum */
|
/** Media type enum */
|
||||||
enum class MediaType { AUDIO, VIDEO }
|
enum class MediaType { AUDIO, VIDEO }
|
||||||
|
|
||||||
/** SurfaceView for video playback */
|
/** TextureView for video playback — see getOrCreateSurfaceView() for why. */
|
||||||
private var surfaceView: SurfaceView? = null
|
private var videoView: TextureView? = null
|
||||||
private var surfaceHolder: SurfaceHolder? = null
|
|
||||||
|
/** The Surface handed to ExoPlayer, owned here rather than by the player. */
|
||||||
|
private var videoSurface: android.view.Surface? = null
|
||||||
|
|
||||||
/** Last reported video frame size, used to fit the surface to the screen preserving aspect ratio */
|
/** Last reported video frame size, used to fit the surface to the screen preserving aspect ratio */
|
||||||
private var videoWidth: Int = 0
|
private var videoWidth: Int = 0
|
||||||
private var videoHeight: Int = 0
|
private var videoHeight: Int = 0
|
||||||
@@ -254,6 +259,45 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
* (and leak) a focus request we already own. */
|
* (and leak) a focus request we already own. */
|
||||||
private var hasAudioFocus = false
|
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 {
|
init {
|
||||||
// Configure audio attributes for music playback with audio focus handling
|
// Configure audio attributes for music playback with audio focus handling
|
||||||
val audioAttributes = AudioAttributes.Builder()
|
val audioAttributes = AudioAttributes.Builder()
|
||||||
@@ -261,8 +305,23 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||||
.build()
|
.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)
|
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)
|
.setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true)
|
||||||
// Pause when the audio output is removed (wired headphones unplugged or
|
// Pause when the audio output is removed (wired headphones unplugged or
|
||||||
// Bluetooth device disconnected). ExoPlayer listens for the system
|
// Bluetooth device disconnected). ExoPlayer listens for the system
|
||||||
@@ -326,6 +385,14 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
val state = if (isPlaying) "playing" else "paused"
|
val state = if (isPlaying) "playing" else "paused"
|
||||||
nativeOnStateChanged(state, currentMediaId)
|
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) {
|
if (isPlaying) {
|
||||||
startPositionUpdates()
|
startPositionUpdates()
|
||||||
} else {
|
} else {
|
||||||
@@ -336,6 +403,33 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
updatePlaybackServiceNotification(isPlaying)
|
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) {
|
override fun onPlayerError(error: PlaybackException) {
|
||||||
android.util.Log.e("JellyTauPlayer", "▶▶▶ PLAYER ERROR: ${error.errorCodeName}", error)
|
android.util.Log.e("JellyTauPlayer", "▶▶▶ PLAYER ERROR: ${error.errorCodeName}", error)
|
||||||
android.util.Log.e("JellyTauPlayer", " Error code: ${error.errorCode}")
|
android.util.Log.e("JellyTauPlayer", " Error code: ${error.errorCode}")
|
||||||
@@ -827,11 +921,16 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
artworkUrl: String?,
|
artworkUrl: String?,
|
||||||
durationMs: Long,
|
durationMs: Long,
|
||||||
mediaType: String = "audio",
|
mediaType: String = "audio",
|
||||||
subtitlesJson: String = "[]"
|
subtitlesJson: String = "[]",
|
||||||
|
nonResumableStream: Boolean = false
|
||||||
) {
|
) {
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
currentMediaId = mediaId
|
currentMediaId = mediaId
|
||||||
endedNotified = false
|
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
|
// Store metadata for notification updates
|
||||||
currentTitle = title
|
currentTitle = title
|
||||||
@@ -1017,6 +1116,7 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
fun release() {
|
fun release() {
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
stopPositionUpdates()
|
stopPositionUpdates()
|
||||||
|
com.dtourolle.jellytau.ScreenWakeManager.onNativePlaybackChanged(false, false)
|
||||||
coroutineScope.cancel()
|
coroutineScope.cancel()
|
||||||
releaseAudioEffects()
|
releaseAudioEffects()
|
||||||
exoPlayer.release()
|
exoPlayer.release()
|
||||||
@@ -1078,52 +1178,111 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or create the SurfaceView for video playback.
|
* Get or create the video view, and hand it to ExoPlayer.
|
||||||
* Returns the view ID that can be attached to the view hierarchy.
|
|
||||||
*
|
*
|
||||||
* Note: The surface is created but not automatically attached to the view hierarchy.
|
* This is a **TextureView**, not a SurfaceView, and that is the whole point.
|
||||||
* Call attachSurfaceToActivity() or use VideoOverlayManager to attach it.
|
*
|
||||||
|
* A SurfaceView renders on its own layer *outside* the app window and punches
|
||||||
|
* a transparent hole through the window to show it. Anything drawn above
|
||||||
|
* that hole — for us, the entire Svelte UI in a transparent WebView — is at
|
||||||
|
* the mercy of that composition path, and Android's own graphics
|
||||||
|
* documentation says plainly that "overlays do not currently work correctly
|
||||||
|
* with SurfaceView or TextureView". On device that showed up as the WebView
|
||||||
|
* overlay silently dropping its incremental damage: the clock text stopped
|
||||||
|
* advancing on screen while the DOM kept updating (slider 476 → 479 across
|
||||||
|
* three seconds behind a display showing neither), the control bar would not
|
||||||
|
* fade, and rotation lost the transport UI. Only *structural* DOM changes
|
||||||
|
* got through, which is why the play overlay — an `{#if}` block that is added
|
||||||
|
* and removed — always appeared to work while the progress bar never did.
|
||||||
|
*
|
||||||
|
* A TextureView is an ordinary view: its frames are drawn as a texture inside
|
||||||
|
* the window's normal rendering pass, so there is no second layer, no
|
||||||
|
* transparent region, and the WebView above composites like it would over any
|
||||||
|
* other view. This is the standard remedy for ExoPlayer overlay problems and
|
||||||
|
* is why media3 offers `surface_type="texture_view"` at all.
|
||||||
|
*
|
||||||
|
* The cost is real and accepted: TextureView uses more power and memory than
|
||||||
|
* SurfaceView and adds a frame of latency. Hardware decode through MediaCodec
|
||||||
|
* is unaffected — only presentation changes — so the reason native video
|
||||||
|
* exists survives the trade.
|
||||||
|
*
|
||||||
|
* `setVideoTextureView` installs ExoPlayer's own `SurfaceTextureListener`, so
|
||||||
|
* there is deliberately no listener of ours here; adding one would displace
|
||||||
|
* it and the video would never appear.
|
||||||
|
*
|
||||||
|
* Note: the view is created but not attached to the hierarchy. Call
|
||||||
|
* attachSurfaceToActivity() or use VideoOverlayManager to attach it.
|
||||||
|
*
|
||||||
|
* TRACES: UR-003, UR-004 | DR-192
|
||||||
*/
|
*/
|
||||||
fun getOrCreateSurfaceView(): Int {
|
fun getOrCreateSurfaceView(): Int {
|
||||||
if (surfaceView == null) {
|
if (videoView == null) {
|
||||||
surfaceView = SurfaceView(appContext).apply {
|
videoView = TextureView(appContext).apply {
|
||||||
layoutParams = FrameLayout.LayoutParams(
|
layoutParams = FrameLayout.LayoutParams(
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
)
|
)
|
||||||
// Render BEHIND WebView - video shows through transparent areas
|
// The view is opaque where video is drawn; the WebView above it
|
||||||
setZOrderMediaOverlay(false)
|
// is what supplies transparency, exactly as before.
|
||||||
|
isOpaque = true
|
||||||
|
|
||||||
// Set up SurfaceHolder callbacks
|
// Own the listener rather than calling `setVideoTextureView`,
|
||||||
holder.addCallback(object : SurfaceHolder.Callback {
|
// which installs ExoPlayer's own. Handing ExoPlayer the Surface
|
||||||
override fun surfaceCreated(holder: SurfaceHolder) {
|
// directly is the same wiring `setVideoTextureView` does
|
||||||
android.util.Log.d("JellyTauPlayer", "Surface created")
|
// internally, and owning the listener keeps surface creation and
|
||||||
surfaceHolder = holder
|
// teardown symmetrical with `videoSurface` below.
|
||||||
exoPlayer.setVideoSurfaceHolder(holder)
|
//
|
||||||
|
// (This was originally introduced to observe frame arrival for
|
||||||
|
// the letterbox artefact. That turned out to be the wrong lead —
|
||||||
|
// see fitSurfaceToScreen — but the explicit wiring is worth
|
||||||
|
// keeping on its own terms.)
|
||||||
|
//
|
||||||
|
// TRACES: UR-003, UR-004 | DR-194
|
||||||
|
surfaceTextureListener = object : TextureView.SurfaceTextureListener {
|
||||||
|
override fun onSurfaceTextureAvailable(
|
||||||
|
texture: android.graphics.SurfaceTexture,
|
||||||
|
width: Int,
|
||||||
|
height: Int
|
||||||
|
) {
|
||||||
|
videoSurface?.release()
|
||||||
|
videoSurface = android.view.Surface(texture)
|
||||||
|
exoPlayer.setVideoSurface(videoSurface)
|
||||||
android.util.Log.d("JellyTauPlayer", "Video surface attached to ExoPlayer")
|
android.util.Log.d("JellyTauPlayer", "Video surface attached to ExoPlayer")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
|
override fun onSurfaceTextureSizeChanged(
|
||||||
android.util.Log.d("JellyTauPlayer", "Surface changed: ${width}x${height}")
|
texture: android.graphics.SurfaceTexture,
|
||||||
|
width: Int,
|
||||||
|
height: Int
|
||||||
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
override fun onSurfaceTextureDestroyed(
|
||||||
android.util.Log.d("JellyTauPlayer", "Surface destroyed")
|
texture: android.graphics.SurfaceTexture
|
||||||
exoPlayer.clearVideoSurfaceHolder(holder)
|
): Boolean {
|
||||||
surfaceHolder = null
|
exoPlayer.setVideoSurface(null)
|
||||||
|
videoSurface?.release()
|
||||||
|
videoSurface = null
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
override fun onSurfaceTextureUpdated(
|
||||||
|
texture: android.graphics.SurfaceTexture
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
android.util.Log.d("JellyTauPlayer", "Video TextureView created")
|
||||||
}
|
}
|
||||||
return surfaceView!!.hashCode()
|
return videoView!!.hashCode()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the SurfaceView instance (for VideoOverlayManager).
|
* Get the video view instance (for VideoOverlayManager).
|
||||||
* Returns null if no surface has been created yet.
|
* Returns null if none has been created yet.
|
||||||
*/
|
*/
|
||||||
fun getSurfaceView(): SurfaceView? {
|
fun getSurfaceView(): TextureView? {
|
||||||
return surfaceView
|
return videoView
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1138,7 +1297,7 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
* This should be called from MainActivity when video playback is active.
|
* This should be called from MainActivity when video playback is active.
|
||||||
*/
|
*/
|
||||||
fun attachSurfaceToActivity(activity: android.app.Activity) {
|
fun attachSurfaceToActivity(activity: android.app.Activity) {
|
||||||
if (surfaceView != null && currentMediaType == MediaType.VIDEO) {
|
if (videoView != null && currentMediaType == MediaType.VIDEO) {
|
||||||
com.dtourolle.jellytau.VideoOverlayManager.attachVideoSurface(activity)
|
com.dtourolle.jellytau.VideoOverlayManager.attachVideoSurface(activity)
|
||||||
android.util.Log.d("JellyTauPlayer", "Surface attached to Activity")
|
android.util.Log.d("JellyTauPlayer", "Surface attached to Activity")
|
||||||
}
|
}
|
||||||
@@ -1184,7 +1343,7 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
*/
|
*/
|
||||||
fun fitSurfaceToScreen() {
|
fun fitSurfaceToScreen() {
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
val view = surfaceView ?: return@post
|
val view = videoView ?: return@post
|
||||||
val parent = view.parent as? ViewGroup
|
val parent = view.parent as? ViewGroup
|
||||||
// Available area: prefer the parent's measured size, fall back to the screen.
|
// Available area: prefer the parent's measured size, fall back to the screen.
|
||||||
val availW = parent?.width?.takeIf { it > 0 }
|
val availW = parent?.width?.takeIf { it > 0 }
|
||||||
@@ -1216,6 +1375,20 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
if (lp is FrameLayout.LayoutParams) {
|
if (lp is FrameLayout.LayoutParams) {
|
||||||
lp.gravity = android.view.Gravity.CENTER
|
lp.gravity = android.view.Gravity.CENTER
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deliberately no alpha-hiding across the resize.
|
||||||
|
//
|
||||||
|
// Two earlier attempts hid the view here (and from
|
||||||
|
// onConfigurationChanged) until a fresh frame landed, on the reading
|
||||||
|
// that the letterbox flash was a retained TextureView frame drawn at
|
||||||
|
// the old size. It was not: the bars were showing stale *framebuffer*
|
||||||
|
// content because nothing painted them — see the window-background
|
||||||
|
// note in MainActivity.setTransparent. Hiding the video view made
|
||||||
|
// that strictly worse, since the TextureView is the one view in the
|
||||||
|
// hierarchy that reliably paints its own rect; dropping its alpha to
|
||||||
|
// 0 simply widened the un-painted area.
|
||||||
|
//
|
||||||
|
// TRACES: UR-003, UR-066 | DR-194
|
||||||
lp.width = targetW
|
lp.width = targetW
|
||||||
lp.height = targetH
|
lp.height = targetH
|
||||||
view.layoutParams = lp
|
view.layoutParams = lp
|
||||||
@@ -1228,14 +1401,28 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear the video surface when switching to audio playback.
|
* Clear the video surface when switching to audio playback, or on stop.
|
||||||
|
*
|
||||||
|
* Detaching is not optional bookkeeping: dropping the reference without
|
||||||
|
* removing the view left the SurfaceView parented to the content view for
|
||||||
|
* the life of the process, and the next video stacked another one under it.
|
||||||
|
* See VideoOverlayManager.detachVideoSurface.
|
||||||
|
*
|
||||||
|
* Always called on the main thread (every caller runs inside a
|
||||||
|
* `mainHandler.post`), which is what touching the view hierarchy requires.
|
||||||
|
*
|
||||||
|
* TRACES: UR-003, UR-041 | DR-184
|
||||||
*/
|
*/
|
||||||
private fun clearVideoSurface() {
|
private fun clearVideoSurface() {
|
||||||
surfaceView?.let {
|
// Whatever happens to the view, video is no longer what is on screen, so
|
||||||
|
// the display hold goes with it. Outside the let: the hold must be
|
||||||
|
// released even when no view was ever created. (DR-202)
|
||||||
|
com.dtourolle.jellytau.ScreenWakeManager.onNativePlaybackChanged(false, false)
|
||||||
|
videoView?.let {
|
||||||
exoPlayer.clearVideoSurface()
|
exoPlayer.clearVideoSurface()
|
||||||
surfaceView = null
|
com.dtourolle.jellytau.VideoOverlayManager.detachVideoSurface()
|
||||||
surfaceHolder = null
|
videoView = null
|
||||||
android.util.Log.d("JellyTauPlayer", "Video surface cleared")
|
android.util.Log.d("JellyTauPlayer", "Video surface cleared and detached")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1243,7 +1430,12 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
* Request audio focus for video playback.
|
* Request audio focus for video playback.
|
||||||
* This is critical for video to have audio on Android.
|
* This is critical for video to have audio on Android.
|
||||||
*
|
*
|
||||||
* TRACES: UR-004 | DR-145
|
* 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.
|
* @return true if focus was granted outright and playback may start now.
|
||||||
* false for a DELAYED or refused request — the caller must hold playback
|
* 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? {
|
fun getCredential(key: String): String? {
|
||||||
try {
|
val encoded = prefs.getString(key, null)
|
||||||
val encoded = prefs.getString(key, null) ?: return null
|
if (encoded == null) {
|
||||||
|
Log.d(TAG, "No credential stored for: $key")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
val combined = Base64.decode(encoded, Base64.DEFAULT)
|
val combined = Base64.decode(encoded, Base64.DEFAULT)
|
||||||
|
|
||||||
// Extract IV (first 12 bytes for GCM)
|
// Extract IV (first 12 bytes for GCM)
|
||||||
@@ -114,10 +132,16 @@ class SecureStorage private constructor(context: Context) {
|
|||||||
cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec)
|
cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec)
|
||||||
|
|
||||||
val decrypted = cipher.doFinal(encrypted)
|
val decrypted = cipher.doFinal(encrypted)
|
||||||
return String(decrypted, Charsets.UTF_8)
|
String(decrypted, Charsets.UTF_8)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to get credential: $key", e)
|
Log.w(
|
||||||
return null
|
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,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
App name as shown on the home screen, in the app drawer and in the task
|
||||||
|
switcher.
|
||||||
|
|
||||||
|
`tauri android init` generates this file from `productName`, and its output
|
||||||
|
was the lowercase "jellytau" that shipped in every release build. The mistake
|
||||||
|
was invisible during development because build.gradle.kts overrides
|
||||||
|
manifestPlaceholders["appLabel"] to "JellyTau Debug" for the debug build type,
|
||||||
|
so the side-by-side install a developer looks at every day was correctly
|
||||||
|
cased — only the release users install was wrong.
|
||||||
|
|
||||||
|
Held in the canonical android/src tree so sync-android-sources.sh copies it
|
||||||
|
over the generated one (it already syncs res/values/*.xml for themes.xml),
|
||||||
|
which keeps it from being lost the next time gen/ is regenerated.
|
||||||
|
|
||||||
|
TRACES: | DR-214
|
||||||
|
-->
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">JellyTau</string>
|
||||||
|
<string name="main_activity_title">JellyTau</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Backup / transfer policy for JellyTau (API 31+; see android:allowBackup in
|
||||||
|
AndroidManifest.xml for API 24-30).
|
||||||
|
|
||||||
|
Nothing is eligible for extraction, from either channel:
|
||||||
|
|
||||||
|
* cloud-backup - already off via android:allowBackup="false".
|
||||||
|
* device-transfer - NOT covered by allowBackup on Android 12+, which is why
|
||||||
|
this file exists. A D2D transfer would otherwise copy the same data the
|
||||||
|
cloud backup used to.
|
||||||
|
|
||||||
|
Why nothing is extractable:
|
||||||
|
|
||||||
|
* Credentials are unrecoverable off-device. jellytau_secure_prefs holds
|
||||||
|
AES-GCM ciphertext encrypted under an Android Keystore key, and Keystore
|
||||||
|
keys are never backed up or transferred. Restoring the prefs without the
|
||||||
|
key produces ciphertext nothing can read - a silent auth failure that looks
|
||||||
|
like a broken app rather than a logged-out one.
|
||||||
|
* Everything else is a rebuildable cache. The SQLite catalogue is a mirror of
|
||||||
|
the Jellyfin server (library metadata, watch history, offline downloads);
|
||||||
|
signing in again reproduces it, and watch state lives on the server anyway.
|
||||||
|
Backing it up would export a user's library and viewing history to their
|
||||||
|
Google account for no gain.
|
||||||
|
|
||||||
|
Exclude rules are listed per domain rather than relying on "root" alone,
|
||||||
|
because database/, shared_prefs/, files/ and external storage are addressed
|
||||||
|
as their own domains by the extraction engine.
|
||||||
|
-->
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<exclude domain="root" />
|
||||||
|
<exclude domain="file" />
|
||||||
|
<exclude domain="database" />
|
||||||
|
<exclude domain="sharedpref" />
|
||||||
|
<exclude domain="external" />
|
||||||
|
</cloud-backup>
|
||||||
|
<device-transfer>
|
||||||
|
<exclude domain="root" />
|
||||||
|
<exclude domain="file" />
|
||||||
|
<exclude domain="database" />
|
||||||
|
<exclude domain="sharedpref" />
|
||||||
|
<exclude domain="external" />
|
||||||
|
</device-transfer>
|
||||||
|
</data-extraction-rules>
|
||||||
@@ -12,7 +12,12 @@
|
|||||||
remote server still has to be HTTPS — this must not become a blanket
|
remote server still has to be HTTPS — this must not become a blanket
|
||||||
cleartext opt-in.
|
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>
|
<network-security-config>
|
||||||
<base-config cleartextTrafficPermitted="false" />
|
<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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Pinned Rust toolchain for the JellyTau backend.
|
||||||
|
#
|
||||||
|
# TRACES: | DR-206
|
||||||
|
#
|
||||||
|
# Why pin: the toolchain was unpinned, so the CI builder image (rustc 1.97.1)
|
||||||
|
# and developer machines (as low as 1.92.0) were five releases apart. Clippy's
|
||||||
|
# lint set and rustfmt's output both move between releases, which means a green
|
||||||
|
# `cargo clippy` / `cargo fmt --check` locally proved nothing about CI — and vice
|
||||||
|
# versa. Everything in this file exists to make both sides run the same compiler.
|
||||||
|
#
|
||||||
|
# 🔴 This value MUST match the rustc that Dockerfile.builder installs (see
|
||||||
|
# RUST_VERSION there). If they drift, rustup downloads the pinned toolchain at
|
||||||
|
# job time inside the container — a toolchain install in CI, which is exactly
|
||||||
|
# what CLAUDE.md's "CI installs no system tools" rule forbids. To move the pin:
|
||||||
|
# bump BOTH this file and Dockerfile.builder, then rebuild and push the image
|
||||||
|
# with scripts/build-builder-image.sh before merging.
|
||||||
|
#
|
||||||
|
# No `targets` key on purpose: listing the Android/Windows targets here would
|
||||||
|
# make rustup fetch all of them on every plain `cargo test`, including on
|
||||||
|
# machines that never cross-compile. The builder image already carries them
|
||||||
|
# (`rustup target add` in Dockerfile.builder), and the cross-build scripts add
|
||||||
|
# them locally when needed.
|
||||||
|
|
||||||
|
[toolchain]
|
||||||
|
channel = "1.97.1"
|
||||||
|
components = ["rustfmt", "clippy"]
|
||||||
@@ -129,6 +129,18 @@ impl AuthManager {
|
|||||||
Ok(normalized)
|
Ok(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Normalize a username before it goes to the server.
|
||||||
|
///
|
||||||
|
/// Only surrounding whitespace is stripped — interior spaces are legal in
|
||||||
|
/// Jellyfin usernames. Without this, a trailing space from a soft keyboard's
|
||||||
|
/// autocorrect makes the server report an unknown user, which surfaces as a
|
||||||
|
/// 401 that looks exactly like a wrong password.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-042 | DR-054
|
||||||
|
pub fn normalize_username(username: &str) -> String {
|
||||||
|
username.trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// Connect to server and get server info
|
/// Connect to server and get server info
|
||||||
pub async fn connect_to_server(&self, server_url: &str) -> Result<ServerInfo, String> {
|
pub async fn connect_to_server(&self, server_url: &str) -> Result<ServerInfo, String> {
|
||||||
let normalized_url = Self::normalize_url(server_url)?;
|
let normalized_url = Self::normalize_url(server_url)?;
|
||||||
@@ -185,6 +197,7 @@ impl AuthManager {
|
|||||||
) -> Result<AuthResult, String> {
|
) -> Result<AuthResult, String> {
|
||||||
let url = Self::normalize_url(server_url)?;
|
let url = Self::normalize_url(server_url)?;
|
||||||
let endpoint = format!("{}/Users/AuthenticateByName", url);
|
let endpoint = format!("{}/Users/AuthenticateByName", url);
|
||||||
|
let username = Self::normalize_username(username);
|
||||||
|
|
||||||
log::info!("[AuthManager] Authenticating user: {}", username);
|
log::info!("[AuthManager] Authenticating user: {}", username);
|
||||||
|
|
||||||
@@ -443,6 +456,26 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Usernames must be trimmed before they reach the server: the Android soft
|
||||||
|
/// keyboard appends a trailing space after autocorrect, and Jellyfin then
|
||||||
|
/// reports an unknown user — a 401 indistinguishable from a wrong password.
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_username_trims_whitespace() {
|
||||||
|
assert_eq!(AuthManager::normalize_username("duncan "), "duncan");
|
||||||
|
assert_eq!(AuthManager::normalize_username(" duncan"), "duncan");
|
||||||
|
assert_eq!(AuthManager::normalize_username(" duncan "), "duncan");
|
||||||
|
assert_eq!(AuthManager::normalize_username("duncan\n"), "duncan");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Interior spaces are legal in Jellyfin usernames and must survive.
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_username_preserves_interior_spaces() {
|
||||||
|
assert_eq!(
|
||||||
|
AuthManager::normalize_username(" duncan tourolle "),
|
||||||
|
"duncan tourolle"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Test URL normalization - real world case
|
/// Test URL normalization - real world case
|
||||||
#[test]
|
#[test]
|
||||||
fn test_normalize_url_real_world_case() {
|
fn test_normalize_url_real_world_case() {
|
||||||
|
|||||||
@@ -418,13 +418,13 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_auth_manager_wrapper_structure() {
|
fn test_auth_manager_wrapper_structure() {
|
||||||
// Verify wrapper type exists and has correct 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]
|
#[test]
|
||||||
fn test_session_verifier_wrapper_structure() {
|
fn test_session_verifier_wrapper_structure() {
|
||||||
// Verify wrapper type exists and has correct 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]
|
#[test]
|
||||||
|
|||||||
@@ -496,7 +496,7 @@ pub(crate) async fn requeue_mistyped_video_downloads(
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
let query = Query::new(&format!(
|
let query = Query::new(format!(
|
||||||
"UPDATE downloads
|
"UPDATE downloads
|
||||||
SET status = 'pending', stream_url = NULL, progress = 0,
|
SET status = 'pending', stream_url = NULL, progress = 0,
|
||||||
bytes_downloaded = 0, started_at = NULL, completed_at = NULL
|
bytes_downloaded = 0, started_at = NULL, completed_at = NULL
|
||||||
@@ -520,15 +520,27 @@ pub(crate) async fn requeue_mistyped_video_downloads(
|
|||||||
/// `pending`/`stream_url IS NULL` row, resolve each via `resolve` (returning
|
/// `pending`/`stream_url IS NULL` row, resolve each via `resolve` (returning
|
||||||
/// `None` leaves the row pending), and heal the row so the pump can start it.
|
/// `None` leaves the row pending), and heal the row so the pump can start it.
|
||||||
/// The `resolve` closure receives `(item_id, media_type, quality_preset)`.
|
/// The `resolve` closure receives `(item_id, media_type, quality_preset)`.
|
||||||
|
///
|
||||||
|
/// `only_ids` restricts the sweep to specific download rows. Reconnect passes
|
||||||
|
/// `None` and heals everything; a bulk enqueue (an album, say) passes the rows
|
||||||
|
/// it just created, so clicking download on one album cannot also start every
|
||||||
|
/// unrelated row that has been sitting pending.
|
||||||
pub(crate) async fn resolve_pending_download_urls<F, Fut>(
|
pub(crate) async fn resolve_pending_download_urls<F, Fut>(
|
||||||
db_service: &Arc<crate::storage::db_service::RusqliteService>,
|
db_service: &Arc<crate::storage::db_service::RusqliteService>,
|
||||||
target_dir: &str,
|
target_dir: &str,
|
||||||
|
only_ids: Option<&[i64]>,
|
||||||
resolve: F,
|
resolve: F,
|
||||||
) -> Result<ResumeQueuedResult, String>
|
) -> Result<ResumeQueuedResult, String>
|
||||||
where
|
where
|
||||||
F: Fn(String, String, String) -> Fut,
|
F: Fn(String, String, String) -> Fut,
|
||||||
Fut: std::future::Future<Output = Option<String>>,
|
Fut: std::future::Future<Output = Option<String>>,
|
||||||
{
|
{
|
||||||
|
if only_ids.is_some_and(|ids| ids.is_empty()) {
|
||||||
|
return Ok(ResumeQueuedResult {
|
||||||
|
resolved: 0,
|
||||||
|
failed: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
// A row's own media_type wins; otherwise the *item's* type decides. Rows
|
// A row's own media_type wins; otherwise the *item's* type decides. Rows
|
||||||
// queued from a media card never carry one (`download_item` does not record
|
// queued from a media card never carry one (`download_item` does not record
|
||||||
// it), and defaulting that NULL to 'audio' resolved movies against
|
// it), and defaulting that NULL to 'audio' resolved movies against
|
||||||
@@ -541,7 +553,17 @@ where
|
|||||||
.map(|t| format!("'{t}'"))
|
.map(|t| format!("'{t}'"))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ");
|
.join(", ");
|
||||||
let rows_query = Query::new(&format!(
|
let id_filter = match only_ids {
|
||||||
|
Some(ids) => format!(
|
||||||
|
" AND d.id IN ({})",
|
||||||
|
ids.iter()
|
||||||
|
.map(|id| id.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
),
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
|
let rows_query = Query::new(format!(
|
||||||
"SELECT d.id, d.item_id,
|
"SELECT d.id, d.item_id,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
d.media_type,
|
d.media_type,
|
||||||
@@ -552,7 +574,7 @@ where
|
|||||||
COALESCE(d.quality_preset, 'original')
|
COALESCE(d.quality_preset, 'original')
|
||||||
FROM downloads d
|
FROM downloads d
|
||||||
LEFT JOIN items i ON i.id = d.item_id
|
LEFT JOIN items i ON i.id = d.item_id
|
||||||
WHERE d.status = 'pending' AND d.stream_url IS NULL"
|
WHERE d.status = 'pending' AND d.stream_url IS NULL{id_filter}"
|
||||||
));
|
));
|
||||||
let rows: Vec<(i64, String, String, String)> = db_service
|
let rows: Vec<(i64, String, String, String)> = db_service
|
||||||
.query_many(rows_query, |row| {
|
.query_many(rows_query, |row| {
|
||||||
@@ -676,6 +698,7 @@ pub async fn resume_queued_downloads(
|
|||||||
let outcome = resolve_pending_download_urls(
|
let outcome = resolve_pending_download_urls(
|
||||||
&db_service,
|
&db_service,
|
||||||
&target_dir,
|
&target_dir,
|
||||||
|
None,
|
||||||
move |item_id: String, media_type: String, quality: String| {
|
move |item_id: String, media_type: String, quality: String| {
|
||||||
let repo = Arc::clone(&repo_for_resolve);
|
let repo = Arc::clone(&repo_for_resolve);
|
||||||
async move {
|
async move {
|
||||||
@@ -730,6 +753,7 @@ pub async fn resume_queued_downloads(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::storage::db_service::RusqliteService;
|
use crate::storage::db_service::RusqliteService;
|
||||||
|
use crate::utils::lock::MutexSafe;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
@@ -861,12 +885,14 @@ mod tests {
|
|||||||
// A completed row: irrelevant.
|
// A completed row: irrelevant.
|
||||||
insert_download(&db, "done", "completed", Some("http://done/url"), None).await;
|
insert_download(&db, "done", "completed", Some("http://done/url"), None).await;
|
||||||
|
|
||||||
let out =
|
let out = resolve_pending_download_urls(
|
||||||
resolve_pending_download_urls(&db, "/data/downloads", |item_id, _mt, _q| async move {
|
&db,
|
||||||
Some(format!("http://resolved/{item_id}"))
|
"/data/downloads",
|
||||||
})
|
None,
|
||||||
.await
|
|item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}")) },
|
||||||
.unwrap();
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(out.resolved, 1);
|
assert_eq!(out.resolved, 1);
|
||||||
assert_eq!(out.failed, 0);
|
assert_eq!(out.failed, 0);
|
||||||
@@ -882,15 +908,79 @@ mod tests {
|
|||||||
assert_eq!(url2.as_deref(), Some("http://existing/url"));
|
assert_eq!(url2.as_deref(), Some("http://existing/url"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A bulk enqueue resolves only the rows it just created. Downloading one
|
||||||
|
/// album must not also start every unrelated row that has been sitting
|
||||||
|
/// pending with no URL (the smart cache leaves plenty of those).
|
||||||
|
///
|
||||||
|
/// TRACES: UR-018, UR-055 | DR-173 | UT-171
|
||||||
|
#[tokio::test]
|
||||||
|
async fn only_ids_restricts_the_sweep_to_the_given_rows() {
|
||||||
|
let db = test_db();
|
||||||
|
insert_download(&db, "mine", "pending", None, Some("audio")).await;
|
||||||
|
insert_download(&db, "someone-elses", "pending", None, Some("audio")).await;
|
||||||
|
|
||||||
|
let mine: i64 = db
|
||||||
|
.query_one(
|
||||||
|
Query::new("SELECT id FROM downloads WHERE item_id = 'mine'"),
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let out = resolve_pending_download_urls(
|
||||||
|
&db,
|
||||||
|
"/data",
|
||||||
|
Some(&[mine]),
|
||||||
|
|item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}")) },
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(out.resolved, 1);
|
||||||
|
assert_eq!(out.failed, 0);
|
||||||
|
|
||||||
|
let (_s, url, _t) = get_row(&db, "mine").await;
|
||||||
|
assert_eq!(url.as_deref(), Some("http://resolved/mine"));
|
||||||
|
|
||||||
|
let (status, other_url, _t) = get_row(&db, "someone-elses").await;
|
||||||
|
assert_eq!(status, "pending");
|
||||||
|
assert_eq!(
|
||||||
|
other_url, None,
|
||||||
|
"a scoped resolve must leave unrelated pending rows alone"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An empty id list resolves nothing — it must not fall through to "sweep
|
||||||
|
/// everything", which is what an unguarded `IN ()` would amount to.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-018, UR-055 | DR-173 | UT-171
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_empty_id_list_resolves_nothing() {
|
||||||
|
let db = test_db();
|
||||||
|
insert_download(&db, "untouched", "pending", None, Some("audio")).await;
|
||||||
|
|
||||||
|
let out =
|
||||||
|
resolve_pending_download_urls(&db, "/data", Some(&[]), |item_id, _mt, _q| async move {
|
||||||
|
Some(format!("http://resolved/{item_id}"))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(out.resolved, 0);
|
||||||
|
let (_s, url, _t) = get_row(&db, "untouched").await;
|
||||||
|
assert_eq!(url, None);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn counts_unresolvable_rows_as_failed_and_leaves_them_pending() {
|
async fn counts_unresolvable_rows_as_failed_and_leaves_them_pending() {
|
||||||
let db = test_db();
|
let db = test_db();
|
||||||
insert_download(&db, "bad", "pending", None, None).await;
|
insert_download(&db, "bad", "pending", None, None).await;
|
||||||
|
|
||||||
// Resolver returns None (e.g. server lookup failed).
|
// Resolver returns None (e.g. server lookup failed).
|
||||||
let out = resolve_pending_download_urls(&db, "/data", |_id, _mt, _q| async move { None })
|
let out =
|
||||||
.await
|
resolve_pending_download_urls(&db, "/data", None, |_id, _mt, _q| async move { None })
|
||||||
.unwrap();
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(out.resolved, 0);
|
assert_eq!(out.resolved, 0);
|
||||||
assert_eq!(out.failed, 1);
|
assert_eq!(out.failed, 1);
|
||||||
@@ -920,17 +1010,17 @@ mod tests {
|
|||||||
|
|
||||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||||
let seen_c = Arc::clone(&seen);
|
let seen_c = Arc::clone(&seen);
|
||||||
resolve_pending_download_urls(&db, "/data", move |item_id, media_type, _q| {
|
resolve_pending_download_urls(&db, "/data", None, move |item_id, media_type, _q| {
|
||||||
let seen = Arc::clone(&seen_c);
|
let seen = Arc::clone(&seen_c);
|
||||||
async move {
|
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}"))
|
Some(format!("http://resolved/{item_id}"))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let seen = seen.lock().unwrap().clone();
|
let seen = seen.lock_safe().clone();
|
||||||
let of = |id: &str| {
|
let of = |id: &str| {
|
||||||
seen.iter()
|
seen.iter()
|
||||||
.find(|(i, _)| i == id)
|
.find(|(i, _)| i == id)
|
||||||
@@ -953,17 +1043,17 @@ mod tests {
|
|||||||
|
|
||||||
let seen = Arc::new(Mutex::new(String::new()));
|
let seen = Arc::new(Mutex::new(String::new()));
|
||||||
let seen_c = Arc::clone(&seen);
|
let seen_c = Arc::clone(&seen);
|
||||||
resolve_pending_download_urls(&db, "/data", move |_id, media_type, _q| {
|
resolve_pending_download_urls(&db, "/data", None, move |_id, media_type, _q| {
|
||||||
let seen = Arc::clone(&seen_c);
|
let seen = Arc::clone(&seen_c);
|
||||||
async move {
|
async move {
|
||||||
*seen.lock().unwrap() = media_type;
|
*seen.lock_safe() = media_type;
|
||||||
Some("http://x".to_string())
|
Some("http://x".to_string())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.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.
|
/// An explicit `media_type` on the row always wins over the item's type.
|
||||||
@@ -977,17 +1067,17 @@ mod tests {
|
|||||||
|
|
||||||
let seen = Arc::new(Mutex::new(String::new()));
|
let seen = Arc::new(Mutex::new(String::new()));
|
||||||
let seen_c = Arc::clone(&seen);
|
let seen_c = Arc::clone(&seen);
|
||||||
resolve_pending_download_urls(&db, "/data", move |_id, media_type, _q| {
|
resolve_pending_download_urls(&db, "/data", None, move |_id, media_type, _q| {
|
||||||
let seen = Arc::clone(&seen_c);
|
let seen = Arc::clone(&seen_c);
|
||||||
async move {
|
async move {
|
||||||
*seen.lock().unwrap() = media_type;
|
*seen.lock_safe() = media_type;
|
||||||
Some("http://x".to_string())
|
Some("http://x".to_string())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(*seen.lock().unwrap(), "video");
|
assert_eq!(*seen.lock_safe(), "video");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rows already downloaded under the audio default hold an audio-only
|
/// Rows already downloaded under the audio default hold an audio-only
|
||||||
@@ -1037,13 +1127,17 @@ mod tests {
|
|||||||
let db = test_db();
|
let db = test_db();
|
||||||
insert_download(&db, "vid-1", "pending", None, Some("video")).await;
|
insert_download(&db, "vid-1", "pending", None, Some("video")).await;
|
||||||
|
|
||||||
let out =
|
let out = resolve_pending_download_urls(
|
||||||
resolve_pending_download_urls(&db, "/data", |item_id, media_type, _q| async move {
|
&db,
|
||||||
|
"/data",
|
||||||
|
None,
|
||||||
|
|item_id, media_type, _q| async move {
|
||||||
assert_eq!(media_type, "video");
|
assert_eq!(media_type, "video");
|
||||||
Some(format!("http://transcode/{item_id}"))
|
Some(format!("http://transcode/{item_id}"))
|
||||||
})
|
},
|
||||||
.await
|
)
|
||||||
.unwrap();
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(out.resolved, 1);
|
assert_eq!(out.resolved, 1);
|
||||||
let (_s, url, _t) = get_row(&db, "vid-1").await;
|
let (_s, url, _t) = get_row(&db, "vid-1").await;
|
||||||
|
|||||||
@@ -97,6 +97,6 @@ mod tests {
|
|||||||
// due to its dependencies, so we just test the wrapper type structure
|
// due to its dependencies, so we just test the wrapper type structure
|
||||||
|
|
||||||
// This verifies the wrapper type exists and can hold Arc<Mutex>
|
// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -187,7 +187,7 @@ pub async fn get_album_recommendations(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sort by tracks played (descending)
|
// Sort by tracks played (descending)
|
||||||
recommendations.sort_by(|a, b| b.tracks_played.cmp(&a.tracks_played));
|
recommendations.sort_by_key(|r| std::cmp::Reverse(r.tracks_played));
|
||||||
|
|
||||||
Ok(recommendations)
|
Ok(recommendations)
|
||||||
}
|
}
|
||||||
@@ -224,7 +224,7 @@ pub fn get_album_affinity_status(
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Sort by play count (descending)
|
// Sort by play count (descending)
|
||||||
statuses.sort_by(|a, b| b.unique_tracks_played.cmp(&a.unique_tracks_played));
|
statuses.sort_by_key(|s| std::cmp::Reverse(s.unique_tracks_played));
|
||||||
|
|
||||||
Ok(statuses)
|
Ok(statuses)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ async fn run_drain(app: &tauri::AppHandle) -> Result<(), String> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::utils::lock::MutexSafe;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
@@ -211,7 +212,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn calls(&self) -> Vec<(String, bool)> {
|
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.sort();
|
||||||
calls
|
calls
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
//! Library browsing preferences — currently, which folders are hidden.
|
||||||
|
//!
|
||||||
|
//! The setting replaces a hardcoded frontend filter that dropped any item
|
||||||
|
//! literally named "Podcasts", which was one user's folder layout keyed on an
|
||||||
|
//! English string and shipped to everyone. What is hidden is now a user choice
|
||||||
|
//! made of stable ids, applied in the repository layer
|
||||||
|
//! (`repository::exclusions`) so every query path agrees; the frontend only
|
||||||
|
//! renders a picker over the candidates this module serves.
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-076 | DR-209
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use log::{debug, info, warn};
|
||||||
|
use tauri::{Manager, State};
|
||||||
|
|
||||||
|
use crate::commands::repository::RepositoryManagerWrapper;
|
||||||
|
use crate::commands::storage::DatabaseWrapper;
|
||||||
|
use crate::repository::exclusions;
|
||||||
|
use crate::repository::types::{GetItemsOptions, SearchScope};
|
||||||
|
use crate::repository::MediaRepository;
|
||||||
|
use crate::settings::LibrarySettings;
|
||||||
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||||
|
use crate::utils::lock::MutexSafe;
|
||||||
|
|
||||||
|
/// `app_settings` key holding the persisted library preferences (JSON).
|
||||||
|
///
|
||||||
|
/// Persisted for the same reason the streaming cap is: a hidden folder that
|
||||||
|
/// silently comes back on the next launch is a setting the user has to keep
|
||||||
|
/// re-applying, and they would have no way to tell it had been forgotten.
|
||||||
|
const LIBRARY_SETTINGS_KEY: &str = "library_settings";
|
||||||
|
|
||||||
|
/// How many immediate children of a library the picker will consider.
|
||||||
|
///
|
||||||
|
/// A music library's root listing is folders and (on some layouts) artists, not
|
||||||
|
/// the whole catalog, so this is generous. It exists to stop a pathological
|
||||||
|
/// library from turning the settings page into an unbounded fetch.
|
||||||
|
const CANDIDATE_SCAN_LIMIT: usize = 500;
|
||||||
|
|
||||||
|
/// Something the user may choose to hide: a library, or a folder directly
|
||||||
|
/// inside one.
|
||||||
|
///
|
||||||
|
/// Which containers are *offerable* is a domain question (it depends on the
|
||||||
|
/// library's Jellyfin collection type and on what counts as a folder), so the
|
||||||
|
/// list is assembled here and the frontend renders it verbatim.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-076 | DR-209
|
||||||
|
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ExclusionCandidate {
|
||||||
|
/// Stable Jellyfin item id — what gets stored when the user picks it.
|
||||||
|
pub id: String,
|
||||||
|
/// Display name of the folder (or of the library, for a whole-library entry).
|
||||||
|
pub name: String,
|
||||||
|
/// Library this candidate lives in, so the picker can group and disambiguate
|
||||||
|
/// two folders that share a name.
|
||||||
|
pub library_name: String,
|
||||||
|
/// True when the candidate *is* a library rather than a folder inside one.
|
||||||
|
pub is_library: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The library preferences currently in force.
|
||||||
|
///
|
||||||
|
/// Read from the in-memory exclusion set rather than the database: that set is
|
||||||
|
/// what queries actually consult, so reading it is the only answer that cannot
|
||||||
|
/// disagree with what the user is seeing.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-076 | DR-209
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn library_get_settings() -> Result<LibrarySettings, String> {
|
||||||
|
Ok(LibrarySettings {
|
||||||
|
excluded_item_ids: exclusions::excluded_item_ids(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the library preferences: apply them to every subsequent query and
|
||||||
|
/// persist them.
|
||||||
|
///
|
||||||
|
/// Returns the sanitised value actually applied, so the picker shows what was
|
||||||
|
/// stored rather than what it sent.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-076 | DR-209
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn library_set_settings(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
settings: LibrarySettings,
|
||||||
|
) -> Result<LibrarySettings, String> {
|
||||||
|
let sanitised = settings.sanitised();
|
||||||
|
exclusions::set_excluded_item_ids(&sanitised.excluded_item_ids);
|
||||||
|
persist_library_settings(&db, &sanitised).await;
|
||||||
|
info!(
|
||||||
|
"[Library] {} folder(s) hidden from browsing",
|
||||||
|
sanitised.excluded_item_ids.len()
|
||||||
|
);
|
||||||
|
Ok(sanitised)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The folders the user may choose to hide.
|
||||||
|
///
|
||||||
|
/// Offers each music library and the folders directly inside it. Music is the
|
||||||
|
/// only scope offered because it is the one where a foreign folder — podcasts,
|
||||||
|
/// audiobooks, sound effects — routinely shares a library with the media the
|
||||||
|
/// user actually browses; the scope is decided here rather than in the UI so the
|
||||||
|
/// collection-type table stays out of the frontend
|
||||||
|
/// (see `SearchScope::for_collection_type`).
|
||||||
|
///
|
||||||
|
/// Reads through `HybridRepository::get_items_unfiltered` so folders that are
|
||||||
|
/// *already* hidden still appear — otherwise the setting could never be undone.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-076 | DR-209
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn library_get_exclusion_candidates(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
) -> Result<Vec<ExclusionCandidate>, String> {
|
||||||
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
|
||||||
|
let libraries = repo
|
||||||
|
.as_ref()
|
||||||
|
.get_libraries()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{:?}", e))?;
|
||||||
|
|
||||||
|
let mut candidates: Vec<ExclusionCandidate> = Vec::new();
|
||||||
|
|
||||||
|
for library in libraries {
|
||||||
|
if SearchScope::for_collection_type(&library.collection_type) != Some(SearchScope::Music) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.push(ExclusionCandidate {
|
||||||
|
id: library.id.clone(),
|
||||||
|
name: library.name.clone(),
|
||||||
|
library_name: library.name.clone(),
|
||||||
|
is_library: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
let options = GetItemsOptions {
|
||||||
|
recursive: Some(false),
|
||||||
|
sort_by: Some("SortName".to_string()),
|
||||||
|
sort_order: Some("Ascending".to_string()),
|
||||||
|
limit: Some(CANDIDATE_SCAN_LIMIT),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
match repo.get_items_unfiltered(&library.id, Some(options)).await {
|
||||||
|
Ok(result) => {
|
||||||
|
for item in result.items {
|
||||||
|
if !item.is_folder {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
candidates.push(ExclusionCandidate {
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
library_name: library.name.clone(),
|
||||||
|
is_library: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// One unreachable library must not cost the user the picker for
|
||||||
|
// the others — an empty section is recoverable, an error is not.
|
||||||
|
warn!(
|
||||||
|
"[Library] Could not list folders in {}: {:?}",
|
||||||
|
library.name, e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("[Library] {} exclusion candidate(s)", candidates.len());
|
||||||
|
Ok(candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the preferences to `app_settings`.
|
||||||
|
///
|
||||||
|
/// Failure is logged, not returned: the setting has already been applied in
|
||||||
|
/// memory, and failing the whole call because the write failed would leave the
|
||||||
|
/// picker showing a state that *is* in force.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-076 | DR-209
|
||||||
|
async fn persist_library_settings(db: &State<'_, DatabaseWrapper>, settings: &LibrarySettings) {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock_safe();
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let encoded = match serde_json::to_string(settings) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("[Library] Failed to encode library settings: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = Query::with_params(
|
||||||
|
"INSERT OR REPLACE INTO app_settings (key, value, updated_at)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP)",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(LIBRARY_SETTINGS_KEY.to_string()),
|
||||||
|
QueryParam::String(encoded),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Err(e) = db_service.execute(query).await {
|
||||||
|
warn!("[Library] Failed to persist library settings: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore the persisted preferences at startup, into the exclusion set the
|
||||||
|
/// repository consults.
|
||||||
|
///
|
||||||
|
/// Called from the Tauri `setup` hook. A missing or unreadable row leaves the
|
||||||
|
/// default — nothing hidden — in place, so a database problem shows the user
|
||||||
|
/// more than they asked for rather than less.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-076 | DR-209
|
||||||
|
pub async fn restore_library_settings(app: &tauri::AppHandle) {
|
||||||
|
let db_service = {
|
||||||
|
let Some(db) = app.try_state::<DatabaseWrapper>() else {
|
||||||
|
warn!("[Library] No database available; nothing hidden from browsing");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let database = db.0.lock_safe();
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = Query::with_params(
|
||||||
|
"SELECT value FROM app_settings WHERE key = ?",
|
||||||
|
vec![QueryParam::String(LIBRARY_SETTINGS_KEY.to_string())],
|
||||||
|
);
|
||||||
|
|
||||||
|
let stored: Option<String> = match db_service.query_optional(query, |row| row.get(0)).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("[Library] Failed to read library settings: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(stored) = stored else { return };
|
||||||
|
let settings: LibrarySettings = match serde_json::from_str(&stored) {
|
||||||
|
Ok(settings) => settings,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
"[Library] Ignoring unreadable persisted library settings {:?}: {}",
|
||||||
|
stored, e
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let settings = settings.sanitised();
|
||||||
|
exclusions::set_excluded_item_ids(&settings.excluded_item_ids);
|
||||||
|
if !settings.excluded_item_ids.is_empty() {
|
||||||
|
info!(
|
||||||
|
"[Library] Restored {} hidden folder(s)",
|
||||||
|
settings.excluded_item_ids.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The persisted form must round-trip through the same camelCase JSON the
|
||||||
|
/// IPC boundary uses — a rename here silently un-hides every folder the user
|
||||||
|
/// chose, with no setting having been changed.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-076 | DR-209 | UT-203
|
||||||
|
#[test]
|
||||||
|
fn test_library_settings_round_trip_through_json() {
|
||||||
|
let settings = LibrarySettings {
|
||||||
|
excluded_item_ids: vec!["folder-1".to_string(), "folder-2".to_string()],
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&settings).expect("serialises");
|
||||||
|
assert!(
|
||||||
|
json.contains("\"excludedItemIds\""),
|
||||||
|
"camelCase on the wire"
|
||||||
|
);
|
||||||
|
|
||||||
|
let parsed: LibrarySettings = serde_json::from_str(&json).expect("parses back");
|
||||||
|
assert_eq!(parsed, settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Settings persisted before this feature existed — and a row with the key
|
||||||
|
/// missing entirely — must load as "nothing hidden", never as an error the
|
||||||
|
/// caller has to handle or a default that hides something.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-076 | DR-209 | UT-203
|
||||||
|
#[test]
|
||||||
|
fn test_library_settings_default_hides_nothing() {
|
||||||
|
let parsed: LibrarySettings = serde_json::from_str("{}").expect("parses");
|
||||||
|
assert!(parsed.excluded_item_ids.is_empty());
|
||||||
|
assert!(LibrarySettings::default().excluded_item_ids.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blank and duplicate ids are dropped on the way in, so a half-written or
|
||||||
|
/// hand-edited value cannot grow the list without bound or store an id that
|
||||||
|
/// matches nothing yet still shows as a selection.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-076 | DR-209 | UT-203
|
||||||
|
#[test]
|
||||||
|
fn test_library_settings_sanitised() {
|
||||||
|
let settings = LibrarySettings {
|
||||||
|
excluded_item_ids: vec![
|
||||||
|
" folder-1 ".to_string(),
|
||||||
|
"".to_string(),
|
||||||
|
" ".to_string(),
|
||||||
|
"folder-1".to_string(),
|
||||||
|
"folder-2".to_string(),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
.sanitised();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
settings.excluded_item_ids,
|
||||||
|
vec!["folder-1".to_string(), "folder-2".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ pub mod conversions;
|
|||||||
pub mod device;
|
pub mod device;
|
||||||
pub mod download;
|
pub mod download;
|
||||||
pub mod favorites;
|
pub mod favorites;
|
||||||
|
pub mod library;
|
||||||
pub mod offline;
|
pub mod offline;
|
||||||
pub mod playback_mode;
|
pub mod playback_mode;
|
||||||
pub mod playback_reporting;
|
pub mod playback_reporting;
|
||||||
@@ -25,6 +26,7 @@ pub use connectivity::*;
|
|||||||
pub use conversions::*;
|
pub use conversions::*;
|
||||||
pub use device::*;
|
pub use device::*;
|
||||||
pub use download::*;
|
pub use download::*;
|
||||||
|
pub use library::*;
|
||||||
pub use offline::*;
|
pub use offline::*;
|
||||||
pub use playback_mode::*;
|
pub use playback_mode::*;
|
||||||
#[allow(unused_imports)] // Used when playback_reporting is fully integrated
|
#[allow(unused_imports)] // Used when playback_reporting is fully integrated
|
||||||
|
|||||||
@@ -360,7 +360,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_playback_reporter_wrapper_structure() {
|
fn test_playback_reporter_wrapper_structure() {
|
||||||
// Verify wrapper type can hold Arc<TokioMutex<Option<T>>>
|
// 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]
|
#[test]
|
||||||
|
|||||||
@@ -454,6 +454,49 @@ pub(super) fn background_audio_source(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How a background-audio handoff must start playback, given where its audio
|
||||||
|
/// actually begins.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-040, UR-071 | DR-180 | UT-181
|
||||||
|
pub(super) struct BackgroundAudioPlan {
|
||||||
|
/// The position the stream's own zero corresponds to, recorded as the
|
||||||
|
/// handoff base so later readings can be shifted back to the episode's
|
||||||
|
/// timeline.
|
||||||
|
pub base_seconds: f64,
|
||||||
|
/// Where to seek after loading, if the source does not already start there.
|
||||||
|
pub seek_to: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide the base and the seek for a handoff at `position_seconds`.
|
||||||
|
///
|
||||||
|
/// The two sources start in different places. An audio-only **stream** is built
|
||||||
|
/// with `StartTimeTicks`, so the server makes the handoff point that stream's
|
||||||
|
/// zero: the base is the handoff position, and seeking would skip *past* the
|
||||||
|
/// content by that much again. A downloaded **file** has no such parameter and
|
||||||
|
/// begins at the episode's own zero, so it needs the opposite — no base, and a
|
||||||
|
/// real seek. Treating a file like a stream is why backgrounding a downloaded
|
||||||
|
/// episode restarted it from 0:00 while the lockscreen showed the right time.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-040, UR-071 | DR-180 | UT-181
|
||||||
|
pub(super) fn background_audio_plan(
|
||||||
|
is_local_file: bool,
|
||||||
|
position_seconds: f64,
|
||||||
|
) -> BackgroundAudioPlan {
|
||||||
|
let position = position_seconds.max(0.0);
|
||||||
|
|
||||||
|
if is_local_file {
|
||||||
|
BackgroundAudioPlan {
|
||||||
|
base_seconds: 0.0,
|
||||||
|
seek_to: (position > 0.0).then_some(position),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
BackgroundAudioPlan {
|
||||||
|
base_seconds: position,
|
||||||
|
seek_to: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve the on-disk file backing a completed download, if there is one.
|
/// Resolve the on-disk file backing a completed download, if there is one.
|
||||||
///
|
///
|
||||||
/// A `downloads` row is not proof of a file: it can outlive the bytes (manual
|
/// A `downloads` row is not proof of a file: it can outlive the bytes (manual
|
||||||
@@ -710,6 +753,9 @@ pub async fn player_enter_background_audio(
|
|||||||
item.id
|
item.id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// A downloaded file starts at the episode's zero; a stream starts at the
|
||||||
|
// handoff point. Only one of them has a base, and only the other needs a seek.
|
||||||
|
let plan = background_audio_plan(local_path.is_some(), position_seconds);
|
||||||
let source = background_audio_source(local_path, item.stream_url, &item.id);
|
let source = background_audio_source(local_path, item.stream_url, &item.id);
|
||||||
|
|
||||||
// Build an AUDIO media item pointing at the audio-only stream. We do not use
|
// Build an AUDIO media item pointing at the audio-only stream. We do not use
|
||||||
@@ -753,21 +799,28 @@ pub async fn player_enter_background_audio(
|
|||||||
// Same base offset drives the lockscreen scrubber: ExoPlayer reports position
|
// Same base offset drives the lockscreen scrubber: ExoPlayer reports position
|
||||||
// relative to the stream's StartTimeTicks zero, but the metadata duration is
|
// relative to the stream's StartTimeTicks zero, but the metadata duration is
|
||||||
// absolute, so shift the reported position back to absolute for the scrubber.
|
// absolute, so shift the reported position back to absolute for the scrubber.
|
||||||
let _ = crate::player::set_lockscreen_position_offset(position_seconds.max(0.0));
|
let _ = crate::player::set_lockscreen_position_offset(plan.base_seconds);
|
||||||
|
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
// Remember where the video was: the audio stream's zero == this position
|
// Remember where the video was: for a stream the audio's zero == this
|
||||||
// (the URL was built with StartTimeTicks=position_seconds), so on exit we add
|
// position (the URL was built with StartTimeTicks=position_seconds), so on
|
||||||
// this base to the native player's relative position to get the absolute one.
|
// exit we add this base to the native player's relative position to get the
|
||||||
// The controller owns it so a backend-driven advance to the next episode
|
// absolute one. The controller owns it so a backend-driven advance to the
|
||||||
// clears it along with the stream it described.
|
// next episode clears it along with the stream it described.
|
||||||
controller.enter_background_audio(position_seconds);
|
controller.enter_background_audio(plan.base_seconds);
|
||||||
controller
|
controller
|
||||||
.play_item(media_item)
|
.play_item(media_item)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
// NOTE: do NOT seek here. The audio-only URL already starts at the handoff
|
// Seek ONLY a local file. The audio-only URL already starts at the handoff
|
||||||
// position via StartTimeTicks; the stream's timeline begins at 0 == that
|
// position via StartTimeTicks — its timeline begins at 0 == that point — so
|
||||||
// point, so an extra seek(position_seconds) would jump PAST the content.
|
// seeking a stream would jump PAST the content by the handoff position again.
|
||||||
|
if let Some(seek_to) = plan.seek_to {
|
||||||
|
info!(
|
||||||
|
"player_enter_background_audio: seeking the downloaded file to {:.1}s",
|
||||||
|
seek_to
|
||||||
|
);
|
||||||
|
controller.seek(seek_to).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
controller.emit_queue_changed();
|
controller.emit_queue_changed();
|
||||||
if let Some(emitter) = controller.event_emitter() {
|
if let Some(emitter) = controller.event_emitter() {
|
||||||
@@ -801,7 +854,14 @@ pub async fn player_exit_background_audio(
|
|||||||
// moment it matters most. Capturing into a `let` before stop() is also the
|
// moment it matters most. Capturing into a `let` before stop() is also the
|
||||||
// lock discipline from CLAUDE.md: never hold work across a re-entrant call.
|
// lock discipline from CLAUDE.md: never hold work across a re-entrant call.
|
||||||
// (DR-159)
|
// (DR-159)
|
||||||
let absolute = controller.position();
|
//
|
||||||
|
// `absolute_position` rather than `position`, because a tick that has not
|
||||||
|
// landed *yet* is the same hazard from the other side: returning to the
|
||||||
|
// foreground while the audio-only transcode is still opening read 0.0, and
|
||||||
|
// the video reloaded at StartTimeTicks=0 — the episode restarting from the
|
||||||
|
// beginning. Flooring at the handoff base cannot overshoot: the stream is
|
||||||
|
// physically incapable of being behind its own starting point. (DR-178)
|
||||||
|
let absolute = controller.absolute_position();
|
||||||
|
|
||||||
// Now safe to tear the handoff down, native side first.
|
// Now safe to tear the handoff down, native side first.
|
||||||
let _ = crate::player::set_lockscreen_position_offset(0.0);
|
let _ = crate::player::set_lockscreen_position_offset(0.0);
|
||||||
@@ -1332,7 +1392,6 @@ pub async fn player_seek_video(
|
|||||||
.get_video_stream_url(
|
.get_video_stream_url(
|
||||||
&jellyfin_item_id,
|
&jellyfin_item_id,
|
||||||
media_source_id.as_deref(),
|
media_source_id.as_deref(),
|
||||||
Some(position),
|
|
||||||
audio_stream_index,
|
audio_stream_index,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -1343,6 +1402,13 @@ pub async fn player_seek_video(
|
|||||||
position
|
position
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// `seek_offset` carries the position to RESUME AT, not a base to add
|
||||||
|
// to the element's clock. The reloaded stream starts at the item's
|
||||||
|
// zero — a position on an HLS playlist makes the server 400 every
|
||||||
|
// segment behind it (DR-181) — so the adapter reaches the position by
|
||||||
|
// seeking the element and leaves the transcode offset at zero. The
|
||||||
|
// field keeps its name only because renaming it means regenerating
|
||||||
|
// the specta bindings; `reloadSource` documents the contract.
|
||||||
Ok(VideoSeekResponse::ReloadStream {
|
Ok(VideoSeekResponse::ReloadStream {
|
||||||
new_url,
|
new_url,
|
||||||
seek_offset: position,
|
seek_offset: position,
|
||||||
@@ -1356,7 +1422,6 @@ pub async fn player_seek_video(
|
|||||||
.get_video_stream_url(
|
.get_video_stream_url(
|
||||||
&jellyfin_item_id,
|
&jellyfin_item_id,
|
||||||
media_source_id.as_deref(),
|
media_source_id.as_deref(),
|
||||||
Some(position),
|
|
||||||
audio_stream_index,
|
audio_stream_index,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -1394,6 +1459,11 @@ pub async fn player_seek_video(
|
|||||||
} else {
|
} else {
|
||||||
return Err("No current item after URL update".to_string());
|
return Err("No current item after URL update".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The re-opened stream begins at zero — the position cannot ride
|
||||||
|
// along in the URL without 400ing every segment (DR-181) — so the
|
||||||
|
// seek that the reload was asked for happens here.
|
||||||
|
controller.seek(position).map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
@@ -1408,8 +1478,22 @@ pub async fn player_seek_video(
|
|||||||
|
|
||||||
/// Switch audio track - handles both HTML5 (stream reload) and native (direct switch)
|
/// Switch audio track - handles both HTML5 (stream reload) and native (direct switch)
|
||||||
/// Note: Frontend should handle saving series preferences after this command succeeds
|
/// 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]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[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(
|
pub async fn player_switch_audio_track(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
||||||
@@ -1444,12 +1528,13 @@ pub async fn player_switch_audio_track(
|
|||||||
.to_string()
|
.to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get new stream URL with selected audio track
|
// Get new stream URL with selected audio track. It starts at zero — an
|
||||||
|
// HLS playlist cannot carry a position (DR-181) — and `position` below
|
||||||
|
// tells the frontend where to seek the reloaded element back to.
|
||||||
let new_url = repository
|
let new_url = repository
|
||||||
.get_video_stream_url(
|
.get_video_stream_url(
|
||||||
&jellyfin_item_id,
|
&jellyfin_item_id,
|
||||||
media_source_id.as_deref(),
|
media_source_id.as_deref(),
|
||||||
current_position,
|
|
||||||
Some(stream_index),
|
Some(stream_index),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -1488,6 +1573,10 @@ pub async fn player_switch_audio_track(
|
|||||||
/// TRACES: UR-074 | DR-162
|
/// TRACES: UR-074 | DR-162
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[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(
|
pub async fn player_set_stream_quality(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
||||||
@@ -1540,7 +1629,6 @@ pub async fn player_set_stream_quality(
|
|||||||
.get_video_stream_url(
|
.get_video_stream_url(
|
||||||
&jellyfin_item_id,
|
&jellyfin_item_id,
|
||||||
media_source_id.as_deref(),
|
media_source_id.as_deref(),
|
||||||
current_position,
|
|
||||||
audio_stream_index,
|
audio_stream_index,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -1552,8 +1640,9 @@ pub async fn player_set_stream_quality(
|
|||||||
|
|
||||||
// Native backend (Android/ExoPlayer): stop, repoint the queue entry at the
|
// Native backend (Android/ExoPlayer): stop, repoint the queue entry at the
|
||||||
// new URL, and reload — mirroring `VideoSeekStrategy::BackendReloadStream`.
|
// new URL, and reload — mirroring `VideoSeekStrategy::BackendReloadStream`.
|
||||||
// The URL already carries `StartTimeTicks`, so the reloaded stream begins at
|
// The re-opened stream begins at zero (an HLS playlist cannot carry a start
|
||||||
// the current position rather than at zero.
|
// position without 400ing every segment — DR-181), so it is seeked back to
|
||||||
|
// where the picture was.
|
||||||
{
|
{
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
controller.stop().map_err(|e| e.to_string())?;
|
controller.stop().map_err(|e| e.to_string())?;
|
||||||
@@ -1571,11 +1660,17 @@ pub async fn player_set_stream_quality(
|
|||||||
controller
|
controller
|
||||||
.load_and_play(updated_item)
|
.load_and_play(updated_item)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
if position > 0.0 {
|
||||||
|
controller.seek(position).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(StreamQualityResponse::Native { position })
|
Ok(StreamQualityResponse::Native { position })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the active audio track on a native backend directly.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-021 | IR-019, DR-024
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn player_set_audio_track(
|
pub async fn player_set_audio_track(
|
||||||
@@ -1589,6 +1684,14 @@ pub async fn player_set_audio_track(
|
|||||||
Ok(get_player_status(&controller))
|
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]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn player_set_subtitle_track(
|
pub async fn player_set_subtitle_track(
|
||||||
@@ -1602,6 +1705,23 @@ pub async fn player_set_subtitle_track(
|
|||||||
Ok(get_player_status(&controller))
|
Ok(get_player_status(&controller))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Normalise a volume arriving over IPC to the 0.0..=1.0 range every backend
|
||||||
|
/// works in.
|
||||||
|
///
|
||||||
|
/// NaN is handled before the clamp rather than by it: `f32::clamp` returns NaN
|
||||||
|
/// for a NaN input (it only panics on NaN *bounds*), and NaN then survives every
|
||||||
|
/// comparison downstream, so a backend clamp cannot catch it either. It is
|
||||||
|
/// treated as "no volume asked for" and floored to 0.0.
|
||||||
|
///
|
||||||
|
/// TRACES: DR-212 | UT-206
|
||||||
|
fn normalize_volume(volume: f32) -> f32 {
|
||||||
|
if volume.is_nan() {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
volume.clamp(0.0, 1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn player_set_volume(
|
pub async fn player_set_volume(
|
||||||
@@ -1609,6 +1729,12 @@ pub async fn player_set_volume(
|
|||||||
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
|
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
|
||||||
volume: f32,
|
volume: f32,
|
||||||
) -> Result<PlayerStatus, String> {
|
) -> Result<PlayerStatus, String> {
|
||||||
|
// Clamp at the boundary as well as in each backend: the remote branch below
|
||||||
|
// never reaches a backend clamp, and `(f32::INFINITY * 100.0) as i32` would
|
||||||
|
// hand the server i32::MAX as a volume percentage.
|
||||||
|
// TRACES: DR-212 | UT-206
|
||||||
|
let volume = normalize_volume(volume);
|
||||||
|
|
||||||
// Check if we're in remote mode
|
// Check if we're in remote mode
|
||||||
let mode = playback_mode.0.get_mode();
|
let mode = playback_mode.0.get_mode();
|
||||||
|
|
||||||
@@ -1710,7 +1836,7 @@ pub async fn player_get_status(
|
|||||||
let local_media = {
|
let local_media = {
|
||||||
let queue_arc = controller.queue();
|
let queue_arc = controller.queue();
|
||||||
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
|
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();
|
let local_is_playing = status.state.is_playing();
|
||||||
@@ -1734,10 +1860,7 @@ pub async fn player_get_status(
|
|||||||
log::info!("[PlayerCommands] Merging remote session state");
|
log::info!("[PlayerCommands] Merging remote session state");
|
||||||
|
|
||||||
// Merge media item
|
// Merge media item
|
||||||
status.merged_media = session
|
status.merged_media = session.now_playing_item.as_ref().map(MergedMediaItem::from);
|
||||||
.now_playing_item
|
|
||||||
.as_ref()
|
|
||||||
.map(|item| MergedMediaItem::from(item));
|
|
||||||
|
|
||||||
// Merge isPlaying (NOT isPaused!)
|
// Merge isPlaying (NOT isPaused!)
|
||||||
status.merged_is_playing = session
|
status.merged_is_playing = session
|
||||||
@@ -1846,7 +1969,11 @@ pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
|
|||||||
|
|
||||||
PlayerStatus {
|
PlayerStatus {
|
||||||
state: controller.state(),
|
state: controller.state(),
|
||||||
position: controller.position(),
|
// The position on the item's timeline, whichever of the three paths is
|
||||||
|
// rendering it — the native backend answers for only one of them, and
|
||||||
|
// reads 0 for webview video and for a handoff that has not ticked yet.
|
||||||
|
// TRACES: UR-005 | DR-178
|
||||||
|
position: controller.absolute_position(),
|
||||||
duration: controller.duration(),
|
duration: controller.duration(),
|
||||||
volume: controller.volume(),
|
volume: controller.volume(),
|
||||||
muted: controller.muted(),
|
muted: controller.muted(),
|
||||||
@@ -2663,6 +2790,46 @@ pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> R
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use crate::utils::lock::MutexSafe;
|
||||||
|
|
||||||
|
/// UT-206 — the volume the command hands on is always a real number in
|
||||||
|
/// 0.0..=1.0.
|
||||||
|
///
|
||||||
|
/// Every backend clamps for itself, but the remote branch of
|
||||||
|
/// `player_set_volume` reaches no backend at all: it does
|
||||||
|
/// `(volume * 100.0) as i32`, which turns infinity into `i32::MAX` and NaN
|
||||||
|
/// into 0. NaN also survives `f32::clamp` unchanged, so clamping alone is
|
||||||
|
/// not enough — it has to be tested for.
|
||||||
|
///
|
||||||
|
/// TRACES: DR-212 | UT-206
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_volume_clamps_and_rejects_nan() {
|
||||||
|
use super::normalize_volume;
|
||||||
|
|
||||||
|
// In-range values pass through untouched.
|
||||||
|
assert_eq!(normalize_volume(0.0), 0.0);
|
||||||
|
assert_eq!(normalize_volume(0.5), 0.5);
|
||||||
|
assert_eq!(normalize_volume(1.0), 1.0);
|
||||||
|
|
||||||
|
// Out of range clamps to the same 0.0..=1.0 the backends use.
|
||||||
|
assert_eq!(normalize_volume(-0.5), 0.0);
|
||||||
|
assert_eq!(normalize_volume(42.0), 1.0);
|
||||||
|
assert_eq!(normalize_volume(f32::INFINITY), 1.0);
|
||||||
|
assert_eq!(normalize_volume(f32::NEG_INFINITY), 0.0);
|
||||||
|
|
||||||
|
// NaN is not a volume; it must not reach the Jellyfin percentage
|
||||||
|
// conversion or a backend.
|
||||||
|
let from_nan = normalize_volume(f32::NAN);
|
||||||
|
assert!(!from_nan.is_nan(), "NaN must not pass through the boundary");
|
||||||
|
assert_eq!(from_nan, 0.0);
|
||||||
|
|
||||||
|
// Whatever comes out survives the remote branch's percentage cast.
|
||||||
|
for input in [-1.0, 0.25, 9.0, f32::INFINITY, f32::NAN] {
|
||||||
|
let percent = (normalize_volume(input) * 100.0) as i32;
|
||||||
|
assert!((0..=100).contains(&percent), "input {input} gave {percent}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The subtitle list the frontend resolved must survive the IPC hop and end
|
/// The subtitle list the frontend resolved must survive the IPC hop and end
|
||||||
/// up on the `MediaItem` the native backend loads.
|
/// up on the `MediaItem` the native backend loads.
|
||||||
///
|
///
|
||||||
@@ -2838,6 +3005,49 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The two sources start in different places, so the handoff cannot treat
|
||||||
|
/// them alike.
|
||||||
|
///
|
||||||
|
/// An audio-only *stream* is built with `StartTimeTicks`, so the server makes
|
||||||
|
/// the handoff point that stream's zero: the base is the handoff position and
|
||||||
|
/// seeking would jump past the content. A *downloaded file* has no such
|
||||||
|
/// parameter — it starts at the episode's own zero — so basing it at the
|
||||||
|
/// handoff position claims 18 minutes of audio that is about to play from the
|
||||||
|
/// beginning. That is the downloaded-episode version of "it restarts when the
|
||||||
|
/// screen sleeps", and it needs the opposite treatment: no base, and a seek.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-040, UR-071 | DR-180 | UT-181
|
||||||
|
#[test]
|
||||||
|
fn test_background_audio_plan_seeks_a_file_and_bases_a_stream() {
|
||||||
|
use super::background_audio_plan;
|
||||||
|
|
||||||
|
let local = background_audio_plan(true, 1104.0);
|
||||||
|
assert_eq!(local.base_seconds, 0.0);
|
||||||
|
assert_eq!(local.seek_to, Some(1104.0));
|
||||||
|
|
||||||
|
let streamed = background_audio_plan(false, 1104.0);
|
||||||
|
assert_eq!(streamed.base_seconds, 1104.0);
|
||||||
|
assert_eq!(
|
||||||
|
streamed.seek_to, None,
|
||||||
|
"the URL already starts at the handoff point; seeking again skips past it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handing off at the very start has nothing to seek to and nothing to base:
|
||||||
|
/// both sources are already where they need to be.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-040, UR-071 | DR-180 | UT-181
|
||||||
|
#[test]
|
||||||
|
fn test_background_audio_plan_at_the_start_neither_seeks_nor_bases() {
|
||||||
|
use super::background_audio_plan;
|
||||||
|
|
||||||
|
for local in [true, false] {
|
||||||
|
let plan = background_audio_plan(local, 0.0);
|
||||||
|
assert_eq!(plan.base_seconds, 0.0);
|
||||||
|
assert_eq!(plan.seek_to, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A downloaded item must resolve to its file, and a `downloads` row whose
|
/// A downloaded item must resolve to its file, and a `downloads` row whose
|
||||||
/// file has gone must resolve to `None` so the caller falls back to
|
/// file has gone must resolve to `None` so the caller falls back to
|
||||||
/// streaming instead of handing the player a path that cannot be opened.
|
/// streaming instead of handing the player a path that cannot be opened.
|
||||||
@@ -2942,7 +3152,7 @@ mod tests {
|
|||||||
let database = Database::open_in_memory().unwrap();
|
let database = Database::open_in_memory().unwrap();
|
||||||
{
|
{
|
||||||
let conn = database.connection();
|
let conn = database.connection();
|
||||||
let conn = conn.lock().unwrap();
|
let conn = conn.lock_safe();
|
||||||
conn.execute_batch(&format!(
|
conn.execute_batch(&format!(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO servers (id, name, url) VALUES ('srv', 'Test', 'http://test');
|
INSERT INTO servers (id, name, url) VALUES ('srv', 'Test', 'http://test');
|
||||||
@@ -2998,7 +3208,7 @@ mod tests {
|
|||||||
assert_eq!(switched, 1, "only the download whose file exists switches");
|
assert_eq!(switched, 1, "only the download whose file exists switches");
|
||||||
|
|
||||||
let queue = controller.queue();
|
let queue = controller.queue();
|
||||||
let queue_lock = queue.lock().unwrap();
|
let queue_lock = queue.lock_safe();
|
||||||
match &queue_lock.items()[0].source {
|
match &queue_lock.items()[0].source {
|
||||||
MediaSource::Local {
|
MediaSource::Local {
|
||||||
file_path,
|
file_path,
|
||||||
@@ -3027,7 +3237,7 @@ mod tests {
|
|||||||
index_number: Option<i32>,
|
index_number: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut tracks = vec![
|
let mut tracks = [
|
||||||
MockTrack {
|
MockTrack {
|
||||||
id: "track1".to_string(),
|
id: "track1".to_string(),
|
||||||
name: "Song 1".to_string(),
|
name: "Song 1".to_string(),
|
||||||
@@ -3120,7 +3330,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create tracks in random order (not sorted)
|
// Create tracks in random order (not sorted)
|
||||||
let mut tracks = vec![
|
let mut tracks = [
|
||||||
MockTrack {
|
MockTrack {
|
||||||
id: "id5".to_string(),
|
id: "id5".to_string(),
|
||||||
name: "Track 5".to_string(),
|
name: "Track 5".to_string(),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Tauri commands for repository access
|
//! Tauri commands for repository access
|
||||||
//! Uses handle-based system: UUID -> Arc<HybridRepository>
|
//! 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 crate::utils::lock::MutexSafe;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -67,6 +67,10 @@ pub struct RepositoryManagerWrapper(pub RepositoryManager);
|
|||||||
/// Returns a handle (UUID) for accessing the repository
|
/// Returns a handle (UUID) for accessing the repository
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[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(
|
pub async fn repository_create(
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
player: State<'_, crate::commands::player::PlayerStateWrapper>,
|
player: State<'_, crate::commands::player::PlayerStateWrapper>,
|
||||||
@@ -294,7 +298,13 @@ pub async fn repository_get_latest_items(
|
|||||||
.map_err(|e| format!("{:?}", e))
|
.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]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn repository_get_resume_items(
|
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]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn repository_get_next_up_episodes(
|
pub async fn repository_get_next_up_episodes(
|
||||||
@@ -571,7 +583,13 @@ pub async fn repository_get_playback_info(
|
|||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get video stream URL with optional seeking support
|
/// Get a video stream URL.
|
||||||
|
///
|
||||||
|
/// There is no start-position parameter on purpose: the URL is an HLS playlist
|
||||||
|
/// covering the whole item, and a position on it makes the server reject every
|
||||||
|
/// segment with `400` (DR-181). Callers resume by seeking after load.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-004 | DR-181 | UT-182
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn repository_get_video_stream_url(
|
pub async fn repository_get_video_stream_url(
|
||||||
@@ -579,17 +597,11 @@ pub async fn repository_get_video_stream_url(
|
|||||||
handle: String,
|
handle: String,
|
||||||
item_id: String,
|
item_id: String,
|
||||||
media_source_id: Option<String>,
|
media_source_id: Option<String>,
|
||||||
start_time_seconds: Option<f64>,
|
|
||||||
audio_stream_index: Option<i32>,
|
audio_stream_index: Option<i32>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
repo.as_ref()
|
repo.as_ref()
|
||||||
.get_video_stream_url(
|
.get_video_stream_url(&item_id, media_source_id.as_deref(), audio_stream_index)
|
||||||
&item_id,
|
|
||||||
media_source_id.as_deref(),
|
|
||||||
start_time_seconds,
|
|
||||||
audio_stream_index,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
@@ -1091,7 +1103,6 @@ mod tests {
|
|||||||
let handle = format!("{}", uuid);
|
let handle = format!("{}", uuid);
|
||||||
// UUID should convert to a non-empty string
|
// UUID should convert to a non-empty string
|
||||||
assert!(!handle.is_empty());
|
assert!(!handle.is_empty());
|
||||||
assert!(handle.len() > 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_session_poller_wrapper_structure() {
|
fn test_session_poller_wrapper_structure() {
|
||||||
// Test that wrapper type structure is correct
|
// Test that wrapper type structure is correct
|
||||||
assert_eq!(std::mem::size_of::<SessionPollerWrapper>() > 0, true);
|
assert!(std::mem::size_of::<SessionPollerWrapper>() > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1658,19 +1658,19 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_database_wrapper_structure() {
|
fn test_database_wrapper_structure() {
|
||||||
// Verify DatabaseWrapper can be created and holds Mutex<Database>
|
// 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]
|
#[test]
|
||||||
fn test_credential_store_wrapper_structure() {
|
fn test_credential_store_wrapper_structure() {
|
||||||
// Verify CredentialStoreWrapper can be created
|
// Verify CredentialStoreWrapper can be created
|
||||||
assert_eq!(std::mem::size_of::<CredentialStoreWrapper>() > 0, true);
|
assert!(std::mem::size_of::<CredentialStoreWrapper>() > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_thumbnail_cache_wrapper_structure() {
|
fn test_thumbnail_cache_wrapper_structure() {
|
||||||
// Verify ThumbnailCacheWrapper holds Arc<ThumbnailCache>
|
// Verify ThumbnailCacheWrapper holds Arc<ThumbnailCache>
|
||||||
assert_eq!(std::mem::size_of::<ThumbnailCacheWrapper>() > 0, true);
|
assert!(std::mem::size_of::<ThumbnailCacheWrapper>() > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -481,6 +481,7 @@ pub async fn sync_process_pending(app: tauri::AppHandle) -> Result<DrainReport,
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::utils::lock::MutexSafe;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
@@ -517,7 +518,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn calls(&self) -> Vec<QueuedOp> {
|
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 {
|
if let Some(err) = &self.fail_with {
|
||||||
return Err(err.clone());
|
return Err(err.clone());
|
||||||
}
|
}
|
||||||
self.calls.lock().unwrap().push(op.clone());
|
self.calls.lock_safe().push(op.clone());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+107
-10
@@ -20,9 +20,6 @@ use sha2::{Digest, Sha256};
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
use hostname;
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
const SERVICE_NAME: &str = "com.dtourolle.jellytau";
|
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
|
// secret-tool doesn't support --version, so we test with a search command
|
||||||
// that will succeed even if no items are found
|
// that will succeed even if no items are found
|
||||||
match Command::new("secret-tool")
|
Command::new("secret-tool")
|
||||||
.arg("search")
|
.arg("search")
|
||||||
.arg("service")
|
.arg("service")
|
||||||
.arg("__nonexistent_test__")
|
.arg("__nonexistent_test__")
|
||||||
.output()
|
.output()
|
||||||
{
|
.is_ok()
|
||||||
Ok(_) => true, // If command runs (even with no results), secret-tool is available
|
|
||||||
Err(_) => false, // Command not found or can't execute
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(not(target_os = "android"), not(target_os = "linux")))]
|
#[cfg(all(not(target_os = "android"), not(target_os = "linux")))]
|
||||||
@@ -471,6 +465,19 @@ impl CredentialStore {
|
|||||||
hasher.finalize().into()
|
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> {
|
fn load_credentials_file(&self) -> Result<serde_json::Value, CredentialError> {
|
||||||
if !self.credentials_path.exists() {
|
if !self.credentials_path.exists() {
|
||||||
return Ok(serde_json::json!({}));
|
return Ok(serde_json::json!({}));
|
||||||
@@ -483,8 +490,31 @@ impl CredentialStore {
|
|||||||
return Ok(serde_json::json!({}));
|
return Ok(serde_json::json!({}));
|
||||||
}
|
}
|
||||||
|
|
||||||
let decrypted = self.decrypt(&encrypted_data)?;
|
let decrypted = match self.decrypt(&encrypted_data) {
|
||||||
serde_json::from_str(&decrypted).map_err(|e| CredentialError::Encryption(e.to_string()))
|
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> {
|
fn save_credentials_file(&self, data: &serde_json::Value) -> Result<(), CredentialError> {
|
||||||
@@ -856,6 +886,73 @@ pub use android_keystore::{
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn test_encryption_roundtrip() {
|
fn test_encryption_roundtrip() {
|
||||||
let store = CredentialStore::new();
|
let store = CredentialStore::new();
|
||||||
|
|||||||
@@ -119,11 +119,12 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn item(name: &str, kind: MediaKind) -> MediaItem {
|
fn item(name: &str, kind: MediaKind) -> MediaItem {
|
||||||
let mut item = MediaItem::default();
|
MediaItem {
|
||||||
item.id = format!("id-{}-{:?}", name, kind);
|
id: format!("id-{}-{:?}", name, kind),
|
||||||
item.name = name.to_string();
|
name: name.to_string(),
|
||||||
item.kind = kind;
|
kind,
|
||||||
item
|
..MediaItem::default()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn names(items: &[MediaItem]) -> Vec<&str> {
|
fn names(items: &[MediaItem]) -> Vec<&str> {
|
||||||
|
|||||||
@@ -389,14 +389,18 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_queue_precache_config() {
|
fn test_queue_precache_config() {
|
||||||
let mut config = CacheConfig::default();
|
let config = CacheConfig {
|
||||||
config.queue_precache_enabled = false;
|
queue_precache_enabled: false,
|
||||||
|
..CacheConfig::default()
|
||||||
|
};
|
||||||
|
|
||||||
let cache = SmartCache::new(config);
|
let cache = SmartCache::new(config);
|
||||||
assert!(!cache.should_precache_queue());
|
assert!(!cache.should_precache_queue());
|
||||||
|
|
||||||
let mut new_config = CacheConfig::default();
|
let new_config = CacheConfig {
|
||||||
new_config.wifi_only = false;
|
wifi_only: false,
|
||||||
|
..CacheConfig::default()
|
||||||
|
};
|
||||||
cache.update_config(new_config);
|
cache.update_config(new_config);
|
||||||
|
|
||||||
assert!(cache.should_precache_queue());
|
assert!(cache.should_precache_queue());
|
||||||
@@ -407,9 +411,11 @@ mod tests {
|
|||||||
// wifi_only must not short-circuit precaching: the network gate lives in
|
// wifi_only must not short-circuit precaching: the network gate lives in
|
||||||
// the download pump, which checks the *actual* transport. Enabling
|
// the download pump, which checks the *actual* transport. Enabling
|
||||||
// WiFi-only while on WiFi should still precache.
|
// WiFi-only while on WiFi should still precache.
|
||||||
let mut config = CacheConfig::default();
|
let config = CacheConfig {
|
||||||
config.queue_precache_enabled = true;
|
queue_precache_enabled: true,
|
||||||
config.wifi_only = true;
|
wifi_only: true,
|
||||||
|
..CacheConfig::default()
|
||||||
|
};
|
||||||
|
|
||||||
let cache = SmartCache::new(config);
|
let cache = SmartCache::new(config);
|
||||||
assert!(cache.should_precache_queue());
|
assert!(cache.should_precache_queue());
|
||||||
@@ -422,7 +428,7 @@ mod tests {
|
|||||||
/// TRACES: UR-071 | DR-127 | UT-120
|
/// TRACES: UR-071 | DR-127 | UT-120
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_reclaim_expired_only_takes_expired_temporary_entries() {
|
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 rusqlite::Connection;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
|||||||
+84
-12
@@ -79,6 +79,10 @@ use commands::{
|
|||||||
get_smart_cache_stats,
|
get_smart_cache_stats,
|
||||||
image_get_url,
|
image_get_url,
|
||||||
is_item_pinned,
|
is_item_pinned,
|
||||||
|
// Library browsing preferences (hidden folders)
|
||||||
|
library_get_exclusion_candidates,
|
||||||
|
library_get_settings,
|
||||||
|
library_set_settings,
|
||||||
lms_create_sync_group,
|
lms_create_sync_group,
|
||||||
lms_dissolve_sync_group,
|
lms_dissolve_sync_group,
|
||||||
// LMS multi-room sync group commands
|
// LMS multi-room sync group commands
|
||||||
@@ -314,6 +318,10 @@ use download::DownloadManager;
|
|||||||
use jellyfin::{HttpClient, HttpConfig};
|
use jellyfin::{HttpClient, HttpConfig};
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
use playback_mode::PlaybackModeManager;
|
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};
|
use player::{MediaSessionManager, PlayerBackend, PlayerController, TauriEventEmitter};
|
||||||
// NullBackend is used both for platforms without a native backend AND as a graceful
|
// 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
|
// fallback when a native backend (MPV/ExoPlayer) fails to initialize, so the app can
|
||||||
@@ -449,14 +457,55 @@ impl MediaSessionHandler {
|
|||||||
return;
|
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
|
// Use blocking_lock since this is called from a non-async JNI callback
|
||||||
let controller = self.player.blocking_lock();
|
let controller = self.player.blocking_lock();
|
||||||
|
|
||||||
let result = match command {
|
let result = match command {
|
||||||
"play" => controller.play(),
|
"play" => controller.play(),
|
||||||
"pause" => controller.pause(),
|
"pause" => controller.pause(),
|
||||||
"next" => controller.next(),
|
|
||||||
"previous" => controller.previous(),
|
|
||||||
"stop" => controller.stop(),
|
"stop" => controller.stop(),
|
||||||
_ => {
|
_ => {
|
||||||
warn!("[MediaSession] Unknown command: {}", command);
|
warn!("[MediaSession] Unknown command: {}", command);
|
||||||
@@ -620,7 +669,7 @@ fn create_player_backend(
|
|||||||
match MpvBackend::new(Some(_event_emitter), playback_reporter, position_throttler) {
|
match MpvBackend::new(Some(_event_emitter), playback_reporter, position_throttler) {
|
||||||
Ok(backend) => {
|
Ok(backend) => {
|
||||||
info!("Successfully initialized MPV backend for Linux");
|
info!("Successfully initialized MPV backend for Linux");
|
||||||
return Box::new(backend);
|
Box::new(backend)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("\n========================================");
|
error!("\n========================================");
|
||||||
@@ -645,7 +694,7 @@ fn create_player_backend(
|
|||||||
// still browse the library and manage downloads, and the frontend
|
// still browse the library and manage downloads, and the frontend
|
||||||
// can show a "playback unavailable" notice via this event.
|
// can show a "playback unavailable" notice via this event.
|
||||||
emit_backend_init_failed(&app_handle, "mpv", e.to_string());
|
emit_backend_init_failed(&app_handle, "mpv", e.to_string());
|
||||||
return Box::new(NullBackend::new());
|
Box::new(NullBackend::new())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -839,6 +888,10 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
|||||||
sync_full_catalog,
|
sync_full_catalog,
|
||||||
catalog_sync_status,
|
catalog_sync_status,
|
||||||
set_show_server_catalog,
|
set_show_server_catalog,
|
||||||
|
// Library browsing preferences (UR-076 / DR-209)
|
||||||
|
library_get_settings,
|
||||||
|
library_set_settings,
|
||||||
|
library_get_exclusion_candidates,
|
||||||
resume_queued_downloads,
|
resume_queued_downloads,
|
||||||
get_download_manager_stats,
|
get_download_manager_stats,
|
||||||
set_max_concurrent_downloads,
|
set_max_concurrent_downloads,
|
||||||
@@ -1029,16 +1082,23 @@ fn set_env_if_unset(key: &str, value: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Downloaded media and cached thumbnails are handed to the webview as
|
/// Cached thumbnails are handed to the webview as asset-protocol URLs by
|
||||||
/// `http://asset.localhost/…` URLs by `convertFileSrc`. Tauri only answers that
|
/// `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*
|
/// origin when the `protocol-asset` cargo feature is compiled in *and*
|
||||||
/// `app.security.assetProtocol.enable` is set in `tauri.conf.json`, which also
|
/// `app.security.assetProtocol.enable` is set in `tauri.conf.json`. Both are
|
||||||
/// scopes it to `$APPDATA/**` — the storage root holding the database,
|
/// required together: with either missing the URL resolves to nothing and the
|
||||||
/// `downloads/` and the thumbnail cache. Both are required together: with either
|
/// webview reports `NETWORK_NO_SOURCE`, which is how offline video came to fail
|
||||||
/// missing the URL resolves to nothing and the webview reports
|
/// silently.
|
||||||
/// `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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
// Initialize logger
|
// Initialize logger
|
||||||
@@ -1260,6 +1320,18 @@ pub fn run() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore the folders the user hid from browsing, for the same
|
||||||
|
// reason and in the same way. Until it lands nothing is hidden —
|
||||||
|
// the pre-existing behaviour — and no query can have run this early.
|
||||||
|
//
|
||||||
|
// TRACES: UR-076 | DR-209
|
||||||
|
{
|
||||||
|
let handle = app.handle().clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
crate::commands::restore_library_settings(&handle).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize thumbnail cache
|
// Initialize thumbnail cache
|
||||||
info!("[INIT] Initializing thumbnail cache...");
|
info!("[INIT] Initializing thumbnail cache...");
|
||||||
let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
|
let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
|
||||||
|
|||||||
@@ -624,7 +624,7 @@ impl PlaybackModeManager {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Log first few track IDs for debugging
|
// 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();
|
let preview: Vec<&str> = queue_ids.iter().take(3).map(|s| s.as_str()).collect();
|
||||||
debug!("[PlaybackMode] First track IDs: {:?}...", preview);
|
debug!("[PlaybackMode] First track IDs: {:?}...", preview);
|
||||||
}
|
}
|
||||||
@@ -914,7 +914,7 @@ mod tests {
|
|||||||
|
|
||||||
impl PlayerEventEmitter for CapturingEmitter {
|
impl PlayerEventEmitter for CapturingEmitter {
|
||||||
fn emit(&self, event: PlayerStatusEvent) {
|
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::Local);
|
||||||
manager.set_mode(PlaybackMode::Idle);
|
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");
|
assert_eq!(events.len(), 3, "one event per real mode change");
|
||||||
|
|
||||||
match &events[0] {
|
match &events[0] {
|
||||||
@@ -975,10 +975,10 @@ mod tests {
|
|||||||
|
|
||||||
impl RemoteVolumeControl for RecordingVolumeControl {
|
impl RemoteVolumeControl for RecordingVolumeControl {
|
||||||
fn enable(&self, _initial_volume: i32) {
|
fn enable(&self, _initial_volume: i32) {
|
||||||
self.calls.lock().unwrap().push("enable");
|
self.calls.lock_safe().push("enable");
|
||||||
}
|
}
|
||||||
fn disable(&self) {
|
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);
|
manager.set_mode(PlaybackMode::Idle);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
*volume.calls.lock().unwrap(),
|
*volume.calls.lock_safe(),
|
||||||
vec!["enable", "disable"],
|
vec!["enable", "disable"],
|
||||||
"remote->idle must return volume control to the local speaker"
|
"remote->idle must return volume control to the local speaker"
|
||||||
);
|
);
|
||||||
@@ -1033,7 +1033,7 @@ mod tests {
|
|||||||
manager.set_mode(PlaybackMode::Local);
|
manager.set_mode(PlaybackMode::Local);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
*volume.calls.lock().unwrap(),
|
*volume.calls.lock_safe(),
|
||||||
vec!["enable", "disable"],
|
vec!["enable", "disable"],
|
||||||
"remote->local must return volume control to the local speaker"
|
"remote->local must return volume control to the local speaker"
|
||||||
);
|
);
|
||||||
@@ -1053,7 +1053,7 @@ mod tests {
|
|||||||
manager.set_mode(PlaybackMode::Local);
|
manager.set_mode(PlaybackMode::Local);
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
volume.calls.lock().unwrap().is_empty(),
|
volume.calls.lock_safe().is_empty(),
|
||||||
"local/idle transitions must not touch remote volume routing"
|
"local/idle transitions must not touch remote volume routing"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1074,7 +1074,7 @@ mod tests {
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
*volume.calls.lock().unwrap(),
|
*volume.calls.lock_safe(),
|
||||||
vec!["enable", "enable"],
|
vec!["enable", "enable"],
|
||||||
"remote->remote re-arms control without releasing it to local"
|
"remote->remote re-arms control without releasing it to local"
|
||||||
);
|
);
|
||||||
@@ -1091,7 +1091,7 @@ mod tests {
|
|||||||
manager.set_mode(PlaybackMode::Local);
|
manager.set_mode(PlaybackMode::Local);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
emitter.events.lock().unwrap().len(),
|
emitter.events.lock_safe().len(),
|
||||||
1,
|
1,
|
||||||
"repeated identical mode set emits only once"
|
"repeated identical mode set emits only once"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use super::backend::{PlayerBackend, PlayerError};
|
|||||||
use super::events::{PlayerStatusEvent, SharedEventEmitter};
|
use super::events::{PlayerStatusEvent, SharedEventEmitter};
|
||||||
use super::media::{MediaItem, MediaType};
|
use super::media::{MediaItem, MediaType};
|
||||||
use super::state::PlayerState;
|
use super::state::PlayerState;
|
||||||
|
use super::stream_end;
|
||||||
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
|
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
|
||||||
use crate::settings::{audio_settings_jni_payload, AudioSettings};
|
use crate::settings::{audio_settings_jni_payload, AudioSettings};
|
||||||
use crate::utils::conversions::seconds_to_ticks;
|
use crate::utils::conversions::seconds_to_ticks;
|
||||||
@@ -348,6 +349,9 @@ impl PlayerBackend for ExoPlayerBackend {
|
|||||||
let artwork_url = media.artwork_url.clone();
|
let artwork_url = media.artwork_url.clone();
|
||||||
// Convert duration from seconds to milliseconds
|
// Convert duration from seconds to milliseconds
|
||||||
let duration_ms = media.duration.map(|d| (d * 1000.0) as i64).unwrap_or(0);
|
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
|
// Update local state
|
||||||
{
|
{
|
||||||
@@ -420,7 +424,18 @@ impl PlayerBackend for ExoPlayerBackend {
|
|||||||
None => JValue::Object(&null_obj),
|
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 {
|
let media_type_str = match media.media_type {
|
||||||
MediaType::Video => "video",
|
MediaType::Video => "video",
|
||||||
MediaType::Audio => "audio",
|
MediaType::Audio => "audio",
|
||||||
@@ -443,7 +458,7 @@ impl PlayerBackend for ExoPlayerBackend {
|
|||||||
let result = env.call_method(
|
let result = env.call_method(
|
||||||
&self.player_ref,
|
&self.player_ref,
|
||||||
"loadWithMetadata",
|
"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(&url_jstring),
|
||||||
JValue::Object(&media_id_jstring),
|
JValue::Object(&media_id_jstring),
|
||||||
@@ -454,6 +469,7 @@ impl PlayerBackend for ExoPlayerBackend {
|
|||||||
JValue::Long(duration_ms),
|
JValue::Long(duration_ms),
|
||||||
JValue::Object(&media_type_jstring),
|
JValue::Object(&media_type_jstring),
|
||||||
JValue::Object(&subtitles_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
|
/// Commands from lockscreen controls, notification buttons, and Bluetooth
|
||||||
/// devices are routed through here to the Rust PlayerController.
|
/// 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]
|
#[no_mangle]
|
||||||
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlaybackService_nativeOnMediaCommand(
|
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlaybackService_nativeOnMediaCommand(
|
||||||
mut env: JNIEnv,
|
mut env: JNIEnv,
|
||||||
@@ -1396,6 +1421,8 @@ use crate::player::LockscreenMetadata;
|
|||||||
/// running (in remote mode it is started via [`enable_remote_volume`]); if it
|
/// 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
|
/// isn't, this is a no-op rather than an error so it can be called freely on
|
||||||
/// every poll tick.
|
/// every poll tick.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-006 | IR-006
|
||||||
pub fn update_lockscreen_metadata(meta: &LockscreenMetadata) -> Result<(), String> {
|
pub fn update_lockscreen_metadata(meta: &LockscreenMetadata) -> Result<(), String> {
|
||||||
let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?;
|
let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?;
|
||||||
let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?;
|
let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user