Compare commits

...
30 Commits
Author SHA1 Message Date
dtourolle 20e2331560 chore(release): 0.9.0
🏗️ Build and Test JellyTau / Run Tests (push) Skipped
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 6m43s
Traceability Validation / Check Requirement Traces (push) Successful in 13s
Build & Release / Run Tests (push) Failing after 17m59s
Build & Release / Build Linux (push) Skipped
Build & Release / Build Windows (push) Skipped
Build & Release / Build Android (push) Skipped
Build & Release / Create Release (push) Skipped
2026-08-20 21:21:46 +02:00
dtourolle bb140a8734 docs(release): write the v0.9.0 changelog and refresh artifact names
release:notes is not usable for this batch: it maps changed files to their
TRACES, and the logging sweep touched 63 files spanning most of the codebase, so
it reports nearly every user requirement as changed — including ones explicitly
not implemented. Written by hand instead.

Also updates the checklist's artifact names for the rename and adds the rpm,
which the checklist never listed because it was never published.
2026-08-20 21:07:13 +02:00
dtourolle 28b600304f fix(scripts): check registry auth properly before pushing the builder image
`docker info | grep Username` only reports a Docker Hub session, so for a
private registry the guard never matched: every push dropped into an interactive
docker login, which hangs a non-interactive run. Checks the credential store for
the specific registry instead, and refuses with instructions rather than
prompting when there is no TTY.
2026-08-20 21:06:23 +02:00
dtourolle 8fbf4d92cb ci: match release bundles by extension, and ship the rpm
Renaming the app to JellyTau renamed its bundles, and the release job globbed
`bundle/deb/jellytau_*.deb`. The copy was wrapped in `if [ -f ... ]`, so the
rename would have dropped the .deb from the release silently — a green build
producing an incomplete release. Matching by extension removes the coupling
between the product name and the pipeline, and an empty dist/linux now fails
the job instead of passing quietly.

That `if [ -f "dir/"*.ext ]` guard was also wrong on its own terms: with more
than one match, test gets extra arguments and returns false.

Found while verifying the rename: the rpm has been built by every release since
deb+rpm became the bundle targets, and never copied, published or documented.
It ships now.

Also declares the package rename. Tauri kebab-cases productName into the
Debian package name, so "JellyTau" produces `jelly-tau` — a different package
from the `jellytau` earlier releases installed, which would have put a second
copy alongside the old one. deb now declares Replaces/Conflicts/Provides and
rpm Obsoletes/Provides, verified in the built control file.

TRACES: | DR-214
2026-08-20 21:01:43 +02:00
dtourolle d32ca13d00 chore: give the project its own identity instead of the scaffold's
Cargo.toml still carried `description = "A Tauri App"` and `authors = ["you"]`,
package.json's description was empty with no author or repository, and there was
no LICENSE file at all despite package.json declaring MIT.

The user-visible half matters more. productName was the scaffold's lowercase
"jellytau", which is what the Android *release* build shows under its icon and
what the deb/rpm/NSIS bundles carry as their display name. It went unnoticed
because build.gradle.kts overrides the label to "JellyTau Debug" for the debug
build type — the install a developer sees every day was the only correctly-cased
one. mainBinaryName pins the executable filename to "jellytau" so
build-windows-cross.sh and the Arch PKGBUILD, which both resolve it by name,
need no change.

