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 |
@@ -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'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
# Incremental state is never reused between CI runs -- pure disk cost.
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Tests
|
||||
# A release push triggers build-release.yml on the tag, which runs this exact
|
||||
# test suite itself — and on a single-slot runner the two ~1h workflows would
|
||||
# otherwise serialize/contend. Skip the duplicate for chore(release) commits.
|
||||
# (head_commit is absent on pull_request/workflow_dispatch; startsWith(null,…)
|
||||
# is false there, so those events still run.)
|
||||
if: "!startsWith(github.event.head_commit.message, 'chore(release)')"
|
||||
runs-on: linux/amd64
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||
@@ -27,13 +37,22 @@ jobs:
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v3
|
||||
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: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
|
||||
~/.cargo/registry/index
|
||||
~/.cargo/registry/cache
|
||||
~/.cargo/git/db
|
||||
# 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: |
|
||||
${{ runner.os }}-cargo-host-
|
||||
${{ runner.os }}-cargo-registry-
|
||||
|
||||
- name: Cache Node dependencies
|
||||
uses: actions/cache@v3
|
||||
@@ -56,6 +75,13 @@ jobs:
|
||||
- name: Check frontend/backend boundary
|
||||
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
|
||||
run: |
|
||||
bunx svelte-kit sync
|
||||
@@ -71,21 +97,18 @@ jobs:
|
||||
cd src-tauri
|
||||
cargo fmt --all -- --check
|
||||
|
||||
# ⚠️ Advisory for now — clippy warnings do NOT fail this job yet.
|
||||
# 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.
|
||||
#
|
||||
# The tree carries ~51 pre-existing warnings; adding `-D warnings` today
|
||||
# would paint CI red on unrelated work. A compile *error* still fails the
|
||||
# step, so this is not a no-op: it stops new breakage and surfaces the
|
||||
# backlog in every run.
|
||||
#
|
||||
# TODO: once the existing warnings are cleared, tighten this to
|
||||
# cargo clippy --all-targets -- -D warnings
|
||||
# Flip that flag — do not delete the step. Track progress with
|
||||
# `cd src-tauri && cargo clippy --all-targets 2>&1 | grep -c '^warning'`.
|
||||
- name: Run clippy (advisory)
|
||||
# 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
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
|
||||
- name: Run Rust tests
|
||||
run: |
|
||||
@@ -119,13 +142,22 @@ jobs:
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v3
|
||||
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: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}
|
||||
~/.cargo/registry/index
|
||||
~/.cargo/registry/cache
|
||||
~/.cargo/git/db
|
||||
# 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: |
|
||||
${{ runner.os }}-cargo-android-
|
||||
${{ runner.os }}-cargo-registry-
|
||||
|
||||
- name: Cache Node dependencies
|
||||
uses: actions/cache@v3
|
||||
|
||||
@@ -13,6 +13,8 @@ on:
|
||||
env:
|
||||
RUST_BACKTRACE: 1
|
||||
CARGO_TERM_COLOR: always
|
||||
# Incremental state is never reused between CI runs -- pure disk cost.
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -27,13 +29,22 @@ jobs:
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v3
|
||||
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: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
|
||||
~/.cargo/registry/index
|
||||
~/.cargo/registry/cache
|
||||
~/.cargo/git/db
|
||||
# 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: |
|
||||
${{ runner.os }}-cargo-host-
|
||||
${{ runner.os }}-cargo-registry-
|
||||
|
||||
- name: Cache Node dependencies
|
||||
uses: actions/cache@v3
|
||||
@@ -91,13 +102,22 @@ jobs:
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v3
|
||||
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: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
|
||||
~/.cargo/registry/index
|
||||
~/.cargo/registry/cache
|
||||
~/.cargo/git/db
|
||||
# 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: |
|
||||
${{ runner.os }}-cargo-host-
|
||||
${{ runner.os }}-cargo-registry-
|
||||
|
||||
- name: Cache Node dependencies
|
||||
uses: actions/cache@v3
|
||||
@@ -126,13 +146,32 @@ jobs:
|
||||
- name: Prepare Linux artifacts
|
||||
run: |
|
||||
mkdir -p dist/linux
|
||||
# Copy AppImage
|
||||
if [ -f "src-tauri/target/release/bundle/appimage/jellytau_"*.AppImage ]; then
|
||||
cp src-tauri/target/release/bundle/appimage/jellytau_*.AppImage dist/linux/
|
||||
fi
|
||||
# Copy .deb if built
|
||||
if [ -f "src-tauri/target/release/bundle/deb/jellytau_"*.deb ]; then
|
||||
cp src-tauri/target/release/bundle/deb/jellytau_*.deb dist/linux/
|
||||
# Match by extension, not by product name. Bundle filenames follow
|
||||
# `productName`, so renaming the app (jellytau -> JellyTau) made the
|
||||
# old `jellytau_*.deb` glob match nothing — and because the copy was
|
||||
# wrapped in `if [ -f ... ]`, the artifact simply vanished from the
|
||||
# release with no error. Each bundle directory holds one file.
|
||||
#
|
||||
# `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
|
||||
ls -lah dist/linux/
|
||||
|
||||
@@ -141,7 +180,7 @@ jobs:
|
||||
with:
|
||||
name: jellytau-linux
|
||||
path: dist/linux/
|
||||
retention-days: 30
|
||||
retention-days: 7
|
||||
|
||||
build-windows:
|
||||
name: Build Windows
|
||||
@@ -159,14 +198,31 @@ jobs:
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v3
|
||||
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: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
~/.cache/cargo-xwin
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-cargo-windows-${{ hashFiles('**/Cargo.lock') }}
|
||||
~/.cargo/registry/index
|
||||
~/.cargo/registry/cache
|
||||
~/.cargo/git/db
|
||||
# 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: |
|
||||
${{ 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
|
||||
uses: actions/cache@v3
|
||||
@@ -197,7 +253,7 @@ jobs:
|
||||
with:
|
||||
name: jellytau-windows
|
||||
path: dist/windows/
|
||||
retention-days: 30
|
||||
retention-days: 7
|
||||
|
||||
build-android:
|
||||
name: Build Android
|
||||
@@ -216,13 +272,22 @@ jobs:
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v3
|
||||
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: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}
|
||||
~/.cargo/registry/index
|
||||
~/.cargo/registry/cache
|
||||
~/.cargo/git/db
|
||||
# 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: |
|
||||
${{ runner.os }}-cargo-android-
|
||||
${{ runner.os }}-cargo-registry-
|
||||
|
||||
- name: Cache Node dependencies
|
||||
uses: actions/cache@v3
|
||||
@@ -286,7 +351,7 @@ jobs:
|
||||
with:
|
||||
name: jellytau-android
|
||||
path: dist/android/
|
||||
retention-days: 30
|
||||
retention-days: 7
|
||||
|
||||
create-release:
|
||||
name: Create Release
|
||||
@@ -333,10 +398,11 @@ jobs:
|
||||
echo "" >> release_notes.md
|
||||
echo "#### Linux" >> 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 "#### 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 "#### Android" >> release_notes.md
|
||||
echo "- **APK** - Install via \`adb install jellytau-release.apk\` or sideload via file manager" >> release_notes.md
|
||||
@@ -350,13 +416,13 @@ jobs:
|
||||
echo "" >> release_notes.md
|
||||
echo "#### Linux (AppImage)" >> release_notes.md
|
||||
echo "\`\`\`bash" >> release_notes.md
|
||||
echo "chmod +x jellytau_*.AppImage" >> release_notes.md
|
||||
echo "./jellytau_*.AppImage" >> release_notes.md
|
||||
echo "chmod +x JellyTau_*.AppImage" >> release_notes.md
|
||||
echo "./JellyTau_*.AppImage" >> release_notes.md
|
||||
echo "\`\`\`" >> release_notes.md
|
||||
echo "" >> release_notes.md
|
||||
echo "#### Linux (DEB)" >> 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 "\`\`\`" >> release_notes.md
|
||||
echo "" >> release_notes.md
|
||||
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
#
|
||||
# Keep in sync with MIN_COVERAGE_PERCENT in scripts/extract-traces.ts;
|
||||
# scripts/extract-traces.test.ts fails if the two drift apart.
|
||||
MIN_THRESHOLD=82
|
||||
MIN_THRESHOLD=88
|
||||
if [ "$COVERAGE" -lt "$MIN_THRESHOLD" ]; then
|
||||
echo "❌ ERROR: Coverage ($COVERAGE%) is below minimum threshold ($MIN_THRESHOLD%)"
|
||||
exit 1
|
||||
|
||||
@@ -30,11 +30,6 @@ coverage
|
||||
.nyc_output
|
||||
*.lcov
|
||||
|
||||
# WebdriverIO E2E tests
|
||||
e2e/logs/
|
||||
e2e/screenshots/
|
||||
wdio-*.log
|
||||
|
||||
# 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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
+150
@@ -9,6 +9,156 @@ generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
|
||||
For how long each fixed defect had been shipping before it was found, see
|
||||
[docs/defect-windows.md](docs/defect-windows.md).
|
||||
|
||||
## v0.9.1
|
||||
|
||||
A one-line fix to the home screen, released on its own because it is the kind of
|
||||
small wrongness you notice every time.
|
||||
|
||||
### 🐛 Fixes
|
||||
|
||||
- **Swiping the hero banner now buys you a full six seconds.** The rotation
|
||||
timer was started once when the banner appeared and then left alone, so a
|
||||
swipe, arrow or dot tap inherited whatever was left of the running countdown
|
||||
— swipe five and a half seconds in and the banner moved on half a second
|
||||
later, before you had read the title. Any manual change now restarts the
|
||||
countdown from that moment. (UR-034 → DR-038)
|
||||
|
||||
## v0.9.0
|
||||
|
||||
An audit release. One new setting you asked for, two naming bugs that only ever
|
||||
showed in builds a developer never looks at, and a large amount of tidying that
|
||||
should be invisible in use.
|
||||
|
||||
Note for anyone upgrading a Linux package: the Debian/RPM package is now called
|
||||
`jelly-tau` rather than `jellytau` (the packager derives it from the app name).
|
||||
It declares the rename, so `apt`/`dnf` will replace the old package rather than
|
||||
install a second copy. The command is still `jellytau`.
|
||||
|
||||
### ✨ Changes
|
||||
|
||||
- **You can now hide library folders from music browsing.** Pick the folders to
|
||||
exclude in Settings; they disappear from albums, artists, genres, search and
|
||||
the home rows alike. This replaces a filter that dropped anything *named*
|
||||
"Podcasts" — one person's library layout compiled into the app, which meant an
|
||||
album genuinely called "Podcasts" vanished while a podcast folder named
|
||||
anything else stayed. Exclusion now matches on the folder itself, is decided
|
||||
in one place rather than at the six screens someone remembered to filter, and
|
||||
defaults to excluding nothing. (UR-076 → DR-209)
|
||||
|
||||
- **The app is called JellyTau again.** The Android release build showed
|
||||
`jellytau` under its icon, and the Linux and Windows packages carried the same
|
||||
lowercase name. The debug build has always overridden the label to "JellyTau
|
||||
Debug", so the install a developer looks at every day was the only correctly
|
||||
cased one and nobody saw it. (DR-214)
|
||||
|
||||
- **The RPM package is published.** It has been built by every release since
|
||||
Linux packaging was added, and never copied out of the build — so it existed,
|
||||
cost build time, and reached nobody. (DR-214)
|
||||
|
||||
- **Linux and Windows packages carry their own metadata.** Publisher, copyright,
|
||||
category, description and licence were all absent, so the packages installed
|
||||
with no maintainer and no description. The hand-written Arch package had all
|
||||
of it; only the generated packaging was missing it. (DR-214)
|
||||
|
||||
### 🔒 Hardening
|
||||
|
||||
None of these were reachable in normal use — the app refuses plain-`http`
|
||||
servers, Android blocks cleartext, and the webview runs under a CSP that bars
|
||||
inline script — so they are consistency fixes rather than incidents. Each one
|
||||
had the correct pattern already in the same file, a few lines away.
|
||||
|
||||
- **Thumbnail cache writes stay inside the cache directory.** The filename was
|
||||
built from three values but only one was sanitised, and joining a path does not
|
||||
fold `..` or keep the base when handed an absolute path. (DR-210)
|
||||
|
||||
- **Download paths stay inside the download directory.** A correct sanitiser
|
||||
already existed, but the command that queues a download accepted a raw path,
|
||||
so the guard could be routed around rather than being absent. (DR-211)
|
||||
|
||||
- **Query and URL values are bound and encoded, not pasted in.** The offline
|
||||
item-type filter built SQL by string formatting while its sibling query used
|
||||
placeholders, and browse URLs left values unencoded while the genre parameter
|
||||
next to them was encoded properly. Volume is also range-checked at the command
|
||||
boundary instead of relying on each player backend. (DR-212)
|
||||
|
||||
### 🛠 Development
|
||||
|
||||
Nothing here changes the app, but the previous release's audit found the tooling
|
||||
claiming more than it delivered, and this is the repair.
|
||||
|
||||
- **The frontend has a real logger.** 484 `console` calls shipped to users and
|
||||
ran on every device; the Rust half has had levelled logging with a runtime
|
||||
override since the beginning. There is now a matching facade — quiet in
|
||||
release builds, verbose in debug ones, with warnings and errors never
|
||||
suppressed and `localStorage` able to turn the volume up in a shipped build to
|
||||
diagnose a problem. (DR-204)
|
||||
|
||||
- **The traceability matrix is navigable.** Every one of its ~2,800 file links
|
||||
was broken: the generator wrote repo-root paths into a file that lives in
|
||||
`docs/`. The document the whole traceability system exists to produce could not
|
||||
be clicked through, and had no test. Both are fixed, and a link checker now
|
||||
fails the build on a dead documentation link. (DR-093, DR-208)
|
||||
|
||||
- **The Rust toolchain is pinned.** Developer machines and CI were five releases
|
||||
apart, which meant a clean `cargo clippy` locally proved nothing about CI — the
|
||||
same tree measured zero warnings on one and three on the other. With both sides
|
||||
on the same compiler, clippy is now a hard gate instead of advisory. (DR-206)
|
||||
|
||||
- **The frontend has a linter and formatter**, its first — the Rust half has had
|
||||
`cargo fmt --check` and clippy in CI for a while. A pre-commit hook runs the
|
||||
fast checks, so the "before committing" list is enforced rather than
|
||||
remembered. (DR-205, DR-207)
|
||||
|
||||
- **Containerised builds no longer leave root-owned files** in the working tree,
|
||||
which had accumulated to the point of breaking `cargo clean` and, eventually,
|
||||
`cargo build` itself. (DR-213)
|
||||
|
||||
- Removed: a webdriverio end-to-end suite that had not run in seven months and
|
||||
was wired into nothing, and a frontend validation module whose six exported
|
||||
functions had no caller outside their own tests — which made it read as
|
||||
covered input validation while guarding nothing.
|
||||
|
||||
## v0.8.2
|
||||
|
||||
A single fix, for Android background audio.
|
||||
|
||||
### 🐛 Fixes
|
||||
|
||||
- **Listening to a video in the background no longer jumps back to where you
|
||||
started.** Handing a video off to background audio streams a live mp3
|
||||
transcode, which is chunked — no length, and no duration the player can read.
|
||||
ExoPlayer resumes a failed load in place only when it knows one of those two
|
||||
things; with neither it assumes the source is live and re-requests the URL from
|
||||
the beginning. That URL starts at the moment you locked the screen, so a
|
||||
network blip left a retry armed, and when the buffer eventually ran dry —
|
||||
minutes later, with nothing in between — playback silently resumed from the
|
||||
handoff point and carried on. No error was raised and nothing ended, so none of
|
||||
the existing stream-recovery paths could see it; the only sign was a position
|
||||
that went backwards, which is why it looked random. The player is now refused
|
||||
its own retry for exactly that kind of stream, so the failure surfaces and the
|
||||
backend re-opens the stream at the position playback actually reached, keeping
|
||||
your selected audio track. Music and video are untouched: both declare their
|
||||
timeline, and the player resumes them where the load stopped.
|
||||
(UR-040, UR-004 → DR-203)
|
||||
|
||||
## v0.8.1
|
||||
|
||||
A single fix, for Android.
|
||||
|
||||
### 🐛 Fixes
|
||||
|
||||
- **The screen no longer sleeps while you are watching something.** Android
|
||||
counts its display timeout from the last time you touched the phone, and
|
||||
watching a film is exactly when you do not — so the picture dimmed and the
|
||||
screen went out mid-playback unless you kept tapping it. Nothing in the app
|
||||
ever asked the display to stay on, and neither video renderer does so by
|
||||
itself: ExoPlayer's wake mode keeps the CPU and wifi alive but says nothing
|
||||
about the screen, and an embedded WebView does not take the display wake lock
|
||||
that a browser takes for `<video>`. Both rendering paths now hold the screen
|
||||
awake for as long as video is actually playing, and release it on pause, on
|
||||
stop, and when the player goes away. Audio is deliberately untouched — playing
|
||||
music with the screen off is the point of it. (UR-003, UR-004 → DR-202)
|
||||
|
||||
## v0.8.0
|
||||
|
||||
A security and correctness release, from an audit of the codebase against its own
|
||||
|
||||
@@ -170,7 +170,7 @@ canonical, maintained source; this file only summarizes. See
|
||||
| [09-security.md](docs/architecture/09-security.md) | Token storage, secure storage, network security |
|
||||
|
||||
Release process lives in [docs/release-checklist.md](docs/release-checklist.md)
|
||||
and [docs/build-release.md](docs/build-release.md).
|
||||
and [docs/build/build-release.md](docs/build/build-release.md).
|
||||
|
||||
### Core principles (from the architecture docs)
|
||||
|
||||
|
||||
+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 && \
|
||||
ln -s /root/.bun/bin/bun /usr/local/bin/bun
|
||||
|
||||
# Install Rust using rustup
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && \
|
||||
# Install Rust using rustup, pinned to an exact release.
|
||||
#
|
||||
# 🔴 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 && \
|
||||
rustup default "$RUST_VERSION" && \
|
||||
rustup target add aarch64-linux-android && \
|
||||
rustup target add armv7-linux-androideabi && \
|
||||
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
|
||||
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/) |
|
||||
| Requirements, traceability & technical debt | [docs/requirements.md](docs/requirements.md) |
|
||||
| Build & release process | [docs/build-release.md](docs/build-release.md) |
|
||||
| Build & release process | [docs/build/build-release.md](docs/build/build-release.md) |
|
||||
| Docker builds | [docs/build/docker.md](docs/build/docker.md) |
|
||||
| Traceability tooling & CI | [docs/traceability.md](docs/traceability.md), [docs/traceability-ci.md](docs/traceability-ci.md) |
|
||||
| Release checklist | [docs/release-checklist.md](docs/release-checklist.md) |
|
||||
|
||||
+47
-2
@@ -22,15 +22,60 @@
|
||||
- [Database Design](architecture/08-database-design.md)
|
||||
- [Security](architecture/09-security.md)
|
||||
|
||||
# UX & Specs
|
||||
# UX
|
||||
|
||||
- [UX Flows](ux-flows.md)
|
||||
|
||||
# Specs — Writing One
|
||||
|
||||
- [Spec Template](specs/SPEC-TEMPLATE.md)
|
||||
- [Spec Review Checklist](specs/SPEC-REVIEW-CHECKLIST.md)
|
||||
|
||||
# Specs — Playback & Player
|
||||
|
||||
- [Playback Backend Unification](specs/playback-backend-unification.md)
|
||||
- [Player Facade Enforcement](specs/player-facade-enforcement.md)
|
||||
- [Playback Documentation Corrections](specs/playback-docs-corrections.md)
|
||||
- [Video Background Audio](specs/video-background-audio.md)
|
||||
- [Android Native Video Spike](specs/android-native-video-spike.md)
|
||||
- [Android Audio Settings Parity](specs/android-audio-settings-parity.md)
|
||||
- [Audio Equalizer](specs/audio-equalizer.md)
|
||||
- [Windows Native Audio Backend](specs/windows-native-audio-backend.md)
|
||||
- [libmpv2 Migration](specs/libmpv2-migration.md)
|
||||
- [Streaming Bitrate Cap](specs/streaming-bitrate-cap.md)
|
||||
- [Read-Through Media Cache](specs/read-through-media-cache.md)
|
||||
|
||||
# Specs — Library & Browsing
|
||||
|
||||
- [Scoped Search](specs/scoped-search.md)
|
||||
- [Scoped Search Boundary](specs/scoped-search-boundary.md)
|
||||
- [Scoped Search Boundary — Implementation](specs/scoped-search-boundary-implementation.md)
|
||||
- [Locally-Indexed Search](specs/catalog-index-search.md)
|
||||
- [Favourites Browsing](specs/favorites-browsing.md)
|
||||
- [Library Mosaic](specs/library-mosaic.md)
|
||||
- [Series Current-Episode Navigation](specs/series-current-episode-navigation.md)
|
||||
- [Account Menu](specs/account-menu.md)
|
||||
- [Frontend Domain Model](specs/frontend-domain-model.md)
|
||||
|
||||
# Specs — Downloads & Offline
|
||||
|
||||
- [Downloads as an Offline Library](specs/downloads-as-offline-library.md)
|
||||
- [Offline Downloaded-Only Filter](specs/offline-downloaded-only-filter.md)
|
||||
|
||||
# Specs — Tooling & Build
|
||||
|
||||
- [Traceability Gate Repair](specs/traceability-gate-repair.md)
|
||||
- [Boundary Tripwire Hardening](specs/boundary-tripwire-hardening.md)
|
||||
- [Requirement-Coverage Script Removal](specs/req-coverage-script-removal.md)
|
||||
- [Build Provenance](specs/build-provenance.md)
|
||||
|
||||
# Build & Release
|
||||
|
||||
- [Build & Release](build-release.md)
|
||||
- [Build & Release](build/build-release.md)
|
||||
- [Release Checklist](release-checklist.md)
|
||||
- [Desktop Packaging](build/build-desktop-packages.md)
|
||||
- [Windows Build](build/build-windows.md)
|
||||
- [Defect Windows](defect-windows.md)
|
||||
- [Docker](build/docker.md)
|
||||
- [Builder Image](build/build-builder-image.md)
|
||||
|
||||
|
||||
@@ -6,14 +6,14 @@ run in Docker so no host toolchain setup is required. Outputs land in `./dist`.
|
||||
## One builder image (shared with CI)
|
||||
|
||||
The deb/rpm and Windows-cross flows build on the **unified registry builder**
|
||||
([../Dockerfile.builder](../Dockerfile.builder) →
|
||||
([../Dockerfile.builder](../../Dockerfile.builder) →
|
||||
`gitea.tourolle.paris/dtourolle/jellytau-builder`), the same image CI uses. It
|
||||
carries every packaging tool: Android SDK/NDK, `rpm`/`file` (Linux bundler),
|
||||
`cargo-xwin` + `lld` + `llvm` + `nsis` + the `x86_64-pc-windows-msvc` rust target
|
||||
(Windows). There is **one** dependency source of truth — no per-stage tool
|
||||
installs.
|
||||
|
||||
The desktop stages in [../Dockerfile](../Dockerfile) are thin `FROM
|
||||
The desktop stages in [../Dockerfile](../../Dockerfile) are thin `FROM
|
||||
${BUILDER_IMAGE}` environments; the actual build runs at container-run time on
|
||||
your bind-mounted source (like the `dev` service), so source edits need no image
|
||||
rebuild.
|
||||
@@ -28,7 +28,7 @@ docker build -f Dockerfile.builder -t jellytau-builder:latest .
|
||||
BUILDER_IMAGE=jellytau-builder:latest bun run docker:build:windows
|
||||
```
|
||||
|
||||
Arch uses a separate `archlinux` image ([../Dockerfile.arch](../Dockerfile.arch))
|
||||
Arch uses a separate `archlinux` image ([../Dockerfile.arch](../../Dockerfile.arch))
|
||||
because `makepkg` is Arch-specific — it is not part of the unified builder.
|
||||
|
||||
| Target | Format | Docker command | Functional? |
|
||||
@@ -40,7 +40,7 @@ because `makepkg` is Arch-specific — it is not part of the unified builder.
|
||||
## Linux: deb + rpm
|
||||
|
||||
Tauri's bundler produces these natively. The build runs on the existing Ubuntu
|
||||
builder image ([../Dockerfile](../Dockerfile), `desktop-linux-build` stage):
|
||||
builder image ([../Dockerfile](../../Dockerfile), `desktop-linux-build` stage):
|
||||
|
||||
```bash
|
||||
bun run docker:build:linux # deb + rpm -> ./dist
|
||||
@@ -58,8 +58,8 @@ transcoded video). The deb/rpm declare these.
|
||||
|
||||
**Tauri has no `pacman` bundle target** (as of tauri-cli 2.9.x — valid targets
|
||||
are deb/rpm/appimage/msi/nsis/app/dmg). So we ship a hand-written PKGBUILD in
|
||||
[../packaging/arch/PKGBUILD](../packaging/arch/PKGBUILD) and build it with
|
||||
`makepkg` on an Arch base image ([../Dockerfile.arch](../Dockerfile.arch)):
|
||||
[../packaging/arch/PKGBUILD](../../packaging/arch/PKGBUILD) and build it with
|
||||
`makepkg` on an Arch base image ([../Dockerfile.arch](../../Dockerfile.arch)):
|
||||
|
||||
```bash
|
||||
bun run docker:build:arch # .pkg.tar.zst -> ./dist
|
||||
+19
-6
@@ -116,17 +116,30 @@ Runs after both builds succeed (only on version tags):
|
||||
- **Use:** Run directly on any Linux distro
|
||||
- **Installation:**
|
||||
```bash
|
||||
chmod +x jellytau_*.AppImage
|
||||
./jellytau_*.AppImage
|
||||
chmod +x JellyTau_*.AppImage
|
||||
./JellyTau_*.AppImage
|
||||
```
|
||||
|
||||
#### DEB Package
|
||||
- **File:** `jellytau_*.deb`
|
||||
- **File:** `JellyTau_*.deb`
|
||||
- **Size:** ~80-120 MB
|
||||
- **Use:** Install on Debian/Ubuntu/similar
|
||||
- **Installation:**
|
||||
```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
|
||||
```
|
||||
|
||||
@@ -294,8 +307,8 @@ bun run tauri build # Local build test
|
||||
```
|
||||
|
||||
### Documentation
|
||||
1. Update [CHANGELOG.md](../CHANGELOG.md) with changes
|
||||
2. Update [README.md](../README.md) with new features
|
||||
1. Update [CHANGELOG.md](../../CHANGELOG.md) with changes
|
||||
2. Update [README.md](../../README.md) with new features
|
||||
3. Document breaking changes
|
||||
4. Add migration guide if needed
|
||||
|
||||
+3
-3
@@ -12,10 +12,10 @@ job / SMTC lockscreen), but it runs and plays media.
|
||||
h264 fine. No Windows-specific code.
|
||||
- **Audio-only (music)** — the native audio backends are libmpv (Linux) and
|
||||
ExoPlayer (Android); neither exists on Windows. Instead
|
||||
`create_player_backend()` in [../src-tauri/src/lib.rs](../src-tauri/src/lib.rs)
|
||||
`create_player_backend()` in [../src-tauri/src/lib.rs](../../src-tauri/src/lib.rs)
|
||||
uses `WebviewAudioBackend` on non-Linux/non-Android targets: it hands the stream
|
||||
URL to a webview `<audio>` element (see
|
||||
[../src/lib/services/webviewAudio.ts](../src/lib/services/webviewAudio.ts)),
|
||||
[../src/lib/services/webviewAudio.ts](../../src/lib/services/webviewAudio.ts)),
|
||||
which reports state back through the same `player_report_*` round-trip the video
|
||||
path uses. Pure Rust + Tauri events.
|
||||
|
||||
@@ -33,7 +33,7 @@ Tauri CLI bundle the **NSIS installer from a Linux host**.
|
||||
> `--runner cargo-xwin --target x86_64-pc-windows-msvc` is what flips it into
|
||||
> Windows mode and enables the `nsis`/`msi` bundlers on Linux.
|
||||
|
||||
The builder image ([../Dockerfile.builder](../Dockerfile.builder)) bakes in the
|
||||
The builder image ([../Dockerfile.builder](../../Dockerfile.builder)) bakes in the
|
||||
whole toolchain: the `x86_64-pc-windows-msvc` rust target, `cargo-xwin`, `lld`,
|
||||
`llvm`, and `nsis`.
|
||||
|
||||
@@ -1,532 +0,0 @@
|
||||
# JellyTau Codebase Audit
|
||||
|
||||
**Date:** 2026-08-16 · **Version:** v0.6.0 · **Commit:** `be907b49` (master)
|
||||
|
||||
A review of the Rust/Svelte/Android codebase against its own requirements matrix
|
||||
and against current Android and Tauri v2 platform practice. Every finding was
|
||||
verified by running the project's own tooling or reading the code it points at —
|
||||
nothing here is inferred from documentation alone.
|
||||
|
||||
**Scale:** 55,835 LOC Rust · 50,490 LOC TS/Svelte · 530 requirements · 824 traces
|
||||
|
||||
| Severity | Count |
|
||||
|----------|-------|
|
||||
| High | 5 |
|
||||
| Medium | 9 |
|
||||
| Low | 6 |
|
||||
| Tests passing | 1,719 |
|
||||
| Untraced requirements | 86 |
|
||||
| Traceability coverage | 86% (285/330) |
|
||||
|
||||
> **Revisions, 2026-08-16.** Three rankings changed after device testing and
|
||||
> platform research, all documented in place:
|
||||
> - **B1 High → Low.** The predicted impact was refuted on a physical Android 16
|
||||
> device. The residual risk turned out to be a different, narrower one.
|
||||
> - **B7 Low → Medium, re-framed.** The original reading of predictive back was
|
||||
> backwards: at targetSdk 36 it is already enabled, not merely un-opted-into.
|
||||
> - **B8 added (Medium).** Android 16 Local Network Protections versus a
|
||||
> LAN-hosted Jellyfin server.
|
||||
> - **D3 Medium → Low.** The "820 unwraps" figure was a measurement error; the
|
||||
> real number is 19, and none are in command handlers.
|
||||
> - **B1's stated mechanism was wrong** even though its conclusion held. FGS
|
||||
> notifications are *not* exempt from `POST_NOTIFICATIONS`; media-session
|
||||
> notifications are. See B1 — the distinction changes what the fix should be.
|
||||
>
|
||||
> Original ranking was 6 High / 8 Medium / 5 Low.
|
||||
|
||||
**Verified by running:** `bun run check` · `bun run test` · `cargo test` ·
|
||||
`cargo clippy --all-targets` · `bun run check:boundary` · `bun run traces:json`
|
||||
|
||||
**Device-verified (2026-08-16):** B1 and B2 were checked against a physical HONOR
|
||||
ROD2-W09 running Android 16 (SDK 36) with the shipped app installed. B2 was
|
||||
confirmed; B1 was refuted and downgraded.
|
||||
|
||||
**Not covered:** the e2e suite (`test:e2e` is not wired into CI and was not run),
|
||||
Windows and Arch packaging paths, and the docs-site build. B3, C1 and C2 still
|
||||
need a device/desktop playback pass.
|
||||
|
||||
---
|
||||
|
||||
## A. Requirements versus code
|
||||
|
||||
The traceability matrix is the project's own claim about what is built. Of 530
|
||||
defined requirement IDs, 86 carry no `TRACES:` tag anywhere in the tree. Most of
|
||||
those gaps are documentation debt rather than missing features — which is
|
||||
precisely the problem, because it makes the matrix unreliable as evidence.
|
||||
|
||||
### A1 · High · Twelve requirements are marked "Done" but have zero traces
|
||||
|
||||
`UR-006` (lockscreen/BLE control), `UR-037` (video library presentation),
|
||||
`IR-006` (Android MediaSession), `IR-008` (audio focus), `IR-022` (person/cast
|
||||
API), `IR-024` (home-screen API) and six Jellyfin API requirements (`JA-006`,
|
||||
`JA-009`, `JA-013`, `JA-014`, `JA-015`, `JA-018`) all claim completion with
|
||||
nothing pointing at an implementation.
|
||||
|
||||
These features demonstrably work — lockscreen control, Next Up, favourites are
|
||||
all shipped. The code is there; the tags are not. That means the matrix currently
|
||||
over-reports on exactly the requirements a reviewer would most want to verify,
|
||||
and a regression in any of them would leave no trace to follow.
|
||||
|
||||
**Fix:** Tag the existing implementations. Highest value per keystroke in the
|
||||
whole audit: six of the twelve are single Jellyfin API call sites.
|
||||
|
||||
### A2 · Medium · Requirement statuses contradict each other across layers
|
||||
|
||||
`UR-020` (subtitle selection) and `UR-021` (audio track selection) are marked
|
||||
*Done*, while the integration requirements they decompose into — `IR-018` and
|
||||
`IR-019`, both libmpv-specific — are still *Planned*. Similarly `IR-005` (MPRIS)
|
||||
sits at *Planned* under a *Done* `UR-006`.
|
||||
|
||||
The likely truth is that these user requirements were satisfied through a
|
||||
different path than the one originally specified (HTML5 `<video>` and ExoPlayer
|
||||
rather than libmpv), and the IRs were never re-scoped. Left as-is, the matrix
|
||||
reads as though shipped features depend on unbuilt integrations.
|
||||
|
||||
**Fix:** Re-scope or retire the stale IRs so each Done UR rests on Done IRs.
|
||||
|
||||
### A3 · Medium · The traceability gate is set far below actual coverage
|
||||
|
||||
`traceability-check.yml` fails only below 50%. Real coverage is well above that,
|
||||
so the gate cannot catch a coverage regression until roughly half the matrix has
|
||||
rotted. A gate that can only fire after a catastrophe is not protecting anything.
|
||||
|
||||
**Measured coverage: 86% (285/330)** — UR 71/75, IR 19/32, DR 166/187, JA 29/36.
|
||||
IR is by far the weakest dimension, which corroborates A1.
|
||||
|
||||
**Fix applied:** `MIN_THRESHOLD` ratcheted 50 → 82, with the ratchet policy
|
||||
written into the workflow (only goes up; never lowered to make a red build pass).
|
||||
The same figure is mirrored as `MIN_COVERAGE_PERCENT` in
|
||||
`scripts/extract-traces.ts` so local `traces:coverage` gates on the same bar, and
|
||||
a test parses the workflow YAML and fails if the two drift apart.
|
||||
|
||||
### A4 · Low · Two traced IDs do not exist in the requirements document
|
||||
|
||||
`DR-189` and `UT-188` are referenced by `TRACES:` comments but are defined
|
||||
nowhere in `docs/requirements.md`. The extraction tool accepts them silently, so
|
||||
typos and renames pass unnoticed.
|
||||
|
||||
**Fix:** Add a dangling-ID check to the extractor and fail CI on it — cheap, and
|
||||
it keeps the matrix honest in both directions.
|
||||
|
||||
### A5 · Not a gap · The remaining untraced requirements are legitimately unbuilt
|
||||
|
||||
`UR-016`, `UR-022` and `UR-070` are Planned or Proposed, and `UR-031`
|
||||
(crossfade) is explicitly blocked by `DR-034`. Their absence from the trace graph
|
||||
is correct and needs no action — noted so it does not get swept into the fix list.
|
||||
|
||||
---
|
||||
|
||||
## B. Android platform practice
|
||||
|
||||
The app targets SDK 36 with a minSdk of 24. Several manifest and WebView settings
|
||||
still reflect an earlier target level.
|
||||
|
||||
### B1 · Low · `POST_NOTIFICATIONS` is declared but never requested at runtime
|
||||
|
||||
*Downgraded from High. The original ranking was refuted by device testing — the
|
||||
evidence is below, and it is the reason this finding is now near-trivial.*
|
||||
|
||||
The permission appears in the manifest, but there is no `requestPermissions` call
|
||||
anywhere in the Kotlin, Rust or TypeScript sources, and
|
||||
`JellyTauPlaybackService.startForeground()` runs with no `checkSelfPermission`
|
||||
guard. On Android 13+ notification permission defaults to denied.
|
||||
|
||||
This was ranked High on the theory that it would suppress the media notification
|
||||
and with it the lockscreen transport controls (`UR-006`). Testing on an HONOR
|
||||
ROD2-W09 running **Android 16 (SDK 36)**, with the shipped app installed and
|
||||
playing, shows otherwise. The permission is genuinely denied:
|
||||
|
||||
```
|
||||
POST_NOTIFICATIONS: granted=false, flags=[USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED]
|
||||
appops POST_NOTIFICATION: ignore
|
||||
```
|
||||
|
||||
and the notification is nonetheless live and complete:
|
||||
|
||||
```
|
||||
ServiceRecord{... com.dtourolle.jellytau/.player.JellyTauPlaybackService}
|
||||
isForeground=true foregroundId=1 types=0x00000002
|
||||
foregroundNoti=Notification(flags=NO_CLEAR|FOREGROUND_SERVICE
|
||||
category=transport actions=3 vis=PUBLIC)
|
||||
```
|
||||
|
||||
**`UR-006` is not at risk.** But the *reason* is not the one this audit first
|
||||
gave, and the correction is load-bearing rather than pedantic.
|
||||
|
||||
The first explanation here was "foreground-service notifications are exempt." That
|
||||
is wrong. Android's own wording is that the permission covers "non-exempt
|
||||
(**including Foreground Services (FGS)**) notifications", and that users who deny
|
||||
it see FGS notices "in the Task Manager but [not] in the notification drawer" — an
|
||||
FGS notification is explicitly *not* exempt. What is exempt is **media-session**
|
||||
notifications. The platform predicate is `Notification.isMediaNotification()`,
|
||||
requiring `MediaStyle`/`DecoratedMediaCustomViewStyle` **and** a non-null
|
||||
`EXTRA_MEDIA_SESSION`; it is byte-identical across API 33–36, and
|
||||
`NotificationManagerService` has no FGS clause in either enforcement site.
|
||||
|
||||
Why the difference matters: under the FGS theory, anything the service posts is
|
||||
safe, and the code needs no care. Under the correct one, the exemption is earned
|
||||
per-notification by the token — so losing the token loses not just the shade entry
|
||||
but the lockscreen controls entirely, since SystemUI's media carousel
|
||||
(`MediaDataProcessor.onNotificationAdded`) gates on the *same* predicate. A
|
||||
token-less notification never even reaches the notification listener.
|
||||
|
||||
**The real risk here is not the permission — it is how narrowly the exemption is
|
||||
earned.** AOSP's `Notification.isMediaNotification()` grants it only when the
|
||||
style is `MediaStyle`/`DecoratedMediaCustomViewStyle` **and**
|
||||
`Notification.EXTRA_MEDIA_SESSION` holds a non-null *platform* session token. If
|
||||
either is missing while the permission is denied, the notification is **silently
|
||||
suppressed** — no exception, no log.
|
||||
|
||||
JellyTau earns it at two sites, both of which hang it on a null-safe call:
|
||||
|
||||
```kotlin
|
||||
androidx.media.app.NotificationCompat.MediaStyle()
|
||||
.setMediaSession(mediaSessionCompat?.sessionToken) // :273 and :466
|
||||
```
|
||||
|
||||
Ordering currently saves it — `mediaSessionCompat` is assigned in `onCreate`
|
||||
(:195) and `createBasicNotification()` is only reached from `onStartCommand`
|
||||
(:251) — and the device test confirms it works. But it is one reordering away
|
||||
from breaking invisibly, and only for users who denied the permission, which is
|
||||
a population most developers never test as.
|
||||
|
||||
**Fix:** Keep the permission declared — download-service FGS notifications are
|
||||
*not* covered by the media exemption, and this app has a downloads feature that
|
||||
may want them. Comment both `setMediaSession` sites to record what earns the
|
||||
exemption, and log loudly if the token is ever null at build time, converting a
|
||||
silent failure into a diagnosable one.
|
||||
|
||||
**Location:** `src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlaybackService.kt:251`, `:273`, `:466`
|
||||
|
||||
### B2 · High · Cloud backup is on by default, and it will break credential restore
|
||||
|
||||
The manifest sets neither `android:allowBackup="false"` nor a
|
||||
`dataExtractionRules`/`fullBackupContent` file, so Android's default applies: the
|
||||
app's data directory is backed up to the user's Google account. That ships the
|
||||
SQLite catalogue — library metadata and watch history — off the device.
|
||||
|
||||
The credential path makes it worse rather than better. `SecureStorage.kt`
|
||||
encrypts with AES/GCM under an Android Keystore key, and Keystore keys are never
|
||||
backed up. A user restoring onto a new phone therefore gets the ciphertext
|
||||
without the key: undecryptable credentials and a silent authentication failure,
|
||||
with no code path that recognises the situation.
|
||||
|
||||
**Fix applied.** `allowBackup="false"`. Extraction rules that merely excluded the
|
||||
DB and credential prefs would have left nothing worth backing up: the SQLite
|
||||
catalogue is a rebuildable mirror of the server and watch state lives server-side,
|
||||
so there is no user-authored data to preserve.
|
||||
|
||||
**A gap this audit missed:** on API 31+, `allowBackup="false"` disables *cloud*
|
||||
backup but **not device-to-device transfer**, which reproduces the identical
|
||||
failure — the prefs travel, the Keystore key does not. A
|
||||
`data_extraction_rules.xml` excluding all five domains from both `<cloud-backup>`
|
||||
and `<device-transfer>` was added to close it.
|
||||
|
||||
**A real bug found while fixing this:** the Rust encrypted-file fallback in
|
||||
`credentials.rs` propagated a decrypt failure as `CredentialError::Encryption`,
|
||||
which `storage_get_access_token` turned into a hard `Err` — so an undecryptable
|
||||
blob was an error state, not a logout. It now logs and returns an empty map, so
|
||||
the caller sees `NotFound` → `Ok(None)` → login screen, and the next sign-in
|
||||
self-heals the file. `SecureStorage.getCredential` on the Kotlin side already
|
||||
returned null, but could not distinguish "nothing stored" from "unreadable" and
|
||||
left the dead blob in prefs forever; it now separates the cases and discards it.
|
||||
Three tests written and watched fail first, per the red→green rule.
|
||||
|
||||
### B3 · High · `MIXED_CONTENT_ALWAYS_ALLOW` undoes the network security config
|
||||
|
||||
`network_security_config.xml` is careful and well-argued: cleartext blocked
|
||||
everywhere, exempted only for `127.0.0.1` so the local media server can serve
|
||||
downloads. Its own comment warns "this must not become a blanket cleartext
|
||||
opt-in."
|
||||
|
||||
But `MainActivity.kt` sets `mixedContentMode = MIXED_CONTENT_ALWAYS_ALLOW`, which
|
||||
permits the WebView to load http subresources into an https page from any origin.
|
||||
Alongside it, `allowFileAccess = true` and `allowContentAccess = true` are both
|
||||
broader than anything the app needs, since Tauri serves the UI from its own scheme
|
||||
and media comes from the token-guarded loopback server. These read as leftovers
|
||||
from before the media server existed.
|
||||
|
||||
**Fix:** Drop to `MIXED_CONTENT_COMPATIBILITY_MODE` and set both file and content
|
||||
access to false, then verify offline video still plays.
|
||||
|
||||
**Location:** `src-tauri/android/src/main/java/com/dtourolle/jellytau/MainActivity.kt:504-507`
|
||||
|
||||
### B4 · Medium · Android TV is half-declared
|
||||
|
||||
The manifest advertises `LEANBACK_LAUNCHER` and a non-required leanback feature,
|
||||
but omits `<uses-feature android:name="android.hardware.touchscreen"
|
||||
android:required="false"/>` and an `android:banner`. That combination fails Play's
|
||||
TV validation, and on a real TV the app would launch into a UI with no D-pad focus
|
||||
model behind it.
|
||||
|
||||
**Fix:** Either commit to TV — add the feature declaration, a banner, and a focus
|
||||
pass — or remove the leanback category until you do.
|
||||
|
||||
### B5 · Medium · `jvmTarget` is pinned to 1.8 under compileSdk 36
|
||||
|
||||
The Kotlin target has not moved with the SDK. AGP 8 warns on it, and it locks the
|
||||
Kotlin sources out of APIs and desugaring behaviour that everything else in the
|
||||
toolchain assumes.
|
||||
|
||||
**Fix:** Move `jvmTarget` and the Java source/target compatibility to 17.
|
||||
|
||||
### B6 · Low · Media3 is several minor versions behind
|
||||
|
||||
`androidx.media3` is pinned at 1.5.0 across exoplayer, hls, session and common.
|
||||
Given how much of this app's hard-won behaviour lives in ExoPlayer edge cases —
|
||||
truncated progressive streams, background audio handoff, HLS resume — staying
|
||||
current on its bug-fix releases has unusually high value here.
|
||||
|
||||
**Fix:** Schedule a Media3 bump with a device pass over the playback regression list.
|
||||
|
||||
### B7 · Medium · Predictive back is already on, not merely un-opted-into
|
||||
|
||||
*Upgraded from Low, and re-framed — the original framing was backwards.*
|
||||
|
||||
The audit first read the absent `enableOnBackInvokedCallback` as the app
|
||||
*forgoing* the Android 13+ back-gesture preview. That is not what the flag means
|
||||
at this target level. Predictive back is enabled by default for apps targeting
|
||||
recent SDKs, and Android 16's own behaviour-change list carries "Migration or
|
||||
opt-out required for predictive back" — with the opt-out being removed. Targeting
|
||||
36, JellyTau is already getting predictive back; it simply hasn't been checked
|
||||
against it.
|
||||
|
||||
That matters more than a missing opt-in would, because the app does not use
|
||||
ordinary Android back. It runs a WebView with its own history model —
|
||||
`src/lib/utils/navigation.ts` tracks a depth counter, applies a popstate delta,
|
||||
and falls back to a path when `history.back()` would trap the user, with
|
||||
`scrollRestore.ts` keying off the same popstate events. That is exactly the kind
|
||||
of custom back handling predictive back is most likely to disagree with.
|
||||
|
||||
**Fix:** This is a device test, not a code change — exercise the back gesture
|
||||
(including the drag-and-release preview and the cancel) from a library page, a
|
||||
detail page, the player, and the settings screen, and watch for the depth counter
|
||||
desynchronising. Only change code if it misbehaves.
|
||||
|
||||
Separately and unrelatedly: `JellyTauPlaybackService` is `exported="true"` with a
|
||||
`MediaSessionService` intent filter — conventional for Media3, but it means any
|
||||
app on the device can attempt to bind and drive playback. Confirm the session's
|
||||
`onConnect` callback rejects unknown packages.
|
||||
|
||||
### B8 · Medium (forward-looking) · Android 16 Local Network Protections vs a LAN Jellyfin server
|
||||
|
||||
*New finding, surfaced while researching B1.*
|
||||
|
||||
Android 16's behaviour-change list includes **Local Network Permission**. JellyTau's
|
||||
entire purpose is reaching a Jellyfin server that, for most users, sits on the
|
||||
local network — so a permission gate on local-network access is a direct threat to
|
||||
the app's core function, not a peripheral concern.
|
||||
|
||||
Stated carefully, because the timing matters: in Android 16 this is **opt-in for
|
||||
testing**, not enforced by default, with enforcement signalled for a future
|
||||
release. Nothing is broken today, and the device test will not surface it. But
|
||||
this is the rare platform change that could stop the app working at all, and it
|
||||
is much cheaper to handle before it is mandatory.
|
||||
|
||||
**Fix:** Investigate what the permission will require, then test the app against
|
||||
it with the opt-in flag enabled on the Android 16 device already to hand. Track it
|
||||
as a release-blocking item for whichever Android version enforces it.
|
||||
|
||||
---
|
||||
|
||||
## C. Tauri v2 configuration
|
||||
|
||||
The capability model here is genuinely well done — see section E. The gaps are in
|
||||
the two settings that govern what a compromised web layer could reach.
|
||||
|
||||
### C1 · High · `"csp": null` contradicts the project's own security convention
|
||||
|
||||
`CLAUDE.md` lists "keep the CSP restrictive in `tauri.conf.json`" as a standing
|
||||
rule; the config disables CSP entirely. With it off, any script that reaches the
|
||||
web layer inherits the full IPC surface.
|
||||
|
||||
The realistic exposure today is low, and worth stating plainly rather than
|
||||
inflating: the frontend has a single `{@html}` — an app-owned icon in
|
||||
`GenericGenreBrowser.svelte`, not server data — and no `innerHTML`, `eval` or
|
||||
`new Function` outside tests. So this is a missing defence rather than an open
|
||||
hole. But it is the defence that stops the next careless interpolation of a
|
||||
Jellyfin-supplied string from becoming a full compromise.
|
||||
|
||||
**Fix:** Set a CSP permitting `'self'`, `asset.localhost`, `http://127.0.0.1:*`
|
||||
for media, and the configured Jellyfin origin for images. Expect one or two
|
||||
iterations against HLS playback.
|
||||
|
||||
### C2 · Medium · The asset protocol scope is wider than what it serves
|
||||
|
||||
`assetProtocol.scope` is `$APPDATA/**`, which covers the whole app data directory
|
||||
— the SQLite database and the credential store included — while the protocol only
|
||||
needs to reach cached thumbnails and downloaded media.
|
||||
|
||||
Since `DR-137` introduced the token-guarded loopback media server, the asset
|
||||
protocol's remaining job may be thumbnails alone, which would make the narrowing
|
||||
nearly free.
|
||||
|
||||
**Fix applied:** scoped to `$APPDATA/thumbnails/**`. Confirmed on device that
|
||||
`jellytau.db` (8 MB catalogue) and `shared_prefs` sit in the `$APPDATA` root and
|
||||
are now outside the grant.
|
||||
|
||||
**But device testing found the finding was aimed at the wrong thing.** The asset
|
||||
protocol is not narrowly used — it is **entirely unused at runtime**:
|
||||
|
||||
- `getCachedImageUrl` in `imageCache.ts` has **no production callers**. Its only
|
||||
references are its own test file. `convertFileSrc`'s sole production mention
|
||||
sits inside that uncalled function, so it never executes.
|
||||
- The real path is `MediaCard` → `CachedImage` → `commands.imageGetUrl()`, which
|
||||
returns **base64 from Rust**. Every image in the app is a `data:` URI delivered
|
||||
over IPC.
|
||||
- Confirmed on device: zero `asset.localhost` requests across a full session of
|
||||
browsing home, the library list and a poster grid; the thumbnail cache stayed
|
||||
at 12 files and never grew, because nothing calls `thumbnailSave` either.
|
||||
|
||||
Two consequences worth acting on, neither yet done:
|
||||
|
||||
1. **The `protocol-asset` Cargo feature and the whole `assetProtocol` config
|
||||
block can likely be removed**, which retires the attack surface rather than
|
||||
shrinking it. `imageCache.ts` is dead code and can go with it.
|
||||
2. **`img-src` in the new CSP can be much tighter.** It currently grants
|
||||
`http: https:` on the reasoning that thumbnails are fetched direct-from-server
|
||||
on a cache miss — but they are not; they arrive as data URIs. With no
|
||||
webview-side server image loads anywhere in `src/`, `img-src 'self' data:
|
||||
blob:` should suffice. That is a real tightening the CSP work left on the
|
||||
table because it reasoned from the dead code path.
|
||||
|
||||
Both need their own device pass, since a wrong `img-src` blanks every image.
|
||||
|
||||
### C3 · Low · Shipped desktop bundles have no update path
|
||||
|
||||
The bundle targets deb, rpm and nsis, but `tauri-plugin-updater` is not among the
|
||||
dependencies. Every desktop user upgrades by manually fetching a new package,
|
||||
which in practice means a long tail of installs pinned to whatever version they
|
||||
first downloaded.
|
||||
|
||||
**Fix:** Add the updater plugin with a signed release manifest, or document the
|
||||
manual upgrade path in the README so the omission is at least deliberate.
|
||||
|
||||
---
|
||||
|
||||
## D. CI and code health
|
||||
|
||||
Local discipline in this project is strong and well documented. CI enforces only
|
||||
part of it, which means the discipline holds exactly as long as every contributor
|
||||
remembers it.
|
||||
|
||||
### D1 · High · CI runs neither `cargo clippy` nor `cargo fmt --check`
|
||||
|
||||
`CLAUDE.md` requires both before committing. Neither appears anywhere in
|
||||
`.gitea/workflows/`. The build-and-test job runs the boundary check, the frontend
|
||||
tests, the Rust tests and an Android `cargo check` — a good set, with the two lint
|
||||
gates missing.
|
||||
|
||||
Clippy currently reports 51 warnings across the lib and its tests, including
|
||||
unused imports and a redundant import that a gate would have stopped at the door.
|
||||
|
||||
**Fix:** Add both to the test job. Start with `-D warnings` on new code only if
|
||||
clearing the existing 51 is too large a first step.
|
||||
|
||||
### D2 · Medium · A flaky test will intermittently redden CI
|
||||
|
||||
`offlineCatalog.test.ts` — "pushes include=true while the server is reachable"
|
||||
(`UT-068`) — timed out at the 5 s limit during a full-suite run, then passed twice
|
||||
in isolation taking 1.13 s and 0.61 s.
|
||||
|
||||
**Root cause (corrected):** this audit originally attributed it to a real
|
||||
wall-clock timer. It isn't. The cost is the **first dynamic
|
||||
`import("./offlineCatalog")`**, which pays to transform the service and its whole
|
||||
dependency graph (~1072 ms cold) inside a test body, charged against vitest's 5 s
|
||||
default. Later re-imports after `vi.resetModules()` cost ~30 ms. Under full-suite
|
||||
contention the cold transform alone crosses the limit.
|
||||
|
||||
**Fix applied:** warm the import once at collection time with a top-level
|
||||
`await import(...)`, so no test is timing the compiler. Slowest test 1072 ms →
|
||||
129 ms; file total 1170 ms → 238 ms. Timeout deliberately left at the default.
|
||||
A latent cross-test leak was also fixed alongside it — the store shim's
|
||||
subscribers were never cleared, so every module instance discarded by
|
||||
`resetModules()` kept pushing its own visibility value.
|
||||
|
||||
**Location:** `src/lib/services/offlineCatalog.test.ts:58`
|
||||
|
||||
### D3 · Low · ~~820~~ **19** production `unwrap()`/`expect()` calls
|
||||
|
||||
*Downgraded from Medium. This audit substantially overstated the problem, and the
|
||||
correction is worth recording because the measurement error is instructive.*
|
||||
|
||||
The original 820 figure came from grepping for `unwrap()`/`expect()` and filtering
|
||||
lines containing "test". That does not exclude test *modules* — it only excludes
|
||||
lines with "test" in them. Scripting the actual `#[cfg(test)]` boundaries gives
|
||||
**19 real production sites**, not 820. `player/mod.rs`'s 154 hits, for instance,
|
||||
are *all* past its `#[cfg(test)]` at line 2183, as are the bulk of
|
||||
`repository/offline.rs`, `storage/mod.rs` and `commands/download/mod.rs`.
|
||||
|
||||
**More importantly: zero bare unwraps exist in any `#[tauri::command]` handler.**
|
||||
The specific risk this finding was built around — a panic inside a command killing
|
||||
the task and stranding shared player state — is already absent.
|
||||
|
||||
The same correction applies to the lock half: all 33 raw `.lock().unwrap()` hits
|
||||
were in test modules (three weren't even code, but prose in `utils/lock.rs`'s doc
|
||||
comment). Production was already fully on `lock_safe()`/`read_safe()`/
|
||||
`write_safe()`. Converting them was consistency work, not a bug fix.
|
||||
|
||||
**What is genuinely worth doing** is a three-site cluster, all the same pattern —
|
||||
`Runtime::new().unwrap()` in threads owning playback-critical state:
|
||||
|
||||
| | Site | Consequence of a panic |
|
||||
|---|------|------------------------|
|
||||
| 1 | `session_poller/mod.rs:102` | Poller thread dies silently; it drives remote-mode state *and* offline→online recovery, so the app strands offline with nothing surfaced |
|
||||
| 2 | `player/mpv_backend.rs:424` | Position reporting stops mid-playback; the scrubber freezes while audio keeps going |
|
||||
| 3 | `player/android/mod.rs:761` | Same pattern across a JNI boundary; progress reporting dies and no resume points are written |
|
||||
|
||||
**Fix:** One shared helper returning `Option<Runtime>` and logging on failure
|
||||
retires all three. The remaining 16 are startup `expect()`s and two provably
|
||||
infallible calls.
|
||||
|
||||
### D4 · Low · Five files carry a disproportionate share of the complexity
|
||||
|
||||
`player/mod.rs` (4,726 lines), `repository/offline.rs` (4,696),
|
||||
`repository/online.rs` (3,702), `commands/player/mod.rs` (3,299) and
|
||||
`commands/download/mod.rs` (3,226), plus `VideoPlayer.svelte` (2,778) on the
|
||||
frontend.
|
||||
|
||||
These are the same files the changelog keeps returning to for deadlocks and
|
||||
playback regressions. Not a defect in itself, and not worth a speculative
|
||||
refactor — but the next time one of them needs substantial work, splitting it is
|
||||
likely cheaper than continuing to grow it.
|
||||
|
||||
---
|
||||
|
||||
## E. Verified sound
|
||||
|
||||
Things this audit specifically went looking for and found in good order —
|
||||
including one that looked alarming from the warning output and turned out to be
|
||||
fine.
|
||||
|
||||
| Area | Finding |
|
||||
|------|---------|
|
||||
| **The 9 "MutexGuard across await" warnings are test-only** | All nine sit in `#[tokio::test]` functions holding a serialization lock, not in the production async paths that `CLAUDE.md`'s deadlock gotcha warns about. |
|
||||
| **The local media server is exemplary** | Loopback-only bind, a 32-hex-char per-session token, lexical `..` folding rather than `canonicalize`, and a test asserting reads stay inside the data directory. |
|
||||
| **Tauri capabilities are minimal** | Three permissions total — `core:default`, `opener:default`, `core:path:default`. No blanket grants, no `withGlobalTauri`. |
|
||||
| **SQL is parameterised** | Two `format!`-built statements in the whole Rust tree, neither interpolating caller-controlled input into a query. |
|
||||
| **R8 keep rules are correct and explained** | JNI-loaded player and security classes, the JavascriptInterface bridges and Media3 are all kept, each with a comment naming the crash it prevents. |
|
||||
| **Type and boundary gates are green** | `svelte-check`: 0 errors, 0 warnings. `check:boundary` passes with three reviewed allowlist entries. 698 Rust tests and 1,021 frontend tests pass. |
|
||||
|
||||
---
|
||||
|
||||
## F. Suggested order
|
||||
|
||||
Sequenced so the cheap gates land before the work they would have caught. B2
|
||||
leads because it is the finding a user is most likely to actually feel.
|
||||
|
||||
*(B1 originally led this list. It was demoted to row 10 after device testing —
|
||||
see B1. This is a good advertisement for testing a finding before scheduling
|
||||
work against it.)*
|
||||
|
||||
| # | Finding | What it buys | Effort |
|
||||
|---|---------|--------------|--------|
|
||||
| 1 | B2 | Catalogue and credentials stop leaving the device; restore stops failing silently | S — **confirmed on device**: `ALLOW_BACKUP` set, Google transport active |
|
||||
| 3 | D1 | Lint discipline becomes enforced rather than remembered | S |
|
||||
| 4 | B3 | The network security config actually holds | S — needs an offline-playback check |
|
||||
| 5 | A1 | The matrix stops over-reporting on twelve shipped requirements | M — mostly mechanical |
|
||||
| 6 | D2 | CI stops flaking | S |
|
||||
| 7 | C1 · C2 | The web layer stops being one interpolation away from full IPC | M — iterate against HLS |
|
||||
| 8 | A2 · A3 · A4 | The matrix becomes self-consistent and defended by a real gate | M |
|
||||
| 9 | B4 · B5 · B6 · B7 | Platform hygiene brought level with the SDK target | M |
|
||||
| 10 | B1 · D3 · C3 · D4 | Long-tail robustness; opportunistic rather than scheduled | L |
|
||||
@@ -36,6 +36,7 @@ went unexercised until a later feature leaned on them.
|
||||
| `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
|
||||
|
||||
@@ -80,6 +81,7 @@ silently correct an out-of-range index — which is exactly why it was reported
|
||||
| Stop-report path never fed the sync queue that existed for it (DR-154) | v0.4.6 | **v0.5.1** | feature (queue + drain landed with no producer) |
|
||||
| Background-audio base applied in two display-only places (DR-159) | v0.2.9 | **v0.5.3** | pickaxe |
|
||||
| Positions reported as 0 before the first tick, and always 0 for webview media (DR-178/179/180) | v0.5.3 | **v0.5.5** | feature (DR-159's tick boundary) |
|
||||
| Length-less handoff transcode left to the player's own load-error retry, which can only restart it (DR-203) | v0.0.16 | **v0.8.2** | feature (the handoff's progressive-mp3 choice) |
|
||||
|
||||
Three of these are worth separating out, because the defect is not a mistake in
|
||||
the code so much as **plumbing that was built and never connected**:
|
||||
|
||||
@@ -109,8 +109,9 @@ git push origin v1.2.0
|
||||
## After Release (Workflow Complete)
|
||||
|
||||
- [ ] Download artifacts from release page:
|
||||
- [ ] `jellytau_*.AppImage` (Linux)
|
||||
- [ ] `jellytau_*.deb` (Linux)
|
||||
- [ ] `JellyTau_*.AppImage` (Linux)
|
||||
- [ ] `JellyTau_*.deb` (Linux)
|
||||
- [ ] `JellyTau-*.rpm` (Linux)
|
||||
- [ ] `jellytau-release.apk` (Android)
|
||||
- [ ] `jellytau-release.aab` (Android)
|
||||
|
||||
@@ -255,9 +256,8 @@ First build takes longer (cache warming). Subsequent releases are faster due to
|
||||
**Android:** 8.0+
|
||||
|
||||
### 🔗 Links
|
||||
- [Changelog](../../CHANGELOG.md)
|
||||
- [Issues](../../issues)
|
||||
- [Discussion](../../discussions)
|
||||
- [Changelog](https://gitea.tourolle.paris/dtourolle/jellytau/src/branch/master/CHANGELOG.md)
|
||||
- [Issues](https://gitea.tourolle.paris/dtourolle/jellytau/issues)
|
||||
|
||||
---
|
||||
Built with Tauri, SvelteKit, and Rust 🦀
|
||||
|
||||
+71
-3
@@ -85,6 +85,7 @@ For a narrative overview of the system design, see
|
||||
| UR-073 | Watched state is something the viewer can **set**, not only something playback records. Any episode, season, series or movie can be marked watched — or unwatched again — from where it is shown, without sitting through it or erasing its history wholesale. Marking a season or series covers the episodes inside it, and works with the server unreachable | Medium | Done |
|
||||
| UR-072 | Each page opens where a page should open. Moving to a new screen starts at the top of it, and going Back returns the viewer to the place they left — their position in a long library grid or home screen, not the top of it. A page never inherits the scroll position of the page before it | Medium | Done |
|
||||
| UR-075 | Artwork is shown at the shape it was made in. Where a screen presents a set of things side by side — the libraries on the library page and on home — they are laid out as a mosaic: rows of a common height in which each tile is as wide as its own picture, rather than a grid that crops every cover to one box. Favourites are reachable per category from that same mosaic, beside the library they belong to, not only as one undifferentiated list | Medium | Done |
|
||||
| UR-076 | Music browsing shows only what the listener considers music. A Jellyfin server commonly keeps podcasts, audiobooks, sound effects or sample packs in their own folders inside a music library; those folders can be **excluded by choice**, once, and every music surface — library grids, artist and album listings, genre rows, search and the home screen — then agrees on what is in scope. The choice is by folder, not by a name the app happens to recognise, so a folder called anything at all can be excluded and an item is never dropped because its title matched a word | Medium | Proposed |
|
||||
| UR-074 | Video streaming can be held to a **bandwidth budget the viewer sets**, rather than spent at whatever rate the server would otherwise send. A ceiling chosen once — from the source's own bitrate down to a rung that still plays on a poor connection — governs every video the app opens, live TV included, and survives a restart, so a metered connection is not quietly drained by the next thing played. A single video can be moved to a different ceiling from the player, resuming where it was, without disturbing that default | Medium | Done |
|
||||
|
||||
---
|
||||
@@ -375,6 +376,8 @@ Internal architecture, components, and application logic.
|
||||
| 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 |
|
||||
@@ -390,6 +393,17 @@ Internal architecture, components, and application logic.
|
||||
| DR-137 | Local media is served to the player over a loopback HTTP server, not the asset protocol. Tauri's `asset` protocol answers a request carrying no `Range` header by reading the whole file into memory, and only advertises `Accept-Ranges: bytes` from *inside* its range branch — so the first request never learns ranges exist and a multi-gigabyte body is attempted instead. Chromium abandoned it with `PIPELINE_ERROR_READ` after ~31s, which reached the user as "downloaded video does not play offline". Real HTTP on `127.0.0.1` is chosen over a custom URI scheme deliberately: range support becomes a property of the transport rather than depending on whether a platform's webview forwards `Range` to a custom scheme. No response ever exceeds a 4 MiB chunk and bodies stream from the file handle, so memory is bounded regardless of file size. Because **loopback is shared between apps on Android**, the server binds `127.0.0.1` only and every URL carries a random per-session token; paths are additionally confined to the app data directory, so a leaked URL cannot read outside it. This is stage 1 of making the server the single media origin — remote passthrough and download-while-watching are deliberately out of scope here | Playback | UR-071 | Done |
|
||||
| DR-138 | Loopback is exempted from Android's cleartext ban, and nothing else is. Release builds set `usesCleartextTraffic="false"`, so the webview's request to the local media server (DR-137) was rejected by network security policy before any I/O — `<video>` failed in the same millisecond as `loadstart`, with `NETWORK_NO_SOURCE` and no server-side log at all, which is why it looked identical to a missing file. A `network-security-config` resource permits cleartext for `127.0.0.1` only and keeps `base-config cleartextTrafficPermitted="false"`, so a remote server must still be HTTPS; this is deliberately not a blanket opt-in. The manifest attribute is ignored once the config is present, so the config is the single authority. `sync-android-sources.sh` also had to learn to copy `res/xml`, which it skipped — the manifest references the resource, so a missed copy fails the resource link rather than degrading quietly | Security | UR-071 | Done |
|
||||
| DR-093 | Traceability coverage gate derives its requirement denominators from `requirements.md` at run time rather than hardcoded literals: `countDefinedRequirements` counts an ID only where it leads a markdown table row (ignoring the "Traces To" column and prose) and deduplicates IDs listed both in the definition tables and in the §3 traceability matrix; `computeCoverage` reports the *intersection* of traced and defined IDs so an ID traced in code but absent from `requirements.md` is surfaced as `orphaned` instead of inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. CI and `bun run traces:coverage` share this computation and fail on both a sub-threshold and an impossible >100% result | Tooling | - | Done |
|
||||
| DR-204 | A leveled logging facade for the frontend, replacing raw `console.*` calls. One module owns the log sinks, so a level (error/warn/info/debug) decides at run time what is emitted rather than every call site deciding permanently at authoring time: a release build stays quiet, a developer chasing a playback bug turns the player's debug output on without editing and rebuilding, and nothing that reaches the console is written by a `console.log` nobody can find again. Scoped loggers carry the subsystem in the message, so a filtered console is usable while a player, a download worker and a store are all talking | Tooling | - | Proposed |
|
||||
| DR-205 | ESLint + Prettier run as a gate over the frontend, so lint and formatting are decided once by configuration rather than per reviewer. Formatting is not a matter of opinion at review time, and the classes of bug a linter sees (unused bindings, floating promises, accidental globals) should never reach a human reviewer at all. Wired as an npm script so the same command runs locally and in CI, matching how `check:boundary` and the traceability gate already work | Tooling | - | Proposed |
|
||||
| DR-206 | The Rust toolchain is pinned in-repo (`rust-toolchain.toml`) and the pin is what both a developer's machine and CI use. Without it, `cargo fmt --check` and `cargo clippy` are run by whatever version each host happens to have, so a formatting or lint result differs between a laptop and the builder image and CI fails on a diff that was clean locally — the failure mode is a red build nobody can reproduce. The builder image carries the pinned toolchain, so pinning is a *declaration*, not a CI-time install (see the no-toolchain-installs rule) | Tooling | - | Proposed |
|
||||
| DR-207 | A pre-commit hook runs the "Before Committing" gates — frontend checks and tests, `cargo fmt`, clippy, the boundary tripwire and the traceability checks — so the gates are enforced at the commit rather than discovered in CI. The gates already exist and are already documented; what is missing is that nothing runs them, which makes compliance a matter of memory. The hook is the mechanism that makes the documented list actually binding | Tooling | - | Proposed |
|
||||
| DR-208 | Documentation link integrity is checked mechanically (`scripts/check-doc-links.sh`): every relative markdown link in every tracked `.md` must resolve to a file that exists on disk. This is a real defect class, not hygiene — the generated traceability matrix shipped ~2,800 dead file links because it was written to `docs/` while its hrefs were repo-root-relative, and nothing noticed for months because no check existed and nobody clicks 2,800 links. The check validates *paths*, deliberately not anchors or external URLs: anchor resolution needs a markdown renderer's slug rules and network checks make the gate flaky, so both are out of scope and stated as such in the script | Tooling | - | Done |
|
||||
| DR-209 | Library folders are excluded from music browsing **server-side, by folder id**, replacing a hardcoded frontend filter that dropped anything whose name contained "Podcasts". The name filter was wrong in three separate ways: it encoded a domain classification in the presentation layer, it matched on a title rather than on what an item *is* (so an album legitimately called "Podcasts" vanished while a podcast folder named anything else did not), and it applied only where someone had remembered to call it, so the same library was in scope on one screen and out of scope on the next. Excluded folder ids are stored as user configuration and applied by the repository layer to every music query — libraries, artists, albums, genres, search and the home rows — so scope is decided in one place and is the same everywhere | Repository | UR-076 | Proposed |
|
||||
| DR-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 |
|
||||
|
||||
---
|
||||
@@ -403,7 +417,7 @@ Internal architecture, components, and application logic.
|
||||
| UR-001 | IR-001, IR-002 | - |
|
||||
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
|
||||
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010, DR-182, DR-183, DR-184, DR-185, DR-186, DR-187, DR-188, DR-190, DR-191, DR-192, DR-193, DR-194, DR-195, DR-196 |
|
||||
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188 |
|
||||
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188, DR-203 |
|
||||
| UR-005 | - | DR-001, DR-005, DR-009, DR-178, DR-179, DR-186, DR-193, DR-195 |
|
||||
| UR-006 | IR-005, IR-006, IR-007, IR-008 | DR-200, DR-201 |
|
||||
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
|
||||
@@ -439,7 +453,7 @@ Internal architecture, components, and application logic.
|
||||
| UR-037 | IR-010 | DR-042 |
|
||||
| UR-038 | IR-010 | DR-043 |
|
||||
| UR-039 | - | DR-045, DR-046 |
|
||||
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190, DR-196, DR-201 |
|
||||
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190, DR-196, DR-201, DR-203 |
|
||||
| UR-041 | IR-026 | DR-053, DR-160, DR-161, DR-172, DR-182, DR-183, DR-184, DR-185, DR-188 |
|
||||
| UR-042 | IR-009, IR-014 | DR-054 |
|
||||
| UR-043 | IR-027 | DR-055 |
|
||||
@@ -474,6 +488,7 @@ Internal architecture, components, and application logic.
|
||||
| UR-073 | - | DR-158 |
|
||||
| UR-074 | - | DR-162, DR-177, DR-181 |
|
||||
| UR-075 | - | DR-174, DR-175 |
|
||||
| UR-076 | - | DR-209 |
|
||||
|
||||
---
|
||||
|
||||
@@ -675,6 +690,15 @@ Internal architecture, components, and application logic.
|
||||
| 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
|
||||
|
||||
@@ -700,6 +724,50 @@ Internal architecture, components, and application logic.
|
||||
|
||||
## 5. Technical Debt
|
||||
|
||||
### Open items carried over from the v0.6.0 codebase audit
|
||||
|
||||
The 2026-08-16 audit (v0.6.0, commit `be907b49`) was a point-in-time snapshot
|
||||
with no status markers, and by v0.8.2 most of it had been either fixed or
|
||||
overtaken. It was **retired** rather than left to rot into a document that
|
||||
half-describes the code: what survived it is the table below, which is now the
|
||||
record. Each row is self-contained — the audit is not needed to act on it.
|
||||
|
||||
What was dropped as demonstrably closed, so it is not re-raised: the CSP and
|
||||
asset-protocol scope findings (now DR-198), cloud backup and credential restore,
|
||||
the WebView mixed-content override (DR-199), `POST_NOTIFICATIONS` and the
|
||||
media-session exemption (DR-200), the `jvmTarget` 1.8 pin (now 17), the
|
||||
half-declared Android TV leanback category (removed), the untraced-but-Done
|
||||
requirements and the contradictory UR/IR statuses (re-scoped in §2.1), the 50%
|
||||
traceability gate (ratcheted, and gated on a live denominator by DR-093), the
|
||||
flaky `offlineCatalog` test, the clippy warning backlog (cleared, and `cargo
|
||||
fmt --check` plus clippy now run in CI), and the "820 production `unwrap()`s"
|
||||
figure — a measurement error that counted test modules, corrected in the audit
|
||||
itself to ~19 and standing at 27 today, none of them in a command handler. The
|
||||
three `Runtime::new().unwrap()` sites that genuinely matter survive as row 5.
|
||||
|
||||
Ordered by what would hurt most if left.
|
||||
|
||||
> **Closed 2026-08-17:** the R8-minified release APK was validated on device.
|
||||
> That was the last item gating confidence in the v0.8.0 release itself; R8
|
||||
> stripping JNI-loaded classes has broken release builds here before, and
|
||||
> v0.8.0 added a new Kotlin path (`onFastForward`/`onRewind`) that the
|
||||
> unminified debug pass did not cover.
|
||||
|
||||
| # | Item | Why it matters | Size |
|
||||
|---|------|----------------|------|
|
||||
| 1 | **Android 16 Local Network Protections** | The rare platform change that could stop the app working at all: JellyTau's core function is reaching a Jellyfin server that, for most users, is on the LAN. Opt-in for testing in Android 16, enforcement signalled for a later release — so nothing is broken today and no device test will surface it. Far cheaper to handle before it is mandatory. An Android 16 device is already to hand to test the opt-in flag against | M |
|
||||
| 2 | **The traceability matrix cannot see Kotlin** | `scripts/extract-traces.ts` walks only `src`, `src-tauri/src` and `scripts`, so every `TRACES:` comment in `src-tauri/android/**` is invisible — pre-existing ones included. A whole platform is unmeasured, which is plausibly why the Android IRs sat untagged for so long, and it means the 90% coverage figure is computed over a codebase that excludes the Android tree | S |
|
||||
| 3 | **Delete the asset protocol outright** | It is not narrowly used, it is **unused**. `getCachedImageUrl` has no production callers (only its own test file), so `convertFileSrc` never executes; images arrive as base64 `data:` URIs from `image_get_url`. Confirmed on device: zero `asset.localhost` requests across a full browsing session. Dropping `protocol-asset` and the `assetProtocol` block retires the surface instead of shrinking it, and `imageCache.ts` goes with it | S |
|
||||
| 4 | **Tighten `img-src`** | The v0.8.0 CSP grants `img-src … http: https:` on the premise that thumbnails are fetched direct-from-server by the webview. They are not (see #3). With no webview-side server image loads anywhere in `src/`, `'self' data: blob:` should suffice. Needs its own device pass — a wrong `img-src` blanks every image, silently | S |
|
||||
| 5 | **Three `Runtime::new().unwrap()` in playback-critical threads** | `session_poller/mod.rs:102`, `player/mpv_backend.rs:424`, `player/android/mod.rs:761`. A panic strands the app offline with nothing surfaced, freezes the scrubber mid-playback, or kills progress reporting across a JNI boundary. One shared helper returning `Option<Runtime>` and logging on failure retires all three. (The wider "820 unwraps" figure was a measurement error — the real count is 19, and none are in command handlers) | S |
|
||||
| 6 | **Confirm the playback service rejects unknown callers** | `JellyTauPlaybackService` is `exported="true"` with a `MediaSessionService` intent filter — conventional for Media3, but it means any app on the device can attempt to bind and drive playback. The session's `onConnect` should reject unknown packages. (Predictive back, raised alongside this, was verified working on device and needs nothing) | S |
|
||||
| 7 | **Media3 is several minor versions behind** | Pinned at 1.5.0 across exoplayer/hls/session/common. Much of this app's hard-won behaviour lives in ExoPlayer edge cases — truncated progressive streams, background-audio handoff, HLS resume — so its bug-fix releases have unusually high value here. Schedule with a device pass over the playback regression list | M |
|
||||
| 8 | **Shipped desktop bundles have no update path** | deb/rpm/nsis are built but `tauri-plugin-updater` is absent, so every desktop user upgrades by manually fetching a package — in practice a long tail of installs pinned to whatever they first downloaded. Add the updater with a signed manifest, or document the manual path so the omission is deliberate | M |
|
||||
| 9 | **`DR-042` overstates what ships** | It promises "poster cards, year, **and rating badges**", but `MediaCard.svelte` renders only `productionYear`; `CommunityRating`/`OfficialRating` appear solely as sort keys, never as a badge. Either build the badge or correct the requirement text — a requirement that describes unbuilt behaviour is worse than an untraced one | S |
|
||||
| 10 | **Stray duplicate `JellyTauPlayer.kt`** | A copy exists at `src-tauri/android/app/src/main/java/.../player/JellyTauPlayer.kt`, outside the canonical `src-tauri/android/src` tree that `sync-android-sources.sh` reads. Two files with one name in a tree with a strict canonical-source rule is a trap for the next edit | S |
|
||||
| 11 | **Six modules carry a disproportionate share of the complexity** | `src-tauri/src/player/mod.rs` (4,732 lines), `src-tauri/src/repository/offline.rs` (4,705), `src-tauri/src/repository/online.rs` (3,760), `src-tauri/src/commands/player/mod.rs` (3,327), `src-tauri/src/commands/download/mod.rs` (3,238) and `src/lib/components/player/VideoPlayer.svelte` (2,786) — all still growing. The cost is not the line count itself, it is that **these are the same modules `CLAUDE.md`'s Gotchas section keeps having to warn about**: the deadlock rule about locking in event callbacks, the `AutoplayDecision` scrutinee, the "no lifecycle calls after an `await` in `onMount`" rule, the HLS `master.m3u8` rule, the download concurrency cap. A file that needs a standing warning in the project's onboarding document is a file whose invariants are no longer local to it, and every such warning is a rule a newcomer has to be *told* rather than one the structure enforces. **Recorded, not scheduled** — a speculative refactor of six files this size buys nothing on its own. The trigger is the next time one of them needs substantial work: splitting it then is likely cheaper than growing it, and each rule that moves from Gotchas into a module boundary is one fewer thing to remember | L |
|
||||
|
||||
|
||||
### Linux Keyring Integration Workaround
|
||||
|
||||
**Issue**: The `keyring-rs` crate (v3.x) has issues with retrieving credentials from the Linux Secret Service API, despite successfully saving them.
|
||||
@@ -811,7 +879,7 @@ deprecated in current Media3.)
|
||||
**Affected Files**:
|
||||
- [src/lib/components/player/AudioPlayer.svelte](../src/lib/components/player/AudioPlayer.svelte) - Duplicate handlers
|
||||
- [src/lib/components/player/MiniPlayer.svelte](../src/lib/components/player/MiniPlayer.svelte) - Duplicate handlers
|
||||
- [src/lib/services/playbackControl.ts](../src/lib/services/playbackControl.ts) - Position conversion
|
||||
- [src/lib/utils/playbackUnits.ts](../src/lib/utils/playbackUnits.ts) - Position conversion (the shared helper the "Future Fix" below called for; `playbackControl.ts`, previously listed here, has since been removed)
|
||||
- [src/lib/stores/playbackMode.ts](../src/lib/stores/playbackMode.ts) - Position conversion
|
||||
- [src/lib/services/playbackReporting.ts](../src/lib/services/playbackReporting.ts) - Position conversion
|
||||
|
||||
|
||||
@@ -50,7 +50,8 @@ Copy the boxes into the review comment (or the PR) and tick them.
|
||||
- [ ] Linked to existing URs, or new URs/DRs are allocated in
|
||||
[requirements.md](../requirements.md).
|
||||
- [ ] Requirement-implementing code will carry `// TRACES:` comments (CLAUDE.md).
|
||||
- [ ] Traceability coverage stays ≥ 50% (the CI gate).
|
||||
- [ ] Traceability coverage stays ≥ 88% (the CI gate — a ratchet, so check
|
||||
`bun run traces:coverage` rather than trusting this number).
|
||||
|
||||
## Conflicts & hygiene
|
||||
|
||||
|
||||
@@ -332,7 +332,7 @@ Frontend (`bun run test`):
|
||||
|------|--------|
|
||||
| UT-105 | `favorites` store override precedence: store value beats `userData.isFavorite` beats `false` |
|
||||
| UT-106 | Un-hearting removes the item from a favourites list view (pure logic extracted to a `.ts` module, per the TrackList/episodeStrip pattern) |
|
||||
| IT-0xx | `repositoryGetFavorites` param naming — add to [tauriIntegration.test.ts](../../src/lib/utils/tauriIntegration.test.ts): camelCase top-level params, scope serialised as `"movies"` etc. |
|
||||
| IT-0xx | `repositoryGetFavorites` param naming — add to the IPC param-naming suite under `src/lib/utils/` (`tauriIntegration.test.ts` no longer exists — see the current camelCase guards in `src/lib/stores/`): camelCase top-level params, scope serialised as `"movies"` etc. |
|
||||
|
||||
Any component logic worth testing gets extracted into a plain `.ts` module first
|
||||
(`favoritesView.ts`), rather than tested through the component.
|
||||
|
||||
+14
-13
@@ -15,7 +15,7 @@ The CI/CD pipeline automatically validates that code changes are properly traced
|
||||
Traceability validation lives in `.gitea/workflows/traceability-check.yml`:
|
||||
|
||||
- ✅ Automatic trace extraction
|
||||
- ✅ Coverage validation against minimum threshold (82%, ratcheted)
|
||||
- ✅ Coverage validation against minimum threshold (88%, ratcheted)
|
||||
- ✅ Modified file checking
|
||||
- ✅ Artifact preservation
|
||||
- ✅ Summary reports
|
||||
@@ -43,7 +43,7 @@ Extracts all TRACES comments from:
|
||||
|
||||
### 2. Coverage Thresholds
|
||||
The workflow checks:
|
||||
- **Minimum overall coverage:** 82% (`MIN_THRESHOLD`)
|
||||
- **Minimum overall coverage:** 88% (`MIN_THRESHOLD`)
|
||||
|
||||
Denominators are **derived from `docs/requirements.md` at run time** — they are
|
||||
never hardcoded here or in the workflow. Run `bun run traces:coverage` for the
|
||||
@@ -67,9 +67,10 @@ or if it computes above 100%, which can only mean the gate is miscounting.
|
||||
#### Ratchet policy
|
||||
|
||||
`MIN_THRESHOLD` **only ever goes up.** It is deliberately set a few points below
|
||||
the coverage actually achieved (82 against a real 86%), so a genuine regression
|
||||
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.
|
||||
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
|
||||
@@ -151,13 +152,13 @@ TRACES: [UR-###, ...] | [IR-###, ...] | [DR-###, ...] | [JA-###, ...]
|
||||
|
||||
### On Push to Main Branch
|
||||
1. ✅ Extracts all traces from code
|
||||
2. ✅ Validates coverage is >= 82%
|
||||
2. ✅ Validates coverage is >= 88%
|
||||
3. ✅ Generates full traceability report
|
||||
4. ✅ Saves report as artifact
|
||||
|
||||
### On Pull Request
|
||||
1. ✅ Extracts all traces
|
||||
2. ✅ Validates coverage >= 82%
|
||||
2. ✅ Validates coverage >= 88%
|
||||
3. ✅ Checks modified files for TRACES
|
||||
4. ✅ Warns if new code lacks TRACES
|
||||
5. ✅ Suggests proper format
|
||||
@@ -165,7 +166,7 @@ TRACES: [UR-###, ...] | [IR-###, ...] | [DR-###, ...] | [JA-###, ...]
|
||||
|
||||
### Failure Scenarios
|
||||
The workflow **fails** (blocks merge) if:
|
||||
- Coverage drops below 82%
|
||||
- Coverage drops below 88%
|
||||
- A `TRACES:` comment names an ID `docs/requirements.md` does not define
|
||||
- JSON extraction fails
|
||||
- Invalid trace format
|
||||
@@ -203,12 +204,12 @@ below threshold. Numbers are deliberately not pinned here; the previous snapshot
|
||||
in this section (51%, 56/114) was stale by roughly 100 requirements and was what
|
||||
made the broken CI arithmetic look plausible for so long.
|
||||
|
||||
As of July 2026 overall coverage is ~86% (182/212).
|
||||
As of August 2026 overall coverage is ~90%.
|
||||
|
||||
### Targets
|
||||
- **Short term** (Sprint): Maintain ≥82% overall (the current ratchet)
|
||||
- **Medium term** (Month): Reach 70% overall coverage
|
||||
- **Long term** (Release): Reach 90% coverage with focus on:
|
||||
- **Short term** (Sprint): Maintain ≥88% overall (the current ratchet)
|
||||
- **Medium term** (Month): Hold above 90% and ratchet the gate to match
|
||||
- **Long term** (Release): Reach 95% coverage with focus on:
|
||||
- IR requirements (API clients)
|
||||
- JA requirements (Jellyfin API endpoints)
|
||||
- Remaining UR/DR requirements
|
||||
@@ -241,14 +242,14 @@ When submitting a pull request:
|
||||
|
||||
- [ ] All new code has TRACES comments linking to requirements
|
||||
- [ ] TRACES format is correct: `// TRACES: UR-001 | DR-002`
|
||||
- [ ] Workflow passes (coverage ≥ 82%)
|
||||
- [ ] Workflow passes (coverage ≥ 88%)
|
||||
- [ ] No coverage regressions
|
||||
- [ ] Artifact traceability report was generated
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Coverage below minimum threshold"
|
||||
**Problem:** Workflow fails with coverage < 82%
|
||||
**Problem:** Workflow fails with coverage < 88%
|
||||
|
||||
**Solution:**
|
||||
1. Run `bun run traces:json` locally
|
||||
|
||||
+7494
-6737
File diff suppressed because it is too large
Load Diff
+10
-10
@@ -52,10 +52,10 @@ fn test_queue_next() {
|
||||
|
||||
## Where to Find Requirements
|
||||
|
||||
1. **User Requirements (UR):** [README.md](README.md#1-user-requirements)
|
||||
2. **Integration Requirements (IR):** [README.md](README.md#21-integration-requirements)
|
||||
3. **Development Requirements (DR):** [README.md](README.md#23-development-requirements)
|
||||
4. **Jellyfin API (JA):** [README.md](README.md#22-jellyfin-api-requirements)
|
||||
1. **User Requirements (UR):** [requirements.md](requirements.md#1-user-requirements)
|
||||
2. **Integration Requirements (IR):** [requirements.md](requirements.md#21-integration-requirements)
|
||||
3. **Development Requirements (DR):** [requirements.md](requirements.md#23-development-requirements)
|
||||
4. **Jellyfin API (JA):** [requirements.md](requirements.md#22-jellyfin-api-requirements)
|
||||
|
||||
## How to Add TRACES
|
||||
|
||||
@@ -139,13 +139,13 @@ bun run traces:json | jq '.requirements."UR-005"'
|
||||
## CI/CD Validation
|
||||
|
||||
The workflow automatically checks:
|
||||
- ✅ Coverage stays >= 82% (a ratchet — raise it, never lower it)
|
||||
- ✅ Coverage stays >= 88% (a ratchet — raise it, never lower it)
|
||||
- ✅ Every traced ID is defined in `docs/requirements.md`
|
||||
- ✅ New files have TRACES
|
||||
- ✅ JSON format is valid
|
||||
- ✅ Reports are generated
|
||||
|
||||
See [traceability-ci.md](docs/traceability-ci.md) for details.
|
||||
See [traceability-ci.md](traceability-ci.md) for details.
|
||||
|
||||
## Tips & Tricks
|
||||
|
||||
@@ -199,10 +199,10 @@ A: Yes! TRACES show your implementation plan.
|
||||
|
||||
## See Also
|
||||
|
||||
- [Full Traceability Matrix](docs/traceability.md)
|
||||
- [CI/CD Pipeline Guide](docs/traceability-ci.md)
|
||||
- [Requirements Specification](README.md)
|
||||
- [Extraction Script](scripts/README.md#extract-tracests)
|
||||
- [Full Traceability Matrix](traceability.md)
|
||||
- [CI/CD Pipeline Guide](traceability-ci.md)
|
||||
- [Requirements Specification](requirements.md)
|
||||
- [Extraction Script](../scripts/README.md#extract-tracests)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
);
|
||||
+27
-13
@@ -1,7 +1,14 @@
|
||||
{
|
||||
"name": "jellytau",
|
||||
"version": "0.8.0",
|
||||
"description": "",
|
||||
"version": "0.9.1",
|
||||
"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",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"scripts": {
|
||||
@@ -10,14 +17,19 @@
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test": "vitest",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"test:e2e": "wdio run ./wdio.conf.ts",
|
||||
"test:e2e:dev": "wdio run ./wdio.conf.ts --watch",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:all": "./scripts/test-all.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:links": "bash scripts/check-doc-links.sh",
|
||||
"hooks:install": "./scripts/install-hooks.sh",
|
||||
"android:build": "./scripts/build-android.sh",
|
||||
"android:build:release": "./scripts/build-android.sh release",
|
||||
"android:build:device": "./scripts/build-android.sh --device",
|
||||
@@ -42,7 +54,6 @@
|
||||
"traces:validate": "bun run scripts/extract-traces.ts --format validate",
|
||||
"release:notes": "bun run scripts/release-notes.ts"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
@@ -51,6 +62,7 @@
|
||||
"svelte-dnd-action": "^0.9.69"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.9.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
@@ -59,18 +71,20 @@
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"@vitest/ui": "^4.0.16",
|
||||
"@wdio/cli": "^9.5.0",
|
||||
"@wdio/local-runner": "^9.5.0",
|
||||
"@wdio/mocha-framework": "^9.5.0",
|
||||
"@wdio/spec-reporter": "^9.5.0",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.23.0",
|
||||
"globals": "^17.11.0",
|
||||
"happy-dom": "^20.0.11",
|
||||
"jsdom": "^27.4.0",
|
||||
"prettier": "^3.9.6",
|
||||
"prettier-plugin-svelte": "^4.1.1",
|
||||
"svelte": "^5.47.1",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "~5.6.2",
|
||||
"typescript-eslint": "^8.67.0",
|
||||
"vite": "^6.0.3",
|
||||
"vitest": ">=1.0.0 <5.0.0",
|
||||
"webdriverio": "^9.5.0"
|
||||
"vitest": ">=1.0.0 <5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
+45
-2
@@ -8,7 +8,7 @@
|
||||
# tarball/VCS URL and drop the local-copy prepare() step.
|
||||
|
||||
pkgname=jellytau
|
||||
pkgver=0.0.18
|
||||
pkgver=0.9.1
|
||||
pkgrel=1
|
||||
pkgdesc="A cross-platform Jellyfin client"
|
||||
arch=('x86_64')
|
||||
@@ -29,7 +29,45 @@ build() {
|
||||
bun run build
|
||||
# Only the raw binary is needed; packaging is done in package() below so we
|
||||
# control the Arch filesystem layout ourselves rather than via tauri-bundler.
|
||||
(cd src-tauri && cargo build --release --locked)
|
||||
#
|
||||
# 🔴 `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() {
|
||||
@@ -42,6 +80,11 @@ package() {
|
||||
install -Dm644 "packaging/arch/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)
|
||||
install -Dm644 "src-tauri/icons/32x32.png" \
|
||||
"$pkgdir/usr/share/icons/hicolor/32x32/apps/jellytau.png"
|
||||
|
||||
+73
-1
@@ -13,11 +13,26 @@ Run all tests (frontend + Rust backend).
|
||||
### `test-frontend.sh`
|
||||
Run frontend tests only.
|
||||
```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 --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`
|
||||
Run Rust tests only.
|
||||
```bash
|
||||
@@ -120,6 +135,59 @@ For details, see:
|
||||
- [Traceability CI Guide](../docs/traceability-ci.md) - Full CI/CD documentation
|
||||
- [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
|
||||
|
||||
### `clean.sh`
|
||||
@@ -132,8 +200,12 @@ Clean all build artifacts.
|
||||
|
||||
You can also run these via npm/bun:
|
||||
```bash
|
||||
bun run test # Frontend tests (single pass)
|
||||
bun run test:all # All 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:deploy # Deploy to device
|
||||
bun run android:dev # Build + deploy debug
|
||||
|
||||
@@ -115,3 +115,7 @@ fi
|
||||
echo ""
|
||||
echo "✅ APK build complete!"
|
||||
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}
|
||||
|
||||
# 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..."
|
||||
if ! docker info | grep -q "Username"; then
|
||||
echo "Not authenticated to Docker. Logging in to ${REGISTRY_HOST}..."
|
||||
docker login ${REGISTRY_HOST}
|
||||
DOCKER_CFG="${DOCKER_CONFIG:-$HOME/.docker}/config.json"
|
||||
if ! grep -q "\"${REGISTRY_HOST}\"" "$DOCKER_CFG" 2>/dev/null; then
|
||||
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
|
||||
|
||||
# Step 4: Push to registry
|
||||
|
||||
@@ -42,3 +42,7 @@ if [[ -n "${OUTPUT_DIR:-}" ]]; then
|
||||
echo ""
|
||||
echo "📦 Copied bundles to $OUTPUT_DIR"
|
||||
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.
|
||||
#
|
||||
# Playback on Windows: video renders via WebView2 and audio via the webview
|
||||
# <audio> backend (WebviewAudioBackend) — see docs/build-windows.md.
|
||||
# <audio> backend (WebviewAudioBackend) — see docs/build/build-windows.md.
|
||||
#
|
||||
# Requirements (present in the Docker windows-cross target / unified builder):
|
||||
# - rustup target x86_64-pc-windows-msvc
|
||||
@@ -67,3 +67,7 @@ if [[ -n "${OUTPUT_DIR:-}" ]]; then
|
||||
echo ""
|
||||
echo "📦 Copied Windows artifacts to $OUTPUT_DIR"
|
||||
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.)"
|
||||
@@ -11,6 +11,7 @@
|
||||
*
|
||||
* @req-test: UT-089 - Requirement definitions parsed from requirements.md
|
||||
* @req-test: UT-090 - Coverage is the intersection of traced and defined IDs
|
||||
* @req-test: UT-202 - Generated matrix links resolve from docs/
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
@@ -20,7 +21,10 @@ 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.
|
||||
@@ -250,29 +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", () => {
|
||||
it("parses the real file to the counts the CI gate must use", () => {
|
||||
// Guards the specific regression: CI hardcoded UR/39, IR/24, DR/48, JA/3
|
||||
// (total 114) while the real file had grown to 211. Update these numbers
|
||||
// deliberately when requirements are added — that edit is the signal the
|
||||
// denominator is live rather than frozen.
|
||||
it("parses the real file into a self-consistent denominator", () => {
|
||||
// Guards the original regression: CI hardcoded UR/39, IR/24, DR/48, JA/3
|
||||
// (total 114) while the real file had grown past 200, so the gate compared
|
||||
// live traces against a frozen denominator and reported 158% coverage.
|
||||
//
|
||||
// Deliberately asserts *invariants*, not exact totals. Pinning the counts
|
||||
// was tried and turned this test into a merge-conflict magnet: every
|
||||
// requirement added on any branch had to edit the numbers here too, and the
|
||||
// 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(
|
||||
path.resolve(HERE, "../docs/requirements.md"),
|
||||
"utf-8"
|
||||
);
|
||||
const defined = countDefinedRequirements(md);
|
||||
|
||||
expect(defined.UR).toBe(75);
|
||||
expect(defined.IR).toBe(32);
|
||||
// 192 = 187 + four requirements added independently on four audit branches,
|
||||
// plus DR-201 (lockscreen skip resolution). Originally 191 = 187 + four
|
||||
// that landed together: DR-189 (control-bar auto-hide), DR-198 (asset
|
||||
// scope/CSP), DR-199 (webview mixed-content) and DR-200 (the
|
||||
// POST_NOTIFICATIONS media-session exemption; renumbered from 198 on
|
||||
// merge, where it collided). Each branch bumped for its own — merged,
|
||||
// they sum. Resolve this by summing, never by taking one side.
|
||||
expect(defined.DR).toBe(192);
|
||||
expect(defined.JA).toBe(36);
|
||||
expect(defined.total).toBe(335);
|
||||
// The parser found real rows of every type: a section silently failing to
|
||||
// parse would shrink the denominator and inflate coverage.
|
||||
expect(defined.UR).toBeGreaterThan(0);
|
||||
expect(defined.IR).toBeGreaterThan(0);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ interface RequirementMapping {
|
||||
[reqId: string]: TraceEntry[];
|
||||
}
|
||||
|
||||
interface TracesData {
|
||||
export interface TracesData {
|
||||
timestamp: string;
|
||||
totalFiles: number;
|
||||
totalTraces: number;
|
||||
@@ -56,7 +56,7 @@ interface TracesData {
|
||||
*
|
||||
* TRACES: | DR-093
|
||||
*/
|
||||
export const MIN_COVERAGE_PERCENT = 82;
|
||||
export const MIN_COVERAGE_PERCENT = 88;
|
||||
|
||||
// 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.
|
||||
@@ -366,7 +366,34 @@ export function readDefinedRequirements(): DefinedRequirements {
|
||||
return countDefinedRequirements(fs.readFileSync(reqPath, "utf-8"));
|
||||
}
|
||||
|
||||
function generateMarkdown(data: TracesData): string {
|
||||
/**
|
||||
* Path prefix that turns a repo-root-relative file path into a link target that
|
||||
* resolves from `docs/traceability.md`, where this markdown is written.
|
||||
*
|
||||
* The generated matrix lives one directory below the repo root, so a bare
|
||||
* `src-tauri/src/player/mod.rs` href resolves to `docs/src-tauri/…` and 404s —
|
||||
* in the repo browser and on the published mdBook site alike. Every file link
|
||||
* in the matrix was dead for this reason. The *display text* stays
|
||||
* repo-root-relative (that is the path a developer types and greps for); only
|
||||
* the href is rewritten.
|
||||
*
|
||||
* TRACES: | DR-093 | UT-202
|
||||
*/
|
||||
export const MATRIX_LINK_PREFIX = "../";
|
||||
|
||||
/**
|
||||
* Build the ``[`path`](href#Lnn)`` link used for one trace entry in the matrix.
|
||||
*
|
||||
* Exported so extract-traces.test.ts can resolve a generated href against
|
||||
* `docs/` and assert the target exists on disk.
|
||||
*
|
||||
* TRACES: | DR-093 | UT-202
|
||||
*/
|
||||
export function formatMatrixFileLink(file: string, line: number): string {
|
||||
return `[\`${file}\`](${MATRIX_LINK_PREFIX}${file}#L${line})`;
|
||||
}
|
||||
|
||||
export function generateMarkdown(data: TracesData): string {
|
||||
let md = `# Code Traceability Matrix
|
||||
|
||||
**Generated:** ${new Date(data.timestamp).toLocaleString()}
|
||||
@@ -424,7 +451,7 @@ ${data.byType.JA.join(", ")}
|
||||
md += `**Locations:** ${entries.length} file(s)\n\n`;
|
||||
|
||||
for (const entry of entries) {
|
||||
md += `- **File:** [\`${entry.file}\`](${entry.file}#L${entry.line})\n`;
|
||||
md += `- **File:** ${formatMatrixFileLink(entry.file, entry.line)}\n`;
|
||||
md += ` - **Line:** ${entry.line}\n`;
|
||||
const contextPreview = entry.context.substring(0, 70);
|
||||
md += ` - **Context:** \`${contextPreview}${entry.context.length > 70 ? "..." : ""}\`\n`;
|
||||
|
||||
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"
|
||||
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
|
||||
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 ----------------------------------------------------
|
||||
# Only when the generated Android project exists (i.e. after `tauri android
|
||||
# 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"),
|
||||
"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) {
|
||||
@@ -178,4 +183,29 @@ describe("set-version.sh", () => {
|
||||
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/security" "$TARGET_DIR/"
|
||||
|
||||
# JVM unit tests (src/test). Plain JUnit over the pure decision helpers — no
|
||||
# Android framework classes — run with `./gradlew :app:testDebugUnitTest` from
|
||||
# gen/android. Mirrored here so the canonical tree stays the only place tests
|
||||
# are edited.
|
||||
TEST_SOURCE_DIR="$PROJECT_ROOT/src-tauri/android/src/test/java/com/dtourolle/jellytau"
|
||||
TEST_TARGET_DIR="$PROJECT_ROOT/src-tauri/gen/android/app/src/test/java/com/dtourolle/jellytau"
|
||||
if [ -d "$TEST_SOURCE_DIR" ]; then
|
||||
rm -rf "$TEST_TARGET_DIR"
|
||||
mkdir -p "$TEST_TARGET_DIR"
|
||||
cp -r "$TEST_SOURCE_DIR"/. "$TEST_TARGET_DIR/"
|
||||
echo " Copied unit tests: src/test"
|
||||
fi
|
||||
|
||||
# Copy individual Kotlin files (like VideoOverlayManager.kt)
|
||||
for kt_file in "$SOURCE_DIR"/*.kt; do
|
||||
if [ -f "$kt_file" ]; then
|
||||
|
||||
+6
-2
@@ -7,7 +7,9 @@ echo "🧪 Running all tests..."
|
||||
echo ""
|
||||
|
||||
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 "🦀 Running Rust tests..."
|
||||
@@ -19,7 +21,9 @@ echo ""
|
||||
echo "🚧 Checking architectural gates..."
|
||||
# Boundary tripwire (DR-094): no Jellyfin taxonomy in the presentation layer.
|
||||
bun run check:boundary
|
||||
# Traceability coverage (DR-093): fails below 50%, or above 100% (miscount).
|
||||
# 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
|
||||
|
||||
echo ""
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
#!/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
|
||||
|
||||
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]]
|
||||
name = "jellytau"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
[package]
|
||||
name = "jellytau"
|
||||
version = "0.8.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
version = "0.9.1"
|
||||
description = "A cross-platform Jellyfin client"
|
||||
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
|
||||
license = "MIT"
|
||||
repository = "https://gitea.tourolle.paris/dtourolle/jellytau"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
@@ -85,6 +85,11 @@ class MainActivity : TauriActivity() {
|
||||
super.onWebViewCreate(webView)
|
||||
android.util.Log.d("MainActivity", "onWebViewCreate - installing bridges before first page load")
|
||||
mediaWebView = webView
|
||||
// A new WebView means a new page, which reports no video yet. Anything the
|
||||
// previous one left held would otherwise pin the screen on for the life of
|
||||
// the process, since a page that goes away never sends its final
|
||||
// setHtml5VideoState(false, …). (DR-202)
|
||||
ScreenWakeManager.releaseAll()
|
||||
installJavascriptBridges(webView)
|
||||
configureWebViewSettings(webView)
|
||||
}
|
||||
@@ -115,6 +120,11 @@ class MainActivity : TauriActivity() {
|
||||
// TRACES: UR-003, UR-041 | DR-151
|
||||
com.dtourolle.jellytau.player.JellyTauPlayer.setActivity(this)
|
||||
|
||||
// The window whose FLAG_KEEP_SCREEN_ON is toggled while video plays. Set on
|
||||
// every onCreate so a recreated Activity (rotation) re-applies the current
|
||||
// hold to its new window. (UR-003, DR-202)
|
||||
ScreenWakeManager.setActivity(this)
|
||||
|
||||
// Configure WebView for media playback after Tauri initialization
|
||||
handler.postDelayed({
|
||||
configureWebViewForMedia()
|
||||
@@ -188,6 +198,7 @@ class MainActivity : TauriActivity() {
|
||||
|
||||
override fun onDestroy() {
|
||||
NetworkTypeMonitor.stopWatching(this)
|
||||
ScreenWakeManager.clearActivity(this)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@@ -311,6 +322,10 @@ class MainActivity : TauriActivity() {
|
||||
@JavascriptInterface
|
||||
fun setHtml5VideoState(active: Boolean, width: Int, height: Int, playing: Boolean) {
|
||||
PictureInPictureManager.setHtml5VideoState(active, width, height, playing)
|
||||
// The same report is what keeps the display awake on the webview
|
||||
// rendering path — the WebView takes no display wake lock of its own
|
||||
// for `<video>`. (DR-202)
|
||||
ScreenWakeManager.onHtml5VideoState(active, playing)
|
||||
}
|
||||
}, "AndroidPictureInPicture")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidPictureInPicture' added")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@ import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy
|
||||
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
/**
|
||||
@@ -256,6 +259,45 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
* (and leak) a focus request we already own. */
|
||||
private var hasAudioFocus = false
|
||||
|
||||
/**
|
||||
* Whether the stream that is loaded may be retried by the player itself.
|
||||
*
|
||||
* Set from Rust on every load; see [StreamRetryDecision] for why the
|
||||
* background-audio handoff transcode must answer no. (DR-203)
|
||||
*/
|
||||
private val streamRetry = StreamRetryDecision()
|
||||
|
||||
/**
|
||||
* The default retry behaviour, except that a stream the player could only
|
||||
* restart is not retried at all.
|
||||
*
|
||||
* `C.TIME_UNSET` makes `ProgressiveMediaPeriod.onLoadError` return
|
||||
* `DONT_RETRY_FATAL` *before* it reaches `configureRetry`, which is the
|
||||
* method that would otherwise reset the sample queues and re-request the URL
|
||||
* from offset 0. The error then surfaces through [onPlayerError] as
|
||||
* recoverable, and Rust re-opens the stream at the position playback
|
||||
* actually reached (DR-129).
|
||||
*
|
||||
* TRACES: UR-040, UR-004 | DR-203
|
||||
*/
|
||||
private val loadErrorHandlingPolicy: LoadErrorHandlingPolicy =
|
||||
object : DefaultLoadErrorHandlingPolicy() {
|
||||
override fun getRetryDelayMsFor(
|
||||
loadErrorInfo: LoadErrorHandlingPolicy.LoadErrorInfo
|
||||
): Long {
|
||||
if (!streamRetry.playerMayRetry) {
|
||||
android.util.Log.w(
|
||||
"JellyTauPlayer",
|
||||
"Load error on a stream that cannot be resumed in place — " +
|
||||
"declining the player's retry so the backend can re-open it: " +
|
||||
"${loadErrorInfo.exception}"
|
||||
)
|
||||
return C.TIME_UNSET
|
||||
}
|
||||
return super.getRetryDelayMsFor(loadErrorInfo)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// Configure audio attributes for music playback with audio focus handling
|
||||
val audioAttributes = AudioAttributes.Builder()
|
||||
@@ -273,6 +315,13 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
//
|
||||
// TRACES: UR-004, UR-006 | IR-008
|
||||
exoPlayer = ExoPlayer.Builder(appContext)
|
||||
// Decline the player's own load-error retry for a stream it could
|
||||
// only restart (DR-203). Every other source keeps the default
|
||||
// behaviour, which resumes the failed load where it stopped.
|
||||
.setMediaSourceFactory(
|
||||
DefaultMediaSourceFactory(appContext)
|
||||
.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
|
||||
)
|
||||
.setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true)
|
||||
// Pause when the audio output is removed (wired headphones unplugged or
|
||||
// Bluetooth device disconnected). ExoPlayer listens for the system
|
||||
@@ -336,6 +385,14 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
val state = if (isPlaying) "playing" else "paused"
|
||||
nativeOnStateChanged(state, currentMediaId)
|
||||
|
||||
// Hold the display awake for video, release it for a pause or for
|
||||
// audio: the display timeout counts from the last user input, and
|
||||
// watching something is exactly when there is none. (DR-202)
|
||||
com.dtourolle.jellytau.ScreenWakeManager.onNativePlaybackChanged(
|
||||
isPlaying,
|
||||
currentMediaType == MediaType.VIDEO
|
||||
)
|
||||
|
||||
if (isPlaying) {
|
||||
startPositionUpdates()
|
||||
} else {
|
||||
@@ -346,6 +403,33 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
updatePlaybackServiceNotification(isPlaying)
|
||||
}
|
||||
|
||||
/**
|
||||
* A jump in the timeline nobody asked for.
|
||||
*
|
||||
* Logged rather than acted on: with the load-error retry declined for
|
||||
* streams that can only be restarted (DR-203), a backwards
|
||||
* `DISCONTINUITY_REASON_INTERNAL` here means the player rewound one
|
||||
* anyway, and this line is what would show it.
|
||||
*/
|
||||
override fun onPositionDiscontinuity(
|
||||
oldPosition: Player.PositionInfo,
|
||||
newPosition: Player.PositionInfo,
|
||||
reason: Int
|
||||
) {
|
||||
val message = "▶ Position discontinuity: ${oldPosition.positionMs}ms -> " +
|
||||
"${newPosition.positionMs}ms (reason=$reason)"
|
||||
if (reason == Player.DISCONTINUITY_REASON_INTERNAL) {
|
||||
// The player moved the timeline of its own accord — the
|
||||
// signature of the DR-203 rewind. Loud, because with the
|
||||
// retry declined it should no longer be reachable.
|
||||
android.util.Log.w("JellyTauPlayer", "$message — player-initiated")
|
||||
} else if (newPosition.positionMs < oldPosition.positionMs - 1000) {
|
||||
// Backwards, but asked for: a seek, or the re-prepare a
|
||||
// stream resume does (reason REMOVE). Normal, so quiet.
|
||||
android.util.Log.d("JellyTauPlayer", message)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
android.util.Log.e("JellyTauPlayer", "▶▶▶ PLAYER ERROR: ${error.errorCodeName}", error)
|
||||
android.util.Log.e("JellyTauPlayer", " Error code: ${error.errorCode}")
|
||||
@@ -837,11 +921,16 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
artworkUrl: String?,
|
||||
durationMs: Long,
|
||||
mediaType: String = "audio",
|
||||
subtitlesJson: String = "[]"
|
||||
subtitlesJson: String = "[]",
|
||||
nonResumableStream: Boolean = false
|
||||
) {
|
||||
mainHandler.post {
|
||||
currentMediaId = mediaId
|
||||
endedNotified = false
|
||||
// Who owns recovery for this stream, decided in Rust (DR-203). Set
|
||||
// before prepare(), since the first load error can arrive as soon as
|
||||
// the player starts reading.
|
||||
streamRetry.onLoad(nonResumableStream)
|
||||
|
||||
// Store metadata for notification updates
|
||||
currentTitle = title
|
||||
@@ -1027,6 +1116,7 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
fun release() {
|
||||
mainHandler.post {
|
||||
stopPositionUpdates()
|
||||
com.dtourolle.jellytau.ScreenWakeManager.onNativePlaybackChanged(false, false)
|
||||
coroutineScope.cancel()
|
||||
releaseAudioEffects()
|
||||
exoPlayer.release()
|
||||
@@ -1324,6 +1414,10 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
* TRACES: UR-003, UR-041 | DR-184
|
||||
*/
|
||||
private fun clearVideoSurface() {
|
||||
// Whatever happens to the view, video is no longer what is on screen, so
|
||||
// the display hold goes with it. Outside the let: the hold must be
|
||||
// released even when no view was ever created. (DR-202)
|
||||
com.dtourolle.jellytau.ScreenWakeManager.onNativePlaybackChanged(false, false)
|
||||
videoView?.let {
|
||||
exoPlayer.clearVideoSurface()
|
||||
com.dtourolle.jellytau.VideoOverlayManager.detachVideoSurface()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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"]
|
||||
@@ -3,7 +3,7 @@
|
||||
#[cfg(test)]
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use log::{debug, error, info, warn};
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::{Manager, State};
|
||||
|
||||
@@ -132,6 +132,73 @@ fn sanitize_filename(name: &str) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The directory every download has to stay inside: the storage root
|
||||
/// `storage_get_path` hands the frontend, which is the database's parent.
|
||||
///
|
||||
/// TRACES: DR-211 | UT-205
|
||||
fn download_root(db: &DatabaseWrapper) -> Result<PathBuf, String> {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
database
|
||||
.path()
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
.ok_or_else(|| "Database path has no parent directory".to_string())
|
||||
}
|
||||
|
||||
/// Fold `..` out of `candidate` and require what is left to sit inside `root`.
|
||||
///
|
||||
/// Lexical rather than `canonicalize`, the same way `media_server::resolve_path`
|
||||
/// does it: the file usually does not exist yet, so canonicalising would fail on
|
||||
/// the ordinary case. The check has to come *after* the caller's join, because
|
||||
/// `Path::join` drops the base when the joined half is absolute — such a path is
|
||||
/// not folded, it is obeyed, and only the `starts_with` below catches it.
|
||||
///
|
||||
/// TRACES: DR-211 | UT-205
|
||||
fn confine_to_root(root: &Path, candidate: &Path) -> Result<PathBuf, String> {
|
||||
let mut resolved = PathBuf::new();
|
||||
for component in candidate.components() {
|
||||
match component {
|
||||
Component::ParentDir => {
|
||||
resolved.pop();
|
||||
}
|
||||
Component::CurDir => {}
|
||||
other => resolved.push(other),
|
||||
}
|
||||
}
|
||||
|
||||
if resolved.starts_with(root) {
|
||||
Ok(resolved)
|
||||
} else {
|
||||
Err(format!(
|
||||
"Refusing a download path outside the download directory: {}",
|
||||
candidate.display()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitize a queued download's path and confine it to the download directory.
|
||||
///
|
||||
/// Every path the app builds for itself comes back unchanged — files on disk and
|
||||
/// `downloads` rows point at these exact spellings — and [`sanitize_filename`]
|
||||
/// is idempotent, so the already-safe name `download_item_and_start` passes in
|
||||
/// is not sanitized into a second, different one.
|
||||
///
|
||||
/// TRACES: DR-211 | UT-205
|
||||
fn confine_queued_path(root: &Path, file_path: &str) -> Result<String, String> {
|
||||
let mut sanitized = PathBuf::new();
|
||||
for component in Path::new(file_path).components() {
|
||||
match component {
|
||||
Component::Normal(part) => sanitized.push(sanitize_filename(&part.to_string_lossy())),
|
||||
// Kept as they are, so `confine_to_root` is the single thing
|
||||
// deciding whether what they add up to is still inside the root.
|
||||
other => sanitized.push(other),
|
||||
}
|
||||
}
|
||||
|
||||
confine_to_root(root, &root.join(&sanitized))?;
|
||||
Ok(sanitized.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// Request payload for download_item_and_start (bundled to stay within specta's
|
||||
/// 10-argument command limit).
|
||||
#[derive(Debug, specta::Type, serde::Deserialize)]
|
||||
@@ -253,6 +320,18 @@ pub async fn download_item(
|
||||
album_name,
|
||||
expected_size,
|
||||
} = request;
|
||||
|
||||
// `start_download` joins this onto the target directory, and `Path::join`
|
||||
// drops the base when the second half is absolute, so the row itself has to
|
||||
// be confined — not only the place it is used. `download_item_and_start`
|
||||
// sanitizes the name it builds, but `download_item` is a command in its own
|
||||
// right, so that guard was simply routed around by calling this directly.
|
||||
// TRACES: DR-211 | UT-205
|
||||
let file_path = {
|
||||
let root = download_root(&db)?;
|
||||
confine_queued_path(&root, &file_path)?
|
||||
};
|
||||
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
@@ -1286,6 +1365,20 @@ pub async fn mark_download_completed(
|
||||
bytes_downloaded: i64,
|
||||
file_path: String,
|
||||
) -> Result<(), String> {
|
||||
// Deleting a download reads this straight back into `std::fs::remove_file`,
|
||||
// so a row must never come to name a file outside the download directory.
|
||||
// The worker reports the absolute path it wrote, and joining an absolute
|
||||
// path onto the root yields it unchanged, so that case is stored verbatim;
|
||||
// the frontend's fallback to the row's own (relative) path resolves under
|
||||
// the root, where the worker put it.
|
||||
// TRACES: DR-211 | UT-205
|
||||
let file_path = {
|
||||
let root = download_root(&db)?;
|
||||
confine_to_root(&root, &root.join(&file_path))?
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
};
|
||||
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
@@ -1416,6 +1509,14 @@ pub async fn start_download(
|
||||
item_id, file_path, file_size
|
||||
);
|
||||
|
||||
// Both halves of this join reached us from the frontend, so resolve them
|
||||
// against the download directory before a single byte is written.
|
||||
// TRACES: DR-211 | UT-205
|
||||
let target_path = {
|
||||
let root = download_root(&db)?;
|
||||
confine_to_root(&root, &PathBuf::from(&target_dir).join(&file_path))?
|
||||
};
|
||||
|
||||
// Make a HEAD request to get the file size from Content-Length header
|
||||
debug!("Making HEAD request to get file size...");
|
||||
let head_response = reqwest::Client::new().head(&stream_url).send().await;
|
||||
@@ -1489,9 +1590,6 @@ pub async fn start_download(
|
||||
Err(e) => error!(" Event emit failed: {:?}", e),
|
||||
}
|
||||
|
||||
// Build target path
|
||||
let target_path = PathBuf::from(&target_dir).join(&file_path);
|
||||
|
||||
// Get a clone of the active downloads Arc for unregistering later
|
||||
let active_downloads = {
|
||||
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
||||
@@ -1762,6 +1860,36 @@ pub(crate) async fn pump_download_queue(
|
||||
None => return, // Nothing pending to start
|
||||
};
|
||||
|
||||
// Confine the row's path before it takes a slot. A row whose target
|
||||
// escapes the download directory can never start, so it is failed here
|
||||
// rather than picked again on the next pass — this loop re-queries, so
|
||||
// merely skipping it would not terminate.
|
||||
// TRACES: DR-211 | UT-205
|
||||
let confined = {
|
||||
let db_state = app.state::<DatabaseWrapper>();
|
||||
download_root(&db_state).and_then(|root| {
|
||||
confine_to_root(&root, &PathBuf::from(&target_dir).join(&file_path))
|
||||
})
|
||||
};
|
||||
let target_path = match confined {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
error!("[pump] Refusing download {}: {}", download_id, e);
|
||||
let fail_query = Query::with_params(
|
||||
"UPDATE downloads SET status = 'failed', error_message = ? WHERE id = ?",
|
||||
vec![QueryParam::String(e), QueryParam::Int64(download_id)],
|
||||
);
|
||||
if let Err(db_err) = db_service.execute(fail_query).await {
|
||||
error!(
|
||||
"[pump] Failed to mark download {} failed: {}",
|
||||
download_id, db_err
|
||||
);
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Register the slot. If registration fails (race: another pump filled
|
||||
// the last slot), stop — we'll be re-pumped when a slot frees.
|
||||
{
|
||||
@@ -1809,7 +1937,6 @@ pub(crate) async fn pump_download_queue(
|
||||
},
|
||||
);
|
||||
|
||||
let target_path = PathBuf::from(&target_dir).join(&file_path);
|
||||
spawn_download_worker(
|
||||
app.clone(),
|
||||
download_id,
|
||||
@@ -2421,6 +2548,99 @@ mod tests {
|
||||
assert_eq!(sanitize_filename("track/1.flac"), "track_1.flac");
|
||||
}
|
||||
|
||||
/// The download directory as it looks on a device, for the path tests.
|
||||
const TEST_ROOT: &str = "/data/data/com.dtourolle.jellytau/files";
|
||||
|
||||
/// A queued `file_path` cannot walk out of the download directory.
|
||||
///
|
||||
/// `download_item` is a command in its own right, so sanitizing in
|
||||
/// `download_item_and_start` was routed around by invoking it directly, and
|
||||
/// `start_download` then joined the raw string onto the target directory.
|
||||
///
|
||||
/// TRACES: DR-211 | UT-205
|
||||
#[test]
|
||||
fn test_queued_download_paths_cannot_escape_the_download_directory() {
|
||||
let root = Path::new(TEST_ROOT);
|
||||
|
||||
assert!(confine_queued_path(root, "downloads/../../../../etc/cron.d/pwn").is_err());
|
||||
assert!(confine_queued_path(root, "../.bashrc").is_err());
|
||||
assert!(confine_queued_path(root, "/etc/cron.d/pwn").is_err());
|
||||
|
||||
// Why the absolute case needs its own guard rather than folding: the
|
||||
// join the download path performs discards the base entirely.
|
||||
//
|
||||
// clippy::join_absolute_paths flags exactly this shape, and is right to
|
||||
// in production code — here the discarded base *is* the assertion, so
|
||||
// the lint is allowed rather than the code changed. Note the lint would
|
||||
// not have caught the original defect: the real join sites take a
|
||||
// variable, and the lint only fires on a literal starting with `/`.
|
||||
#[allow(clippy::join_absolute_paths)]
|
||||
{
|
||||
assert_eq!(
|
||||
PathBuf::from(root).join("/etc/cron.d/pwn"),
|
||||
PathBuf::from("/etc/cron.d/pwn")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The paths the app builds for itself have to survive unchanged: files are
|
||||
/// already on disk and `downloads` rows point at these exact spellings.
|
||||
///
|
||||
/// TRACES: DR-211 | UT-205
|
||||
#[test]
|
||||
fn test_queued_download_paths_are_otherwise_unchanged() {
|
||||
let root = Path::new(TEST_ROOT);
|
||||
|
||||
for path in [
|
||||
"downloads/9f8e7d6c", // MediaCard's queue-for-reconnect
|
||||
"videos/movies/Arrival.mp4", // VideoDownloadButton
|
||||
"albums/abc123/01 - Opening.mp3", // queue_album_tracks
|
||||
// download_series/download_season build an absolute path, because
|
||||
// their base_path is `${targetDir}/videos`.
|
||||
"/data/data/com.dtourolle.jellytau/files/videos/Show/S01E02_Pilot.mp4",
|
||||
] {
|
||||
assert_eq!(confine_queued_path(root, path).unwrap(), path);
|
||||
}
|
||||
|
||||
// `download_item_and_start` sanitizes the name before calling
|
||||
// `download_item`; sanitizing it again must not yield a second, different
|
||||
// name, which would orphan the row and the file it names.
|
||||
let already = format!("downloads/{}.mp3", sanitize_filename("AC/DC: Live?"));
|
||||
assert_eq!(confine_queued_path(root, &already).unwrap(), already);
|
||||
}
|
||||
|
||||
/// A completed row's `file_path` is read straight back into
|
||||
/// `std::fs::remove_file` when the download is deleted, so `mark_download_completed`
|
||||
/// must not be able to register a file outside the download directory.
|
||||
///
|
||||
/// TRACES: DR-211 | UT-205
|
||||
#[test]
|
||||
fn test_a_completed_download_cannot_register_a_file_outside_the_root() {
|
||||
let root = Path::new(TEST_ROOT);
|
||||
|
||||
// What the worker actually reports — the absolute path it wrote. Stored
|
||||
// exactly as it arrives.
|
||||
let written = "/data/data/com.dtourolle.jellytau/files/downloads/9f8e7d6c";
|
||||
assert_eq!(
|
||||
confine_to_root(root, Path::new(written)).unwrap(),
|
||||
PathBuf::from(written)
|
||||
);
|
||||
|
||||
// The row's own path, if the frontend falls back to it: relative, and it
|
||||
// resolves to where the worker wrote the file.
|
||||
assert_eq!(
|
||||
confine_to_root(root, &root.join("downloads/9f8e7d6c")).unwrap(),
|
||||
PathBuf::from(written)
|
||||
);
|
||||
|
||||
assert!(confine_to_root(root, Path::new("/home/u/.ssh/id_ed25519")).is_err());
|
||||
assert!(confine_to_root(
|
||||
root,
|
||||
Path::new("/data/data/com.dtourolle.jellytau/files/../../../../etc/passwd")
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
/// Helper to set up test database with required foreign key data
|
||||
fn setup_test_db() -> Database {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
|
||||
@@ -187,7 +187,7 @@ pub async fn get_album_recommendations(
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -224,7 +224,7 @@ pub fn get_album_affinity_status(
|
||||
.collect();
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -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 download;
|
||||
pub mod favorites;
|
||||
pub mod library;
|
||||
pub mod offline;
|
||||
pub mod playback_mode;
|
||||
pub mod playback_reporting;
|
||||
@@ -25,6 +26,7 @@ pub use connectivity::*;
|
||||
pub use conversions::*;
|
||||
pub use device::*;
|
||||
pub use download::*;
|
||||
pub use library::*;
|
||||
pub use offline::*;
|
||||
pub use playback_mode::*;
|
||||
#[allow(unused_imports)] // Used when playback_reporting is fully integrated
|
||||
|
||||
@@ -1705,6 +1705,23 @@ pub async fn player_set_subtitle_track(
|
||||
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]
|
||||
#[specta::specta]
|
||||
pub async fn player_set_volume(
|
||||
@@ -1712,6 +1729,12 @@ pub async fn player_set_volume(
|
||||
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
|
||||
volume: f32,
|
||||
) -> 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
|
||||
let mode = playback_mode.0.get_mode();
|
||||
|
||||
@@ -2769,6 +2792,44 @@ pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> R
|
||||
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
|
||||
/// up on the `MediaItem` the native backend loads.
|
||||
///
|
||||
|
||||
@@ -79,6 +79,10 @@ use commands::{
|
||||
get_smart_cache_stats,
|
||||
image_get_url,
|
||||
is_item_pinned,
|
||||
// Library browsing preferences (hidden folders)
|
||||
library_get_exclusion_candidates,
|
||||
library_get_settings,
|
||||
library_set_settings,
|
||||
lms_create_sync_group,
|
||||
lms_dissolve_sync_group,
|
||||
// LMS multi-room sync group commands
|
||||
@@ -884,6 +888,10 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
sync_full_catalog,
|
||||
catalog_sync_status,
|
||||
set_show_server_catalog,
|
||||
// Library browsing preferences (UR-076 / DR-209)
|
||||
library_get_settings,
|
||||
library_set_settings,
|
||||
library_get_exclusion_candidates,
|
||||
resume_queued_downloads,
|
||||
get_download_manager_stats,
|
||||
set_max_concurrent_downloads,
|
||||
@@ -1312,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
|
||||
info!("[INIT] Initializing thumbnail cache...");
|
||||
let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
|
||||
|
||||
@@ -17,6 +17,7 @@ use super::backend::{PlayerBackend, PlayerError};
|
||||
use super::events::{PlayerStatusEvent, SharedEventEmitter};
|
||||
use super::media::{MediaItem, MediaType};
|
||||
use super::state::PlayerState;
|
||||
use super::stream_end;
|
||||
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
|
||||
use crate::settings::{audio_settings_jni_payload, AudioSettings};
|
||||
use crate::utils::conversions::seconds_to_ticks;
|
||||
@@ -348,6 +349,9 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
let artwork_url = media.artwork_url.clone();
|
||||
// Convert duration from seconds to milliseconds
|
||||
let duration_ms = media.duration.map(|d| (d * 1000.0) as i64).unwrap_or(0);
|
||||
// A stream the player could only "retry" by restarting it must not be
|
||||
// retried by the player at all — recovery is ours. (DR-203)
|
||||
let player_retry_restarts_stream = stream_end::player_retry_restarts_stream(media);
|
||||
|
||||
// Update local state
|
||||
{
|
||||
@@ -454,7 +458,7 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
let result = env.call_method(
|
||||
&self.player_ref,
|
||||
"loadWithMetadata",
|
||||
"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;)V",
|
||||
"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;Z)V",
|
||||
&[
|
||||
JValue::Object(&url_jstring),
|
||||
JValue::Object(&media_id_jstring),
|
||||
@@ -465,6 +469,7 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
JValue::Long(duration_ms),
|
||||
JValue::Object(&media_type_jstring),
|
||||
JValue::Object(&subtitles_jstring),
|
||||
JValue::Bool(player_retry_restarts_stream as u8),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1670,8 +1670,7 @@ impl PlayerController {
|
||||
/// audio-only handoff, the only place a length-less progressive transcode is
|
||||
/// used. Jellyfin's item-type taxonomy stays in Rust (CLAUDE.md).
|
||||
fn is_audio_only_video(item: &MediaItem) -> bool {
|
||||
item.media_type == MediaType::Audio
|
||||
&& matches!(item.item_type.as_deref(), Some("Episode") | Some("Movie"))
|
||||
stream_end::is_audio_only_video(item)
|
||||
}
|
||||
|
||||
/// Claim a resume attempt for the current stream, returning the absolute
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
//! not a finish — and the right response is to re-open the stream where it died,
|
||||
//! which is the "buffer and resume" the user expects.
|
||||
|
||||
use crate::player::media::{MediaItem, MediaSource, MediaType};
|
||||
|
||||
/// How far short of the item's runtime a stream may end and still count as a
|
||||
/// natural finish.
|
||||
///
|
||||
@@ -45,6 +47,53 @@ pub const MAX_STALLED_RESUME_ATTEMPTS: u32 = 3;
|
||||
/// either the resume made progress, or a different item is loaded.
|
||||
const RESUME_PROGRESS_EPSILON_SECS: f64 = 1.0;
|
||||
|
||||
/// A video item played through the native *audio* path — i.e. the background
|
||||
/// audio-only handoff, the only place a length-less progressive transcode is
|
||||
/// used. Jellyfin's item-type taxonomy stays in Rust (CLAUDE.md).
|
||||
///
|
||||
/// TRACES: UR-040 | DR-129, DR-203 | UT-117, UT-200
|
||||
pub fn is_audio_only_video(item: &MediaItem) -> bool {
|
||||
item.media_type == MediaType::Audio
|
||||
&& matches!(item.item_type.as_deref(), Some("Episode") | Some("Movie"))
|
||||
}
|
||||
|
||||
/// Would the *player's own* load-error retry restart this stream from its
|
||||
/// beginning? If so the retry must be switched off and recovery left to
|
||||
/// [`crate::player::PlayerController::recoverable_error_resume`].
|
||||
///
|
||||
/// ExoPlayer resumes a failed load in place only when it knows where "in place"
|
||||
/// is: `ProgressiveMediaPeriod.configureRetry` keeps the load position when the
|
||||
/// content length is known *or* the extractor produced a seek map with a
|
||||
/// duration, and otherwise treats the source as live — the data at the URL is
|
||||
/// assumed to have changed, so it resets every sample queue and re-requests the
|
||||
/// URL from offset 0.
|
||||
///
|
||||
/// The handoff transcode satisfies neither condition: it is chunked (no
|
||||
/// `Content-Length`) and a live mp3 encode carries no `Xing` header, so the
|
||||
/// player reports its duration as unset — visible in logcat as every position
|
||||
/// tick reading `<position> / 0.0`. Its URL carries `StartTimeTicks` = the
|
||||
/// handoff point, so restarting it from offset 0 restarts the *episode* at the
|
||||
/// handoff point, and playback then runs on from there. Nothing surfaces: no
|
||||
/// error, no `STATE_ENDED`, so neither the truncation path nor the error path of
|
||||
/// DR-129 is consulted, and the app's only sign of it is a position that jumps
|
||||
/// backwards. That is the "it randomly jumps back to where audio-only started"
|
||||
/// the user sees, and how random it is depends on whether a network blip happens
|
||||
/// to land while a load is in flight rather than while the ~50s buffer covers it.
|
||||
///
|
||||
/// A retry that can only restart the stream is worth less than no retry at all:
|
||||
/// declining it turns the silent rewind into a recoverable error, which
|
||||
/// `recoverable_error_resume` answers by re-opening the stream at the position
|
||||
/// playback actually reached (`StartTimeTicks` rewritten, backoff and attempt
|
||||
/// budget included). Every other source keeps the player's retry: a static file
|
||||
/// and an HLS playlist both declare their timeline, so ExoPlayer resumes them
|
||||
/// exactly where the load failed.
|
||||
///
|
||||
/// TRACES: UR-040, UR-004 | DR-203 | UT-200
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub fn player_retry_restarts_stream(item: &MediaItem) -> bool {
|
||||
is_audio_only_video(item) && matches!(item.source, MediaSource::Remote { .. })
|
||||
}
|
||||
|
||||
/// Did this end-of-stream happen far enough short of the item's runtime to be a
|
||||
/// truncation rather than a finish?
|
||||
///
|
||||
@@ -202,6 +251,88 @@ impl ResumeTracker {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The background-audio handoff item, as `player_enter_background_audio`
|
||||
/// builds it: the episode replayed as AUDIO off a remote stream URL whose
|
||||
/// `StartTimeTicks` is the handoff point.
|
||||
fn handoff_item() -> MediaItem {
|
||||
MediaItem {
|
||||
id: "ep2".to_string(),
|
||||
title: "Episode 2".to_string(),
|
||||
name: None,
|
||||
artist: None,
|
||||
album: None,
|
||||
album_name: None,
|
||||
album_id: None,
|
||||
artist_items: None,
|
||||
artists: None,
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: Some("Episode".to_string()),
|
||||
playlist_id: None,
|
||||
duration: Some(1500.0),
|
||||
artwork_url: None,
|
||||
media_type: MediaType::Audio,
|
||||
source: MediaSource::Remote {
|
||||
stream_url: "http://s/Audio/ep2/universal?Container=mp3&StartTimeTicks=1250000000"
|
||||
.to_string(),
|
||||
jellyfin_item_id: "ep2".to_string(),
|
||||
},
|
||||
video_codec: None,
|
||||
needs_transcoding: false,
|
||||
video_width: None,
|
||||
video_height: None,
|
||||
subtitles: vec![],
|
||||
series_id: Some("series1".to_string()),
|
||||
server_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The reported bug: a load error on the length-less handoff transcode let
|
||||
/// ExoPlayer "retry" the only way it can — from offset 0 — which re-opens
|
||||
/// the URL at its `StartTimeTicks` and drops playback back to the handoff
|
||||
/// point, silently. This item must never be left to the player's own retry.
|
||||
#[test]
|
||||
fn test_handoff_transcode_must_not_use_the_players_own_retry() {
|
||||
assert!(player_retry_restarts_stream(&handoff_item()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_music_keeps_the_players_retry() {
|
||||
// `/Audio/{id}/stream?Static=true` — a real Content-Length and byte
|
||||
// ranges, so ExoPlayer resumes it where the load failed.
|
||||
let track = MediaItem {
|
||||
item_type: Some("Audio".to_string()),
|
||||
..handoff_item()
|
||||
};
|
||||
assert!(!player_retry_restarts_stream(&track));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_video_keeps_the_players_retry() {
|
||||
// An HLS playlist declares its segments, so a failed segment load is
|
||||
// retried at that segment, not at the start of the episode.
|
||||
let video = MediaItem {
|
||||
media_type: MediaType::Video,
|
||||
..handoff_item()
|
||||
};
|
||||
assert!(!player_retry_restarts_stream(&video));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_downloaded_episode_keeps_the_players_retry() {
|
||||
// A local file has no length problem and no network to lose.
|
||||
let local = MediaItem {
|
||||
source: MediaSource::Local {
|
||||
file_path: PathBuf::from("/data/ep2.mkv"),
|
||||
jellyfin_item_id: Some("ep2".to_string()),
|
||||
},
|
||||
..handoff_item()
|
||||
};
|
||||
assert!(!player_retry_restarts_stream(&local));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_end_near_duration_is_a_natural_finish() {
|
||||
// Episode runtime 25:00, stream ended at 24:56 — that is the end.
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
//! Library folders the user has chosen to keep out of browsing.
|
||||
//!
|
||||
//! Some people file things inside a library that they never want to see while
|
||||
//! browsing it — a "Podcasts" folder sitting in the music library is the
|
||||
//! canonical case: its albums and tracks leak into album, artist, track and
|
||||
//! playlist listings even though the user thinks of them as a different medium.
|
||||
//!
|
||||
//! This is a *domain* rule, not a presentation one: what an item belongs to, and
|
||||
//! therefore whether a query should return it, is decided here in the repository
|
||||
//! layer so every query path agrees. The predecessor of this module was a
|
||||
//! frontend filter that dropped anything literally named "Podcasts" — one user's
|
||||
//! folder layout, keyed on an English string, shipped to everyone. Excluding by
|
||||
//! **id** instead of name is what makes the setting survive a rename, a
|
||||
//! translation, or two folders sharing a name.
|
||||
//!
|
||||
//! The excluded set is process-wide rather than a field on a repository for the
|
||||
//! same reason as `online::STREAMING_QUALITY`: it is a preference about *this
|
||||
//! user's browsing*, not about a server session, so it must survive a repository
|
||||
//! being rebuilt on re-login. It is written by the settings command and restored
|
||||
//! from the database at startup.
|
||||
//!
|
||||
//! TRACES: UR-076 | DR-209
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use super::types::{MediaItem, SearchResult};
|
||||
use crate::utils::lock::RwLockSafe;
|
||||
|
||||
/// Ids (normalised — see [`normalise_id`]) of items the user has hidden.
|
||||
///
|
||||
/// Empty by default: nobody inherits somebody else's folder layout.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209
|
||||
static EXCLUDED_IDS: RwLock<Vec<String>> = RwLock::new(Vec::new());
|
||||
|
||||
/// Jellyfin writes the same GUID both dashed and undashed depending on the
|
||||
/// endpoint, and ids arriving over IPC may carry stray whitespace. Comparing a
|
||||
/// canonical form means a stored id keeps matching whichever spelling a query
|
||||
/// happens to return.
|
||||
fn normalise_id(id: &str) -> String {
|
||||
id.trim().replace('-', "").to_ascii_lowercase()
|
||||
}
|
||||
|
||||
/// Replace the excluded set. Ids are normalised, de-duplicated and blanks
|
||||
/// dropped, so a malformed value can never hide more than it names.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209
|
||||
pub fn set_excluded_item_ids(ids: &[String]) {
|
||||
let mut normalised: Vec<String> = Vec::with_capacity(ids.len());
|
||||
for id in ids {
|
||||
let id = normalise_id(id);
|
||||
if id.is_empty() || normalised.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
normalised.push(id);
|
||||
}
|
||||
*EXCLUDED_IDS.write_safe() = normalised;
|
||||
}
|
||||
|
||||
/// The excluded set as currently applied, normalised.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209
|
||||
pub fn excluded_item_ids() -> Vec<String> {
|
||||
EXCLUDED_IDS.read_safe().clone()
|
||||
}
|
||||
|
||||
/// Snapshot of the excluded set, taken once per list so a long listing does not
|
||||
/// re-lock per item.
|
||||
fn excluded_snapshot() -> HashSet<String> {
|
||||
EXCLUDED_IDS.read_safe().iter().cloned().collect()
|
||||
}
|
||||
|
||||
/// Whether `item` falls under one of `excluded`.
|
||||
///
|
||||
/// The set is passed in rather than read from the global so the rule itself is a
|
||||
/// pure function and can be tested without touching process state.
|
||||
///
|
||||
/// An item matches on its own id or on any of the *links* it carries back to a
|
||||
/// container: parent, album, library, series or season, and its artist entries.
|
||||
/// That covers the shapes a hidden folder actually reaches a listing in — the
|
||||
/// folder itself in a container listing, its albums (whose `parent_id` is the
|
||||
/// folder), and their tracks (whose `album_id` is the album). It is deliberately
|
||||
/// link-based rather than a full ancestry walk: the repository has no ancestor
|
||||
/// index, and walking one would cost a round trip per row.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209
|
||||
pub fn is_excluded_by(excluded: &HashSet<String>, item: &MediaItem) -> bool {
|
||||
if excluded.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
fn hidden(excluded: &HashSet<String>, id: &str) -> bool {
|
||||
excluded.contains(&normalise_id(id))
|
||||
}
|
||||
|
||||
fn hidden_opt(excluded: &HashSet<String>, id: &Option<String>) -> bool {
|
||||
match id {
|
||||
Some(id) => hidden(excluded, id),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
hidden(excluded, &item.id)
|
||||
|| hidden_opt(excluded, &item.parent_id)
|
||||
|| hidden_opt(excluded, &item.album_id)
|
||||
|| hidden_opt(excluded, &item.library_id)
|
||||
|| hidden_opt(excluded, &item.series_id)
|
||||
|| hidden_opt(excluded, &item.season_id)
|
||||
|| match &item.artist_items {
|
||||
Some(artists) => artists.iter().any(|a| hidden(excluded, &a.id)),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the user's hidden items from a repository result.
|
||||
///
|
||||
/// Implemented as a trait so the hybrid repository's generic result helpers —
|
||||
/// where the cache and server legs of every cache-first race converge — can
|
||||
/// apply it to whatever they are carrying, instead of each query having to
|
||||
/// remember to.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209
|
||||
pub trait ExcludeHidden: Sized {
|
||||
fn without_excluded(self) -> Self;
|
||||
}
|
||||
|
||||
impl ExcludeHidden for Vec<MediaItem> {
|
||||
fn without_excluded(mut self) -> Self {
|
||||
let excluded = excluded_snapshot();
|
||||
if excluded.is_empty() {
|
||||
return self;
|
||||
}
|
||||
self.retain(|item| !is_excluded_by(&excluded, item));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl ExcludeHidden for SearchResult {
|
||||
fn without_excluded(mut self) -> Self {
|
||||
let before = self.items.len();
|
||||
self.items = self.items.without_excluded();
|
||||
// `total_record_count` is what the UI shows as "N results" and what
|
||||
// paging is built against; leaving the server's count would advertise
|
||||
// rows that were just removed.
|
||||
let removed = before.saturating_sub(self.items.len());
|
||||
self.total_record_count = self.total_record_count.saturating_sub(removed);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl ExcludeHidden for MediaItem {
|
||||
/// A single item fetched by id is never hidden.
|
||||
///
|
||||
/// Exclusion hides things from *browsing*. An item asked for by id was
|
||||
/// navigated to deliberately, or is being resolved by the player or a
|
||||
/// download — answering "not found" there would break playback of anything
|
||||
/// inside a hidden folder rather than merely tidying a listing.
|
||||
fn without_excluded(self) -> Self {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::repository::types::ArtistItem;
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Serialises the tests that write the process-global excluded set. Cargo
|
||||
/// runs a crate's tests in one process, so without this two of them racing
|
||||
/// would see each other's ids.
|
||||
static EXCLUSION_TEST_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn item(id: &str) -> MediaItem {
|
||||
MediaItem {
|
||||
id: id.to_string(),
|
||||
name: format!("item {id}"),
|
||||
..MediaItem::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn excluded(ids: &[&str]) -> HashSet<String> {
|
||||
ids.iter().map(|id| normalise_id(id)).collect()
|
||||
}
|
||||
|
||||
/// The default is empty: nobody inherits another user's folder layout, which
|
||||
/// is exactly what the hardcoded "Podcasts" name filter did.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209 | UT-203
|
||||
#[test]
|
||||
fn test_no_exclusions_by_default_keeps_everything() {
|
||||
let empty = HashSet::new();
|
||||
assert!(!is_excluded_by(&empty, &item("anything")));
|
||||
|
||||
let items = vec![item("a"), item("b")];
|
||||
assert_eq!(items.without_excluded().len(), 2);
|
||||
}
|
||||
|
||||
/// The folder itself, and anything linking back to it, is hidden.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209 | UT-203
|
||||
#[test]
|
||||
fn test_excludes_the_folder_and_what_points_at_it() {
|
||||
let set = excluded(&["folder-1"]);
|
||||
|
||||
assert!(is_excluded_by(&set, &item("folder-1")), "the folder itself");
|
||||
|
||||
let album = MediaItem {
|
||||
parent_id: Some("folder-1".to_string()),
|
||||
..item("album-1")
|
||||
};
|
||||
assert!(is_excluded_by(&set, &album), "an album inside the folder");
|
||||
|
||||
let track = MediaItem {
|
||||
album_id: Some("folder-1".to_string()),
|
||||
..item("track-1")
|
||||
};
|
||||
assert!(is_excluded_by(&set, &track), "a track of the folder");
|
||||
|
||||
let elsewhere = MediaItem {
|
||||
parent_id: Some("folder-2".to_string()),
|
||||
..item("album-2")
|
||||
};
|
||||
assert!(!is_excluded_by(&set, &elsewhere), "an unrelated album");
|
||||
}
|
||||
|
||||
/// A whole library, a series/season and an artist are all excludable by the
|
||||
/// same check — the setting is "hide this container", not "hide albums".
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209 | UT-203
|
||||
#[test]
|
||||
fn test_excludes_via_every_container_link() {
|
||||
let set = excluded(&["container"]);
|
||||
|
||||
let by_library = MediaItem {
|
||||
library_id: Some("container".to_string()),
|
||||
..item("x")
|
||||
};
|
||||
assert!(is_excluded_by(&set, &by_library));
|
||||
|
||||
let by_series = MediaItem {
|
||||
series_id: Some("container".to_string()),
|
||||
..item("x")
|
||||
};
|
||||
assert!(is_excluded_by(&set, &by_series));
|
||||
|
||||
let by_season = MediaItem {
|
||||
season_id: Some("container".to_string()),
|
||||
..item("x")
|
||||
};
|
||||
assert!(is_excluded_by(&set, &by_season));
|
||||
|
||||
let by_artist = MediaItem {
|
||||
artist_items: Some(vec![
|
||||
ArtistItem {
|
||||
id: "other".to_string(),
|
||||
name: "Other".to_string(),
|
||||
},
|
||||
ArtistItem {
|
||||
id: "container".to_string(),
|
||||
name: "Hidden".to_string(),
|
||||
},
|
||||
]),
|
||||
..item("x")
|
||||
};
|
||||
assert!(is_excluded_by(&set, &by_artist));
|
||||
}
|
||||
|
||||
/// Ids are matched by identity, not spelling: Jellyfin serves the same GUID
|
||||
/// dashed on one endpoint and undashed on another, and a stored id that
|
||||
/// stopped matching would silently un-hide the folder.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209 | UT-203
|
||||
#[test]
|
||||
fn test_id_matching_ignores_dashes_case_and_padding() {
|
||||
let set = excluded(&[" A1B2C3D4-0000-0000-0000-000000000000 "]);
|
||||
assert!(is_excluded_by(
|
||||
&set,
|
||||
&item("a1b2c3d4-0000-0000-0000-000000000000")
|
||||
));
|
||||
assert!(is_excluded_by(
|
||||
&set,
|
||||
&item("A1B2C3D4000000000000000000000000")
|
||||
));
|
||||
assert!(!is_excluded_by(&set, &item("a1b2c3d4-0000-0000-0000-1")));
|
||||
}
|
||||
|
||||
/// Filtering a `SearchResult` must also correct its count — the listing
|
||||
/// header reads it, and a stale total advertises rows that are not there.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209 | UT-203
|
||||
#[test]
|
||||
fn test_search_result_count_follows_the_filter() {
|
||||
let _guard = EXCLUSION_TEST_LOCK.lock_safe();
|
||||
set_excluded_item_ids(&["hidden".to_string()]);
|
||||
|
||||
let result = SearchResult {
|
||||
items: vec![item("keep"), item("hidden"), item("keep-2")],
|
||||
total_record_count: 3,
|
||||
}
|
||||
.without_excluded();
|
||||
|
||||
set_excluded_item_ids(&[]);
|
||||
|
||||
assert_eq!(result.items.len(), 2);
|
||||
assert_eq!(result.total_record_count, 2);
|
||||
assert!(result.items.iter().all(|i| i.id != "hidden"));
|
||||
}
|
||||
|
||||
/// A single item asked for by id is never withheld: exclusion hides things
|
||||
/// from browsing, and refusing it here would break playback and downloads of
|
||||
/// anything inside a hidden folder.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209 | UT-203
|
||||
#[test]
|
||||
fn test_direct_item_lookup_is_never_hidden() {
|
||||
let _guard = EXCLUSION_TEST_LOCK.lock_safe();
|
||||
set_excluded_item_ids(&["hidden".to_string()]);
|
||||
|
||||
let still_matches = is_excluded_by(&excluded_snapshot(), &item("hidden"));
|
||||
let survives = item("hidden").without_excluded();
|
||||
|
||||
set_excluded_item_ids(&[]);
|
||||
|
||||
assert!(still_matches, "the predicate still matches the item");
|
||||
assert_eq!(survives.id, "hidden", "but a direct lookup keeps it");
|
||||
}
|
||||
|
||||
/// The stored set is sanitised on the way in: blanks dropped, duplicates
|
||||
/// collapsed, spellings normalised.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209 | UT-203
|
||||
#[test]
|
||||
fn test_set_excluded_item_ids_sanitises() {
|
||||
let _guard = EXCLUSION_TEST_LOCK.lock_safe();
|
||||
set_excluded_item_ids(&[
|
||||
" ".to_string(),
|
||||
"AB-CD".to_string(),
|
||||
"abcd".to_string(),
|
||||
"ef".to_string(),
|
||||
]);
|
||||
let stored = excluded_item_ids();
|
||||
|
||||
set_excluded_item_ids(&[]);
|
||||
let cleared = excluded_item_ids();
|
||||
|
||||
assert_eq!(stored, vec!["abcd".to_string(), "ef".to_string()]);
|
||||
assert!(cleared.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use async_trait::async_trait;
|
||||
use log::{debug, warn};
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
use super::exclusions::ExcludeHidden;
|
||||
use super::{types::*, MediaRepository, OfflineRepository, OnlineRepository};
|
||||
|
||||
/// Hybrid repository combining online and offline data sources
|
||||
@@ -151,6 +152,27 @@ impl HybridRepository {
|
||||
Ok(result.items)
|
||||
}
|
||||
|
||||
/// Immediate children of a container with the user's browsing exclusions
|
||||
/// **not** applied.
|
||||
///
|
||||
/// Exists for the exclusion picker in settings. Everything else in this
|
||||
/// repository hides what the user has hidden, which would make the setting
|
||||
/// one-way: a folder already excluded would vanish from the list of folders
|
||||
/// to exclude and could never be un-hidden. Server-first so the picker sees
|
||||
/// the real library, falling back to the cache when unreachable.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209
|
||||
pub async fn get_items_unfiltered(
|
||||
&self,
|
||||
parent_id: &str,
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
match self.online.get_items(parent_id, options.clone()).await {
|
||||
Ok(result) => Ok(result),
|
||||
Err(e) => self.offline.get_items(parent_id, options).await.or(Err(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Search only the local SQLite cache (downloaded content).
|
||||
///
|
||||
/// Fast (100ms timeout) — used to render instant results before the server
|
||||
@@ -165,6 +187,7 @@ impl HybridRepository {
|
||||
let query = query.to_string();
|
||||
self.cache_with_timeout(async move { offline.search(&query, options).await })
|
||||
.await
|
||||
.map(ExcludeHidden::without_excluded)
|
||||
}
|
||||
|
||||
/// Favourites held locally, without touching the server. Backs the instant
|
||||
@@ -179,6 +202,7 @@ impl HybridRepository {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
self.cache_with_timeout(async move { offline.get_favorites(scope, options).await })
|
||||
.await
|
||||
.map(ExcludeHidden::without_excluded)
|
||||
}
|
||||
|
||||
/// Favourites straight from the server, persisted to the cache on the way
|
||||
@@ -199,7 +223,7 @@ impl HybridRepository {
|
||||
debug!("[HybridRepo] Failed to cache favourites: {:?}", e);
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
Ok(result.without_excluded())
|
||||
}
|
||||
|
||||
/// Fetch a folder's items from the live server and persist them to the
|
||||
@@ -235,6 +259,10 @@ impl HybridRepository {
|
||||
parent_id: &str,
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
// Deliberately *not* filtered by the user's hidden folders: this surface
|
||||
// manages what is on the device, and hiding a download would leave the
|
||||
// user unable to delete a file they can still see the disk usage of.
|
||||
// TRACES: UR-076 | DR-209
|
||||
self.offline.get_downloaded_items(parent_id, options).await
|
||||
}
|
||||
|
||||
@@ -258,7 +286,10 @@ impl HybridRepository {
|
||||
query: &str,
|
||||
options: Option<SearchOptions>,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
self.online.search(query, options).await
|
||||
self.online
|
||||
.search(query, options)
|
||||
.await
|
||||
.map(ExcludeHidden::without_excluded)
|
||||
}
|
||||
|
||||
/// Merge cache and server search results into a single de-duplicated list.
|
||||
@@ -318,20 +349,30 @@ impl HybridRepository {
|
||||
/// 3. If cache is empty/stale → query server (fresh data)
|
||||
/// 4. If server fails → return cache even if empty (offline fallback)
|
||||
///
|
||||
/// Both legs are passed through [`ExcludeHidden`] before the "does the cache
|
||||
/// have content?" question is asked. This is the single place the cache and
|
||||
/// server results of a cache-first query converge, so applying the user's
|
||||
/// browsing exclusions here covers every query built on it at once — and
|
||||
/// filtering *before* the content check is what makes a cache page holding
|
||||
/// nothing but hidden items fall through to the server instead of being
|
||||
/// served as an empty listing.
|
||||
///
|
||||
/// @req: UR-002 - Access media when online or offline
|
||||
/// @req: DR-013 - Repository pattern for online/offline data access
|
||||
///
|
||||
/// TRACES: UR-002, UR-076 | DR-013, DR-209
|
||||
async fn parallel_race<T, F1, F2>(
|
||||
&self,
|
||||
cache_future: F1,
|
||||
server_future: F2,
|
||||
) -> Result<T, RepoError>
|
||||
where
|
||||
T: MeaningfulContent + Clone + Send + 'static,
|
||||
T: MeaningfulContent + ExcludeHidden + Clone + Send + 'static,
|
||||
F1: std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||
F2: std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||
{
|
||||
// Try cache first (100ms timeout already applied by callers)
|
||||
let cache_result = cache_future.await;
|
||||
let cache_result = cache_future.await.map(ExcludeHidden::without_excluded);
|
||||
|
||||
if let Ok(data) = &cache_result {
|
||||
if data.has_content() {
|
||||
@@ -343,7 +384,7 @@ impl HybridRepository {
|
||||
// Cache miss — fall back to server
|
||||
debug!("[HybridRepo] Cache miss, querying server");
|
||||
match server_future.await {
|
||||
Ok(data) => Ok(data),
|
||||
Ok(data) => Ok(data.without_excluded()),
|
||||
Err(e) => {
|
||||
// Server failed, try to return cache even if empty
|
||||
cache_result.or(Err(e))
|
||||
@@ -364,7 +405,7 @@ impl HybridRepository {
|
||||
/// The callback runs only on a cache hit — on a miss the server result is
|
||||
/// already being fetched and cached by the normal path.
|
||||
///
|
||||
/// TRACES: UR-002, UR-025 | DR-155
|
||||
/// TRACES: UR-002, UR-025, UR-076 | DR-155, DR-209
|
||||
async fn race_with_refresh<T, F1, F2, R>(
|
||||
&self,
|
||||
cache_future: F1,
|
||||
@@ -372,12 +413,12 @@ impl HybridRepository {
|
||||
on_cache_hit: R,
|
||||
) -> Result<T, RepoError>
|
||||
where
|
||||
T: MeaningfulContent + Clone + Send + 'static,
|
||||
T: MeaningfulContent + ExcludeHidden + Clone + Send + 'static,
|
||||
F1: std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||
F2: std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||
R: FnOnce(),
|
||||
{
|
||||
let cache_result = cache_future.await;
|
||||
let cache_result = cache_future.await.map(ExcludeHidden::without_excluded);
|
||||
|
||||
if let Ok(data) = &cache_result {
|
||||
if data.has_content() {
|
||||
@@ -389,7 +430,7 @@ impl HybridRepository {
|
||||
|
||||
debug!("[HybridRepo] Cache miss, querying server");
|
||||
match server_future.await {
|
||||
Ok(data) => Ok(data),
|
||||
Ok(data) => Ok(data.without_excluded()),
|
||||
Err(e) => cache_result.or(Err(e)),
|
||||
}
|
||||
}
|
||||
@@ -475,10 +516,18 @@ impl MediaRepository for HybridRepository {
|
||||
let server_handle =
|
||||
tokio::spawn(async move { online.get_items(&parent_id_clone, options).await });
|
||||
|
||||
// Check cache first (fast, 100ms timeout)
|
||||
// Check cache first (fast, 100ms timeout).
|
||||
//
|
||||
// Exclusions are applied here rather than at each return below so the
|
||||
// "has content" decisions further down are made about what the user will
|
||||
// actually see. `get_items` is the one query that does not go through
|
||||
// `parallel_race` — it interleaves the downloads-only gate and a
|
||||
// background cache write — so it applies the filter itself.
|
||||
// TRACES: UR-076 | DR-209
|
||||
let cache_result = self
|
||||
.cache_with_timeout(async move { offline.get_items(&parent_id, opts_clone).await })
|
||||
.await;
|
||||
.await
|
||||
.map(ExcludeHidden::without_excluded);
|
||||
|
||||
// Downloads-only gate: when the "Show all server media" toggle is off
|
||||
// (offline), an empty offline result is authoritative — the user asked
|
||||
@@ -555,7 +604,12 @@ impl MediaRepository for HybridRepository {
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(server_data)
|
||||
// The cache keeps the server's full page (above) — an exclusion
|
||||
// is a view preference and can be undone, so hiding items from
|
||||
// the *cache* would make un-hiding them require a re-crawl. Only
|
||||
// what is handed back is filtered.
|
||||
// TRACES: UR-076 | DR-209
|
||||
Ok(server_data.without_excluded())
|
||||
}
|
||||
Ok(Err(e)) => cache_result.or(Err(e)),
|
||||
Err(join_err) => cache_result.or(Err(RepoError::Network {
|
||||
@@ -667,7 +721,10 @@ impl MediaRepository for HybridRepository {
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, RepoError> {
|
||||
// Next up is dynamic, always fetch from server
|
||||
self.online.get_next_up_episodes(series_id, limit).await
|
||||
self.online
|
||||
.get_next_up_episodes(series_id, limit)
|
||||
.await
|
||||
.map(ExcludeHidden::without_excluded)
|
||||
}
|
||||
|
||||
async fn get_recently_played_audio(
|
||||
@@ -831,12 +888,18 @@ impl MediaRepository for HybridRepository {
|
||||
|
||||
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
|
||||
// Live TV requires server communication - delegate to online repository
|
||||
self.online.get_live_tv_channels().await
|
||||
self.online
|
||||
.get_live_tv_channels()
|
||||
.await
|
||||
.map(ExcludeHidden::without_excluded)
|
||||
}
|
||||
|
||||
async fn get_channels(&self) -> Result<SearchResult, RepoError> {
|
||||
// Plugin channels require server communication - delegate to online repository
|
||||
self.online.get_channels().await
|
||||
self.online
|
||||
.get_channels()
|
||||
.await
|
||||
.map(ExcludeHidden::without_excluded)
|
||||
}
|
||||
|
||||
async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError> {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
pub mod device_profile;
|
||||
/// User-chosen browsing exclusions (UR-076 / DR-209).
|
||||
pub mod exclusions;
|
||||
pub mod hybrid;
|
||||
pub mod offline;
|
||||
pub mod online;
|
||||
|
||||
@@ -1128,7 +1128,7 @@ impl OfflineRepository {
|
||||
let device_total_bytes: i64 = leaves.iter().map(|(_, b)| *b).sum();
|
||||
|
||||
let mut sizes = std::collections::HashMap::new();
|
||||
for (id, bytes) in leaves.into_iter().chain(containers.into_iter()) {
|
||||
for (id, bytes) in leaves.into_iter().chain(containers) {
|
||||
// A container id can never collide with a leaf id, so a plain insert
|
||||
// is fine; use entry to be defensive against duplicate rows.
|
||||
*sizes.entry(id).or_insert(0) += bytes;
|
||||
@@ -1245,20 +1245,22 @@ impl MediaRepository for OfflineRepository {
|
||||
_ => "i.sort_name ASC, i.name ASC",
|
||||
};
|
||||
|
||||
// Build type filter for optional filtering
|
||||
let type_filter = if let Some(include_item_types) = &opts.include_item_types {
|
||||
if !include_item_types.is_empty() {
|
||||
let types = include_item_types
|
||||
.iter()
|
||||
.map(|t| format!("'{}'", t))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
format!(" AND i.item_type IN ({})", types)
|
||||
} else {
|
||||
// Bind the type filter rather than interpolating it: `include_item_types`
|
||||
// is settable straight from the frontend (GenericMediaListPage passes it),
|
||||
// so a quote in a type must be data, not syntax. Same shape as `search`
|
||||
// and `get_favorites`.
|
||||
//
|
||||
// TRACES: UR-065 | DR-212 | UT-206
|
||||
let type_values: &[String] = opts
|
||||
.include_item_types
|
||||
.as_deref()
|
||||
.filter(|types| !types.is_empty())
|
||||
.unwrap_or(&[]);
|
||||
let type_filter = if type_values.is_empty() {
|
||||
String::new()
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
let placeholders = vec!["?"; type_values.len()].join(",");
|
||||
format!(" AND i.item_type IN ({})", placeholders)
|
||||
};
|
||||
|
||||
// Favourites narrowing for a normal library listing. Bound rather than
|
||||
@@ -1350,6 +1352,11 @@ impl MediaRepository for OfflineRepository {
|
||||
QueryParam::String(parent_id.to_string()), // i.series_id = ?
|
||||
QueryParam::String(parent_id.to_string()), // libraries.id = ?
|
||||
];
|
||||
// Positional order matters: the type placeholders sit in `{type_filter}`,
|
||||
// which the statement interpolates immediately after the parent-matching
|
||||
// group and before `{favorites_filter}`, so they bind here — after the
|
||||
// six ids above, before the favourites user id.
|
||||
params.extend(type_values.iter().cloned().map(QueryParam::String));
|
||||
if !favorites_filter.is_empty() {
|
||||
params.push(QueryParam::String(self.user_id.clone())); // ud.user_id = ?
|
||||
}
|
||||
@@ -4481,6 +4488,113 @@ mod tests {
|
||||
assert_eq!(ids, vec!["movie-fav"]);
|
||||
}
|
||||
|
||||
/// UT-206 — `include_item_types` reaches the listing query as bound
|
||||
/// parameters, so a type name can only ever be compared as data.
|
||||
///
|
||||
/// Interpolated, the type below closed the `IN (` list and commented out the
|
||||
/// rest of the line, leaving `... AND i.item_type IN ('Movie') OR 1=1`, which
|
||||
/// is true for every row — the listing then returned the whole cache
|
||||
/// regardless of parent or type. Bound, it is just a type name that matches
|
||||
/// nothing.
|
||||
///
|
||||
/// TRACES: UR-065 | DR-212 | UT-206
|
||||
#[tokio::test]
|
||||
async fn test_get_items_type_filter_is_bound_not_interpolated() {
|
||||
let _guard = lock_catalog_browse();
|
||||
set_include_catalog_browse(true);
|
||||
|
||||
let db_service = create_test_db();
|
||||
seed_favorites(&db_service).await;
|
||||
let repo = OfflineRepository::new(
|
||||
db_service,
|
||||
"test-server".to_string(),
|
||||
"test-user".to_string(),
|
||||
);
|
||||
|
||||
let injected = repo
|
||||
.get_items(
|
||||
"lib-1",
|
||||
Some(GetItemsOptions {
|
||||
include_item_types: Some(vec!["Movie') OR 1=1 --".to_string()]),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("a hostile type name must be data, not a broken query");
|
||||
assert!(
|
||||
injected.items.is_empty(),
|
||||
"no cached item has that type, so nothing may come back; got {:?}",
|
||||
injected
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| i.id.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
// A quote on its own is likewise just a character in a type name.
|
||||
let quoted = repo
|
||||
.get_items(
|
||||
"lib-1",
|
||||
Some(GetItemsOptions {
|
||||
include_item_types: Some(vec!["Mo'vie".to_string()]),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("an embedded quote must not break the query");
|
||||
assert!(quoted.items.is_empty());
|
||||
}
|
||||
|
||||
/// UT-206 — binding the type filter must not disturb the positions of the
|
||||
/// parameters around it: the parent ids bind before it and the favourites
|
||||
/// user id after it. A misordered vec would silently compare `user_id`
|
||||
/// against `item_type`, so this asserts the filters still compose.
|
||||
///
|
||||
/// TRACES: UR-065, UR-067 | DR-212 | UT-206
|
||||
#[tokio::test]
|
||||
async fn test_get_items_binds_multiple_types_in_parameter_order() {
|
||||
let _guard = lock_catalog_browse();
|
||||
set_include_catalog_browse(true);
|
||||
|
||||
let db_service = create_test_db();
|
||||
seed_favorites(&db_service).await;
|
||||
let repo = OfflineRepository::new(
|
||||
db_service,
|
||||
"test-server".to_string(),
|
||||
"test-user".to_string(),
|
||||
);
|
||||
|
||||
let both = repo
|
||||
.get_items(
|
||||
"lib-1",
|
||||
Some(GetItemsOptions {
|
||||
include_item_types: Some(vec!["Movie".to_string(), "MusicAlbum".to_string()]),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut ids: Vec<&str> = both.items.iter().map(|i| i.id.as_str()).collect();
|
||||
ids.sort();
|
||||
assert_eq!(ids, vec!["album-fav", "movie-fav", "movie-plain"]);
|
||||
|
||||
// Two type placeholders *and* the favourites parameter after them.
|
||||
let favourites = repo
|
||||
.get_items(
|
||||
"lib-1",
|
||||
Some(GetItemsOptions {
|
||||
include_item_types: Some(vec!["Movie".to_string(), "MusicAlbum".to_string()]),
|
||||
favorites_only: Some(true),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut ids: Vec<&str> = favourites.items.iter().map(|i| i.id.as_str()).collect();
|
||||
ids.sort();
|
||||
assert_eq!(ids, vec!["album-fav", "movie-fav"]);
|
||||
}
|
||||
|
||||
/// UT-102 — caching a server result mirrors its favourite state locally,
|
||||
/// but never over a row still waiting to be pushed.
|
||||
///
|
||||
|
||||
@@ -235,7 +235,11 @@ impl OnlineRepository {
|
||||
item_id: &str,
|
||||
t: f64,
|
||||
) -> Result<Vec<JRayActor>, RepoError> {
|
||||
let endpoint = format!("/Plugins/JRay/Items/{}/jray?t={}", item_id, t);
|
||||
let endpoint = format!(
|
||||
"/Plugins/JRay/Items/{}/jray?t={}",
|
||||
urlencoding::encode(item_id),
|
||||
t
|
||||
);
|
||||
match self.get_json::<JRayContext>(&endpoint).await {
|
||||
Ok(context) => Ok(context.actors),
|
||||
// No plugin / no truth data for this item — not an error to the user.
|
||||
@@ -802,7 +806,17 @@ fn build_get_items_endpoint(
|
||||
parent_id: &str,
|
||||
options: Option<&GetItemsOptions>,
|
||||
) -> String {
|
||||
let mut endpoint = format!("/Users/{}/Items?ParentId={}", user_id, parent_id);
|
||||
// Every value below is percent-encoded before it goes into the query
|
||||
// string, the same way `Genres` and `SearchTerm` already are: these are
|
||||
// values, not URL syntax, so a space or an `&` in one must not split it
|
||||
// into another parameter.
|
||||
//
|
||||
// TRACES: UR-007 | DR-212 | UT-206
|
||||
let mut endpoint = format!(
|
||||
"/Users/{}/Items?ParentId={}",
|
||||
user_id,
|
||||
urlencoding::encode(parent_id)
|
||||
);
|
||||
|
||||
if let Some(opts) = options {
|
||||
if let Some(limit) = opts.limit {
|
||||
@@ -812,13 +826,25 @@ fn build_get_items_endpoint(
|
||||
endpoint.push_str(&format!("&StartIndex={}", start_index));
|
||||
}
|
||||
if let Some(types) = &opts.include_item_types {
|
||||
endpoint.push_str(&format!("&IncludeItemTypes={}", types.join(",")));
|
||||
// Encode each type, not the joined string: the comma is the
|
||||
// list separator Jellyfin splits on.
|
||||
let encoded: Vec<String> = types
|
||||
.iter()
|
||||
.map(|t| urlencoding::encode(t).into_owned())
|
||||
.collect();
|
||||
endpoint.push_str(&format!("&IncludeItemTypes={}", encoded.join(",")));
|
||||
}
|
||||
if let Some(sort_by) = &opts.sort_by {
|
||||
endpoint.push_str(&format!("&SortBy={}", sort_by));
|
||||
// SortBy is likewise a comma-delimited list (`hybrid.rs` sends
|
||||
// "ParentIndexNumber,IndexNumber,SortName"), so encode per field.
|
||||
let encoded: Vec<String> = sort_by
|
||||
.split(',')
|
||||
.map(|field| urlencoding::encode(field).into_owned())
|
||||
.collect();
|
||||
endpoint.push_str(&format!("&SortBy={}", encoded.join(",")));
|
||||
}
|
||||
if let Some(sort_order) = &opts.sort_order {
|
||||
endpoint.push_str(&format!("&SortOrder={}", sort_order));
|
||||
endpoint.push_str(&format!("&SortOrder={}", urlencoding::encode(sort_order)));
|
||||
}
|
||||
if let Some(recursive) = opts.recursive {
|
||||
endpoint.push_str(&format!("&Recursive={}", recursive));
|
||||
@@ -1147,7 +1173,7 @@ impl MediaRepository for OnlineRepository {
|
||||
///
|
||||
/// TRACES: UR-021, UR-035 | IR-016, IR-022, JA-005, JA-009
|
||||
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
|
||||
let endpoint = format!("/Users/{}/Items/{}?Fields=BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources,PremiereDate,UserData", self.user_id, item_id);
|
||||
let endpoint = format!("/Users/{}/Items/{}?Fields=BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources,PremiereDate,UserData", self.user_id, urlencoding::encode(item_id));
|
||||
|
||||
let item: JellyfinItem = self.get_json(&endpoint).await?;
|
||||
let media_item = item.into_media_item(self.user_id.clone());
|
||||
@@ -1510,7 +1536,7 @@ impl MediaRepository for OnlineRepository {
|
||||
}
|
||||
|
||||
async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError> {
|
||||
let endpoint = format!("/Items/{}/PlaybackInfo", item_id);
|
||||
let endpoint = format!("/Items/{}/PlaybackInfo", urlencoding::encode(item_id));
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
@@ -1939,7 +1965,7 @@ impl MediaRepository for OnlineRepository {
|
||||
live_stream_id: Option<String>,
|
||||
}
|
||||
|
||||
let endpoint = format!("/Items/{}/PlaybackInfo", item_id);
|
||||
let endpoint = format!("/Items/{}/PlaybackInfo", urlencoding::encode(item_id));
|
||||
let request = OpenLiveStreamRequest {
|
||||
user_id: self.user_id.clone(),
|
||||
auto_open_live_stream: true,
|
||||
@@ -2214,7 +2240,11 @@ impl MediaRepository for OnlineRepository {
|
||||
}
|
||||
|
||||
async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
let endpoint = format!("/Users/{}/FavoriteItems/{}", self.user_id, item_id);
|
||||
let endpoint = format!(
|
||||
"/Users/{}/FavoriteItems/{}",
|
||||
self.user_id,
|
||||
urlencoding::encode(item_id)
|
||||
);
|
||||
self.post_json(&endpoint, &serde_json::json!({})).await
|
||||
}
|
||||
|
||||
@@ -2244,7 +2274,11 @@ impl MediaRepository for OnlineRepository {
|
||||
///
|
||||
/// TRACES: UR-017 | JA-018, DR-021
|
||||
async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
let endpoint = format!("/Users/{}/FavoriteItems/{}", self.user_id, item_id);
|
||||
let endpoint = format!(
|
||||
"/Users/{}/FavoriteItems/{}",
|
||||
self.user_id,
|
||||
urlencoding::encode(item_id)
|
||||
);
|
||||
let url = format!("{}{}", self.server_url, endpoint);
|
||||
|
||||
let result = async {
|
||||
@@ -2286,7 +2320,11 @@ impl MediaRepository for OnlineRepository {
|
||||
///
|
||||
/// TRACES: UR-064 | DR-106, JA-033
|
||||
async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
let endpoint = format!("/Users/{}/PlayedItems/{}", self.user_id, item_id);
|
||||
let endpoint = format!(
|
||||
"/Users/{}/PlayedItems/{}",
|
||||
self.user_id,
|
||||
urlencoding::encode(item_id)
|
||||
);
|
||||
let url = format!("{}{}", self.server_url, endpoint);
|
||||
|
||||
let result = async {
|
||||
@@ -2327,7 +2365,11 @@ impl MediaRepository for OnlineRepository {
|
||||
///
|
||||
/// TRACES: UR-025 | DR-131 | JA-035
|
||||
async fn mark_played(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
let endpoint = format!("/Users/{}/PlayedItems/{}", self.user_id, item_id);
|
||||
let endpoint = format!(
|
||||
"/Users/{}/PlayedItems/{}",
|
||||
self.user_id,
|
||||
urlencoding::encode(item_id)
|
||||
);
|
||||
let url = format!("{}{}", self.server_url, endpoint);
|
||||
|
||||
let result = async {
|
||||
@@ -2372,7 +2414,11 @@ impl MediaRepository for OnlineRepository {
|
||||
///
|
||||
/// TRACES: UR-035, UR-036 | IR-022, JA-030
|
||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
let endpoint = format!("/Users/{}/Items/{}", self.user_id, person_id);
|
||||
let endpoint = format!(
|
||||
"/Users/{}/Items/{}",
|
||||
self.user_id,
|
||||
urlencoding::encode(person_id)
|
||||
);
|
||||
let item: JellyfinItem = self.get_json(&endpoint).await?;
|
||||
Ok(item.into_media_item(self.user_id.clone()))
|
||||
}
|
||||
@@ -2461,7 +2507,7 @@ impl MediaRepository for OnlineRepository {
|
||||
|
||||
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
|
||||
info!("[OnlineRepo] Deleting playlist {}", playlist_id);
|
||||
let endpoint = format!("/Items/{}", playlist_id);
|
||||
let endpoint = format!("/Items/{}", urlencoding::encode(playlist_id));
|
||||
let url = format!("{}{}", self.server_url, endpoint);
|
||||
|
||||
let request = self
|
||||
@@ -2496,7 +2542,7 @@ impl MediaRepository for OnlineRepository {
|
||||
"[OnlineRepo] Renaming playlist {} to '{}'",
|
||||
playlist_id, name
|
||||
);
|
||||
let endpoint = format!("/Items/{}", playlist_id);
|
||||
let endpoint = format!("/Items/{}", urlencoding::encode(playlist_id));
|
||||
self.post_json(&endpoint, &serde_json::json!({ "Name": name }))
|
||||
.await
|
||||
}
|
||||
@@ -2534,8 +2580,17 @@ impl MediaRepository for OnlineRepository {
|
||||
item_ids.len(),
|
||||
playlist_id
|
||||
);
|
||||
let ids_param = item_ids.join(",");
|
||||
let endpoint = format!("/Playlists/{}/Items?Ids={}", playlist_id, ids_param);
|
||||
// Encode each id, not the joined string: the comma separates the list.
|
||||
let ids_param = item_ids
|
||||
.iter()
|
||||
.map(|id| urlencoding::encode(id).into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let endpoint = format!(
|
||||
"/Playlists/{}/Items?Ids={}",
|
||||
urlencoding::encode(playlist_id),
|
||||
ids_param
|
||||
);
|
||||
self.post_json(&endpoint, &serde_json::json!({})).await
|
||||
}
|
||||
|
||||
@@ -2549,8 +2604,16 @@ impl MediaRepository for OnlineRepository {
|
||||
entry_ids.len(),
|
||||
playlist_id
|
||||
);
|
||||
let ids_param = entry_ids.join(",");
|
||||
let endpoint = format!("/Playlists/{}/Items?EntryIds={}", playlist_id, ids_param);
|
||||
let ids_param = entry_ids
|
||||
.iter()
|
||||
.map(|id| urlencoding::encode(id).into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let endpoint = format!(
|
||||
"/Playlists/{}/Items?EntryIds={}",
|
||||
urlencoding::encode(playlist_id),
|
||||
ids_param
|
||||
);
|
||||
let url = format!("{}{}", self.server_url, endpoint);
|
||||
|
||||
let request = self
|
||||
@@ -3529,6 +3592,73 @@ mod tests {
|
||||
assert!(!off.contains("Filters=IsFavorite"));
|
||||
}
|
||||
|
||||
/// UT-206 — the values this endpoint builder puts in the query string are
|
||||
/// percent-encoded, like `Genres` and `SearchTerm` already are.
|
||||
///
|
||||
/// Unencoded, a value carrying `&` or `=` splits into an extra query
|
||||
/// parameter (a parent id containing a space produced a malformed URL
|
||||
/// outright), so the request the server sees is not the one that was built.
|
||||
///
|
||||
/// TRACES: UR-007 | DR-212 | UT-206
|
||||
#[test]
|
||||
fn test_get_items_endpoint_encodes_query_values() {
|
||||
let endpoint = build_get_items_endpoint(
|
||||
"u1",
|
||||
"lib 1&Filters=IsFavorite",
|
||||
Some(&GetItemsOptions {
|
||||
include_item_types: Some(vec!["Movie&x=1".to_string()]),
|
||||
sort_by: Some("Sort Name".to_string()),
|
||||
sort_order: Some("Ascending&y=2".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert!(
|
||||
endpoint.contains("ParentId=lib%201%26Filters%3DIsFavorite"),
|
||||
"{endpoint}"
|
||||
);
|
||||
assert!(
|
||||
endpoint.contains("&IncludeItemTypes=Movie%26x%3D1"),
|
||||
"{endpoint}"
|
||||
);
|
||||
assert!(endpoint.contains("&SortBy=Sort%20Name"), "{endpoint}");
|
||||
assert!(
|
||||
endpoint.contains("&SortOrder=Ascending%26y%3D2"),
|
||||
"{endpoint}"
|
||||
);
|
||||
// Nothing smuggled in as a parameter of its own.
|
||||
assert!(!endpoint.contains("&Filters=IsFavorite"), "{endpoint}");
|
||||
assert!(!endpoint.contains("&x=1"), "{endpoint}");
|
||||
assert!(!endpoint.contains("&y=2"), "{endpoint}");
|
||||
}
|
||||
|
||||
/// The separators inside a list parameter must survive encoding: Jellyfin
|
||||
/// splits `SortBy` and `IncludeItemTypes` on commas, and `hybrid.rs` sends
|
||||
/// "ParentIndexNumber,IndexNumber,SortName" to order episodes.
|
||||
///
|
||||
/// TRACES: UR-007 | DR-212 | UT-206
|
||||
#[test]
|
||||
fn test_get_items_endpoint_keeps_list_separators() {
|
||||
let endpoint = build_get_items_endpoint(
|
||||
"u1",
|
||||
"lib-1",
|
||||
Some(&GetItemsOptions {
|
||||
sort_by: Some("ParentIndexNumber,IndexNumber,SortName".to_string()),
|
||||
include_item_types: Some(vec!["Movie".to_string(), "Series".to_string()]),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert!(
|
||||
endpoint.contains("&SortBy=ParentIndexNumber,IndexNumber,SortName"),
|
||||
"{endpoint}"
|
||||
);
|
||||
assert!(
|
||||
endpoint.contains("&IncludeItemTypes=Movie,Series"),
|
||||
"{endpoint}"
|
||||
);
|
||||
// A plain GUID parent id is unchanged by encoding.
|
||||
assert!(endpoint.contains("ParentId=lib-1"), "{endpoint}");
|
||||
}
|
||||
|
||||
/// A newly-added album must arrive as one entry, not one per track.
|
||||
///
|
||||
/// Jellyfin's `/Items/Latest` defaults to `GroupItems=false`, which returns
|
||||
|
||||
@@ -320,6 +320,52 @@ impl VideoSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Library browsing preferences.
|
||||
///
|
||||
/// Currently a single list: the folders (or whole libraries) the user has asked
|
||||
/// to keep out of browsing. It is a *list of ids*, never names — names are
|
||||
/// unstable, locale-dependent and non-unique, and the hardcoded name filter this
|
||||
/// setting replaced broke on exactly that. What the ids then hide is decided in
|
||||
/// `repository::exclusions`; this struct is only how the choice is carried and
|
||||
/// persisted.
|
||||
///
|
||||
/// The default is an empty list: nobody inherits another user's folder layout.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209
|
||||
#[derive(specta::Type, Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibrarySettings {
|
||||
/// Stable item ids of the folders/libraries hidden from browsing.
|
||||
///
|
||||
/// `#[serde(default)]` so settings JSON persisted before this field existed
|
||||
/// loads as the previous behaviour (nothing hidden).
|
||||
#[serde(default)]
|
||||
pub excluded_item_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl LibrarySettings {
|
||||
/// Drop blanks and duplicates from the id list.
|
||||
///
|
||||
/// Applied on the way in from IPC and on the way out of the database, so a
|
||||
/// hand-edited or half-written value cannot make the list grow without bound
|
||||
/// or carry an empty id (which would match nothing but still be shown as a
|
||||
/// selection in the picker).
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209
|
||||
pub fn sanitised(mut self) -> Self {
|
||||
let mut seen: Vec<String> = Vec::with_capacity(self.excluded_item_ids.len());
|
||||
for id in self.excluded_item_ids.drain(..) {
|
||||
let id = id.trim().to_string();
|
||||
if id.is_empty() || seen.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
seen.push(id);
|
||||
}
|
||||
self.excluded_item_ids = seen;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialise `AudioSettings` into the JSON payload handed to the Android player
|
||||
/// over JNI.
|
||||
///
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Thumbnail cache manager with LRU eviction
|
||||
|
||||
use log::error;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
@@ -28,6 +28,19 @@ impl Default for CacheConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Make one part of a cache filename safe to put in a path.
|
||||
///
|
||||
/// Every part of the name comes from the caller — the item id and image type are
|
||||
/// taken verbatim from Jellyfin JSON — so none of them may contribute a path
|
||||
/// separator or a `..`. The rule is the one the image tag has always used
|
||||
/// (non-alphanumerics become `_`), applied to all three parts, so values that
|
||||
/// were already safe keep producing exactly the filename they did before.
|
||||
///
|
||||
/// TRACES: | DR-210 | UT-204
|
||||
fn safe_component(value: &str) -> String {
|
||||
value.replace(|c: char| !c.is_alphanumeric(), "_")
|
||||
}
|
||||
|
||||
/// Thumbnail cache with LRU eviction
|
||||
pub struct ThumbnailCache {
|
||||
config: Arc<Mutex<CacheConfig>>,
|
||||
@@ -50,6 +63,34 @@ impl ThumbnailCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a cache filename against the cache directory, refusing anything
|
||||
/// that lands outside it.
|
||||
///
|
||||
/// `..` is folded away lexically rather than through `canonicalize`, so a
|
||||
/// file that does not exist yet still resolves — the same approach as
|
||||
/// `media_server::resolve_path`. `safe_component` should already have made an
|
||||
/// escape impossible; this is the check at the point of use.
|
||||
///
|
||||
/// TRACES: | DR-210 | UT-204
|
||||
fn resolve_in_cache_dir(&self, filename: &str) -> Result<PathBuf, String> {
|
||||
let mut resolved = self.cache_dir.clone();
|
||||
for part in Path::new(filename).components() {
|
||||
match part {
|
||||
std::path::Component::ParentDir => {
|
||||
resolved.pop();
|
||||
}
|
||||
std::path::Component::CurDir => {}
|
||||
other => resolved.push(other),
|
||||
}
|
||||
}
|
||||
|
||||
if resolved.starts_with(&self.cache_dir) {
|
||||
Ok(resolved)
|
||||
} else {
|
||||
Err("Thumbnail path escapes the cache directory".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if caching is enabled
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.config.lock().map(|c| c.enabled).unwrap_or(true)
|
||||
@@ -155,6 +196,8 @@ impl ThumbnailCache {
|
||||
}
|
||||
|
||||
/// Save thumbnail to cache
|
||||
///
|
||||
/// TRACES: | DR-210 | UT-204
|
||||
// The arguments are the cache key (item/type/tag) plus the payload and its
|
||||
// dimensions — all independent scalars borrowed from the caller. A parameter
|
||||
// struct would only move the same list one level down.
|
||||
@@ -173,10 +216,16 @@ impl ThumbnailCache {
|
||||
return Err("Thumbnail caching is disabled".to_string());
|
||||
}
|
||||
|
||||
// Generate safe filename
|
||||
let safe_tag = tag.replace(|c: char| !c.is_alphanumeric(), "_");
|
||||
let filename = format!("{}_{}_{}.jpg", item_id, image_type, safe_tag);
|
||||
let file_path = self.cache_dir.join(&filename);
|
||||
// Generate safe filename. The database keeps the *raw* key below, so the
|
||||
// lookup in `get_cached_path` still matches what the caller asks for; only
|
||||
// the on-disk name is sanitised, and the row records where it landed.
|
||||
let filename = format!(
|
||||
"{}_{}_{}.jpg",
|
||||
safe_component(item_id),
|
||||
safe_component(image_type),
|
||||
safe_component(tag)
|
||||
);
|
||||
let file_path = self.resolve_in_cache_dir(&filename)?;
|
||||
|
||||
// Ensure we have space (evict LRU items if needed)
|
||||
self.ensure_space(db.clone(), data.len() as u64).await?;
|
||||
@@ -571,4 +620,171 @@ mod tests {
|
||||
let size = cache.get_cache_size(conn.clone()).await;
|
||||
assert!(size <= 60);
|
||||
}
|
||||
|
||||
/// A traversal-style `item_id` must not steer a cache write out of the cache
|
||||
/// directory. The id reaches `save_thumbnail` verbatim from Jellyfin JSON, so
|
||||
/// it is not ours to trust.
|
||||
///
|
||||
/// TRACES: | DR-210 | UT-204
|
||||
#[tokio::test]
|
||||
async fn test_save_thumbnail_confines_traversal_item_id() {
|
||||
let (conn, temp_dir) = setup_test_db();
|
||||
let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
|
||||
|
||||
let result = cache
|
||||
.save_thumbnail(
|
||||
conn.clone(),
|
||||
"../evil",
|
||||
"Primary",
|
||||
"tag1",
|
||||
b"fake image data",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Where `../evil` lands if `..` is honoured: the cache dir's parent.
|
||||
let escaped = temp_dir.path().join("evil_Primary_tag1.jpg");
|
||||
assert!(
|
||||
!escaped.exists(),
|
||||
"wrote outside the cache directory: {}",
|
||||
escaped.display()
|
||||
);
|
||||
|
||||
// Refusing is acceptable; succeeding is too, as long as it stayed inside.
|
||||
if let Ok(path) = result {
|
||||
assert!(
|
||||
path.starts_with(&cache.cache_dir) && !path.to_string_lossy().contains(".."),
|
||||
"returned a path outside the cache directory: {}",
|
||||
path.display()
|
||||
);
|
||||
assert!(path.exists());
|
||||
}
|
||||
}
|
||||
|
||||
/// `Path::join` discards the base when handed an absolute path, so an
|
||||
/// absolute `item_id` would otherwise pick the write location outright.
|
||||
///
|
||||
/// TRACES: | DR-210 | UT-204
|
||||
#[tokio::test]
|
||||
async fn test_save_thumbnail_confines_absolute_item_id() {
|
||||
let (conn, temp_dir) = setup_test_db();
|
||||
let outside = TempDir::new().unwrap();
|
||||
let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
|
||||
|
||||
let absolute_id = outside.path().join("evil").to_string_lossy().to_string();
|
||||
let result = cache
|
||||
.save_thumbnail(
|
||||
conn.clone(),
|
||||
&absolute_id,
|
||||
"Primary",
|
||||
"tag1",
|
||||
b"fake image data",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let escaped = outside.path().join("evil_Primary_tag1.jpg");
|
||||
assert!(
|
||||
!escaped.exists(),
|
||||
"wrote outside the cache directory: {}",
|
||||
escaped.display()
|
||||
);
|
||||
|
||||
if let Ok(path) = result {
|
||||
assert!(
|
||||
path.starts_with(&cache.cache_dir),
|
||||
"returned a path outside the cache directory: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `image_type` is equally unsanitised, and equally caller-supplied.
|
||||
///
|
||||
/// TRACES: | DR-210 | UT-204
|
||||
#[tokio::test]
|
||||
async fn test_save_thumbnail_confines_traversal_image_type() {
|
||||
let (conn, temp_dir) = setup_test_db();
|
||||
let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
|
||||
|
||||
let path = cache
|
||||
.save_thumbnail(
|
||||
conn.clone(),
|
||||
"item1",
|
||||
"../Primary",
|
||||
"tag1",
|
||||
b"fake image data",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("a malformed image_type should be sanitised, not break caching");
|
||||
|
||||
// The file belongs directly in the cache dir — no separator from the
|
||||
// image type may survive into the filename.
|
||||
assert_eq!(path.parent(), Some(cache.cache_dir.as_path()));
|
||||
assert!(path.exists());
|
||||
}
|
||||
|
||||
/// Ids, types and tags that were already filesystem-safe — the overwhelming
|
||||
/// majority — keep producing exactly the filename they did before, so
|
||||
/// sanitising does not orphan existing cache entries.
|
||||
///
|
||||
/// TRACES: | DR-210 | UT-204
|
||||
#[tokio::test]
|
||||
async fn test_save_thumbnail_filename_unchanged_for_safe_values() {
|
||||
let (conn, temp_dir) = setup_test_db();
|
||||
let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
|
||||
|
||||
let path = cache
|
||||
.save_thumbnail(
|
||||
conn.clone(),
|
||||
"a1b2c3d4e5f60718293a4b5c6d7e8f90",
|
||||
"Primary",
|
||||
"abcdef0123456789",
|
||||
b"fake image data",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
path,
|
||||
cache
|
||||
.cache_dir
|
||||
.join("a1b2c3d4e5f60718293a4b5c6d7e8f90_Primary_abcdef0123456789.jpg")
|
||||
);
|
||||
}
|
||||
|
||||
/// Sanitising the filename must not desynchronise the write path from the
|
||||
/// read path: the database keeps the raw key and the resolved path, so a
|
||||
/// lookup after a save still finds the file that was written.
|
||||
///
|
||||
/// TRACES: | DR-210 | UT-204
|
||||
#[tokio::test]
|
||||
async fn test_traversal_item_id_still_round_trips() {
|
||||
let (conn, temp_dir) = setup_test_db();
|
||||
let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
|
||||
|
||||
let saved = cache
|
||||
.save_thumbnail(
|
||||
conn.clone(),
|
||||
"../evil",
|
||||
"Primary",
|
||||
"tag1",
|
||||
b"fake image data",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let cached = cache
|
||||
.get_cached_path(conn.clone(), "../evil", "Primary", "tag1")
|
||||
.await;
|
||||
assert_eq!(cached, Some(saved));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "jellytau",
|
||||
"version": "0.8.0",
|
||||
"productName": "JellyTau",
|
||||
"version": "0.9.1",
|
||||
"identifier": "com.dtourolle.jellytau",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
@@ -12,9 +12,12 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "jellytau",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
"title": "JellyTau",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600,
|
||||
"resizable": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
@@ -22,19 +25,53 @@
|
||||
"devCsp": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: asset: http://asset.localhost http: https:; media-src 'self' blob: asset: http://asset.localhost http://127.0.0.1:* http: https:; connect-src 'self' ipc: http://ipc.localhost http: https: ws: wss:; worker-src 'self' blob:; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'",
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": ["$APPDATA/thumbnails/**"]
|
||||
"scope": [
|
||||
"$APPDATA/thumbnails/**"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["deb", "rpm", "nsis"],
|
||||
"targets": [
|
||||
"deb",
|
||||
"rpm",
|
||||
"nsis"
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"publisher": "Duncan Tourolle",
|
||||
"copyright": "Copyright \u00a9 2026 Duncan Tourolle",
|
||||
"category": "Video",
|
||||
"shortDescription": "A cross-platform Jellyfin client",
|
||||
"longDescription": "JellyTau is a Jellyfin client for Linux and Android. It streams and downloads music and video from a Jellyfin server, plays them back offline, and can control other Jellyfin sessions on the network.",
|
||||
"licenseFile": "../LICENSE",
|
||||
"linux": {
|
||||
"deb": {
|
||||
"provides": [
|
||||
"jellytau"
|
||||
],
|
||||
"conflicts": [
|
||||
"jellytau"
|
||||
],
|
||||
"replaces": [
|
||||
"jellytau"
|
||||
]
|
||||
},
|
||||
"rpm": {
|
||||
"provides": [
|
||||
"jellytau"
|
||||
],
|
||||
"obsoletes": [
|
||||
"jellytau"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"mainBinaryName": "jellytau"
|
||||
}
|
||||
|
||||
@@ -1079,6 +1079,48 @@ async catalogSyncStatus() : Promise<CatalogSyncStatus> {
|
||||
async setShowServerCatalog(show: boolean) : Promise<void> {
|
||||
await TAURI_INVOKE("set_show_server_catalog", { show });
|
||||
},
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
async libraryGetSettings() : Promise<LibrarySettings> {
|
||||
return await TAURI_INVOKE("library_get_settings");
|
||||
},
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
async librarySetSettings(settings: LibrarySettings) : Promise<LibrarySettings> {
|
||||
return await TAURI_INVOKE("library_set_settings", { settings });
|
||||
},
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
async libraryGetExclusionCandidates(handle: string) : Promise<ExclusionCandidate[]> {
|
||||
return await TAURI_INVOKE("library_get_exclusion_candidates", { handle });
|
||||
},
|
||||
/**
|
||||
* Resolve the stream URL for every download row that was queued while offline
|
||||
* (`status = 'pending' AND stream_url IS NULL`), then pump the queue so they
|
||||
@@ -2080,6 +2122,34 @@ remaining: number }
|
||||
* TRACES: UR-027 | DR-030
|
||||
*/
|
||||
export type EqPreset = "flat" | "rock" | "pop" | "jazz" | "classical" | "bassBoost" | "trebleBoost" | "vocal"
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export type ExclusionCandidate = {
|
||||
/**
|
||||
* Stable Jellyfin item id — what gets stored when the user picks it.
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Display name of the folder (or of the library, for a whole-library entry).
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Library this candidate lives in, so the picker can group and disambiguate
|
||||
* two folders that share a name.
|
||||
*/
|
||||
libraryName: string;
|
||||
/**
|
||||
* True when the candidate *is* a library rather than a folder inside one.
|
||||
*/
|
||||
isLibrary: boolean }
|
||||
/**
|
||||
* Genre
|
||||
*/
|
||||
@@ -2137,6 +2207,28 @@ export type Library = { id: string; name: string; collectionType: string; imageT
|
||||
* TRACES: UR-075 | DR-175
|
||||
*/
|
||||
favoritesScope?: SearchScope | null }
|
||||
/**
|
||||
* Library browsing preferences.
|
||||
*
|
||||
* Currently a single list: the folders (or whole libraries) the user has asked
|
||||
* to keep out of browsing. It is a *list of ids*, never names — names are
|
||||
* unstable, locale-dependent and non-unique, and the hardcoded name filter this
|
||||
* setting replaced broke on exactly that. What the ids then hide is decided in
|
||||
* `repository::exclusions`; this struct is only how the choice is carried and
|
||||
* persisted.
|
||||
*
|
||||
* The default is an empty list: nobody inherits another user's folder layout.
|
||||
*
|
||||
* TRACES: UR-076 | DR-209
|
||||
*/
|
||||
export type LibrarySettings = {
|
||||
/**
|
||||
* Stable item ids of the folders/libraries hidden from browsing.
|
||||
*
|
||||
* `#[serde(default)]` so settings JSON persisted before this field existed
|
||||
* loads as the previous behaviour (nothing hidden).
|
||||
*/
|
||||
excludedItemIds?: string[] }
|
||||
/**
|
||||
* Live stream information returned from opening a Live TV / channel stream.
|
||||
*
|
||||
|
||||
@@ -19,6 +19,9 @@ import type {
|
||||
PlaylistEntry,
|
||||
PlaylistCreatedResult,
|
||||
} from "./types";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("RepositoryClient");
|
||||
|
||||
/**
|
||||
* Repository client - thin wrapper over Rust HybridRepository
|
||||
@@ -39,14 +42,14 @@ export class RepositoryClient {
|
||||
accessToken: string,
|
||||
serverId: string
|
||||
): Promise<string> {
|
||||
console.log("[RepositoryClient] Creating Rust repository...");
|
||||
log.debug("Creating Rust repository...");
|
||||
this.handle = await commands.repositoryCreate(serverUrl, userId, accessToken, serverId);
|
||||
|
||||
// Store for URL construction
|
||||
this._serverUrl = serverUrl;
|
||||
this._accessToken = accessToken;
|
||||
|
||||
console.log("[RepositoryClient] Repository created with handle:", this.handle);
|
||||
log.debug("Repository created with handle:", this.handle);
|
||||
return this.handle;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
import { haptics } from "$lib/utils/haptics";
|
||||
import { toast } from "$lib/stores/toast";
|
||||
import { favoriteOverrides } from "$lib/stores/favorites";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("FavoriteButton");
|
||||
|
||||
interface Props {
|
||||
itemId: string;
|
||||
@@ -78,7 +81,7 @@
|
||||
isAnimating = false;
|
||||
}, 600);
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle favorite:", error);
|
||||
log.error("Failed to toggle favorite:", error);
|
||||
toast.show("Failed to update favorites", "error");
|
||||
isAnimating = false;
|
||||
} finally {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { downloads, type DownloadInfo } from "$lib/stores/downloads";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("DownloadItem");
|
||||
|
||||
interface Props {
|
||||
download: DownloadInfo;
|
||||
@@ -69,7 +72,7 @@
|
||||
// Refresh to update UI
|
||||
await downloads.refresh(download.userId);
|
||||
} catch (error) {
|
||||
console.error("Failed to pause download:", error);
|
||||
log.error("Failed to pause download:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +82,7 @@
|
||||
// Refresh to update UI
|
||||
await downloads.refresh(download.userId);
|
||||
} catch (error) {
|
||||
console.error("Failed to resume download:", error);
|
||||
log.error("Failed to resume download:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +92,7 @@
|
||||
// Refresh to update UI
|
||||
await downloads.refresh(download.userId);
|
||||
} catch (error) {
|
||||
console.error("Failed to cancel download:", error);
|
||||
log.error("Failed to cancel download:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +102,7 @@
|
||||
// Refresh to update UI
|
||||
await downloads.refresh(download.userId);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete download:", error);
|
||||
log.error("Failed to delete download:", error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { goto } from "$app/navigation";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
import { createRotationTimer, type RotationTimer } from "./heroRotation";
|
||||
|
||||
interface Props {
|
||||
items: MediaItem[];
|
||||
@@ -13,7 +14,7 @@
|
||||
let { items, autoRotate = true, interval = 6000 }: Props = $props();
|
||||
|
||||
let currentIndex = $state(0);
|
||||
let intervalId: number | null = null;
|
||||
let rotation: RotationTimer | null = null;
|
||||
|
||||
// Touch/swipe state
|
||||
let touchStartX = $state(0);
|
||||
@@ -70,8 +71,22 @@
|
||||
currentIndex = (currentIndex - 1 + items.length) % items.length;
|
||||
}
|
||||
|
||||
// Manual navigation (swipe, arrows, dots) restarts the countdown, so the
|
||||
// banner always waits a full interval after the last change instead of
|
||||
// firing whatever was left of the previous one.
|
||||
function showNext() {
|
||||
next();
|
||||
rotation?.restart();
|
||||
}
|
||||
|
||||
function showPrev() {
|
||||
prev();
|
||||
rotation?.restart();
|
||||
}
|
||||
|
||||
function goToIndex(idx: number) {
|
||||
currentIndex = idx;
|
||||
rotation?.restart();
|
||||
}
|
||||
|
||||
// Touch/swipe handlers
|
||||
@@ -96,10 +111,10 @@
|
||||
if (Math.abs(diff) > swipeThreshold) {
|
||||
if (diff > 0) {
|
||||
// Swiped left - go to next
|
||||
next();
|
||||
showNext();
|
||||
} else {
|
||||
// Swiped right - go to previous
|
||||
prev();
|
||||
showPrev();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,9 +125,12 @@
|
||||
// Auto-rotate logic
|
||||
$effect(() => {
|
||||
if (autoRotate && items.length > 1) {
|
||||
intervalId = window.setInterval(next, interval);
|
||||
const timer = createRotationTimer(interval, next);
|
||||
rotation = timer;
|
||||
timer.restart();
|
||||
return () => {
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
timer.stop();
|
||||
rotation = null;
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -219,7 +237,7 @@
|
||||
|
||||
<!-- Swipe Indicators (Desktop hover) -->
|
||||
<button
|
||||
onclick={prev}
|
||||
onclick={showPrev}
|
||||
class="absolute left-4 top-1/2 transform -translate-y-1/2 w-12 h-12 bg-black/40 backdrop-blur-sm rounded-full items-center justify-center transition-opacity opacity-0 group-hover:opacity-100 hover:bg-black/60 hidden md:flex"
|
||||
aria-label="Previous item"
|
||||
>
|
||||
@@ -229,7 +247,7 @@
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={next}
|
||||
onclick={showNext}
|
||||
class="absolute right-4 top-1/2 transform -translate-y-1/2 w-12 h-12 bg-black/40 backdrop-blur-sm rounded-full items-center justify-center transition-opacity opacity-0 group-hover:opacity-100 hover:bg-black/60 hidden md:flex"
|
||||
aria-label="Next item"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// TRACES: UR-034 | DR-038 | UT-207
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createRotationTimer } from "./heroRotation";
|
||||
|
||||
describe("hero banner rotation timer", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("advances once per interval while running", () => {
|
||||
const onElapse = vi.fn();
|
||||
const timer = createRotationTimer(6000, onElapse);
|
||||
timer.restart();
|
||||
|
||||
vi.advanceTimersByTime(6000);
|
||||
expect(onElapse).toHaveBeenCalledTimes(1);
|
||||
vi.advanceTimersByTime(6000);
|
||||
expect(onElapse).toHaveBeenCalledTimes(2);
|
||||
|
||||
timer.stop();
|
||||
});
|
||||
|
||||
it("does nothing until started", () => {
|
||||
const onElapse = vi.fn();
|
||||
createRotationTimer(6000, onElapse);
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(onElapse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The bug: a manual swipe/click left the interval running, so the banner
|
||||
// rotated again almost immediately instead of waiting a full interval.
|
||||
it("restarts the countdown from now, not from the last auto-advance", () => {
|
||||
const onElapse = vi.fn();
|
||||
const timer = createRotationTimer(6000, onElapse);
|
||||
timer.restart();
|
||||
|
||||
// 5.5s in the user swipes — the timer must restart from that moment.
|
||||
vi.advanceTimersByTime(5500);
|
||||
timer.restart();
|
||||
|
||||
// The remaining 500ms of the old countdown must NOT fire.
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(onElapse).not.toHaveBeenCalled();
|
||||
|
||||
// A full interval after the swipe, it advances.
|
||||
vi.advanceTimersByTime(5500);
|
||||
expect(onElapse).toHaveBeenCalledTimes(1);
|
||||
|
||||
timer.stop();
|
||||
});
|
||||
|
||||
it("does not stack timers when restarted repeatedly", () => {
|
||||
const onElapse = vi.fn();
|
||||
const timer = createRotationTimer(1000, onElapse);
|
||||
timer.restart();
|
||||
timer.restart();
|
||||
timer.restart();
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(onElapse).toHaveBeenCalledTimes(1);
|
||||
|
||||
timer.stop();
|
||||
});
|
||||
|
||||
it("stops firing after stop()", () => {
|
||||
const onElapse = vi.fn();
|
||||
const timer = createRotationTimer(1000, onElapse);
|
||||
timer.restart();
|
||||
timer.stop();
|
||||
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(onElapse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports whether it is running", () => {
|
||||
const timer = createRotationTimer(1000, () => {});
|
||||
expect(timer.isRunning()).toBe(false);
|
||||
timer.restart();
|
||||
expect(timer.isRunning()).toBe(true);
|
||||
timer.stop();
|
||||
expect(timer.isRunning()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
// Auto-rotation timer for the home-screen hero banner.
|
||||
//
|
||||
// Extracted from HeroBanner.svelte so it can be unit-tested: a manual swipe or
|
||||
// dot/arrow click must restart the countdown from that moment. The old code
|
||||
// installed one bare setInterval and left it running, so swiping late in an
|
||||
// interval made the banner jump to the next item almost immediately.
|
||||
//
|
||||
// TRACES: UR-034 | DR-038 | UT-207
|
||||
|
||||
export interface RotationTimer {
|
||||
/** (Re)start the countdown from now, replacing any pending tick. */
|
||||
restart(): void;
|
||||
/** Cancel the countdown. */
|
||||
stop(): void;
|
||||
isRunning(): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a repeating timer that calls `onElapse` every `interval` ms once
|
||||
* started. Restarting is idempotent — there is never more than one live timer.
|
||||
*/
|
||||
export function createRotationTimer(interval: number, onElapse: () => void): RotationTimer {
|
||||
let handle: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function stop() {
|
||||
if (handle !== null) {
|
||||
clearInterval(handle);
|
||||
handle = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
restart() {
|
||||
stop();
|
||||
handle = setInterval(onElapse, interval);
|
||||
},
|
||||
stop,
|
||||
isRunning: () => handle !== null,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("AlbumDownloadButton");
|
||||
|
||||
interface Props {
|
||||
albumId: string;
|
||||
@@ -60,7 +63,7 @@
|
||||
try {
|
||||
const userId = $auth.user?.id;
|
||||
if (!userId) {
|
||||
console.error("No user ID found");
|
||||
log.error("No user ID found");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -95,7 +98,7 @@
|
||||
await downloads.refresh(userId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Album download operation failed:", error);
|
||||
log.error("Album download operation failed:", error);
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
|
||||
@@ -66,6 +66,9 @@
|
||||
|
||||
// Recompute when the reserved bottom gap changes (mini-player shows/hides).
|
||||
$effect(() => {
|
||||
// Bare read: registers `bottomGap` as a dependency of this effect. Svelte 5
|
||||
// idiom, not a stray expression.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
bottomGap;
|
||||
measure();
|
||||
});
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
||||
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("ArtistDetailView");
|
||||
|
||||
interface Props {
|
||||
artist: MediaItem;
|
||||
@@ -47,7 +50,7 @@
|
||||
});
|
||||
albums = albumsResult.items.filter(item => item.kind === "album");
|
||||
} catch (e) {
|
||||
console.warn("Failed to load albums:", e);
|
||||
log.warn("Failed to load albums:", e);
|
||||
} finally {
|
||||
albumsLoading = false;
|
||||
}
|
||||
@@ -62,7 +65,7 @@
|
||||
});
|
||||
topTracks = tracksResult.items.filter(item => item.kind === "track");
|
||||
} catch (e) {
|
||||
console.warn("Failed to load tracks:", e);
|
||||
log.warn("Failed to load tracks:", e);
|
||||
} finally {
|
||||
tracksLoading = false;
|
||||
}
|
||||
@@ -82,14 +85,14 @@
|
||||
.slice(0, 6);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to load related artists:", e);
|
||||
log.warn("Failed to load related artists:", e);
|
||||
} finally {
|
||||
artistsLoading = false;
|
||||
}
|
||||
|
||||
singlesLoading = false;
|
||||
} catch (e) {
|
||||
console.error("Error loading artist content:", e);
|
||||
log.error("Error loading artist content:", e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
<script lang="ts">
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { isServerReachable } from "$lib/stores/connectivity";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("ClearHistoryButton");
|
||||
|
||||
interface Props {
|
||||
/** Series or season id to clear. */
|
||||
@@ -51,7 +54,7 @@
|
||||
await auth.getRepository().clearWatchHistory(itemId);
|
||||
onCleared?.();
|
||||
} catch (e) {
|
||||
console.error("Failed to clear watch history:", e);
|
||||
log.error("Failed to clear watch history:", e);
|
||||
alert(
|
||||
`Could not clear watch history: ${e instanceof Error ? e.message : String(e)}`
|
||||
);
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import DownloadButtonCore from "./DownloadButtonCore.svelte";
|
||||
import type { DownloadState } from "./DownloadButtonCore.svelte";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("DownloadButton");
|
||||
|
||||
/**
|
||||
* Single audio track download button
|
||||
@@ -39,7 +42,7 @@
|
||||
});
|
||||
|
||||
async function handleClick() {
|
||||
console.log("🖱️ Download button clicked! Current status:", status);
|
||||
log.debug("🖱️ Download button clicked! Current status:", status);
|
||||
if (isProcessing) return;
|
||||
|
||||
isProcessing = true;
|
||||
@@ -63,25 +66,25 @@
|
||||
// Start download
|
||||
const userId = $auth.user?.id;
|
||||
if (!userId) {
|
||||
console.error("No user ID found");
|
||||
log.error("No user ID found");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
|
||||
console.log("🎯 Starting download for item:", itemId);
|
||||
log.debug("🎯 Starting download for item:", itemId);
|
||||
|
||||
// Get stream URL
|
||||
const streamUrl = await repo.getAudioStreamUrl(itemId);
|
||||
console.log(" Stream URL obtained:", streamUrl?.substring(0, 50) + "...");
|
||||
log.debug(" Stream URL obtained:", streamUrl?.substring(0, 50) + "...");
|
||||
if (!streamUrl) {
|
||||
throw new Error("Failed to get stream URL");
|
||||
}
|
||||
|
||||
// Get target directory
|
||||
const targetDir = await commands.storageGetPath();
|
||||
console.log(" Target directory:", targetDir);
|
||||
log.debug(" Target directory:", targetDir);
|
||||
|
||||
// Queue and start download in single atomic operation
|
||||
const downloadId = await commands.downloadItemAndStart({
|
||||
@@ -93,16 +96,16 @@
|
||||
artistName: artistName || null,
|
||||
albumName: albumName || null,
|
||||
});
|
||||
console.log(" Download queued and started with ID:", downloadId);
|
||||
log.debug(" Download queued and started with ID:", downloadId);
|
||||
|
||||
// Refresh downloads list
|
||||
await downloads.refresh(userId);
|
||||
} catch (e) {
|
||||
console.error("❌ Failed to start download:", e);
|
||||
log.error("❌ Failed to start download:", e);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Download operation failed:", error);
|
||||
log.error("Download operation failed:", error);
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
||||
import { useOfflineFilterReload } from "$lib/composables/useOfflineFilterReload";
|
||||
import type { Genre, MediaItem, ItemType } from "$lib/api/types";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("GenericGenreBrowser");
|
||||
|
||||
/**
|
||||
* Generic genre browser supporting Movies, Music Albums, and TV Series
|
||||
@@ -92,7 +95,7 @@
|
||||
genres = result.sort((a, b) => a.name.localeCompare(b.name));
|
||||
applyFilter();
|
||||
} catch (e) {
|
||||
console.error("Failed to load genres:", e);
|
||||
log.error("Failed to load genres:", e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
@@ -115,7 +118,7 @@
|
||||
});
|
||||
genreItems = result.items;
|
||||
} catch (e) {
|
||||
console.error("Failed to load genre items:", e);
|
||||
log.error("Failed to load genre items:", e);
|
||||
} finally {
|
||||
loadingItems = false;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
import LibraryGrid from "./LibraryGrid.svelte";
|
||||
import TrackList from "./TrackList.svelte";
|
||||
import AlphabetScrollBar from "./AlphabetScrollBar.svelte";
|
||||
import { excludePodcasts } from "$lib/utils/podcastFilter";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("GenericMediaListPage");
|
||||
|
||||
/**
|
||||
* Generic media list page supporting Albums, Artists, Playlists, and Tracks
|
||||
@@ -84,7 +86,7 @@
|
||||
unlistenSearch = await listen<SearchUpdateEvent>("search-event", (event) => {
|
||||
const { requestId, result } = event.payload;
|
||||
if (requestId !== searchRequestId) return;
|
||||
items = excludePodcasts(result.items);
|
||||
items = result.items;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -118,8 +120,9 @@
|
||||
if (items.length === 0) loading = true;
|
||||
const repo = auth.getRepository();
|
||||
|
||||
// Use backend search if search query is provided, otherwise use getItems with sort
|
||||
// HACK: excludePodcasts drops the "Podcasts" folder stored in the music library.
|
||||
// Use backend search if search query is provided, otherwise use getItems
|
||||
// with sort. Neither result is filtered here: folders the user chose to
|
||||
// hide are dropped by the repository layer. TRACES: UR-076 | DR-209
|
||||
if (debouncedSearchQuery.trim()) {
|
||||
// Phase 1: instant cache-only (downloaded) results. The merged
|
||||
// cache+server union arrives later via the `search-event` listener,
|
||||
@@ -136,7 +139,7 @@
|
||||
);
|
||||
// Only apply if this is still the active query.
|
||||
if (requestId === searchRequestId) {
|
||||
items = excludePodcasts(result.items);
|
||||
items = result.items;
|
||||
}
|
||||
} else {
|
||||
// Leaving search — invalidate any in-flight server results.
|
||||
@@ -151,10 +154,10 @@
|
||||
// resolves to online vs offline. TRACES: UR-067 | DR-116
|
||||
favoritesOnly: favoritesOnly ? true : undefined,
|
||||
});
|
||||
items = excludePodcasts(result.items);
|
||||
items = result.items;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to load ${config.itemType}:`, e);
|
||||
log.error(`Failed to load ${config.itemType}:`, e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
||||
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("MediaCard");
|
||||
|
||||
interface Props {
|
||||
item: MediaItem | Library;
|
||||
@@ -171,7 +174,7 @@
|
||||
media.albumName ?? undefined
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[MediaCard] Failed to queue download:", err);
|
||||
log.error("Failed to queue download:", err);
|
||||
queueError = "Failed to queue";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
import LibraryGrid from "./LibraryGrid.svelte";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("PersonDetailView");
|
||||
|
||||
interface Props {
|
||||
person: MediaItem;
|
||||
@@ -34,7 +37,7 @@
|
||||
movies = result.items.filter(item => item.kind === "movie");
|
||||
series = result.items.filter(item => item.kind === "series");
|
||||
} catch (e) {
|
||||
console.error("Failed to load filmography:", e);
|
||||
log.error("Failed to load filmography:", e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
||||
import { favoriteOverrides } from "$lib/stores/favorites";
|
||||
import { formatDuration } from "$lib/utils/duration";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("PlaylistDetail");
|
||||
|
||||
interface Props {
|
||||
playlist: MediaItem;
|
||||
@@ -40,7 +43,7 @@
|
||||
const repo = auth.getRepository();
|
||||
entries = await repo.getPlaylistItems(playlist.id);
|
||||
} catch (e) {
|
||||
console.error("[PlaylistDetail] Failed to load items:", e);
|
||||
log.error("Failed to load items:", e);
|
||||
toast.error("Failed to load playlist items");
|
||||
} finally {
|
||||
loading = false;
|
||||
@@ -62,7 +65,7 @@
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[PlaylistDetail] Failed to play all:", e);
|
||||
log.error("Failed to play all:", e);
|
||||
toast.error("Failed to play playlist");
|
||||
}
|
||||
}
|
||||
@@ -82,7 +85,7 @@
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[PlaylistDetail] Failed to shuffle play:", e);
|
||||
log.error("Failed to shuffle play:", e);
|
||||
toast.error("Failed to shuffle playlist");
|
||||
}
|
||||
}
|
||||
@@ -100,7 +103,7 @@
|
||||
playlist.name = trimmed;
|
||||
toast.success("Playlist renamed");
|
||||
} catch (e) {
|
||||
console.error("[PlaylistDetail] Failed to rename:", e);
|
||||
log.error("Failed to rename:", e);
|
||||
toast.error("Failed to rename playlist");
|
||||
editName = playlist.name;
|
||||
} finally {
|
||||
@@ -115,7 +118,7 @@
|
||||
toast.success("Playlist deleted");
|
||||
goto("/library");
|
||||
} catch (e) {
|
||||
console.error("[PlaylistDetail] Failed to delete:", e);
|
||||
log.error("Failed to delete:", e);
|
||||
toast.error("Failed to delete playlist");
|
||||
} finally {
|
||||
showDeleteConfirm = false;
|
||||
@@ -129,7 +132,7 @@
|
||||
entries = entries.filter(e => e.playlistItemId !== entry.playlistItemId);
|
||||
toast.success("Track removed");
|
||||
} catch (e) {
|
||||
console.error("[PlaylistDetail] Failed to remove track:", e);
|
||||
log.error("Failed to remove track:", e);
|
||||
toast.error("Failed to remove track");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import type { MediaItem, MediaKind, Person } from "$lib/api/types";
|
||||
import MediaCard from "./MediaCard.svelte";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("RelatedItemsSection");
|
||||
|
||||
interface Props {
|
||||
currentItemId: string;
|
||||
@@ -57,7 +60,7 @@
|
||||
return; // Success - return early
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to load similar items from API:", e);
|
||||
log.warn("Failed to load similar items from API:", e);
|
||||
// Fall through to genre-based loading
|
||||
}
|
||||
}
|
||||
@@ -78,7 +81,7 @@
|
||||
|
||||
items = result.items.filter(item => item.id !== currentItemId);
|
||||
} catch (e) {
|
||||
console.warn("Failed to load related items by genre:", e);
|
||||
log.warn("Failed to load related items by genre:", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +97,7 @@
|
||||
const artistAlbums = result.items.filter(item => item.id !== currentItemId);
|
||||
items = [...items, ...artistAlbums];
|
||||
} catch (e) {
|
||||
console.warn("Failed to load albums by artist:", e);
|
||||
log.warn("Failed to load albums by artist:", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +109,7 @@
|
||||
relatedItems = uniqueItems;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Failed to load related items";
|
||||
console.error("Error loading related items:", e);
|
||||
log.error("Error loading related items:", e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user