strings.xml moves into the canonical android tree rather than being edited in
gen/, since sync-android-sources.sh already copies res/values/*.xml — so the fix
survives the next regeneration.

Bundle metadata (publisher, copyright, category, descriptions, licence) was
absent entirely, so the packages shipped with no maintainer or description. The
hand-written PKGBUILD and .desktop had all of it; only the generated packaging
was wrong.

Adds .env.example: three scripts require signing vars from a gitignored .env
and .gitignore already whitelists the example, but none existed.

TRACES: | DR-214
2026-08-20 20:38:16 +02:00
dtourolle 2a3f08f8a4 build: hand containerised build artifacts back to the host user
The compose services bind-mount the repo and build as root, so every artifact
they leave in src-tauri/target belongs to root on the host. It accumulates:
11,124 such files had built up, enough that cargo clean and scripts/clean.sh
failed with EACCES — and a plain cargo build died part-way through, because
build scripts compile for the host and land in target/debug even when
cross-compiling to Android. That is what blocked the device build in this batch.

Restores ownership at the end of each containerised build, reading the intended
owner from the checkout so no uid has to be plumbed through from the host. A
no-op when not running as root, so the native build scripts call it
unconditionally.

Running the containers as the host uid is the tidier fix and stays open — it
needs the cargo/bun cache volumes moved off /root first, which is why this is
not a one-line user: directive.

TRACES: | DR-213
2026-08-20 20:17:36 +02:00
dtourolle 68ca1d585d chore: regenerate bindings and the traceability matrix
bindings.ts picks up the library-exclusion commands and types from tauri-specta.
The matrix regenerates because validation.ts and its test are gone — the doc
link checker caught the stale references, which is the first time that gate has
paid for itself on a generated artifact rather than a hand-written link.

Also drops exclusions::is_excluded: a wrapper over is_excluded_by that only a
test called, while the trait impls hoist the snapshot themselves. The test now
calls the same path production does.
2026-08-20 20:14:15 +02:00
dtourolle 0815445aa7 feat(library): exclude chosen folders from music browsing
Replaces a hardcoded filter that dropped anything named "Podcasts" from music
results — one user's library layout compiled into the shipped product, keyed on
an English literal, applied only at the six call sites someone had remembered.

Exclusion is now a user setting stored in Rust and applied at the repository
layer's convergence points, so scope is decided once and is the same on every
screen. It matches on folder id rather than name: a title is not what an item
is, which is why an album legitimately called "Podcasts" used to vanish.

Deliberately not filtered: get_item (an id asked for by name was navigated to on
purpose, and refusing it would break playback of anything inside a hidden
folder), get_downloaded_items (hiding a download would leave the user unable to
delete a file whose disk usage they can still see), and the offline cache (an
exclusion is a view preference and must be reversible without a re-crawl).

Also removes src/lib/utils/validation.ts — six exported validators with no
caller outside their own test file, which made the module read as covered
input validation while guarding nothing.

TRACES: UR-076 | DR-209 | UT-203
2026-08-20 20:09:57 +02:00
dtourolle 048c99ebcc fix(downloads): allow the deliberate join_absolute_paths lint in a test
The assertion documents that PathBuf::join discards its base when handed an
absolute path — which is why confinement has to happen after the join, not
instead of it. clippy::join_absolute_paths flags that shape, correctly for
production code, so the lint is allowed here rather than the test weakened.

Worth recording: this lint would not have caught the original defect. The real
join sites pass a variable, and it only fires on a literal.
2026-08-20 20:09:19 +02:00
dtourolle 34026d22b4 fix(logging): keep debug logging in a packaged debug build
import.meta.env.DEV is true only under the vite dev server, but
scripts/build-android.sh produces the debug APK with a plain `bun run build` —
so the logger defaulted to warn there too and the debug package lost every
frontend message from logcat. `bun run android:logs` is a documented workflow
that depends on them.

vite now defines __JT_DEBUG_BUILD__ from Tauri's TAURI_ENV_DEBUG, which the CLI
sets while running beforeBuildCommand. The decision is split into a pure
resolveDefaultLogLevel(isDevServer, isDebugBuild) because neither
import.meta.env.DEV nor a vite define can be varied from inside a test.

Also replaces the pinned requirement counts in extract-traces.test.ts with
invariants. The pins guarded nothing the computeCoverage fixtures don't already
cover, while forcing every branch that adds a requirement to edit the numbers —
the comment above them had become a ledger of which branch contributed which row.

TRACES: | DR-204 | UT-201
2026-08-20 20:06:51 +02:00
dtourolle aeb29f916b docs(requirements): add rows for the path-confinement and query-binding work 2026-08-20 20:03:43 +02:00
dtourolle f83c7ed1f0 fix(downloads): confine download paths to the download root
file_path and target_dir reached PathBuf::join unchecked from the frontend, and
mark_download_completed stored a caller-supplied path that is later fed to
remove_file. A correct sanitiser already existed — download_item_and_start used
it — but download_item is itself a command taking file_path raw, so the guard
was simply routed around. It now lives inside download_item, alongside a
join-then-confine check modelled on media_server::resolve_path.

Sanitising is per path component, not whole-string: the latter would silently
turn downloads/x.mp3 into downloads_x.mp3 and relocate every existing download.

TRACES: | DR-211 | UT-205
2026-08-20 20:03:04 +02:00
dtourolle b313b61717 fix(repository): bind query parameters and encode URL values
Three consistency fixes, each one applying a pattern the same file already
used a few lines away: the offline get_items type filter now binds placeholders
like search at offline.rs:1786 does, build_get_items_endpoint percent-encodes
its values like the Genres block below it does, and player_set_volume clamps
NaN and out-of-range input at the command boundary rather than relying on each
backend to do it.

TRACES: | DR-212 | UT-206
2026-08-20 20:02:57 +02:00
dtourolle fb6bd5cae1 fix(thumbnails): confine cache writes to the cache directory
item_id and image_type reached the cache filename unsanitised while tag was
already being sanitised, and Path::join neither folds .. nor keeps the base
when handed an absolute path. Applies the tag's existing rule to all three
parts and adds a starts_with(cache_dir) check at the point of use, modelled on
media_server::resolve_path.

Not exploitable as shipped — server URLs must be HTTPS (auth/mod.rs) and
Android blocks cleartext, so the id would have to come from a server the user
chose to trust. This makes the write path consistent with how the rest of the
codebase already handles caller-supplied paths.

TRACES: | DR-210 | UT-204
2026-08-20 20:02:57 +02:00
dtourolle da6b039b29 fix(downloads): confine download paths to the download root
Both halves of the path a download writes to arrived from the frontend
unchecked. `start_download` and the queue pump built their target as
`PathBuf::from(target_dir).join(file_path)`, and `mark_download_completed`
stored a frontend-supplied `file_path` on the row verbatim — the same
column that is later read back into `std::fs::remove_file` when a
download is deleted. A correct sanitiser already existed and
`download_item_and_start` used it, but `download_item` is a command in
its own right, so calling it directly routed the guard around.

The guard moves inside. `confine_to_root` folds `..` away lexically and
requires the result to sit inside the storage root, modelled on
`media_server::resolve_path` — the check comes after the join because
`Path::join` drops the base when the joined half is absolute, so an
absolute `file_path` is obeyed rather than folded. `confine_queued_path`
sanitises a queued path per component (so the already-safe name
`download_item_and_start` passes in is not sanitised into a second,
different one) and confines it. Applied in `download_item`, at both join
sites, and to what `mark_download_completed` writes.

Every path the app builds for itself is returned unchanged, including
the absolute ones `download_series`/`download_season` produce from
`${targetDir}/videos`, so no existing row or file on disk is orphaned.
The pump fails an offending row rather than skipping it, because the
pump re-queries and would otherwise not terminate.

Not a live vulnerability: reaching these commands with hostile input
needs script execution in a webview whose CSP is `script-src 'self'`.
This is hardening and consistency.

TRACES: DR-211 | UT-205
2026-08-20 20:01:52 +02:00
dtourolle 080cdbf383 fix(player): clamp volume at the command boundary
player_set_volume passed `volume` through untouched. Each backend
clamps to 0.0..=1.0 for itself, so local playback was already safe, but
the remote branch reaches no backend: it converts with
`(volume * 100.0) as i32`, which turns infinity into i32::MAX. NaN is
handled explicitly since f32::clamp returns NaN for a NaN input and it
then survives every comparison downstream.

TRACES: DR-212 | UT-206
2026-08-20 20:00:35 +02:00
dtourolle 6b7ce512ed fix(online): percent-encode query values and path ids
build_get_items_endpoint pasted ParentId, IncludeItemTypes, SortBy and
SortOrder straight into the query string while the Genres parameter
twenty lines below and the SearchTerm parameter both percent-encode
theirs. Encode them the same way, per list element so the commas
Jellyfin splits on survive.

The per-call ids interpolated into request paths (item, person and
playlist ids) get the same treatment; a Jellyfin GUID is unchanged by
encoding, so this is consistency, not a behaviour change. self.user_id
is left alone throughout, as it is at the endpoint builders already.

TRACES: UR-007 | DR-212 | UT-206
2026-08-20 19:59:25 +02:00
dtourolle 55b37ba2f4 ci: make clippy a hard gate
The advisory step existed because the tree carried a warning backlog. Measured
on 1.97.1 — the pinned toolchain CI actually uses — that backlog is three
warnings, not the ~51 the comment claimed: two unnecessary_sort_by in
smart_cache and one redundant into_iter in offline. Fixed, so clippy now runs
with -D warnings and a warning means new breakage.

Worth recording why this took a toolchain pin to do safely: the same tree
measured 0 warnings on 1.92.0 and 3 on 1.97.1. Flipping the flag on a local
measurement, without the pin, would have reddened CI on the next push.

TRACES: | DR-206
2026-08-20 19:58:39 +02:00
dtourolle d52470e0cd fix(offline): bind item-type filter as query parameters
get_items built its `AND i.item_type IN (…)` fragment by interpolating
each requested type into the SQL string, while `search`, `get_favorites`
and `prune_stale_catalog` in the same file bind the identical filter as
`?` placeholders. Follow the existing pattern so the listing query is
consistent with its neighbours.

The type values bind between the six parent-matching ids and the
favourites user id, matching where `{type_filter}` lands in the
statement.

TRACES: UR-065 | DR-212 | UT-206
2026-08-20 19:56:40 +02:00
dtourolle e12f0065a6 fix(thumbnails): confine cache writes to the cache directory
The thumbnail cache built its filename from `item_id`, `image_type` and
`tag`, but only sanitised the tag. `Path::join` neither folds `..` nor
keeps its base when handed an absolute path, so a malformed id could
place a cache write outside the cache directory.

Sanitise all three parts through one helper using the rule the tag
already used (non-alphanumerics become `_`), so ids and types that were
already safe keep producing exactly the same filename, and resolve the
result against the cache dir with a lexical `..` fold plus a
`starts_with` check, modelled on `media_server::resolve_path`.

The database still stores the raw key and the resolved path, so the
lookup in `get_cached_path` keeps matching what the caller asks for.
2026-08-20 19:56:23 +02:00
dtourolle 63d4df0cde chore(tooling): keep lint and format out of the scratch worktrees
.claude/worktrees holds full checkouts of this repo, generated .svelte-kit
trees included, so 'eslint .' was linting every in-flight branch — 410 errors,
none of them ours. Same root cause the doc-link checker hit.
2026-08-20 19:55:29 +02:00
dtourolle 6b90582e3e chore(tooling): add lint/format gates, pin the toolchain, enforce commit checks
Adds the frontend's first linter and formatter — the Rust half has had
cargo fmt --check and clippy in CI for a while, while 274 TS/Svelte files had
only svelte-check. ESLint runs clean; 159 findings are recorded as warnings
rather than suppressed, so the backlog is visible without painting CI red.

Also: `bun run test` no longer drops into watch mode (the "Before Committing"
list told people to run a command that never returns), the traceability ratchet
moves 82% -> 88%, a pre-commit hook enforces the fast half of that list instead
of relying on memory, the dead webdriverio e2e suite and its five devDeps are
removed, and the Rust toolchain is pinned to 1.97.1 so the developer machine and
the CI builder image stop being five releases apart.

TRACES: | DR-205, DR-206, DR-207
2026-08-20 19:53:13 +02:00
dtourolle ea3c765561 chore: remove unused frontend validation module
`src/lib/utils/validation.ts` exported six validators (validateItemId,
validateImageType, validateMediaSourceId, validateUrlPathSegment,
validateNumericParam, validateQueryParamValue). Nothing outside its own
213-line test suite ever called them, so the module read as covered,
guarded input validation while guarding nothing — a green test run over
code no input ever passes through.

Deleting it does not weaken any check that was running; it removes the
false assurance that one was.

Note: the layer this validation belongs in per CLAUDE.md ("Validate all
inputs in Rust command handlers") does not implement it either. That is
a separate concern and is left untouched here.
2026-08-20 19:38:12 +02:00
dtourolle ac3cd67164 feat(library): exclude chosen folders from music browsing
Replaces `src/lib/utils/podcastFilter.ts` — a shipped personal workaround
that dropped any item whose name, album, album artist or artist was
literally "Podcasts" — with a real user setting applied in Rust.

The old filter was wrong twice over: it hardcoded one user's folder
layout keyed on an English literal, and it put a domain rule (what a
query should return) in the presentation layer. It slipped past
`check:boundary` only because it matched on names rather than on an
item-type array.

- `repository::exclusions` owns the rule and the process-wide id set,
  the same shape as `online::STREAMING_QUALITY` so it survives a
  repository being rebuilt on re-login.
- `HybridRepository` applies it where the cache and server legs of every
  cache-first query converge (`parallel_race` / `race_with_refresh`),
  plus the bespoke `get_items` path and the server-only reads. Filtering
  before the "has content" check is what makes a cache page of nothing
  but hidden items fall through to the server.
- Exclusion is by stable item id, never by name, and matches an item's
  own id or any container link it carries (parent, album, library,
  series, season, artist).
- A direct `get_item` lookup and the Downloads surface are deliberately
  unfiltered: hiding those would break playback and file management of
  anything inside a hidden folder.
- `LibrarySettings` persists to `app_settings` and is restored in the
  setup hook, alongside the streaming-quality cap. Default is an empty
  list — nobody inherits the old "Podcasts" behaviour.
- New commands `library_get_settings`, `library_set_settings` and
  `library_get_exclusion_candidates`; the candidates read goes through
  `get_items_unfiltered` so an already-hidden folder still appears in the
  picker and the setting can be undone.
- Settings page gains a "Hidden Folders" section that renders the
  backend's candidate list and sends back ticked ids; it decides nothing.

TRACES: UR-076 | DR-209 | UT-203
2026-08-20 19:38:05 +02:00
dtourolle f5bee069c0 fix(desktop): give the window a real title and a usable default size
tauri.conf.json still carried the scaffold defaults: a lowercase "jellytau"
title in an 800x600 window. The title is what the OS shows in the task
switcher and window list, and 800x600 is too small for a media library grid
with a mini player docked at the bottom.

Now "JellyTau" at 1280x800, with minWidth/minHeight held at the old 800x600
so the layout still has a defined floor when a user drags the window small.
2026-08-20 19:36:21 +02:00
dtourolle adcdadfcaf ci: run the documentation link checker
Wires scripts/check-doc-links.sh into build-and-test.yml next to the existing
boundary tripwire, and exposes it as `bun run check:links`.

The docs are the maintained source of truth for architecture and process and
cross-reference each other heavily, so a rename that misses a link quietly
turns a doc into a dead end. Pure shell — nothing is installed at job time.

The script itself is landing separately; this job step is red until it does.
2026-08-20 19:36:10 +02:00
dtourolle 6406ca3fad chore(tooling): add a pre-commit hook for the fast committing gates
CLAUDE.md's five-command "Before Committing" list was enforced by memory
alone. scripts/hooks/pre-commit now runs the half of it that finishes in
seconds — `bun run check`, `bun run test`, check-frontend-boundary.sh, and
`cargo fmt --all -- --check` only when staged files touch src-tauri/.

`cargo clippy` and `cargo test` are left out on purpose. Minutes per commit is
how a hook teaches people to type --no-verify; CI and `bun run test:all` are
where the slow gates belong.

Installed via `bun run hooks:install`, which sets core.hooksPath to the
tracked scripts/hooks directory rather than copying into .git/hooks, so later
changes to the hook reach everyone on their next pull.

The hook runs every gate before reporting, so one commit tells you everything
that is wrong rather than only the first thing. It exits 0 without running
anything during a merge, rebase, or cherry-pick, and when nothing is staged;
`git commit --no-verify` skips it as usual.
2026-08-20 19:36:02 +02:00
dtourolle 4af6ed0f98 build(rust): pin the toolchain to 1.97.1 for dev and CI
The Rust toolchain was unpinned on both sides, and the two sides had drifted
five releases apart: the CI builder image ships rustc 1.97.1, the development
machine was on 1.92.0. Clippy's lint set and rustfmt's output both change
between releases, so a green `cargo clippy` / `cargo fmt --check` locally said
nothing about CI and vice versa — which is the reason the clippy gate could
not be trusted enough to turn on.

src-tauri/rust-toolchain.toml pins channel 1.97.1 with the rustfmt and clippy
components. Deliberately no `targets` list: that would make rustup fetch the
Android and Windows std libraries on every plain `cargo test`, including on
machines that never cross-compile. The image already has them.

Dockerfile.builder installs that exact version instead of "latest stable at
rebuild time", and prints rustc/clippy versions so a mismatch is visible in
the build log.

The pin only becomes authoritative once the image is rebuilt and pushed
(scripts/build-builder-image.sh). Until then CI still runs whatever rustc the
current image has, and if that is not 1.97.1 rustup will download the pinned
toolchain at job time — a toolchain install in CI, which CLAUDE.md forbids.
Both files carry that warning next to the version.

Note: the clippy step in .gitea/workflows/build-and-test.yml is left advisory
here; tightening it wants a warning count measured on 1.97.1 first.
2026-08-20 19:35:51 +02:00
dtourolle 164157f98e chore(ci): raise the traceability ratchet from 82% to 88%
Actual coverage is 90% (`bun run traces:coverage`), so the gate had ~8 points
of slack — a requirement could stop being traced and CI would not notice.
Per the ratchet policy in the workflow, move it up to sit just under the real
figure.

MIN_COVERAGE_PERCENT in scripts/extract-traces.ts moves in lockstep: the
workflow comment says to keep the two in sync and extract-traces.test.ts
asserts it, so changing only the YAML turns the frontend suite red.

(The stale "fails below 50%" comment in scripts/test-all.sh, wrong since the
threshold moved to 82, was corrected in the preceding commit along with the
rest of that file.)
2026-08-20 19:35:38 +02:00
dtourolle 95eb16d5ef chore(tooling): add eslint + prettier, fix the test watch-mode default
Three gaps in the frontend tooling, all in the package.json script surface.

1. No JS/TS linter or formatter existed at all for 274 TS/Svelte files.

   Adds an ESLint flat config (typescript-eslint + eslint-plugin-svelte,
   Svelte 5 + TS strict) and prettier + prettier-plugin-svelte, plus the
   `lint`, `lint:fix`, `format`, `format:check` scripts.

   The tree is error-clean (`npx eslint .` exits 0). Getting there needed
   seven real one-line fixes (braced switch cases that leaked `const` across
   arms, a useless regex escape, two `let`s that never change, a thrown Error
   that dropped its `cause`, and two `// eslint-disable-next-line` comments
   documenting the Svelte 5 bare-read-for-dependency idiom). Everything else
   that fires is set to `warn` with the reason written next to it in
   eslint.config.js — notably ~94 dead bindings and `any` at the IPC
   boundary. Those are real findings to drive to zero, not noise to delete.

   `no-console` is OFF for now: a parallel change is moving all ~468 console
   calls onto a logger facade, and turning the rule on today would collide
   with it. eslint.config.js says so, and says to flip it to `error` once
   that lands.

   `prettier --write` is deliberately NOT run here — it would rewrite ~200
   files and swamp every other diff in flight. The gate is available; the
   sweep is a separate commit. Markdown and CI YAML are in .prettierignore
   because both are hand-laid-out (and docs/traceability.md is generated).

2. `bun run test` was bare `vitest`, i.e. watch mode — while CLAUDE.md's
   "Before Committing" list tells people to run it. It is now `vitest run`,
   with `test:watch` and `test:coverage` (also `--run`-ified) alongside.
   scripts/test-all.sh drops the now-redundant `--run`, and
   scripts/test-frontend.sh keeps `--watch`/`--ui`/`-w` working by routing
   them to a long-running vitest instead of the single-pass one.

3. The webdriverio e2e suite is deleted. It was last touched in January
   ("First working POC"), has never run since, and is not in CI — five
   devDependencies and two scripts of pure decoration. Removes e2e/,
   wdio.conf.ts, the two `test:e2e*` scripts, the @wdio/* + webdriverio
   devDeps, and the WebdriverIO block in .gitignore.

The package.json diff also carries `hooks:install` and `check:links`, wired
up by the following commits.
2026-08-20 19:35:17 +02:00
74 changed files with 9375 additions and 8031 deletions
+23
View File
@@ -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=
+16 -12
View File
@@ -62,6 +62,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
@@ -77,21 +84,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: |
+27 -12
View File
@@ -131,13 +131,27 @@ 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.
shopt -s nullglob
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
cp -v "$bundle" dist/linux/
done
shopt -u nullglob
# 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/
@@ -342,10 +356,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
@@ -359,13 +374,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
+1 -1
View File
@@ -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
-5
View File
@@ -30,11 +30,6 @@ coverage
.nyc_output
*.lcov
# WebdriverIO E2E tests
e2e/logs/
e2e/screenshots/
wdio-*.log
# Vitest
.vitest
+35
View File
@@ -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
View File
@@ -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" }
}
]
}
+95
View File
@@ -9,6 +9,101 @@ 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.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.
+24 -3
View File
@@ -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 && \
+21
View File
@@ -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.
+132 -769
View File
File diff suppressed because it is too large Load Diff
+17 -4
View File
@@ -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
```
+3 -2
View File
@@ -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)
+8
View File
@@ -399,6 +399,11 @@ Internal architecture, components, and application logic.
| 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 |
---
@@ -689,6 +694,9 @@ Internal architecture, components, and application logic.
| 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 |
### Integration Tests
+6392 -5848
View File
File diff suppressed because it is too large Load Diff
-24
View File
@@ -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
View File
@@ -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/)
-105
View File
@@ -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();
-53
View File
@@ -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`);
}
-31
View File
@@ -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;
}
}
}
-55
View File
@@ -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();
-116
View File
@@ -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();
-39
View File
@@ -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);
});
});
-145
View File
@@ -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);
});
});
});
-39
View File
@@ -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);
});
});
+175
View File
@@ -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
View File
@@ -1,7 +1,14 @@
{
"name": "jellytau",
"version": "0.8.2",
"description": "",
"version": "0.9.0",
"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"
}
}
+73 -1
View File
@@ -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
+4
View File
@@ -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"
+19 -3
View File
@@ -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
+4
View File
@@ -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"
+4
View File
@@ -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"
+26 -23
View File
@@ -322,34 +322,37 @@ describe("generated matrix file links", () => {
});
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(76);
expect(defined.IR).toBe(32);
// 192 = 187 + four requirements added independently on four audit branches,
// plus DR-201 (lockscreen skip resolution). Originally 191 = 187 + four
// that landed together: DR-189 (control-bar auto-hide), DR-198 (asset
// scope/CSP), DR-199 (webview mixed-content) and DR-200 (the
// POST_NOTIFICATIONS media-session exemption; renumbered from 198 on
// merge, where it collided). Each branch bumped for its own — merged,
// they sum. Resolve this by summing, never by taking one side. 193 adds
// DR-202 (video keeps the display awake), 194 DR-203 (the handoff
// transcode refusing the player's own load-error retry). 200 adds the
// six tooling/quality requirements DR-204..DR-209 (logging facade, lint
// gate, pinned toolchain, pre-commit hook, doc-link check, server-side
// library folder exclusion); UR rises to 76 with UR-076, which DR-209
// serves.
expect(defined.DR).toBe(200);
expect(defined.JA).toBe(36);
expect(defined.total).toBe(344);
// 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);
});
});
+1 -1
View File
@@ -56,7 +56,7 @@ export 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.
+87
View File
@@ -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."
+43
View File
@@ -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"
+53
View File
@@ -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."
+6 -2
View File
@@ -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 ""
+17 -2
View File
@@ -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 "$@"
+1 -1
View File
@@ -2018,7 +2018,7 @@ dependencies = [
[[package]]
name = "jellytau"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"aes-gcm",
"async-trait",
+5 -3
View File
@@ -1,8 +1,10 @@
[package]
name = "jellytau"
version = "0.8.2"
description = "A Tauri App"
authors = ["you"]
version = "0.9.0"
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
@@ -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>
+26
View File
@@ -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"]
+225 -5
View File
@@ -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)
}
+326
View File
@@ -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()]
);
}
}
+2
View File
@@ -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
+61
View File
@@ -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.
///
+20
View File
@@ -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") {
+352
View File
@@ -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());
}
}
+78 -15
View File
@@ -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> {
+2
View File
@@ -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;
+127 -13
View File
@@ -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.
///
+149 -19
View File
@@ -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
+46
View File
@@ -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.
///
+221 -5
View File
@@ -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));
}
}
+44 -7
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "jellytau",
"version": "0.8.2",
"productName": "JellyTau",
"version": "0.9.0",
"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"
}
+92
View File
@@ -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.
*
@@ -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();
});
@@ -19,7 +19,6 @@
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");
@@ -87,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;
});
}
@@ -121,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,
@@ -139,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.
@@ -154,7 +154,7 @@
// resolves to online vs offline. TRACES: UR-067 | DR-116
favoritesOnly: favoritesOnly ? true : undefined,
});
items = excludePodcasts(result.items);
items = result.items;
}
} catch (e) {
log.error(`Failed to load ${config.itemType}:`, e);
@@ -52,6 +52,9 @@
// A new item in the same slot (scrolling a virtualised list, switching series)
// must drop the previous item's optimistic state or it shows the wrong tick.
$effect(() => {
// Bare read: registers `itemId` as a dependency of this effect. Svelte 5
// idiom, not a stray expression.
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
itemId;
optimistic = null;
});
+3 -2
View File
@@ -263,7 +263,6 @@ export class Html5PlayerAdapter implements PlayerAdapter {
timeoutMs: number
): Promise<boolean> {
return new Promise<boolean>((resolve) => {
let timer: ReturnType<typeof setTimeout>;
const done = (fired: boolean) => {
el.removeEventListener(event, listener);
clearTimeout(timer);
@@ -271,7 +270,9 @@ export class Html5PlayerAdapter implements PlayerAdapter {
};
const listener = () => done(true);
el.addEventListener(event, listener);
timer = setTimeout(() => done(false), timeoutMs);
// `done` closes over `timer`, but can only run once the listener fires or
// the timeout elapses — both strictly after this assignment.
const timer: ReturnType<typeof setTimeout> = setTimeout(() => done(false), timeoutMs);
});
}
}
+1 -1
View File
@@ -38,7 +38,7 @@ export async function getDeviceId(): Promise<string> {
return deviceId;
} catch (e) {
log.error("Failed to get device ID from backend:", e);
throw new Error("Failed to initialize device ID: " + String(e));
throw new Error("Failed to initialize device ID: " + String(e), { cause: e });
}
}
+4 -2
View File
@@ -195,7 +195,7 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
switch (state) {
case "playing":
case "paused":
case "loading":
case "loading": {
// When local playback starts, ensure mode is set to local
const mode = get(playbackMode);
if (mode.mode !== "local") {
@@ -236,9 +236,10 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
}
break;
}
case "idle":
case "stopped":
case "stopped": {
player.setIdle();
// When local playback stops, revert to idle mode
const currentMode = get(playbackMode);
@@ -250,6 +251,7 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
break;
}
}
}
/**
* Handle media loaded event.
+1 -1
View File
@@ -8,7 +8,7 @@ export interface InvokeCall {
}
let invokeHistory: InvokeCall[] = [];
let invokeResponses: Map<string, any> = new Map();
const invokeResponses: Map<string, any> = new Map();
/**
* Mock invoke function that captures calls
+9 -14
View File
@@ -4,7 +4,6 @@
import { writable, derived } from "svelte/store";
import type { MediaItem, Genre } from "$lib/api/types";
import { auth } from "./auth";
import { excludePodcasts } from "$lib/utils/podcastFilter";
import { selectDiverseGenres, sampleAcross } from "$lib/utils/genreDiversity";
import { buildHeroMix } from "$lib/utils/heroMix";
import { createLogger } from "$lib/utils/logger";
@@ -100,23 +99,20 @@ function createMusicStore() {
.catch(() => [] as MediaItem[]),
]);
// HACK: drop the "Podcasts" folder that lives inside the music library.
const recentlyPlayedAlbums = excludePodcasts(recentlyPlayed);
const newlyAddedAlbums = excludePodcasts(newlyAdded.items);
const playlistItems = excludePodcasts(playlistsResult.items);
const rediscoverAlbums = excludePodcasts(rediscover);
const surpriseAlbums = excludePodcasts(surprise);
// Nothing is filtered here: folders the user chose to hide are already
// gone, dropped by the repository layer that answered these queries.
// TRACES: UR-076 | DR-209
// Mix the hero: fresh-in-your-ears first, then "remember this?", then
// random albums from across the library.
const heroItems = buildHeroMix([recentlyPlayedAlbums, rediscoverAlbums, surpriseAlbums], hasArt);
const heroItems = buildHeroMix([recentlyPlayed, rediscover, surprise], hasArt);
update(s => ({
...s,
recentlyPlayed: recentlyPlayedAlbums,
newlyAdded: newlyAddedAlbums,
playlists: playlistItems,
rediscover: rediscoverAlbums,
recentlyPlayed,
newlyAdded: newlyAdded.items,
playlists: playlistsResult.items,
rediscover,
heroItems,
isLoading: false,
}));
@@ -143,8 +139,7 @@ function createMusicStore() {
recursive: true,
limit: SECTION_LIMIT,
});
// HACK: drop the "Podcasts" folder that lives in the music library.
return { id: genre.id, name: genre.name, items: excludePodcasts(result.items) };
return { id: genre.id, name: genre.name, items: result.items };
} catch (e) {
log.warn(`Failed to load genre row "${genre.name}":`, e);
return { id: genre.id, name: genre.name, items: [] };
+26
View File
@@ -8,6 +8,7 @@ import {
parseLogLevel,
readStoredLogLevel,
resetLogLevel,
resolveDefaultLogLevel,
setLogLevel,
type LogLevel,
} from "./logger";
@@ -316,3 +317,28 @@ describe("localStorage override", () => {
expect(spies.error).toHaveBeenCalledWith("[Boot] still reaches the console");
});
});
/**
* A packaged *debug* build must still log at debug level.
*
* `import.meta.env.DEV` is true only under the vite dev server. Both the debug
* and the release APK are produced by a plain `vite build`
* (scripts/build-android.sh runs `bun run build`), so gating on DEV alone
* silences the debug APK too and `bun run android:logs` is a documented
* workflow that depends on those messages reaching logcat.
*
* TRACES: | DR-204 | UT-201
*/
describe("resolveDefaultLogLevel", () => {
it("logs at debug under the dev server", () => {
expect(resolveDefaultLogLevel(true, false)).toBe("debug");
});
it("logs at debug in a packaged debug build", () => {
expect(resolveDefaultLogLevel(false, true)).toBe("debug");
});
it("stays quiet in a packaged release build", () => {
expect(resolveDefaultLogLevel(false, false)).toBe("warn");
});
});
+29 -1
View File
@@ -115,9 +115,37 @@ export function parseLogLevel(raw: unknown): LogLevel | null {
return normalised in LEVEL_RANK ? (normalised as LogLevel) : null;
}
/**
* Injected by vite (see `vite.config.js`) from Tauri's `TAURI_ENV_DEBUG`, which
* the CLI sets while running `beforeBuildCommand`. Undefined outside a Tauri
* build a bare `vite build`, or vitest hence the `typeof` guard.
*/
declare const __JT_DEBUG_BUILD__: boolean | undefined;
/** Is this bundle inside a *debug* Tauri package (a debug APK, say)? */
function isDebugBuild(): boolean {
return typeof __JT_DEBUG_BUILD__ !== "undefined" && __JT_DEBUG_BUILD__ === true;
}
/**
* The default level, as a pure function of the two build facts it depends on.
*
* Split out from {@link defaultLogLevel} so it can be tested neither
* `import.meta.env.DEV` nor a vite `define` can be varied from inside a test.
*
* 🔴 `isDevServer` alone is not enough. `import.meta.env.DEV` is true only under
* the vite dev server, and `scripts/build-android.sh` produces the debug APK
* with a plain `bun run build` so gating on it silences the debug package as
* thoroughly as the release one, and `bun run android:logs` stops showing
* anything from the frontend.
*/
export function resolveDefaultLogLevel(isDevServer: boolean, isDebugBuild: boolean): LogLevel {
return isDevServer || isDebugBuild ? "debug" : "warn";
}
/** The level a build defaults to with no override present. */
export function defaultLogLevel(): LogLevel {
return import.meta.env?.DEV ? "debug" : "warn";
return resolveDefaultLogLevel(Boolean(import.meta.env?.DEV), isDebugBuild());
}
/**
-30
View File
@@ -1,30 +0,0 @@
// HACK: hide "Podcasts" from the music library.
//
// The user stores podcasts inside the music library under a folder/album named
// "Podcasts", so they leak into album/artist/track/playlist queries. Jellyfin's
// item queries here don't give us a clean server-side exclusion for that folder,
// so we filter client-side by name. This is intentionally a blunt instrument:
// anything whose own name, album, or (album) artist is literally "Podcasts" is
// dropped. If the folder is ever renamed, update PODCAST_FOLDER_NAME.
import type { MediaItem } from "$lib/api/types";
const PODCAST_FOLDER_NAME = "podcasts";
function isPodcastName(value: string | null | undefined): boolean {
return value?.trim().toLowerCase() === PODCAST_FOLDER_NAME;
}
/** True when an item belongs to the "Podcasts" folder/album and should be hidden. */
export function isPodcastItem(item: MediaItem): boolean {
return (
isPodcastName(item.name) ||
isPodcastName(item.albumName) ||
isPodcastName(item.albumArtist) ||
(item.artists?.some(isPodcastName) ?? false)
);
}
/** Remove "Podcasts" entries from a list of music items. */
export function excludePodcasts(items: MediaItem[]): MediaItem[] {
return items.filter((item) => !isPodcastItem(item));
}
-118
View File
@@ -1,118 +0,0 @@
/**
* Input validation utility tests
*
* TRACES: UR-009, UR-025 | DR-015
*/
import { describe, it, expect } from "vitest";
import {
validateItemId,
validateImageType,
validateMediaSourceId,
validateNumericParam,
validateQueryParamValue,
} from "./validation";
describe("validateItemId", () => {
it("should accept valid item IDs", () => {
expect(() => validateItemId("123abc")).not.toThrow();
expect(() => validateItemId("abc-123_def")).not.toThrow();
expect(() => validateItemId("12345")).not.toThrow();
});
it("should reject empty or non-string IDs", () => {
expect(() => validateItemId("")).toThrow("must be a non-empty string");
expect(() => validateItemId(null as any)).toThrow("must be a non-empty string");
expect(() => validateItemId(undefined as any)).toThrow("must be a non-empty string");
});
it("should reject IDs exceeding max length", () => {
expect(() => validateItemId("a".repeat(51))).toThrow("exceeds maximum length");
});
it("should reject IDs with invalid characters", () => {
expect(() => validateItemId("abc/def")).toThrow("contains invalid characters");
expect(() => validateItemId("abc..def")).toThrow("contains invalid characters");
expect(() => validateItemId("abc def")).toThrow("contains invalid characters");
});
});
describe("validateImageType", () => {
it("should accept valid image types", () => {
expect(() => validateImageType("Primary")).not.toThrow();
expect(() => validateImageType("Backdrop")).not.toThrow();
expect(() => validateImageType("Banner")).not.toThrow();
expect(() => validateImageType("Logo")).not.toThrow();
});
it("should reject invalid image types", () => {
expect(() => validateImageType("InvalidType")).toThrow("not a valid image type");
expect(() => validateImageType("..")).toThrow("not a valid image type");
expect(() => validateImageType("Primary/Avatar")).toThrow("not a valid image type");
});
it("should reject empty or non-string types", () => {
expect(() => validateImageType("")).toThrow("must be a non-empty string");
});
});
describe("validateMediaSourceId", () => {
it("should accept valid media source IDs", () => {
expect(() => validateMediaSourceId("source-123")).not.toThrow();
expect(() => validateMediaSourceId("video_stream_1")).not.toThrow();
});
it("should reject IDs with invalid characters", () => {
expect(() => validateMediaSourceId("source/path")).toThrow("contains invalid characters");
expect(() => validateMediaSourceId("source..path")).toThrow("contains invalid characters");
});
it("should reject IDs exceeding max length", () => {
expect(() => validateMediaSourceId("a".repeat(51))).toThrow("exceeds maximum length");
});
});
describe("validateNumericParam", () => {
it("should accept valid numbers", () => {
expect(validateNumericParam(100)).toBe(100);
expect(validateNumericParam(0)).toBe(0);
expect(validateNumericParam(9999)).toBe(9999);
});
it("should reject non-integers", () => {
expect(() => validateNumericParam(10.5)).toThrow("must be an integer");
expect(() => validateNumericParam("100")).toThrow("must be an integer");
});
it("should respect min and max bounds", () => {
expect(() => validateNumericParam(-1, 0, 100)).toThrow("must be between 0 and 100");
expect(() => validateNumericParam(101, 0, 100)).toThrow("must be between 0 and 100");
});
it("should allow custom bounds", () => {
expect(validateNumericParam(50, 10, 100)).toBe(50);
expect(() => validateNumericParam(5, 10, 100)).toThrow("must be between 10 and 100");
});
});
describe("validateQueryParamValue", () => {
it("should accept valid query param values", () => {
expect(() => validateQueryParamValue("abc123")).not.toThrow();
expect(() => validateQueryParamValue("value-with-dash")).not.toThrow();
expect(() => validateQueryParamValue("value_with_underscore")).not.toThrow();
});
it("should reject values with invalid characters", () => {
expect(() => validateQueryParamValue("value with spaces")).toThrow("contains invalid characters");
expect(() => validateQueryParamValue("value/path")).toThrow("contains invalid characters");
expect(() => validateQueryParamValue("value?query")).toThrow("contains invalid characters");
});
it("should reject values exceeding max length", () => {
expect(() => validateQueryParamValue("a".repeat(101))).toThrow("exceeds maximum length");
});
it("should respect custom max length", () => {
expect(() => validateQueryParamValue("a".repeat(50), 40)).toThrow("exceeds maximum length");
});
});
+1 -1
View File
@@ -79,7 +79,7 @@ export function validateUrlPathSegment(segment: string): void {
}
// Reject path separators and null bytes
if (/[\/\\%]/.test(segment)) {
if (/[/\\%]/.test(segment)) {
throw new Error("Invalid path segment: contains invalid characters");
}
}
+118 -3
View File
@@ -1,4 +1,4 @@
<!-- TRACES: UR-023, UR-025, UR-027, UR-029, UR-057 | DR-030, DR-048, DR-077, DR-086, DR-132 -->
<!-- TRACES: UR-023, UR-025, UR-027, UR-029, UR-057, UR-076 | DR-030, DR-048, DR-077, DR-086, DR-132, DR-209 -->
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { commands } from "$lib/api/bindings";
@@ -6,6 +6,8 @@
AudioSettings,
CacheConfig,
EqPreset,
ExclusionCandidate,
LibrarySettings,
StreamingQuality,
VideoSettings,
VolumeLevel,
@@ -23,6 +25,7 @@
import SearchGroupOrderList from "$lib/components/settings/SearchGroupOrderList.svelte";
import PendingSyncList from "$lib/components/sync/PendingSyncList.svelte";
import { library, viewMode } from "$lib/stores/library";
import { auth } from "$lib/stores/auth";
import {
isNetworkDetectionSupported,
reportNetworkState,
@@ -88,6 +91,16 @@
temporaryTtlHours: 24 * 7,
});
// Folders the user has hidden from browsing, and the folders they may choose
// from. Both come from Rust: which containers are offerable, and what hiding
// one actually excludes, are domain decisions — this page only renders the
// list and sends back the ids that are ticked.
// TRACES: UR-076 | DR-209
let librarySettings = $state<LibrarySettings>({ excludedItemIds: [] });
let exclusionCandidates = $state<ExclusionCandidate[]>([]);
let exclusionsLoading = $state(false);
const excludedIds = $derived(new Set(librarySettings.excludedItemIds ?? []));
// Whether the platform can actually detect the network type. On desktop it
// can't, so the WiFi-only toggle would be inert — we disable and explain it
// rather than offering a switch that does nothing.
@@ -136,13 +149,16 @@
try {
loading = true;
networkDetectionSupported = isNetworkDetectionSupported();
const [audioResult, videoResult, cacheResult, presets, qualities] = await Promise.all([
const [audioResult, videoResult, cacheResult, presets, qualities, libraryResult] =
await Promise.all([
commands.playerGetAudioSettings(),
commands.playerGetVideoSettings(),
getCacheConfig(),
commands.playerGetEqPresets(),
commands.playerGetStreamingQualities(),
commands.libraryGetSettings(),
]);
librarySettings = libraryResult;
// equalizerBands is optional on the wire (serde default); guarantee a
// dense 10-band array so the slider bindings are never undefined.
settings = {
@@ -153,8 +169,10 @@
cacheConfig = cacheResult;
eqPresets = presets;
streamingQualities = qualities;
// Load cache stats in parallel but don't block on it
// Load cache stats and the folder picker in parallel but don't block on
// either — both need a round trip the rest of the page doesn't.
loadCacheStats();
loadExclusionCandidates();
} catch (e) {
log.error("Failed to load settings:", e);
} finally {
@@ -162,6 +180,47 @@
}
}
/**
* Ask the backend which folders may be hidden. Needs a live repository, so it
* quietly renders nothing when signed out rather than erroring on a page that
* is otherwise perfectly usable offline.
*
* TRACES: UR-076 | DR-209
*/
async function loadExclusionCandidates() {
try {
exclusionsLoading = true;
const handle = auth.getRepository().getHandle();
exclusionCandidates = await commands.libraryGetExclusionCandidates(handle);
} catch (e) {
console.warn("Failed to load library folders:", e);
exclusionCandidates = [];
} finally {
exclusionsLoading = false;
}
}
/**
* Tick or untick one folder. The backend returns the list it actually stored,
* so the picker shows what is in force rather than what was requested.
*
* TRACES: UR-076 | DR-209
*/
async function toggleExcludedItem(itemId: string) {
const current = librarySettings.excludedItemIds ?? [];
const next = current.includes(itemId)
? current.filter((id) => id !== itemId)
: [...current, itemId];
// Optimistic, so the checkbox doesn't lag a round trip behind the tap.
librarySettings = { ...librarySettings, excludedItemIds: next };
try {
librarySettings = await commands.librarySetSettings({ excludedItemIds: next });
} catch (e) {
console.error("Failed to save hidden folders:", e);
librarySettings = { ...librarySettings, excludedItemIds: current };
}
}
async function loadCacheStats() {
try {
cacheLoading = true;
@@ -418,6 +477,62 @@
</div>
</div>
<!-- Hidden folders — music libraries often hold a folder of something the
user doesn't think of as music (podcasts, audiobooks, sound effects),
which otherwise turns up in every album, artist and track listing.
The candidate list and the meaning of "hidden" both come from Rust.
TRACES: UR-076 | DR-209 -->
<div id="hidden-folders" class="scroll-mt-4 bg-[var(--color-surface)] rounded-lg p-6">
<div class="mb-4">
<h2 class="text-xl font-semibold text-white">Hidden Folders</h2>
<p class="text-sm text-gray-400 mt-1">
Folders to leave out of music browsing and search. Useful when a
music library also holds podcasts or audiobooks. Hidden folders can
still be opened from a direct link, and anything already playing or
downloaded is unaffected.
</p>
</div>
{#if exclusionsLoading}
<p class="text-sm text-gray-400">Loading folders...</p>
{:else if exclusionCandidates.length === 0}
<p class="text-sm text-gray-400">
No music folders to choose from. Connect to your server to pick
folders to hide.
</p>
{:else}
<div class="space-y-2">
{#each exclusionCandidates as candidate (candidate.id)}
<button
onclick={() => toggleExcludedItem(candidate.id)}
class="w-full flex items-center justify-between gap-3 py-3 px-4 rounded-lg text-left transition-all {excludedIds.has(
candidate.id
)
? 'bg-[var(--color-jellyfin)] text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
aria-pressed={excludedIds.has(candidate.id)}
>
<span class="min-w-0">
<span class="block font-semibold truncate">{candidate.name}</span>
<span class="block text-xs opacity-75 truncate">
{candidate.isLibrary
? "Whole library"
: `In ${candidate.libraryName}`}
</span>
</span>
<span class="text-xs font-semibold uppercase tracking-wide shrink-0">
{excludedIds.has(candidate.id) ? "Hidden" : "Visible"}
</span>
</button>
{/each}
</div>
<p class="text-xs text-gray-500 mt-3">
Changes apply to listings loaded from now on; reopen a page to see
them take effect.
</p>
{/if}
</div>
<!-- Crossfade -->
<div class="bg-[var(--color-surface)] rounded-lg p-6">
<div class="flex items-start justify-between mb-4">
+8
View File
@@ -8,6 +8,14 @@ const host = process.env.TAURI_DEV_HOST;
export default defineConfig(async () => ({
plugins: [sveltekit(), tailwindcss()],
// Tauri sets TAURI_ENV_DEBUG while it runs `beforeBuildCommand`, which is how
// the frontend can tell a debug package from a release one. `import.meta.env.DEV`
// cannot: it is true only under the dev server, so both APKs look identical to
// it and the debug build loses its logging (see logger.ts).
define: {
__JT_DEBUG_BUILD__: JSON.stringify(process.env.TAURI_ENV_DEBUG === "true"),
},
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent Vite from obscuring rust errors
-89
View File
@@ -1,89 +0,0 @@
import os from "node:os";
import path from "node:path";
import { spawn, type ChildProcess } from "node:child_process";
import fs from "node:fs";
let tauriDriver: ChildProcess;
export const config: WebdriverIO.Config = {
specs: ["./e2e/specs/**/*.e2e.ts"],
exclude: [],
// Run tests sequentially to avoid session conflicts
maxInstances: 1,
port: 4444, // tauri-driver default port
hostname: "localhost",
capabilities: [
{
maxInstances: 1,
"tauri:options": {
application: getTauriApplicationPath(),
// Use a separate test data directory
env: {
JELLYTAU_DATA_DIR: path.join(os.tmpdir(), "jellytau-test-data"),
},
},
} as WebdriverIO.Capabilities,
],
logLevel: "warn", // Reduce log noise
bail: 0,
waitforTimeout: 10000,
connectionRetryTimeout: 120000,
connectionRetryCount: 3,
framework: "mocha",
reporters: ["spec"],
mochaOpts: {
ui: "bdd",
timeout: 60000,
},
// Start tauri-driver before session
onPrepare: async function () {
tauriDriver = spawn(
path.resolve(os.homedir(), ".cargo", "bin", "tauri-driver"),
[],
{ stdio: [null, process.stdout, process.stderr] }
);
return new Promise((resolve) => {
setTimeout(resolve, 1000); // Give tauri-driver time to start
});
},
// Clean up tauri-driver after session
onComplete: async function () {
tauriDriver.kill();
},
};
function getTauriApplicationPath(): string {
const platform = process.platform;
const cwd = process.cwd();
// Check for release build first, fallback to debug
if (platform === "win32") {
const releasePath = path.join(cwd, "src-tauri/target/release/jellytau.exe");
const debugPath = path.join(cwd, "src-tauri/target/debug/jellytau.exe");
if (fs.existsSync(releasePath)) return releasePath;
if (fs.existsSync(debugPath)) return debugPath;
return releasePath; // Return default if neither exists
} else if (platform === "darwin") {
const releasePath = path.join(cwd, "src-tauri/target/release/bundle/macos/jellytau.app");
const debugPath = path.join(cwd, "src-tauri/target/debug/bundle/macos/jellytau.app");
if (fs.existsSync(releasePath)) return releasePath;
if (fs.existsSync(debugPath)) return debugPath;
return releasePath;
} else {
// Linux
const releasePath = path.join(cwd, "src-tauri/target/release/jellytau");
const debugPath = path.join(cwd, "src-tauri/target/debug/jellytau");
if (fs.existsSync(releasePath)) return releasePath;
if (fs.existsSync(debugPath)) return debugPath;
return debugPath; // Return debug path as default for Linux
}
}